mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-10-19 06:09:41 -05:00
Clarify creator requirements and add announcement emails (#96)
* Initial draft * docs: finalize Terms rollout dates and preserve previous version * feat: add reusable creator announcements and Resend sending script
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,3 +42,6 @@ next-env.d.ts
|
||||
|
||||
# local minio storage
|
||||
.minio
|
||||
|
||||
# Private announcement recipient lists, previews, and delivery records
|
||||
/.local/announcements/
|
||||
|
||||
@@ -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):
|
||||
|
||||
80
docs/announcements/README.md
Normal file
80
docs/announcements/README.md
Normal file
@@ -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/<id>/`, 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 <notices@your-verified-domain>
|
||||
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.
|
||||
6
docs/announcements/terms-1.1.0.json
Normal file
6
docs/announcements/terms-1.1.0.json
Normal file
@@ -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."
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
129
docs/legal/archive/TERMS-2025-11-30.md
Normal file
129
docs/legal/archive/TERMS-2025-11-30.md
Normal file
@@ -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)
|
||||
@@ -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": {
|
||||
|
||||
225
scripts/send-announcement.mts
Normal file
225
scripts/send-announcement.mts
Normal file
@@ -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<Resend["emails"], "send">;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string | undefined> {
|
||||
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<Announcement> {
|
||||
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<Database>): Promise<Recipient[]> {
|
||||
const creators = new Map<string, string[]>();
|
||||
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<string, Recipient>();
|
||||
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<Recipient[]> {
|
||||
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 -- <announcement.json> [--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<Database>(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;
|
||||
});
|
||||
}
|
||||
207
scripts/send-announcement.test.mts
Normal file
207
scripts/send-announcement.test.mts
Normal file
@@ -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 <notice@example.com>", replyTo: "test@example.com", subject: announcement.title, html: "<p>Please review your credits.</p>", 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: '<img src=x onerror="alert(1)"> & news',
|
||||
message: 'Hello <script>alert(1)</script>\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("<br /><br />"));
|
||||
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("<script>"));
|
||||
assert.ok(!html.includes('href="javascript:'));
|
||||
});
|
||||
|
||||
test("announcement files validate the campaign ID and required copy", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const file = path.join(directory, "notice.json");
|
||||
await writeFile(file, JSON.stringify(announcement));
|
||||
assert.deepEqual(await loadAnnouncement(file), announcement);
|
||||
for (const invalid of [{ ...announcement, id: "../escape" }, { ...announcement, message: " " }, { ...announcement, title: "Subject\nBcc: someone" }]) {
|
||||
await writeFile(file, JSON.stringify(invalid));
|
||||
await assert.rejects(loadAnnouncement(file), /Announcement needs/);
|
||||
}
|
||||
});
|
||||
|
||||
test("recipient selection paginates, excludes empty drafts, and deduplicates creator emails", async () => {
|
||||
const offsets: number[] = [];
|
||||
const authLookups: string[] = [];
|
||||
const supabase = createClient<Database>("https://test.supabase.co", "test-key", {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: { fetch: async (input, init) => {
|
||||
const url = new URL(new Request(input, init).url);
|
||||
if (url.pathname === "/rest/v1/hacks") {
|
||||
assert.equal(url.searchParams.get("is_archive"), "eq.false");
|
||||
const columns = new Set(["slug", "created_by", "current_patch", "patch_url", "approved"]);
|
||||
const unknown = url.searchParams.get("select")?.split(",").find((column) => !columns.has(column));
|
||||
if (unknown) return Response.json({ message: `column hacks.${unknown} does not exist`, code: "42703" }, { status: 400 });
|
||||
const offset = Number(url.searchParams.get("offset") ?? 0);
|
||||
offsets.push(offset);
|
||||
const row = { created_by: "00000000-0000-4000-8000-000000000001", current_patch: 1, patch_url: "", approved: false };
|
||||
return Response.json(offset === 0
|
||||
? Array.from({ length: 1000 }, (_, i) => ({ ...row, slug: `hack-${i}` }))
|
||||
: [
|
||||
{ ...row, slug: "pending", created_by: "00000000-0000-4000-8000-000000000002", current_patch: 2 },
|
||||
{ ...row, slug: "legacy", created_by: "00000000-0000-4000-8000-000000000003", current_patch: null, patch_url: "patch.bps" },
|
||||
{ ...row, slug: "approved", created_by: "00000000-0000-4000-8000-000000000003", current_patch: null, approved: true },
|
||||
{ ...row, slug: "empty-draft", created_by: "draft-owner", current_patch: null },
|
||||
]);
|
||||
}
|
||||
const id = url.pathname.split("/").at(-1)!;
|
||||
authLookups.push(id);
|
||||
return Response.json({ id, email: id === "00000000-0000-4000-8000-000000000003" ? "second@example.com" : "Creator@Example.com", aud: "authenticated", app_metadata: {}, user_metadata: {}, created_at: "2026-01-01T00:00:00Z" });
|
||||
} },
|
||||
});
|
||||
const result = await collectRecipients(supabase);
|
||||
assert.deepEqual(offsets, [0, 1000]);
|
||||
assert.deepEqual(authLookups, ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002", "00000000-0000-4000-8000-000000000003"]);
|
||||
assert.equal(result.length, 2);
|
||||
assert.equal(result[0].email, "creator@example.com");
|
||||
assert.deepEqual(result[0].userIds, ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002"]);
|
||||
assert.equal(result[0].hacks.length, 1001);
|
||||
assert.deepEqual(result[1].hacks, ["legacy", "approved"]);
|
||||
});
|
||||
|
||||
test("recipient preparation reports database failures instead of returning an empty audience", async () => {
|
||||
const supabase = createClient<Database>("https://test.supabase.co", "test-key", {
|
||||
auth: { persistSession: false },
|
||||
global: { fetch: async () => Response.json({ message: "Permission denied" }, { status: 403 }) },
|
||||
});
|
||||
await assert.rejects(collectRecipients(supabase), /Cannot load hacks: Permission denied/);
|
||||
});
|
||||
|
||||
test("recipient preparation stops when an uploaded hack's creator has no email", async () => {
|
||||
const supabase = createClient<Database>("https://test.supabase.co", "test-key", {
|
||||
auth: { persistSession: false },
|
||||
global: { fetch: async (input, init) => {
|
||||
const url = new URL(new Request(input, init).url);
|
||||
return Response.json(url.pathname === "/rest/v1/hacks"
|
||||
? [{ slug: "uploaded", created_by: "00000000-0000-4000-8000-000000000001", current_patch: 1, patch_url: "", approved: false }]
|
||||
: { id: "00000000-0000-4000-8000-000000000001", aud: "authenticated", app_metadata: {}, user_metadata: {}, created_at: "2026-01-01T00:00:00Z" });
|
||||
} },
|
||||
});
|
||||
await assert.rejects(collectRecipients(supabase), /has no valid account email/);
|
||||
});
|
||||
|
||||
test("conflicting modes are rejected before any network request", async (t) => {
|
||||
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected network request"); });
|
||||
await assert.rejects(main(["notice.json", "--test", "preview@example.com", "--send"]), /at most one mode/);
|
||||
});
|
||||
|
||||
test("default mode renders offline without querying recipients or sending email", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(directory);
|
||||
t.after(() => process.chdir(previousCwd));
|
||||
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected network request"); });
|
||||
await writeFile("notice.json", JSON.stringify({ ...announcement, ready: false }));
|
||||
await main(["notice.json"]);
|
||||
assert.ok((await readFile(".local/announcements/test-notice/preview.html", "utf8")).includes("Creator notice"));
|
||||
assert.deepEqual((await readdir(".local/announcements/test-notice")).sort(), ["preview.html", "preview.txt"]);
|
||||
});
|
||||
|
||||
test("test mode sends only to its explicit address and never uses the creator snapshot", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const previousCwd = process.cwd();
|
||||
process.chdir(directory);
|
||||
t.after(() => process.chdir(previousCwd));
|
||||
const env = { RESEND_FROM: message.from, RESEND_REPLY_TO: message.replyTo, RESEND_API_KEY: "re_test_key" };
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const old = process.env[key];
|
||||
process.env[key] = value;
|
||||
t.after(() => { if (old === undefined) delete process.env[key]; else process.env[key] = old; });
|
||||
}
|
||||
let sent = 0;
|
||||
t.mock.method(globalThis, "fetch", async (input: string, init: RequestInit) => {
|
||||
assert.equal(input, "https://api.resend.com/emails");
|
||||
const body = JSON.parse(String(init.body));
|
||||
assert.equal(body.to, "preview@example.com");
|
||||
assert.equal(body.reply_to, "test@example.com");
|
||||
assert.equal(body.subject, "[TEST] Creator notice");
|
||||
assert.equal(body.cc, undefined);
|
||||
assert.equal(body.bcc, undefined);
|
||||
sent++;
|
||||
return Response.json({ id: "test-email" });
|
||||
});
|
||||
await writeFile("notice.json", JSON.stringify({ ...announcement, ready: false }));
|
||||
await mkdir(".local/announcements/test-notice", { recursive: true });
|
||||
await writeFile(".local/announcements/test-notice/recipients.json", "intentionally invalid snapshot");
|
||||
await main(["notice.json", "--test", "preview@example.com"]);
|
||||
assert.equal(sent, 1);
|
||||
assert.equal(await readFile(".local/announcements/test-notice/recipients.json", "utf8"), "intentionally invalid snapshot");
|
||||
assert.ok(!(await readdir(".local/announcements/test-notice")).includes("payload.json"));
|
||||
});
|
||||
|
||||
test("production sending rejects drafts and unresolved placeholders", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const mailer = new Resend("re_test_key").emails;
|
||||
t.mock.method(globalThis, "fetch", async () => { throw new Error("Unexpected email"); });
|
||||
for (const draft of [{ ...announcement, ready: false }, { ...announcement, message: "Effective {{effectiveDate}}" }]) {
|
||||
await assert.rejects(sendAnnouncement({ announcement: draft, message, recipients, directory, mailer }), /Announcement is a draft/);
|
||||
}
|
||||
assert.deepEqual(await readdir(directory), []);
|
||||
});
|
||||
|
||||
test("accepted sends persist IDs and are skipped on subsequent runs", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
const requests: string[] = [];
|
||||
t.mock.method(globalThis, "fetch", async (_input: string, init: RequestInit) => {
|
||||
requests.push(new Headers(init.headers).get("Idempotency-Key")!);
|
||||
assert.equal(JSON.parse(String(init.body)).to, "creator@example.com");
|
||||
return Response.json({ id: "accepted-email" });
|
||||
});
|
||||
const options = { announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails, delayMs: 0 };
|
||||
assert.deepEqual(await sendAnnouncement(options), { accepted: 1, skipped: 0 });
|
||||
assert.deepEqual(await sendAnnouncement(options), { accepted: 0, skipped: 1 });
|
||||
assert.equal(requests.length, 1);
|
||||
assert.match(requests[0], /^announcement\/test-notice\/[a-f0-9]{64}$/);
|
||||
const recordName = (await readdir(directory)).find((file) => file.endsWith(".sent.json"))!;
|
||||
const record = JSON.parse(await readFile(path.join(directory, recordName), "utf8"));
|
||||
assert.equal(record.emailId, "accepted-email");
|
||||
assert.equal(record.email, "creator@example.com");
|
||||
await assert.rejects(sendAnnouncement({ ...options, message: { ...message, subject: "Changed" } }), /Message or recipients changed/);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test("uncertain delivery stops the run and blocks blind resends", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
let requests = 0;
|
||||
t.mock.method(globalThis, "fetch", async () => { requests++; throw new Error("Connection lost"); });
|
||||
const options = { announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails, delayMs: 0 };
|
||||
await assert.rejects(sendAnnouncement(options), /Resend did not confirm/);
|
||||
await assert.rejects(sendAnnouncement(options), /Unresolved delivery/);
|
||||
assert.equal(requests, 1);
|
||||
const files = await readdir(directory);
|
||||
assert.equal(files.filter((file) => file.endsWith(".pending.json")).length, 1);
|
||||
assert.equal(files.filter((file) => file.endsWith(".sent.json")).length, 0);
|
||||
assert.ok(!files.includes("send.lock"));
|
||||
});
|
||||
|
||||
test("concurrent sending cannot bypass the campaign lock", async (t) => {
|
||||
const directory = await temporaryDirectory(t);
|
||||
await writeFile(path.join(directory, "send.lock"), "another process owns this");
|
||||
await assert.rejects(sendAnnouncement({ announcement, message, recipients, directory, mailer: new Resend("re_test_key").emails }), /EEXIST/);
|
||||
assert.equal(await readFile(path.join(directory, "send.lock"), "utf8"), "another process owns this");
|
||||
});
|
||||
@@ -6,7 +6,9 @@
|
||||
## Players
|
||||
|
||||
### What is Hackdex?
|
||||
Hackdex is a community hub for discovering and playing Pokémon romhacks. The platform centralizes romhack discovery and provides an in-browser patching system to make playing hacks straightforward and accessible.
|
||||
Hackdex is a hosting platform for creators to share their Pokémon romhacks and for players to discover and play them. It hosts patches and lets you apply them to your own base ROM in your browser.
|
||||
|
||||
Hackdex does not handpick hacks based on staff taste or recommendations. Submissions are subject to approval and content rules, but hosting a hack does not mean we endorse its views or guarantee its quality.
|
||||
|
||||
### Do I need my own ROM file?
|
||||
Yes. Hackdex distributes patches, not complete, pre-patched ROM files. You must provide your own legally obtained base ROM of the original Pokémon game. You'll link your base ROM once, then easily apply patches directly in your browser.
|
||||
@@ -26,7 +28,23 @@ Some users have reported issues with their Internet Service Provider (ISP) block
|
||||
If the problem is not related to your ISP, try clearing your browser's cache and reloading the page or using a different browser or device. If the error persists, please contact us.
|
||||
|
||||
### How do I find ROM hacks that interest me?
|
||||
The Discover page features curated sections including "Trending", "Most Popular," "Newest," and "Recently Updated" to help you find active and trending hacks quickly. You can also search or filter by tags, base ROM, and more.
|
||||
Use the [Discover page](/discover) to search for hacks or filter by tags, base ROM, and more. Listings are ordered automatically using the sorting option you choose:
|
||||
|
||||
- "Trending" uses downloads from the past three days along with total downloads.
|
||||
- "Most popular" uses total downloads.
|
||||
- "Newest" uses the date a hack was approved.
|
||||
- "Recently updated" uses the publication date of the hack's current patch.
|
||||
- "Alphabetical" sorts by title.
|
||||
|
||||
Download activity is the only input to popularity and trending rankings. Placement does not represent a staff recommendation.
|
||||
|
||||
### Does Hackdex allow AI-generated content?
|
||||
Yes, with disclosure. Creators must identify AI-generated content in a labeled section of their hack's description so you can decide whether you want to play it. This includes graphics, music, dialogue, and event scripts. AI assistance with general programming does not require disclosure.
|
||||
|
||||
We believe a blanket ban can encourage creators to hide AI use. Requiring disclosure gives creators room to be honest and helps players make an informed choice. Disclosed content must still follow our credits and content rules. See [what creators need to disclose](#ai-disclosure) for details.
|
||||
|
||||
### How do I report a hack or page that breaks the rules?
|
||||
There is a Report option on each hack page. Or you can use the [contact form](/contact). Include the hack's page URL and enough detail for us to locate the issue. For content inside a hack, include the version and where it appears in the game. See our [content guidelines](#content-guidelines) for a summary of what is prohibited.
|
||||
|
||||
### What types of ROM hacks are available?
|
||||
Hackdex is specifically focused on Pokémon ROM hack patches across different generations of Pokémon games. Creators must submit and upload their own ROM hacks. We do not steal the work of other creators without their explicit permission.
|
||||
@@ -89,7 +107,7 @@ Both formats support checksum verification so the patch is linked to the correct
|
||||
### How does my hack gain visibility?
|
||||
We highly recommend linking to your romhack's Hackdex page from PokéCommunity, Reddit, or other social media platforms. Doing so can help boost your hack's visibility and outrank those sketchy ROM sharing sites that steal many creators' hard work.
|
||||
|
||||
Once submitted on Hackdex, your hack can appear in the Discover page's curated sections, including "Newest" hacks for recent submissions and "Recently Updated" when you post updates. Popular hacks gain visibility in the "Most Popular" section based on number of downloads.
|
||||
Once approved, your hack can appear on Discover. "Newest" sorts by approval date, and "Recently updated" sorts by the publication date of your current patch. "Most popular" and "Trending" use download activity. These listings are ordered automatically and do not reflect staff preferences.
|
||||
|
||||
Here are some helpful tips for improving your hack's page to help increase visibility:
|
||||
- Include at least 3 screenshots that help make your hack stand out, with the first one being the most eye-catching.
|
||||
@@ -98,10 +116,36 @@ Here are some helpful tips for improving your hack's page to help increase visib
|
||||
- Use [Markdown formatting](https://github.com/adam-p/markdown-here/wiki/markdown-cheatsheet) for your description to make it more readable and visually appealing.
|
||||
|
||||
### Can I update my romhack after submitting?
|
||||
Yes. Hackdex supports updates to submitted hacks, which will be reflected in the "Recently Updated" section of the Discover page, giving your hack renewed visibility.
|
||||
Yes. When you publish a new current patch, its publication date determines your hack's position when players sort Discover by "Recently updated". Editing your description alone does not move your hack up that list. Keep your credits and any AI disclosure accurate when you update your hack or page.
|
||||
|
||||
### Do I need to include credits? {#credits}
|
||||
Yes. Every hack must have a clearly labeled Credits section in its Hackdex description. List the hack's contributors and the creators or sources of reused assets and code, or link to a complete, publicly accessible credits page. Credits available only inside the game are not enough.
|
||||
|
||||
Keep your credits up to date and follow any attribution requirements for material you use. Giving credit does not replace permission to use someone else's work. This requirement is part of [Section 6 of the Terms of Service](/terms#6-creator-responsibilities).
|
||||
|
||||
### What AI use do I need to disclose? {#ai-disclosure}
|
||||
Add a clearly labeled "AI disclosure" section to your hack's description if the hack or its Hackdex page includes AI-generated content. State which types of content were generated, such as graphics, music, sound effects, story, dialogue, translations, or event scripts. This includes content generated in part by AI, content you edited after generation, and generated content supplied by contributors.
|
||||
|
||||
Event scripting counts even though it is written as code. For example, an AI-generated event script that makes an NPC walk toward the player, displays an exclamation mark, and gives an item requires disclosure, even if it contains no generated dialogue.
|
||||
|
||||
AI assistance with general programming, such as engine changes, bug fixes, development tools, or implementing game systems, does not require disclosure. Brainstorming and unused experiments do not require disclosure when no generated content is included in the hack or its page.
|
||||
|
||||
A useful disclosure names the affected content. For example:
|
||||
|
||||
> AI was used to generate the title screen artwork and event scripts for some NPC interactions.
|
||||
|
||||
Keep this section accurate as you update your hack or page. A disclosure does not exempt the content from our credits or content rules. See [Section 6 of the Terms of Service](/terms#6-creator-responsibilities) for the full requirement.
|
||||
|
||||
### Who retains ownership of submitted hacks?
|
||||
Creators retain ownership of their work. Hackdex serves as a distribution and discovery platform. Specific rights and responsibilities are outlined in the Terms of Service available on the platform.
|
||||
|
||||
### Are there content guidelines for submissions?
|
||||
As a platform focused on Pokémon ROM hack patches, your submission should be a patch file for a Pokémon game. Detailed guidelines and restrictions are available in the Hackdex Terms of Service and on the Submit page. Your hack will be subject to approval before it can be viewed publicly.
|
||||
### Are there content guidelines for submissions? {#content-guidelines}
|
||||
Yes. Submit Pokémon ROM hack patches that you have the right to share, include credits, and disclose AI-generated content as described above. Hacks are subject to approval before they become public, and must continue to follow the rules after approval.
|
||||
|
||||
We prohibit targeted harassment, threats, derogatory attacks against real people or groups, hate speech, and discriminatory abuse. This includes dehumanizing people or promoting hatred, discrimination, or violence based on characteristics such as race, ethnicity, nationality, religion, disability, sex, sexual orientation, or gender identity. These rules cover content inside the hack as well as its title, description, screenshots, cover images, and other page material.
|
||||
|
||||
We consider narrative context. A story depicting conflict or prejudice does not automatically promote it, but calling something fiction, humor, or satire does not excuse targeted harassment, discriminatory abuse, or the promotion of hatred or violence.
|
||||
|
||||
Hacks or hack pages found to contain prohibited content will be removed. Responsible accounts may also be suspended or banned. For missing or incomplete credits or AI disclosures, we may request corrections, refuse publication, or remove the listing until it complies. Deliberate misrepresentation or repeated violations may also lead to suspension or a ban.
|
||||
|
||||
The full restrictions, including rules against ROM sharing, infringement, malware, and sharing personal data without permission, are in [Section 8 of the Terms of Service](/terms#8-prohibited-content-and-conduct). To report a suspected violation, use the [contact form](/contact) or the hack page's Report option.
|
||||
|
||||
27
src/app/terms/2025-11-30/page.tsx
Normal file
27
src/app/terms/2025-11-30/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Markdown from "@/components/Markdown/Markdown";
|
||||
import termsMd from "@/../docs/legal/archive/TERMS-2025-11-30.md";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service, November 30, 2025",
|
||||
alternates: {
|
||||
canonical: "/terms/2025-11-30",
|
||||
},
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default function ArchivedTermsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-lg px-6 py-6 sm:py-12">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<p>
|
||||
This is a preserved copy of version 1.0.2, effective from November 30,
|
||||
2025 through October 2, 2026. <Link href="/terms">Version 1.1.0</Link>{" "}
|
||||
takes effect on October 3, 2026.
|
||||
</p>
|
||||
<Markdown>{termsMd}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import mjml from "mjml";
|
||||
import { MJMLParseError } from "mjml-core";
|
||||
import type { MJMLParseError } from "mjml-core";
|
||||
|
||||
const EMAILS_DIR = path.join(process.cwd(), "src/emails");
|
||||
const TEMPLATES_DIR = path.join(EMAILS_DIR, "templates");
|
||||
@@ -21,6 +21,7 @@ export type HackReviewReplyEmailVars = {
|
||||
};
|
||||
|
||||
export type EmailTemplateVars = {
|
||||
announcement: { title: string; message: string };
|
||||
"hack-approved": HackApprovedEmailVars;
|
||||
"hack-review-reply": HackReviewReplyEmailVars;
|
||||
};
|
||||
@@ -46,9 +47,22 @@ function substituteVars(template: string, vars: Record<string, string>): string
|
||||
});
|
||||
}
|
||||
|
||||
// Announcement bodies are plain text. Preserve line breaks and link HTTP URLs.
|
||||
function formatAnnouncementMessage(message: string): string {
|
||||
return message.split(/(https?:\/\/[^\s<>"']+)/g).map((part, index) => {
|
||||
if (index % 2 === 0) return escapeHtml(part);
|
||||
const url = part.replace(/[.,;!?)]+$/, "");
|
||||
return `<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>${escapeHtml(part.slice(url.length))}`;
|
||||
}).join("").replace(/\r?\n/g, "<br />");
|
||||
}
|
||||
|
||||
const templateNormalizers: {
|
||||
[K in EmailTemplate]: (vars: EmailTemplateVars[K]) => Record<string, string>;
|
||||
} = {
|
||||
announcement: ({ title, message }) => ({
|
||||
title: escapeHtml(title),
|
||||
message: formatAnnouncementMessage(message),
|
||||
}),
|
||||
"hack-approved": ({ title, slug }) => ({
|
||||
title: escapeHtml(title),
|
||||
slug: encodeURIComponent(slug),
|
||||
|
||||
23
src/emails/templates/announcement.mjml
Normal file
23
src/emails/templates/announcement.mjml
Normal file
@@ -0,0 +1,23 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../partials/head.mjml" />
|
||||
<mj-preview>{{title}}</mj-preview>
|
||||
</mj-head>
|
||||
<mj-body background-color="#f3f4f6">
|
||||
<mj-include path="../partials/header.mjml" />
|
||||
<mj-section background-color="#ffffff">
|
||||
<mj-column>
|
||||
<mj-text font-size="24px" font-weight="700" color="#171717" padding-bottom="12px">
|
||||
{{title}}
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" line-height="24px" color="#374151" padding-bottom="16px">
|
||||
{{message}}
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" line-height="24px" color="#374151">
|
||||
You can reply to this email with questions.<br /><br />
|
||||
The Hackdex Team
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -6,6 +6,7 @@
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
@@ -22,6 +23,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.mts", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user