diff --git a/.gitignore b/.gitignore index 51af8a3..a299c42 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ next-env.d.ts # local minio storage .minio + +# Private announcement recipient lists, previews, and delivery records +/.local/announcements/ diff --git a/README.md b/README.md index e391ccf..c1bfef5 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ RESEND_FROM= Register Discord guild commands locally with `npm run discord:register`; in production, an admin can visit `/api/discord/register`. +For creator service notices, see [Creator announcements](docs/announcements/README.md) for the reusable email template and preview, recipient preparation, test, and send commands. The announcement script requires Node 22.18 or newer. + ### Supabase (local) Follow the official guide to run Supabase locally with the CLI (includes Studio): diff --git a/docs/announcements/README.md b/docs/announcements/README.md new file mode 100644 index 0000000..7a273de --- /dev/null +++ b/docs/announcements/README.md @@ -0,0 +1,80 @@ +# Creator announcements + +Use the `announcement` email template for service notices to creators. It takes a title and a plain-text message, preserves paragraph breaks, and makes HTTP/HTTPS URLs clickable. HTML in either field is escaped. It reuses the existing Hackdex email styling and invites replies. + +The script requires Node 22.18 or newer and installed project dependencies. It uses Node's built-in TypeScript support; no additional runner is needed. Run commands from the repository root. + +## Draft and preview + +Copy `docs/announcements/terms-1.1.0.json` for a new announcement. Give it a unique, stable `id`, a `title` used as the email subject, a `message`, and `ready: false`. The ID identifies this mailing, including its delivery records. Never change it to retry the same mailing. + +```sh +npm run email:announcement -- docs/announcements/terms-1.1.0.json +``` + +This default mode renders HTML and plain-text previews without any network access. Output is under `.local/announcements//`, which is ignored by Git. If a recipient snapshot already exists, it also reports the count. + +The Terms announcement uses October 3, 2026 as the effective date and November 2, 2026 as the deadline for existing hacks. Before sending, confirm these dates match the published Terms, including the 30-day grace period, and that the updated Terms and FAQ are live. Send by September 26 to provide seven days of notice. Set `ready` to `true` only after reviewing the final copy. Production sending rejects drafts and unresolved `{{...}}` placeholders. The script does not validate legal deadlines or publish policy changes. + +## Configuration + +The npm command loads `.env.local`, then `.env.announcements` if present. Existing shell environment variables take precedence. Use `.env.announcements` for the intended production credentials and verify the source URL printed during preparation. + +```dotenv +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SECRET_KEY=your-server-secret +RESEND_API_KEY=your-resend-key +RESEND_FROM=Hackdex +RESEND_REPLY_TO=your-monitored-inbox@example.com +``` + +Preview needs no credentials. Preparing recipients needs only Supabase credentials. Test and production sending need the three Resend variables. Use a verified sender and a monitored reply inbox; the review-thread reply addresses are specific to individual hack reviews and should not be reused here. + +## Prepare and review recipients + +```sh +npm run email:announcement -- docs/announcements/terms-1.1.0.json --prepare +``` + +This reads Supabase and saves `recipients.json` without sending email. It includes creators of non-archive hacks that are approved or have an uploaded patch, identified by `current_patch` or the legacy `patch_url`. Pending, rejected, and unpublished hacks with uploads are included. Unapproved entries without an uploaded patch are excluded. Archive entries are excluded because an administrator may be their uploader. Any additional archive-related outreach needs separate review. + +The query is paginated. Account emails come from Supabase Auth. Multiple hacks and accounts sharing the same normalized email result in one recipient. The snapshot includes account IDs, hack slugs, the source Supabase URL, and preparation time. Any query or account-email lookup failure stops preparation instead of silently omitting someone. + +Review the saved list and count before sending. Preparation refuses to overwrite an existing snapshot. Before any production send, you can delete just `recipients.json` and prepare again if necessary. Once sending starts, the message and audience are frozen. + +## Send yourself a test + +```sh +npm run email:announcement -- docs/announcements/terms-1.1.0.json --test you@example.com +``` + +This sends one email to that address with `[TEST]` in the subject. It allows draft copy and never reads or sends to the creator snapshot. Check the layout, links, dates, and reply address. It does not mark any creator as notified. + +## Send the reviewed announcement + +```sh +npm run email:announcement -- docs/announcements/terms-1.1.0.json --send +``` + +This sends one individual email per saved recipient, at most one request per second. Check your Resend account's available daily/monthly quota before starting. Each accepted message is recorded with its Resend email ID, timestamp, account IDs, and stable idempotency key. Subsequent runs skip accepted recipients. A lock prevents simultaneous sends from the same directory. The first run saves the exact message and audience in `payload.json`; subsequent runs refuse changed content or recipients. + +Resend acceptance is not confirmation of delivery or reading. Check delivery events, bounces, and suppressions in the Resend dashboard after sending. Do not use this script to bypass suppressed addresses or send promotional mail to people without the appropriate subscription. + +## Interrupted or failed sends + +A `.pending.json` record is written before each request. On an API error, network failure, or interruption, the script stops. It will not automatically resend an unresolved attempt, even after Resend's 24-hour idempotency window expires. + +Inspect the pending record and Resend's logs. If Resend accepted the email, add its `emailId` to the record and rename its suffix from `.pending.json` to `.sent.json`. If you establish that Resend did not accept it, remove that pending record and rerun the same command. If the outcome remains uncertain, leave it pending. Known rejections such as quota or rate-limit errors also require resolving the pending record before retrying. + +If a process was killed, `send.lock` may remain. Remove it only after checking that no sender is still running, then resolve any pending attempt. Do not delete `payload.json` or accepted-send records to bypass a mismatch. Restore the original message and snapshot instead. + +Keep a private backup of the campaign directory. It contains recipient addresses and is the durable duplicate-send record. Do not commit it, publish it, or restart the mailing from another machine without transferring that state. Update the announcement by starting a new ID only when you intentionally want a separate mailing. + +## Verification + +```sh +npm run test:announcement +npx tsc --noEmit --incremental false +``` + +Tests use temporary files and stub only external HTTP calls. They do not contact Supabase or send real emails. diff --git a/docs/announcements/terms-1.1.0.json b/docs/announcements/terms-1.1.0.json new file mode 100644 index 0000000..5055bcf --- /dev/null +++ b/docs/announcements/terms-1.1.0.json @@ -0,0 +1,6 @@ +{ + "id": "terms-1-1-0", + "ready": false, + "title": "Hackdex Terms update: AI disclosures and creator requirements", + "message": "Hi,\n\nYou're receiving this because you've uploaded a hack to Hackdex. We're updating our Terms of Service and FAQ to clarify a few creator requirements.\n\nThe updated Terms take effect on 2026-10-03. Existing hacks have until 2026-11-02 to meet the credits and AI disclosure requirements. New submissions must meet those requirements from the effective date.\n\nAI-generated content is allowed with disclosure. If your hack or its Hackdex page includes AI-generated graphics, music, dialogue, translations, event scripts, or other content, add a labeled AI disclosure section to your description identifying the affected content. This includes generated content you edited or received from contributors.\n\nEvent scripts count, including scripts that move NPCs, trigger cutscenes, show dialogue, or give items. AI assistance with general programming, such as engine changes, bug fixes, or development tools, does not require disclosure.\n\nWe believe a blanket ban can encourage creators to hide AI use. Disclosure gives creators room to be honest and helps players decide which hacks they want to play.\n\nEvery hack needs a Credits section on its Hackdex page. You can list contributors and sources there or link to a complete, publicly accessible credits page. Credit does not replace permission to use someone else's work.\n\nWe're also making our content rules more explicit. Targeted harassment, threats, discriminatory abuse, hate speech, and derogatory attacks against real people or groups are prohibited inside hacks and on their pages. Violating hacks or pages will be removed, and responsible accounts may be suspended or banned. The credits and disclosure grace period does not delay enforcement of other content rules or existing attribution obligations.\n\nHackdex is a hosting platform. Discover listings are sorted automatically, and placement is not a staff recommendation.\n\nPlease review the updated Terms and FAQ, then update your hack's description if needed. If your credits are already complete and you have no AI-generated content to disclose, no description changes are needed.\n\nTerms: https://www.hackdex.app/terms\nFAQ: https://www.hackdex.app/faq\nYour hacks: https://www.hackdex.app/dashboard\n\nThanks for sharing your work on Hackdex." +} diff --git a/docs/legal/TERMS.md b/docs/legal/TERMS.md index 6077883..4d178c1 100644 --- a/docs/legal/TERMS.md +++ b/docs/legal/TERMS.md @@ -1,6 +1,8 @@ # Hackdex Terms of Service -Version 1.0.2 • Effective: 2025-11-30 +Version 1.1.0 • Effective: 2026-10-03 + +These Terms take effect on October 3, 2026. Until then, the [previous Terms, version 1.0.2](/terms/2025-11-30), remain in effect. Hacks submitted before October 3, 2026 have until November 2, 2026 to meet the credits and AI disclosure requirements in Section 6; all other requirements apply from the effective date. Canonical URL: https://www.hackdex.app/terms @@ -15,7 +17,7 @@ By accessing or using Hackdex (the "Service"), you agree to this Terms of Servic ## 2. Definitions -- "User Content": files (including .bps and .png) and text uploaded to Hackdex by users. +- "User Content": files and text uploaded to Hackdex by users, including patches, content introduced into a game by those patches, screenshots, cover images, descriptions, and other hack page material. - "Creators": users who upload User Content (collectively "hacks," e.g., patches, screenshots, descriptions). - "Users": consumers who browse and/or download and apply patches. @@ -52,23 +54,49 @@ Creators must: - promptly address takedown notices and remove or update infringing or unlawful content upon request; - indemnify Hackdex as set out in Section 12. +### Credits + +Every hack must have a clearly labeled Credits section on its public Hackdex page. This section must name the hack's contributors and credit the creators or sources of reused assets and code, or link to a complete, publicly accessible credits page. Creators must keep credits accurate as the hack is updated and comply with any attribution requirements attached to material they use. Credits do not replace permission or other rights required to use that material. + +### AI-generated content disclosure + +Creators must clearly disclose AI-generated content included in their hack or its public Hackdex page. The disclosure must appear in a labeled section of the hack's description, identify the types of content affected, and remain accurate as the hack or page is updated. This requirement applies to content generated in whole or in part by AI, including generated content that is later edited or supplied by another contributor. + +Content requiring disclosure includes graphics, music, sound effects, story, dialogue, translations, and event scripts. Event scripts include instructions that control NPC actions, cutscenes, dialogue, item interactions, and other in-game events, even when those scripts contain no generated dialogue. + +AI assistance with general programming, such as engine changes, bug fixes, development tools, or implementing game systems, does not require disclosure. This exception does not cover AI-generated event scripts or other content listed above. Brainstorming and experiments that do not result in AI-generated content being included in the hack or its page do not require disclosure. + +Disclosing AI use does not exempt content from the credits requirement, other provisions of these Terms, or third-party rights. + ## 7. Content Moderation -Hackdex may or may not pre‑screen User Content. Hackdex reserves the right, but not the obligation, to move, remove, block, edit, or refuse any User Content or suspend accounts at any time and for any reason (including suspected infringement or violations of these Terms). Hackdex endeavors to apply best‑effort moderation but cannot monitor all content. All User Content is the sole responsibility of the Creator who provided it. +Hackdex is primarily a hosting and distribution platform for creator-submitted hacks. Hosting, submission approval, or placement on Discover does not constitute a recommendation, endorsement of a hack's views, or guarantee of its quality. Discover listings are ordered automatically rather than selected as staff recommendations. + +Hackdex may review submissions for compliance with these Terms, but approval does not mean every part of a hack has been reviewed. Hackdex cannot monitor all content. All User Content is the sole responsibility of the Creator who provided it. + +Hacks or hack pages found to contain content prohibited by Section 8 will be removed. Responsible accounts may also be suspended or banned. Subject to the grace period stated at the beginning of these Terms, when credits or AI disclosures are missing or incomplete, Hackdex may request corrections, refuse publication, or remove the listing until it complies. Deliberate misrepresentation or repeated violations may also result in account suspension or a ban. + +Hackdex otherwise reserves the right, but not the obligation, to move, remove, block, edit, or refuse any User Content or suspend accounts at any time and for any reason, including suspected infringement or violations of these Terms. Suspected violations may be reported through the [contact form](https://www.hackdex.app/contact) or on individual hack pages through the Report option. ## 8. Prohibited Content and Conduct +These restrictions apply to content within hacks as well as hack titles, descriptions, screenshots, cover images, and other material posted on Hackdex. + Do not, and do not allow or encourage others to: - upload, post, or link to ROMs, ROM extracts, copyrighted game assets, or other materials you do not have rights to share; - upload or share decryption keys, DRM‑circumvention tools, or instructions designed to obtain or unlock copyrighted materials unlawfully; - upload malware, malicious code, or content that interferes with the Service or others' devices; - upload personal data of others without permission; +- publish targeted harassment, threats, or derogatory attacks against real people or groups; +- publish hate speech or discriminatory abuse, including content that dehumanizes people or promotes hatred, discrimination, or violence against them based on characteristics such as race, ethnicity, nationality, religion, disability, sex, sexual orientation, or gender identity; - engage in illegal, infringing, misleading, defamatory, hateful, harassing, or otherwise unlawful conduct; - attempt to access accounts or systems without authorization or to disrupt or degrade the Service; - use automated tools (including bots, scrapers, crawlers, scripts, or similar software) to access, scrape, crawl, or systematically download content from the Service without Hackdex's prior written permission; - engage in excessive, systematic, or bulk downloading of patch files or other content in a manner that could disrupt, overload, or degrade the Service or its infrastructure; - redistribute, republish, or commercially exploit patch files or other content downloaded from the Service without authorization from the respective Creator or Hackdex, as applicable. +Hackdex considers context when reviewing fictional depictions of harmful behavior. Depicting conflict or prejudice in a story does not by itself mean a hack promotes it. Describing content as fiction, humor, or satire does not excuse targeted harassment, discriminatory abuse, or the promotion of hatred or violence. + ## 9. IP Infringement and DMCA Hackdex respects intellectual property rights and will respond to notices of alleged infringement consistent with applicable law. If you believe content on Hackdex infringes your rights, please send a notice to our Copyright Agent at copyright@hackdex.app containing: diff --git a/docs/legal/archive/TERMS-2025-11-30.md b/docs/legal/archive/TERMS-2025-11-30.md new file mode 100644 index 0000000..6077883 --- /dev/null +++ b/docs/legal/archive/TERMS-2025-11-30.md @@ -0,0 +1,129 @@ +# Hackdex Terms of Service + +Version 1.0.2 • Effective: 2025-11-30 + +Canonical URL: https://www.hackdex.app/terms + +Important notices +- This Terms of Service applies only to the Hackdex service available at the domain hackdex.app (including subdomains). Any copies of this document in public source repositories are provided for reference only and are void unless explicitly hosted under hackdex.app. +- This document is not licensed under the repository's MIT license and may not be reused without permission. +- Nothing in this document constitutes legal advice. + +## 1. Acceptance of Terms and Privacy + +By accessing or using Hackdex (the "Service"), you agree to this Terms of Service ("Terms") and our Privacy Policy available at [https://www.hackdex.app/privacy](https://www.hackdex.app/privacy). If you do not agree, do not use the Service. We may update these Terms as described in Section 13. If you continue to use the Service after changes take effect, you accept the updated Terms. + +## 2. Definitions + +- "User Content": files (including .bps and .png) and text uploaded to Hackdex by users. +- "Creators": users who upload User Content (collectively "hacks," e.g., patches, screenshots, descriptions). +- "Users": consumers who browse and/or download and apply patches. + +## 3. Eligibility; Accounts and Registration + +- You must be at least 13 years old to use the Service. Creators must be 13+. +- Registration is required only for Creators to upload their own hacks. Browsing, downloading, and local patching can be done without registration. +- If you register, you are responsible for your account, including safeguarding your password and the activity under your account. Notify us promptly of unauthorized use. + +## 4. ROMs and Patching Policy + +- ROM files are never permitted to be uploaded to Hackdex's servers. Do not post, upload, link to, or otherwise provide ROMs or ROM extracts. +- Patching occurs locally on the user's device against legally obtained ROMs owned by the user. ROMs never leave the user's device via the Service. +- Do not upload copyrighted assets, decryption keys, or other circumvention materials. + +## 5. User Content: Representations, Warranties, and License + +Creators are solely responsible for their User Content. By uploading or otherwise making available User Content, you represent and warrant that: + +1) you have all rights necessary to provide the User Content and grant the license below; +2) your User Content does not infringe, misappropriate, or violate any third‑party rights or laws; +3) your User Content complies with these Terms (including Sections 4 and 8) and is accurate and not misleading. + +License to Hackdex (service‑only): You grant Hackdex a worldwide, non‑exclusive, royalty‑free, revocable license to host, cache (including via CDNs), store, reproduce for technical purposes (e.g., thumbnails, formatting, search indexing), transmit, and display your User Content solely to operate and provide the Service. No sublicensing rights are granted. Upon deletion of User Content, Hackdex will cease display and hosting in the ordinary course, except that reasonable backup/archival copies may persist for a limited period. + +You retain ownership of your User Content. These Terms do not grant Hackdex the right to use your User Content for off‑site promotion or with partners; any such use would require your separate consent. + +## 6. Creator Responsibilities + +Creators must: +- ensure they hold necessary rights in patches, screenshots, and text (including any third‑party material they include); +- provide accurate metadata, versioning, and base ROM identification; +- comply with applicable laws, including export, IP, and consumer protection laws; +- promptly address takedown notices and remove or update infringing or unlawful content upon request; +- indemnify Hackdex as set out in Section 12. + +## 7. Content Moderation + +Hackdex may or may not pre‑screen User Content. Hackdex reserves the right, but not the obligation, to move, remove, block, edit, or refuse any User Content or suspend accounts at any time and for any reason (including suspected infringement or violations of these Terms). Hackdex endeavors to apply best‑effort moderation but cannot monitor all content. All User Content is the sole responsibility of the Creator who provided it. + +## 8. Prohibited Content and Conduct + +Do not, and do not allow or encourage others to: +- upload, post, or link to ROMs, ROM extracts, copyrighted game assets, or other materials you do not have rights to share; +- upload or share decryption keys, DRM‑circumvention tools, or instructions designed to obtain or unlock copyrighted materials unlawfully; +- upload malware, malicious code, or content that interferes with the Service or others' devices; +- upload personal data of others without permission; +- engage in illegal, infringing, misleading, defamatory, hateful, harassing, or otherwise unlawful conduct; +- attempt to access accounts or systems without authorization or to disrupt or degrade the Service; +- use automated tools (including bots, scrapers, crawlers, scripts, or similar software) to access, scrape, crawl, or systematically download content from the Service without Hackdex's prior written permission; +- engage in excessive, systematic, or bulk downloading of patch files or other content in a manner that could disrupt, overload, or degrade the Service or its infrastructure; +- redistribute, republish, or commercially exploit patch files or other content downloaded from the Service without authorization from the respective Creator or Hackdex, as applicable. + +## 9. IP Infringement and DMCA + +Hackdex respects intellectual property rights and will respond to notices of alleged infringement consistent with applicable law. If you believe content on Hackdex infringes your rights, please send a notice to our Copyright Agent at copyright@hackdex.app containing: + +1) your signature (electronic or physical) of a person authorized to act on behalf of the owner; +2) identification of the copyrighted work claimed to have been infringed; +3) identification of the material claimed to be infringing and information reasonably sufficient to locate it (e.g., URL); +4) your contact information; +5) a statement of good‑faith belief that the use is not authorized by the owner, its agent, or the law; and +6) a statement under penalty of perjury that the information is accurate and you are authorized to act. + +Counter‑Notice: If you believe material was removed or disabled by mistake or misidentification, you may send a counter‑notice to the Copyright Agent containing: + +1) your signature (electronic or physical); +2) identification of the material that was removed or to which access has been disabled and the location where it appeared before removal; +3) your contact information; +4) a statement under penalty of perjury that you have a good‑faith belief the material was removed or disabled due to mistake or misidentification; and +5) a statement that you consent to jurisdiction of the courts in Salt Lake County, Utah, and will accept service from the person who provided the original notice or that person's agent. + +Repeat Infringers: Hackdex may terminate accounts of repeat infringers in appropriate circumstances. + +## 10. Third‑Party Services and Links + +The Service may link to third‑party sites or services. Hackdex is not responsible for third‑party content, policies, or practices. Your use of third‑party services is at your own risk and subject to their terms. + +## 11. Disclaimers and Limitation of Liability + +THE SERVICE AND ALL CONTENT ARE PROVIDED "AS IS" AND "AS AVAILABLE." TO THE FULLEST EXTENT PERMITTED BY LAW, HACKDEX DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON‑INFRINGEMENT, AND NON‑INTERFERENCE. HACKDEX DOES NOT GUARANTEE THAT THE SERVICE WILL BE UNINTERRUPTED, SECURE, OR ERROR‑FREE. + +TO THE FULLEST EXTENT PERMITTED BY LAW, HACKDEX, ITS AFFILIATES, AND THEIR RESPECTIVE DIRECTORS, OFFICERS, EMPLOYEES, AND AGENTS WILL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, DATA, USE, OR GOODWILL, ARISING OUT OF OR RELATED TO YOUR USE OF THE SERVICE OR USER CONTENT, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. TO THE FULLEST EXTENT PERMITTED BY LAW, THE TOTAL AGGREGATE LIABILITY OF HACKDEX FOR ALL CLAIMS RELATING TO THE SERVICE WILL NOT EXCEED ONE HUNDRED U.S. DOLLARS (US$100). + +The limitations in this Section do not limit liability for Hackdex's willful misconduct and do not limit Creator indemnity obligations or claims arising from a Creator's IP infringement or violation of law. Some jurisdictions do not allow certain disclaimers or limits; in those cases, the limitations apply to the maximum extent permitted by law. + +## 12. Indemnification (Creator‑Focused) + +You agree to indemnify, defend, and hold harmless Hackdex, its affiliates, and their respective directors, officers, employees, and agents from and against any claims, liabilities, damages, losses, and expenses (including reasonable attorneys' fees) arising out of or related to: (a) your User Content; (b) your use of the Service; (c) your violation of these Terms; or (d) your violation of any rights of another person or entity. Creator indemnity expressly includes claims alleging copyright or trademark infringement, misappropriation, or violations of anti‑circumvention laws in connection with User Content. + +## 13. Changes to the Service and to these Terms + +Hackdex may modify, suspend, or discontinue any part of the Service at any time. We may update these Terms by posting an updated version at the canonical URL. If changes are material, we will take reasonable steps to provide notice. Your continued use after changes become effective constitutes acceptance. + +## 14. Termination + +We may suspend or terminate access to the Service (or portions thereof) at any time at our discretion, with or without notice, including if we believe you have violated these Terms or applicable law. Upon termination, Sections intended to survive (including 4–5 and 8–15) will continue to apply. + +## 15. Governing Law; Venue; Dispute Terms + +These Terms are governed by the laws of the State of Utah, USA, without regard to conflict‑of‑laws principles. You agree to the exclusive jurisdiction and venue of the state or federal courts located in Salt Lake County, Utah, for all disputes arising out of or related to these Terms or the Service, except that either party may bring an individual action in small‑claims court of competent jurisdiction. To the fullest extent permitted by law, you and Hackdex waive any right to participate in class or representative actions. Any claim must be filed within one year after the claim accrues, otherwise it is permanently barred, unless a longer period is required by applicable law. + +## 16. Severability; Waiver; Assignment; Entire Agreement + +If any provision of these Terms is deemed unenforceable, it will be enforced to the maximum extent permitted, and the remaining provisions will remain in effect. No waiver of any provision is a continuing waiver. You may not assign or transfer these Terms without our consent. These Terms constitute the entire agreement between you and Hackdex regarding the Service. + +## 17. Contact + +Copyright Agent: [copyright@hackdex.app](mailto:copyright@hackdex.app) + +General inquiries: see the contact form at [https://www.hackdex.app/contact](https://www.hackdex.app/contact) diff --git a/package.json b/package.json index ecab22d..6f3da63 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "db:reset": "supabase db reset && docker exec -i supabase_db_pokemon-romhack-platform psql -U postgres -d postgres -v ON_ERROR_STOP=1 < supabase/seed.sql && npm run seed:storage", "seed:storage": "node --env-file=.env.local --env-file=.env.development.local scripts/seed-storage.mjs", "discord:register": "node scripts/register-discord-commands.mjs", + "email:announcement": "node --env-file-if-exists=.env.local --env-file-if-exists=.env.announcements scripts/send-announcement.mts", + "test:announcement": "node --test scripts/send-announcement.test.mts", "postinstall": "patch-package" }, "dependencies": { diff --git a/scripts/send-announcement.mts b/scripts/send-announcement.mts new file mode 100644 index 0000000..171471a --- /dev/null +++ b/scripts/send-announcement.mts @@ -0,0 +1,225 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { Resend } from "resend"; +import { renderEmail } from "../src/emails/render.ts"; +import type { Database } from "../src/types/db.ts"; + +type Announcement = { id: string; title: string; message: string; ready: boolean }; +type Recipient = { email: string; userIds: string[]; hacks: string[] }; +type Message = { from: string; replyTo: string; subject: string; html: string; text: string }; +type Mailer = Pick; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isEmail(value: unknown): value is string { + return typeof value === "string" && /^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(value); +} + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Set ${name} before running this mode.`); + return value; +} + +function hash(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +async function readOptional(file: string): Promise { + try { + return await readFile(file, "utf8"); + } catch (error) { + if (isRecord(error) && error.code === "ENOENT") return undefined; + throw error; + } +} + +export async function loadAnnouncement(file: string): Promise { + const value: unknown = JSON.parse(await readFile(file, "utf8")); + if (!isRecord(value) || typeof value.id !== "string" || + !/^[a-z0-9][a-z0-9-]{0,79}$/.test(value.id) || + typeof value.title !== "string" || !value.title.trim() || /[\r\n]/.test(value.title) || + typeof value.message !== "string" || !value.message.trim() || typeof value.ready !== "boolean") { + throw new Error("Announcement needs an id in lowercase kebab-case, a title, a message, and a ready boolean."); + } + return { id: value.id, title: value.title, message: value.message, ready: value.ready }; +} + +/** Read approved hacks or hacks with uploaded patches, including pending and unpublished entries. */ +export async function collectRecipients(supabase: SupabaseClient): Promise { + const creators = new Map(); + const pageSize = 1000; + for (let offset = 0; ; offset += pageSize) { + const { data, error } = await supabase.from("hacks") + .select("slug,created_by,current_patch,patch_url,approved") + .eq("is_archive", false).order("slug").range(offset, offset + pageSize - 1); + if (error) throw new Error(`Cannot load hacks: ${error.message}`); + if (!data) throw new Error("No response when loading hacks."); + for (const hack of data) { + if (!hack.current_patch && !hack.patch_url && !hack.approved) continue; + const hacks = creators.get(hack.created_by) ?? []; + hacks.push(hack.slug); + creators.set(hack.created_by, hacks); + } + if (data.length < pageSize) break; + } + + const recipients = new Map(); + for (const [id, hacks] of creators) { + const { data, error } = await supabase.auth.admin.getUserById(id); + if (error) throw new Error(`Cannot load creator ${id}: ${error.message}`); + const email = data.user?.email?.trim().toLowerCase(); + if (!isEmail(email)) throw new Error(`Creator ${id} has no valid account email. Resolve before preparing recipients.`); + const recipient = recipients.get(email) ?? { email, userIds: [], hacks: [] }; + recipient.userIds.push(id); + recipient.hacks.push(...hacks); + recipients.set(email, recipient); + } + return [...recipients.values()].sort((a, b) => a.email.localeCompare(b.email)); +} + +async function loadRecipients(file: string): Promise { + const text = await readOptional(file); + if (!text) throw new Error("No recipient snapshot. Run --prepare and review recipients.json first."); + const value: unknown = JSON.parse(text); + if (!isRecord(value) || !Array.isArray(value.recipients)) throw new Error("Invalid recipient snapshot."); + const recipients = value.recipients.map((row: unknown) => { + if (!isRecord(row) || !isEmail(row.email) || !Array.isArray(row.userIds) || !row.userIds.length || + !row.userIds.every((id): id is string => typeof id === "string" && id.length > 0) || + !Array.isArray(row.hacks) || !row.hacks.every((slug): slug is string => typeof slug === "string")) { + throw new Error("Invalid recipient in snapshot."); + } + return { email: row.email.toLowerCase(), userIds: row.userIds, hacks: row.hacks }; + }); + if (!recipients.length || new Set(recipients.map((row) => row.email)).size !== recipients.length) { + throw new Error("Recipient snapshot is empty or has duplicate email addresses."); + } + return recipients; +} + +/** Fail closed on uncertain delivery. Resolve a pending record in Resend before retrying. */ +export async function sendAnnouncement({ announcement, message, recipients, directory, mailer, delayMs = 1000 }: { + announcement: Announcement; + message: Message; + recipients: Recipient[]; + directory: string; + mailer: Mailer; + delayMs?: number; +}) { + if (!announcement.ready || /\{\{[^}]+\}\}/.test(announcement.title + announcement.message)) { + throw new Error("Announcement is a draft. Finalize the dates and copy, then set ready to true."); + } + await mkdir(directory, { recursive: true, mode: 0o700 }); + const lockPath = path.join(directory, "send.lock"); + const lock = await open(lockPath, "wx", 0o600); + try { + // Freeze both message and audience on first send so retries cannot change either. + const payloadPath = path.join(directory, "payload.json"); + const payload = JSON.stringify({ message, recipients }); + const previous = await readOptional(payloadPath); + if (previous !== undefined && previous !== payload) { + throw new Error("Message or recipients changed after sending began. Restore the original files before resuming."); + } + if (previous === undefined) await writeFile(payloadPath, payload, { flag: "wx", mode: 0o600 }); + + let accepted = 0; + let skipped = 0; + for (const recipient of recipients) { + const recipientKey = hash(recipient.email); + const sentPath = path.join(directory, `${recipientKey}.sent.json`); + if (await readOptional(sentPath) !== undefined) { + skipped++; + continue; + } + const pendingPath = path.join(directory, `${recipientKey}.pending.json`); + if (await readOptional(pendingPath) !== undefined) { + throw new Error(`Unresolved delivery for ${recipient.email}. Check its pending record and Resend before retrying.`); + } + const idempotencyKey = `announcement/${announcement.id}/${recipientKey}`; + const record = { email: recipient.email, userIds: recipient.userIds, idempotencyKey, attemptedAt: new Date().toISOString() }; + await writeFile(pendingPath, JSON.stringify(record, null, 2), { flag: "wx", mode: 0o600 }); + await setTimeout(delayMs); + const { data, error } = await mailer.send({ ...message, to: recipient.email }, { idempotencyKey }); + if (error || !data) throw new Error(`Resend did not confirm ${recipient.email}: ${error?.message ?? "missing email ID"}. Check the pending record before retrying.`); + await writeFile(pendingPath, JSON.stringify({ ...record, emailId: data.id, acceptedAt: new Date().toISOString() }, null, 2), { mode: 0o600 }); + await rename(pendingPath, sentPath); + accepted++; + console.log(`Accepted ${accepted}: ${recipient.email} (${data.id})`); + } + return { accepted, skipped }; + } finally { + await lock.close(); + await unlink(lockPath); + } +} + +export async function main(args = process.argv.slice(2)) { + const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { + prepare: { type: "boolean" }, test: { type: "string" }, send: { type: "boolean" }, help: { type: "boolean" }, + } }); + if (values.help) { + console.log("Usage: npm run email:announcement -- [--prepare | --test you@example.com | --send]\nDefault: render an offline preview. --prepare saves creator recipients. --test sends only to the specified address. --send uses the reviewed snapshot."); + return; + } + if (positionals.length !== 1 || [values.prepare, values.test !== undefined, values.send].filter(Boolean).length > 1) { + throw new Error("Provide one announcement JSON file and at most one mode. See --help."); + } + const announcement = await loadAnnouncement(positionals[0]); + const directory = path.resolve(".local/announcements", announcement.id); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const html = await renderEmail("announcement", announcement); + const text = `${announcement.title}\n\n${announcement.message}\n\nYou can reply to this email with questions.\n\nThe Hackdex Team`; + await writeFile(path.join(directory, "preview.html"), html, { mode: 0o600 }); + await writeFile(path.join(directory, "preview.txt"), text, { mode: 0o600 }); + console.log(`Preview: ${path.join(directory, "preview.html")}${announcement.ready ? "" : " (DRAFT)"}`); + const recipientPath = path.join(directory, "recipients.json"); + + if (values.prepare) { + const source = requiredEnv("NEXT_PUBLIC_SUPABASE_URL"); + const supabase = createClient(source, requiredEnv("SUPABASE_SECRET_KEY"), { + auth: { persistSession: false, autoRefreshToken: false }, + }); + const recipients = await collectRecipients(supabase); + if (!recipients.length) throw new Error("No eligible creators found. No snapshot written."); + await writeFile(recipientPath, JSON.stringify({ source, preparedAt: new Date().toISOString(), recipients }, null, 2), { flag: "wx", mode: 0o600 }); + console.log(`Prepared ${recipients.length} recipients from ${source}. Review ${recipientPath} before sending.`); + return; + } + if (!values.send && values.test === undefined) { + const snapshot = await readOptional(recipientPath); + console.log(snapshot ? `${(await loadRecipients(recipientPath)).length} recipients prepared. No email sent.` : "No recipients prepared. Use --prepare to query Supabase. No email sent."); + return; + } + const from = requiredEnv("RESEND_FROM"); + const replyTo = requiredEnv("RESEND_REPLY_TO"); + if (!isEmail(replyTo)) throw new Error("RESEND_REPLY_TO must be one monitored email address."); + const message = { from, replyTo, subject: announcement.title, html, text }; + const resend = new Resend(requiredEnv("RESEND_API_KEY")); + if (values.test !== undefined) { + if (!isEmail(values.test)) throw new Error("--test needs one email address."); + const { data, error } = await resend.emails.send({ ...message, subject: `[TEST] ${message.subject}`, to: values.test }, { + idempotencyKey: `announcement-test/${randomUUID()}`, + }); + if (error || !data) throw new Error(`Test send failed: ${error?.message ?? "missing email ID"}`); + console.log(`Test accepted for ${values.test}: ${data.id}. Creator recipients were not used.`); + return; + } + const recipients = await loadRecipients(recipientPath); + console.log(`Sending to ${recipients.length} recipients in the saved snapshot.`); + const result = await sendAnnouncement({ announcement, message, recipients, directory, mailer: resend.emails }); + console.log(`Finished: ${result.accepted} accepted by Resend, ${result.skipped} previously accepted. Check delivery and bounces in Resend.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/send-announcement.test.mts b/scripts/send-announcement.test.mts new file mode 100644 index 0000000..3a05a41 --- /dev/null +++ b/scripts/send-announcement.test.mts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test, { type TestContext } from "node:test"; +import { createClient } from "@supabase/supabase-js"; +import { Resend } from "resend"; +import { renderEmail } from "../src/emails/render.ts"; +import type { Database } from "../src/types/db.ts"; +import { collectRecipients, loadAnnouncement, main, sendAnnouncement } from "./send-announcement.mts"; + +const announcement = { id: "test-notice", title: "Creator notice", message: "Please review your credits.", ready: true }; +const message = { from: "Hackdex ", replyTo: "test@example.com", subject: announcement.title, html: "

Please review your credits.

", text: announcement.message }; +const recipients = [{ email: "creator@example.com", userIds: ["00000000-0000-4000-8000-000000000001"], hacks: ["hack-one"] }]; + +async function temporaryDirectory(t: TestContext) { + const directory = await mkdtemp(path.join(os.tmpdir(), "hackdex-announcement-")); + t.after(() => rm(directory, { recursive: true, force: true })); + return directory; +} + +test("announcement escapes content, preserves paragraphs, and links URLs", async () => { + const html = await renderEmail("announcement", { + title: ' & news', + message: 'Hello \n\nRead https://www.hackdex.app/terms?a=1&b=2.\njavascript:alert(1)', + }); + assert.ok(html.includes("<img")); + assert.ok(html.includes("<script>")); + assert.ok(html.includes("

")); + assert.ok(html.includes('href="https://www.hackdex.app/terms?a=1&b=2"')); + assert.ok(html.includes("You can reply to this email")); + assert.ok(!html.includes("Please do not reply")); + assert.ok(!html.includes("