English | 简体中文
Built by 浅水虾 (qianshuixia).
An open-source template for "one photo → an AI video that follows a viral template" SaaS sites. Every trending keyword gets its own site: create a repo from this template, change the config, copy and media, and ship. Everything runs on Cloudflare: Workers + D1 + R2 + KV + Queues + Rate Limiting + Cron Triggers.
shrimpreel-promo-en.mp4
A 72-second tour, no sound (Chinese version; MP4 files in docs/media). Screens are from a local run; the demo site "Neon Shuffle AI" and its clips are placeholders.
Status: the full flow works locally (unit tests + Playwright e2e). It has not been deployed as a production site yet; ROADMAP.md lists what has and hasn't been verified against real services. Code comments, ROADMAP and
docs/are currently in Chinese.
- Frontend/SSR: TanStack Start (React 19) + Tailwind 4. English at
/, Chinese at/zh. - API: Hono (
/api/*,/media/*,/sitemap.xml,/robots.txt, IndexNow key file); entry pointsrc/server.ts. - Payments: Waffo Pancake (default) or Dodo Payments, both Merchants of Record.
mockis for local dev and e2e only. - Video: fal.ai queue API (default) or the optional yir.ai router;
mockfor local dev and e2e. See "Video providers" below. - Included:
- Users and credits: Google sign-in (including One Tap), credit packs, monthly and yearly subscriptions and a credit ledger, dashboard (videos, billing, settings, redeem codes, invite link, account deletion, data export); billing shows amounts paid and refunded, orders and the ledger load page by page, and every order has a printable detail page (tax invoices come from the payment provider).
- Orders and generation: partial and full refunds that claw back credits, a generation queue with a dead-letter queue, credits returned automatically on failure.
- Growth: first- and last-touch attribution (UTM and ad click IDs), a visit → sign-up → checkout → paid → generate funnel, GA4 events behind a consent banner, public share pages for videos (opt-in per video, with link previews).
- Admin: overview (including provider cost and gross margin), users, orders, generations, takedowns, contact messages, payment events, emails, audit log, reports and order CSV, redeem codes, referral leaderboard, runtime settings and connection tests.
- Users: filter by status, role and paying, sort by sign-up, last login or credits; the detail page shows total paid, generation stats, orders, credit grants, the credit ledger and admin actions on that user (granted credits can have an expiry).
- Orders: search by order id, email or provider transaction id; the detail page merges payment webhooks, credit changes, admin actions and emails into one timeline, with refund, check-with-provider and mark-paid actions.
- Generations: search by job id, email or provider request id.
- Transactional email: credits added, video ready, generation failed and refunded, takedown receipts (signed-in users only) and outcomes; admins get new takedowns, deadline reminders and contact messages (Cloudflare Email Service, outbox with dedupe and retries).
- Compliance and safety: AI-generated label on videos; photos deleted after 7 days and videos after 30; takedown requests on a 48-hour queue (every video made from the same source photo is removed and the photo is blocked); upload moderation; Turnstile on the contact and report forms.
- Ops and security: maintenance mode, a site-wide announcement and a daily generation cap switched from the admin without a deploy; health-check alert emails, instant payment notifications (Telegram, Feishu, Discord and more), daily D1 backups to R2, browser error reporting to Workers Logs, CSP (Report-Only, per-request nonce) and Permissions-Policy.
- Content and SEO: one-page home (How it works / What is, plus optional problem and "doing it yourself vs. this site" blocks), an extensible article collection (
/guideshub, related articles), legal pages, contact form; SSR canonical, hreflang, JSON-LD and sitemap; copy is code-split per locale;npm run seo:checklints the whole site.
Feature notes and plans: docs/SAAS_VIDEO_FEATURES.md and ROADMAP.md. SEO rules, keyword planning and audit results: docs/SEO_AUDIT.md. All environment variables and the launch-related site.ts fields: docs/env.md. Adding a payment provider and the webhook response-code contract: docs/payments.md. The subscription switch, plans and provider setup: docs/subscriptions.md. Conventions to follow before changing code (credits, payment fulfillment, bilingual copy, migrations, e2e): AGENTS.md. The docs/ files and AGENTS.md are in Chinese.
npm install
cp .dev.vars.example .dev.vars # set at least SESSION_SECRET
npm run seed:local # apply D1 migrations and upload seed/templates/*.mp4 to local R2
npm run dev -- --host 127.0.0.1 # http://127.0.0.1:3000- Use npm 11.19 or newer;
devEnginesinpackage.jsonwarns on older versions. Runningnpm installwith an older npm rewritespackage-lock.jsoninto a form thatnpm cion CI rejects (Missing: @emnapi/core@… from lock file). PAYMENT_PROVIDER=mockandVIDEO_PROVIDER=mockby default: buying opens a mock checkout and generating copies the template video. Nothing is charged.- Outside production the sign-in page has a "dev login" (
POST /api/auth/dev-login); in production it returns 404. Dev login, mock payments and/api/dev/*only accept direct requests from localhost: anything that came through a tunnel or proxy (withcf-connecting-ip,X-Forwarded-Foror similar headers) gets 404. Someone on your LAN can fake those conditions, so don't start the dev server with--host 0.0.0.0or expose it to the internet directly; for webhooks usescripts/webhook-proxy.mjs(see below). MODERATION_PROVIDER=mockby default: an upload form withmockOutcome=rejectsimulates a rejected image. LeaveTURNSTILE_SITE_KEYempty to disable Turnstile.POST /api/dev/sweep {"days": 8}(localhost only) runs retention cleanup as if N days had passed, for the signed-in user's media only.- Addresses in
ADMIN_EMAILScan open/admin; locally that isadmin@example.com. They are made admins when they sign in, and removing an address later doesn't take the role away: also click "取消管理员" (remove admin) on that user's page under/admin/users. - Email is only simulated locally; check the outbox instead:
GET /api/dev/emails?to=<email>&ref=<order or takedown id>(localhost only).POST /api/dev/takedown-reminders {"takedownId": "tdr_…", "hours": 37}runs the takedown reminder check for one request as if N hours had passed. - Windows: start with
--host 127.0.0.1, otherwise Playwright and other tools may resolvelocalhostto IPv6 and fail to connect. Stop the dev server beforenpm install, or locked.wrangler/ miniflare files causeEBUSY. drainUnreadBodyinsrc/server.tsonly runs in dev: with the current miniflare 5 beta, a Worker that responds without reading the request body breaks the next request on the same connection (500fetch failed). After upgrading@cloudflare/vite-plugin, try removing it and runnpm run test:e2eto check the CSRF tests.
Each keyword site is its own repository that tracks this template as upstream, so payment, security and framework fixes are made once in the template and merged into every site.
With Claude Code, once the site repo exists (step 1) you can just say "new hot site: Moon Hop, the moon-jump dance on TikTok". The bundled new-hot-site skill (.claude/skills/new-hot-site/) covers steps 2–4 and the local part of step 7: it writes a site brief, runs the scaffolder, sets pricing, rewrites the copy in both languages, generates placeholder media, then runs typecheck, tests, build, seo:check and an offline doctor. It only touches per-site files and never commits or deploys. Missing information (final domain, licensed media, etc.) is left as TODO(site): … comments; npm run doctor lists them and blocks a production deploy (APP_ENV=production) while any remain.
-
Create the site repo
git clone https://github.com/qianshuixia/shrimpreel.git moonhop && cd moonhop git remote rename origin upstream git remote add origin git@github.com:<owner>/moonhop.git
Don't use GitHub's "Use this template": the new repo shares no history with the template, so
git merge upstream/mainwon't work later. Forks of a public repo can't be private, so site repos usually aren't forks either. -
Run the scaffolder
node scripts/new-site.mjs --keyword "Moon Hop" --domain moonhop.ai --keywords "AI dance video,Moon Hop filter" # dry run node scripts/new-site.mjs --keyword "Moon Hop" --domain moonhop.ai --keywords "AI dance video,Moon Hop filter" --write # write
It updates the following, then prints a checklist of what still has to be done by hand:
site.ts: keyword, brand, domain, email,template.id, article slugs, secondary keywords, publish/update dates; it also clears the previous site's verification tokens, IndexNow key and analytics IDs.wrangler.jsonc: resource names and the ratelimitnamespace_id; D1/KV ids are reset to placeholders.package.json:name.- Deletes
wrangler.staging.jsonc(the template's own staging config).
Adjust credit packs (
packages), the subscription switch and plans (subscriptionsEnabled,plans; set the switch tofalseto sell packs only),durationsandvideo.falModelinsrc/config/site.tsas needed. A pack can setanchorPriceCentsto show a struck-through regular price and a discount. Use only a price that pack was actually sold at (EU rules use the lowest price in the 30 days before the reduction); new sites usually leave it unset. -
Rewrite the copy in
src/content/en.tsandsrc/content/zh.ts. Copy uses variables such as{keyword}. For every keyword, rewrite at least hero, whatIs, ideas, FAQ and both articles, and cover the secondary keywords naturally in the home page body. Rewrite the home pageproblemandwithWithoutblocks for the new trend, or set them tonullto hide them. Swapping only the keyword across the whole site can be treated by Google as scaled content abuse.- Articles: add an entry to
articlesinsite.ts(key,slug, publish and update dates, optionalrelated), then add the same key underarticlesin every locale's content file; typecheck fails if one is missing. The article lives at/<slug>and is added to/guides, the sitemap and other articles' related links automatically.trendandhowToare linked from the home page; keep them. Aim for at least one pillar page of 1,200+ words plus 4–6 long-tail pages (tutorials, ideas, troubleshooting, comparisons) per site. - Copy is code-split per locale, so the browser only downloads the current language (legal pages included). Components call
getContent(locale)as usual; only code that needs copy outside the root route and before rendering has toawait loadContent(locale)first. - Adding a locale: register it in
LOCALESandLOCALE_METAinsite.ts, createsrc/content/<locale>.ts(same type asen.ts), and add a dynamic import toloadersinsrc/lib/i18n.ts. Email templates are typed per locale as well, so typecheck lists what else needs translating.
- Articles: add an entry to
-
Replace the media
seed/templates/<template.id>-<seconds>s.mp4: source videos for generation; upload them totemplates/in R2 after deploying.public/demo/*: home page demo videos and posters. They must be your own or licensed, with the source credited insite.examples.*.credit.public/og.png,public/favicon.svg.node scripts/generate-placeholder-media.mjs(requires ffmpeg) regenerates original placeholder media, named aftersite.durationsand the brand. Delete the oldtemplate.idfiles fromseed/templates/afterwards.
-
Set
varsinwrangler.jsonc:SITE_URL,APP_ENV=production,PAYMENT_PROVIDER,VIDEO_PROVIDER,MODERATION_PROVIDER,TURNSTILE_SITE_KEY,ADMIN_EMAILS,EMAIL_FROM,*_PRODUCTS,GOOGLE_CLIENT_ID; write the D1/KV ids back after creating the resources (see below). -
Create products with your payment provider: one one-time product per credit pack and one subscription product per plan, with the product ids in
WAFFO_PRODUCTSorDODO_PRODUCTS, e.g.{"starter":"prod_xxx","party":"prod_yyy","squad":"prod_zzz","monthly":"prod_aaa","yearly":"prod_bbb"}. Waffo subscriptions also needWAFFO_STORE_IDfor the customer portal; with the subscription switch off, the setup scripts skip plan products. For Waffo,node scripts/waffo-setup.mjs --store STO_xxx --applycreates them in bulk; for Dodo,node scripts/dodo-setup.mjs --apply --write-dev-vars(add--webhook <url>to also create the webhook and write its signing secret to.dev.vars). Prices on the site are display-only; the provider's dashboard is what actually gets charged, so keep them in sync. -
Verify locally:
npm run typecheck && npm test && npm run test:e2e && npm run seo:check, then deploy (npm run deployruns the pre-deploy check first, see below). -
After launch
- Run
npm run seo:check -- --base https://<domain> --production. - Verify the site in Search Console and Bing Webmaster Tools and submit
https://<domain>/sitemap.xml. - If
site.seo.indexNowKeyis set, runnpm run seo:indexnow -- --submit. - With Claude Code, the
launch-auditskill runs these checks and the remaining launch checklist in one go (see "Deploying to Cloudflare").
- Run
With Claude Code, just say "sync the template". The sync-upstream skill (.claude/skills/sync-upstream/) prints a sync plan, merges, resolves conflicts by the layers below, fills in local config and runs the full verification. It leaves the merge staged for you to review before committing. If the site doesn't have the skill yet, have it read the template's copy: git show upstream/main:.claude/skills/sync-upstream/SKILL.md (on Windows Git Bash, prefix with MSYS_NO_PATHCONV=1).
By hand:
git fetch upstream
git show upstream/main:scripts/sync-plan.mjs > .tmp/sync-plan.mjs && node .tmp/sync-plan.mjs # sync plan, read-only
git merge --no-ff upstream/main
npm install && npm run typecheck && npm test && npm run test:e2e
npm run db:migrate:remote # before deploying, if the template added files to migrations/-
The plan uses the template's latest
scripts/sync-plan.mjsand lists the sync base, new template commits, changes grouped by layer (shared code / site layer / removed in sites / lockfile), files changed on both sides, shared files the site has changed, new migrations, and new vars and secrets.--jsonprints machine-readable output. -
The base is
git merge-base HEAD upstream/main, so sync only withgit merge. Squashing, rebasing or cherry-picking template commits leaves the base where it was, and the next plan lists already-merged commits again. -
End the merge commit message with
Template-sync: <template commit sha>(the plan prints it);git log --grep "Template-sync:"lists past syncs. -
Keep site-specific changes in
src/config/site.ts,src/content/*,public/*,seed/,wrangler.jsoncand thenameinpackage.json. Keep everything else identical to the template: fix shared code in the template and merge it into each site rather than patching one site. -
When those files conflict, keep the site's values and take the template's new keys; typecheck reports missing copy keys.
-
If the template changed
wrangler.staging.jsonc, the site gets a modify/delete conflict; resolve it withgit rm wrangler.staging.jsonc.
Create the resources once per site (names must match wrangler.jsonc):
npx wrangler d1 create <name> # put the database_id in wrangler.jsonc
npx wrangler r2 bucket create <name>-media
npx wrangler kv namespace create <name>-kv # put the id in wrangler.jsonc; titles are unique per account, don't call them all KV
npx wrangler queues create <name>-jobs
npx wrangler queues create <name>-jobs-dlq
npm run db:migrate:remote
npx wrangler r2 object put <name>-media/templates/<file>.mp4 --file seed/templates/<file>.mp4 --content-type video/mp4 --remoteratelimits[].namespace_idmust be unique within an account; don't share the same numbers across sites.- Secrets:
SESSION_SECRET,GOOGLE_CLIENT_SECRET,WAFFO_MERCHANT_ID,WAFFO_PRIVATE_KEY,DODO_API_KEY,DODO_WEBHOOK_SECRET,FAL_KEY; optionallyYIR_API_KEY(VIDEO_PROVIDER=yir),OPENAI_API_KEY(image moderation) andTURNSTILE_SECRET_KEY. Set them one by one withnpx wrangler secret put <NAME>, or sync them in one go withscripts/sync-secrets.mjs:cp .dev.vars.example .dev.vars.production # fill in production values; .dev.vars.* is gitignored node scripts/sync-secrets.mjs # plan only: new, existing, remote-only, still missing; never prints values node scripts/sync-secrets.mjs --apply # write the secrets the Worker does not have yet
- Values go to
wrangler secret bulkover stdin, never through a temp file, and wrangler's output is redacted before it is printed. - Existing secrets are not overwritten by default (rotating
SESSION_SECRETsigns everyone out and invalidates signed image URLs); add--overwriteto replace them. Secrets that exist only on the Worker are listed, never deleted. - It refuses to write when the file is not gitignored, a key is not in
.dev.vars.exampleor is a var, a value is empty or still the placeholder, orSESSION_SECRETis weak. If the remote list cannot be read (not logged in, or the Worker does not exist yet on the first deploy) it needs--overwrite; wrangler creates the Worker if it is missing. - Staging:
node scripts/sync-secrets.mjs -c wrangler.staging.jsonc --file .dev.vars.staging. SESSION_SECRETsigns the photo URLs given to the video provider and the video provider callback token. Generate it withopenssl rand -hex 32. In production and staging, a missing value, one shorter than 32 characters or the value from.dev.vars.exampleis refused: jobs can't be submitted (they are failed and refunded after the queue retries, and Workers Logs names the cause) and/media/uploads/*returns 503.
- Values go to
- Also add the R2 lifecycle rules described under "Compliance and content safety".
- The sending domain must be onboarded in Email Service; see "Transactional email".
- Pre-deploy check:
npm run doctor.npm run deployruns it first and stops on errors.- It checks
APP_ENV, thatSITE_URLmatchessite.domain, leftover mocks, the secrets required by the selected payment, video and moderation providers, that*_PRODUCTScovers every credit pack, placeholder bindings and resource ids, the Cron trigger and dead-letter queue, unfilledTODO(site)markers in the site files, and unapplied migrations on the remote D1. - Secrets are checked by name only, via
wrangler secret list; values are never read. wrangler must be logged in. On the first deploy the Worker does not exist yet, so set secrets first (wrangler secret putorsync-secrets --apply --overwrite; both create the Worker).--offlineskips every remote query, and-c wrangler.staging.jsoncchecks the staging config. - The "Launch config check" card on the admin overview runs the same rules (in
src/server/config-health.ts) against the runtime env, including the strength ofSESSION_SECRET. It only lists problems and never shows values.
- It checks
- Deploy with
npm run deploy. Attach a custom domain to the Worker in the Cloudflare dashboard;SITE_URLandsite.domainmust match it (canonical URLs, the sitemap and the CSRF same-origin check depend on it). - With
APP_ENV=productionorstaging, checkout, generation and upload return 503 if they are still configured withmock, and dev login, the mock payment endpoint and the/checkout/mockpage return 404.robots.txtonly allows crawling in production. - Post-deploy smoke:
SMOKE_BASE_URL=https://<domain> npm run test:smoke; addSMOKE_EXPECT=stagingfor staging. - Deploying from GitHub Actions: in the site repo, copy .github/workflows/deploy.yml.example to
deploy.yml. A manual run does typecheck, unit tests, offline doctor,db:migrate:remote,npm run deploy, then the post-deploy smoke (report uploaded as an artifact).- Under Settings → Environments create
production(Required reviewers recommended) withCLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_ID. Start the token from the "Edit Cloudflare Workers" template and add Account-level D1 Edit and Queues Edit, which the template lacks. - Runtime secrets never pass through CI; sync them locally with sync-secrets. First-deploy steps (creating resources, uploading source videos, binding the domain) are also done locally.
- If the smoke fails, the new version is already live: read the artifact report and
npx wrangler rollbackif needed. Migrations are not rolled back, so keep them compatible with the previous code.
- Under Settings → Environments create
- Launch audit: with Claude Code, say "launch audit". The
launch-auditskill (.claude/skills/launch-audit/) runs the pre-deploy checks (typecheck, test, build, doctor,TODO(site)) and the live checks (seo:check, test:smoke, HTTPS and R2 source videos, mobile Lighthouse and screenshots), then goes through the manual items (webmaster tools, a real payment and refund, sending domain, R2 lifecycle, Google OAuth and so on). It writes.tmp/launch-audit/<date>/report.mdwith one of three verdicts: ready, ready with follow-ups, or not ready. It is read-only: no deploys, migrations or secret writes.- It only sends GETs and POSTs that must be rejected: no sign-in, no orders, no writes, so it is safe to run against production at any time. The browser user agent carries a bot marker so visits stay out of the traffic report, and if dev login turns out to be on it fails right away without calling the dev endpoints.
- It checks that every page in the sitemap returns 200 without script errors; the English and Chinese home pages (including dark mode) hydrate; dev login is off and the payment provider is not mock; dev and mock endpoints and the
/checkout/mockpage return 404; cross-site POSTs are blocked; unsigned payment and video provider webhooks return 401; security headers are present (HSTS on https); robots.txt matches the environment. - The report, plus traces for failures, goes to
.tmp/smoke-prod/.
- Once the custom domain works, set
"workers_dev": falseand"preview_urls": falsein the site'swrangler.jsonc. Otherwise the production Worker also answers on*.workers.dev, outside any WAF or rate-limiting rules on your domain, and each version gets a public Version URL that runs that version's code with the same D1 and R2. The template ships withworkers_devon so a new site is reachable before its domain is attached.
Without a domain, wrangler.staging.jsonc deploys the template itself to https://video-template-staging.<subdomain>.workers.dev to check real bindings, Google sign-in, payment providers in test mode and video provider callbacks.
APP_ENV=staging: like production, mocks and dev login are disabled and cookies are Secure, butrobots.txtblocks crawling. Canonical URLs still point atsite.domain.- All resource names end in
-staging, separate from the production site.tests/unit/staging.test.tschecks that its bindings and var keys matchwrangler.jsonc. new-site deletes this file. - No transactional email (
EMAIL_FROMis empty) and no image moderation by default (MODERATION_PROVIDER=none).
npx wrangler d1 create video-template-staging # put the database_id in wrangler.staging.jsonc
npx wrangler r2 bucket create video-template-staging-media
npx wrangler kv namespace create video-template-staging-kv # put the id in wrangler.staging.jsonc
npx wrangler queues create video-template-staging-jobs
npx wrangler queues create video-template-staging-jobs-dlq
npx wrangler r2 bucket lifecycle add video-template-staging-media expire-uploads uploads/ --expire-days 8
npx wrangler r2 bucket lifecycle add video-template-staging-media expire-outputs outputs/ --expire-days 31
npx wrangler d1 migrations apply DB --remote -c wrangler.staging.jsonc
npx wrangler r2 object put video-template-staging-media/templates/<file>.mp4 --file seed/templates/<file>.mp4 --content-type video/mp4 --remote
npm run doctor -- -c wrangler.staging.jsonc # pre-deploy check
npx vite build --mode staging && npx wrangler deploy # deploy reads the staging config from the build output
node scripts/sync-secrets.mjs -c wrangler.staging.jsonc --file .dev.vars.staging --apply # or wrangler secret put <NAME> -c wrangler.staging.jsonc one by one
SMOKE_BASE_URL=https://video-template-staging.<subdomain>.workers.dev SMOKE_EXPECT=staging npm run test:smoke # post-deploy smoke- Except for
deploy, pass-c wrangler.staging.jsoncto every staging wrangler command: without it, commands such asdeployfollow the config of the most recent build.npm run deployrebuilds withwrangler.jsoncfirst. - For the callback URLs below, replace
<domain>with the workers.dev host. Use only test stores with Waffo and Dodo.
| Service | Where | URL |
|---|---|---|
| Google OAuth | Google Cloud Console → Authorized redirect URIs | https://<domain>/api/auth/google/callback |
| Waffo Pancake | Dashboard → Settings → Webhooks | https://<domain>/api/webhooks/waffo |
| Dodo Payments | Dashboard → Webhooks | https://<domain>/api/webhooks/dodo |
| fal.ai | Added by the code | https://<domain>/api/webhooks/fal |
| yir.ai | Added by the code (https URLs only; local dev relies on polling) | https://<domain>/api/webhooks/yir |
- Waffo:
WAFFO_ENVIRONMENTistestorprod. Webhooks are verified with Waffo's public RSA key (bundled insrc/server/payments/waffo.ts), so no secret is needed.WAFFO_PRIVATE_KEYaccepts PKCS1/PKCS8 and can be a single line with\n.- Refunds are asynchronous: the admin action only returns a ticket
TKT_; the PSP createsREF_and sendsrefund.succeededlater, which took 12–22 minutes in test mode. Credits are clawed back when the webhook arrives. - Each payment can be refunded once (partial allowed), within 14 days of payment. The admin blocks this up front with
waffo_refund_exists/refund_window_expired. - If a webhook is lost, use "record only" with the
REF_refund id (TKT_is rejected, otherwise the late webhook would claw back credits a second time). - Webhooks are delivered by Upstash QStash and retries only go to the URL set at creation, so use a stable domain.
- Refunds are asynchronous: the admin action only returns a ticket
- Testing real webhooks locally:
node scripts/webhook-proxy.mjs --port 3100forwards onlyPOST /api/webhooks/{waffo,dodo}; point the tunnel at it instead of exposing the dev server. Pinggy works (ssh -p 443 -R0:127.0.0.1:3100 qr@a.pinggy.io, 60 minutes on the free plan); localhost.run dropped often. To run the checkout and webhooks end to end with sandbox e2e, see docs/payments.md. - Dodo:
DODO_ENVIRONMENTistestorlive. Webhooks are verified per Standard Webhooks;DODO_WEBHOOK_SECRETis thewhsec_...value from the dashboard.- API keys are scoped per resource:
dodo-setup.mjsneeds Products write, plus Webhooks write with--webhook; at runtime the key needs Checkout sessions and Refunds write, plus read access to Checkout sessions and Payments for order reconciliation; selling subscriptions also needs Subscriptions write (cancel and undo), Customers write (customer portal) and Subscriptions read. - Merchants can refund within 30 days of payment, in several partial refunds, but only one at a time (docs). After 30 days the admin returns
refund_window_expired. - Refunds are paid from the merchant wallet. Each payment is credited only after taxes and fees, which took about 4–5 minutes in test mode. With an insufficient balance Dodo returns 409 "Insufficient funds in wallet", shown in the admin as
payment_provider_conflict. Full refunds on a new store or right after a payout often fail for this reason. - In test mode the refund API returns
succeededimmediately andrefund.succeededarrives about 2 seconds later. Credits are still clawed back on the webhook. - Webhooks are delivered by Svix. Payment events echo the metadata (
orderIdetc.); refund events don't, so the order is looked up bypayment_id. If the tunnel changes domain, update the URL with--webhook-id; the signing secret stays the same. - Both successful and failed payments return to
return_urlwithpayment_id,statusandemail(the buyer's email) appended. GA4'spage_locationhas the email stripped. - The merchant name on the checkout comes from your Dodo business registration; change it to the brand name before going live.
- API keys are scoped per resource:
- fal, yir: the webhook is only a "go check now" signal; results are always fetched again with
FAL_KEYorYIR_API_KEY. A Cron every 5 minutes also polls stuck jobs and fails them (with a credit refund) after 45 minutes.
- Credits: every change writes a ledger entry with a unique
idempotency_key(order:<id>,order_refund:<id>,generation:<id>, …) in the same D1 batch as the grant and balance updates, so a duplicate credit rolls back as a whole.- Grants: every credit is a grant (
credit_grants) that may expire. Purchased credits never expire; the sign-up bonus expires after the validity in runtime settings (defaultsite.signupBonusValidDays, 0 = never); admins can set a validity of 1–3650 days when granting credits. - Spending takes the grant that expires soonest first and never-expiring grants last, recording how much came from each grant in
credit_allocations. Allocation runs in SQL: insufficient credits roll back the whole batch, and concurrent spends can't overdraw. A failed render is refunded to the same grants; a refund clawback takes the order's own grant first. - Expiry: the 5-minute Cron voids what's left on expired grants and writes an
expireledger entry; spending also voids the user's expired grants first.users.creditscaches the sum of remaining credits, so it can include credits that expired in the last 5 minutes; spending is never affected. - Billing shows credits that are about to expire, and the admin user page lists each grant's remaining credits and expiry.
- Grants: every credit is a grant (
- Redeem codes:
/admin/redeem-codesgenerates up to 1000 random codes at a time (12 characters without look-alikes such as I/O/0/1, shown asXXXX-XXXX-XXXX) or one named campaign code (4–32 letters and digits, e.g.LAUNCH2026), and exports a batch as CSV. Each code has a credit amount, a number of uses (each account can use a code once), an optional credit validity and an optional redemption deadline; codes can be disabled, which doesn't touch credits already redeemed.- Users redeem on the settings page (
POST /api/redeem, case, spaces and dashes ignored); it is rate-limited per user and per IP and needs Turnstile when it's configured. A redemption is a grant with sourceredeemand the code as its ref, so it expires and is spent like any other grant. - Redemption runs in one D1 batch: the use is only inserted while the code is enabled, before its deadline and below its use count, a guard row asserts the insert happened, then the count and the grant are written. Concurrent redemptions can't exceed the use count, and an account redeeming twice gets 409
already_redeemed.
- Users redeem on the settings page (
- Referral rewards: every user has an invite link
/i/<code>on the settings page (8 characters without look-alikes, created on first view). When a friend signs up through it and their first order is paid, the inviter gets the reward set in runtime settings (defaultsite.referralRewardCredits= 10; 0 insite.tsturns the feature off and hides the card). Rewards are credits only, never cash.- The link sets a
vt_refcookie (30 days,noindex) that is consumed at sign-up; only brand-new accounts are bound, never existing users or the inviter themselves. - Anti-abuse: at most
site.referralMaxPerIpsign-ups per inviter from the same IP (a salted hash; IPv6 by /64); later ones are recorded as rejected. At mostsite.referralMonthlyCaprewards per inviter in any 30 days, and no reward if the inviter is banned or deleted when the order is paid. - The reward is written in the same D1 batch as the friend's purchase, with a guard row so concurrent first orders reward once. Any refund of the rewarded order (partial ones too) claws the whole reward back from the inviter, at most down to 0, once.
- The admin
/admin/referralspage lists inviters by rewards and the latest invites with their status and rejection reason. Data export includes each side's own records without the other side's id, email or the IP hash; the privacy policy mentions the cookie and the hash while the feature is on.
- The link sets a
- Runtime settings:
/admin/settingschanges non-secret parameters without a deploy: maintenance mode, a site-wide announcement per locale, the sign-up bonus and its validity, the referral reward and a site-wide daily generation cap (src/server/settings.ts). Only values that differ from the defaults insite.tsare stored, in the D1settingstable; saving a default deletes the row, so latersite.tschanges still apply. Reads go through KV with a 60-second cache, so a change can take about a minute to reach every location. Secrets stay in wrangler secrets. Every change is audited with its before and after values.- Maintenance mode returns 503
maintenancefor new generations and checkouts and shows a banner on every page. Sign-in, viewing, downloads and payment webhooks keep working, and admins are exempt so they can verify. - The daily cap counts jobs created per UTC day (failed and refunded ones included) and returns 429
daily_limitwithout charging; concurrent submits are bounded by a guard row in the same D1 batch. 0 = no cap. Use it together with the spend limit in the provider's console. - A referral reward of 0 pauses invites (card hidden, links no longer bind). A first order paid while paused is recorded as rejected (
paused), so resuming doesn't reward a later order. Whensite.referralRewardCreditsis 0 the field is locked, because the privacy policy is generated fromsite.ts. - "Test connections" probes R2, KV, email (a test email to you), the payment provider (Dodo: each pack's product and price compared with
site.ts, where a lower price fails because such payments would be held as mismatches; Waffo: signature and auth by looking up a non-existent order), the video provider (auth and balance, warning below $5) and moderation (auth). Probes are read-only or clean up after themselves, never create a checkout or submit a job, and redact secret values from their messages.
- Maintenance mode returns 503
- Subscriptions: on by default, switched by
site.subscriptionsEnabled. The template ships with monthly $14.90 and yearly $149, both granting 60 credits a month; set the switch tofalseto sell credit packs only (the subscription copy in the pricing page, terms, refund policy and FAQ disappears with it, while existing subscriptions keep renewing and granting credits). The switch, changing plans, provider setup and troubleshooting are covered in the manual, docs/subscriptions.md.- Every charge is its own order. Renewal orders get an id derived from the provider's payment id, so a redelivered charge creates one order; the renewal amount is checked against the price locked at sign-up and a mismatch is held for review.
- Subscription credits are a grant that expires at the end of the month it covers (ledger reason
subscription) and are spent before longer-lived credits. Yearly plans grant the first month at payment and the 5-minute Cron grants the rest month by month, idempotently. - Status events (past due, canceling, canceled…) are ordered by the provider's event time, so a late older event can't overwrite a newer one. One live subscription per user (409
subscription_exists), and an account that will still renew can't be deleted (409subscription_active). - Billing has a subscription card: next renewal, cancel at period end and undo, a past-due notice and the provider's customer portal. Waffo can only undo a cancellation in its portal, which needs
WAFFO_STORE_ID. - A refund claws back only the subscription credits that order granted and stops further yearly grants; it doesn't cancel the subscription. Plan changes, trials and pausing aren't supported. Rules and the Dodo/Waffo differences: "订阅" in docs/payments.md.
- Tested in test mode with webhooks: on Waffo, the monthly first payment, activation, the month's credits, the customer portal and cancellation all pass; on Dodo, the first payment, activation, credits and the portal URL pass, while cancel and undo still need Subscriptions write on the API key before they can be verified. Renewals and failed charges can't be triggered in test mode and are covered by unit tests and the mock e2e. Waffo's payment event carries no period and its periods are date-only, so the period is corrected when the status event arrives and the current grant's expiry follows it.
- Payment events:
payment_events (provider, event_id)is unique. Replays, concurrent retries and a late "paid" after a refund all take effect once. Refunds claw backMIN(balance, credits in the pack)and the ledger records what was actually deducted.- Before crediting, a paid event is checked against the order: the product (Dodo product id, Waffo
productMetadata.packageId) must match, and in the same currency the amount actually charged (tax included) must not be below the list price. Different currencies (Dodo Adaptive Currency) and discount codes skip the amount check. A mismatch is not credited and is recorded asmismatch; a paid event with no matching order is recorded asorphan(events whosemetadata.sitenames another site sharing the merchant account are ignored). Both return 200 and raise the "payments to review" alert: mark the order paid in the admin after checking, or refund it in the provider dashboard (the refund webhook then closes the order as refunded without touching credits). - A refund that arrives before its payment (the paid webhook is still being retried) is recorded as failed and returns 5xx so the provider retries it after the payment is credited.
- If a webhook is lost or late, the site asks the provider directly (
src/server/reconcile.ts; Dodo: checkout session → payment, Waffo: GraphQLpaymentsby merchant order id). The success page asks once at about 15 s and once at about 60 s; the 5-minute Cron checks pending orders between 10 minutes and 24 hours old with exponential backoff (about 10, 20, 40… minutes, at most 10 orders per run); the admin order page has a "查单" (check with provider) action to use before mark-paid. A payment found this way goes through the same checks and idempotency keys as the webhook, so a late webhook isn't credited again. The browser can only send the order id, never a payment status. Orders that already have amismatchevent are left for manual review.
- Before crediting, a paid event is checked against the order: the product (Dodo product id, Waffo
- Generation: credits are deducted before enqueueing and returned automatically on failure, timeout or when an admin fails the job. The video provider webhook and the poller may handle the same job concurrently; only one of them can mark it succeeded, and the other never deletes the stored video.
- Cost and margin: when a video is produced, the provider cost is stored in
generations.provider_cost_cents: the actual charge when yir's bill is already settled, otherwise an estimate from the prices insite.video.- fal bills per frame: 16 frames count as one video second, $0.04 per video second at 480p (pricing). The template source videos are 24fps, so a video costs 6¢ per second, about $1.08 for 18 seconds. Update
costCentsPerVideoSecondandsourceFpswhen changing the model, resolution or source frame rate. - Jobs failed after a video was produced (timeout, failed by an admin) still record the cost; failures without a video don't. mock records 0.
- The admin overview shows the last 30 days of provider cost, revenue from credits consumed and estimated gross margin; the generations list shows cost and margin per job. Revenue per credit = net revenue of paid USD orders ÷ credits not clawed back. The provider's invoice is the source of truth.
- fal bills per frame: 16 frames count as one video second, $0.04 per video second at 480p (pricing). The template source videos are 24fps, so a video costs 6¢ per second, about $1.08 for 18 seconds. Update
- Video providers:
VIDEO_PROVIDERpicksfal(default),yirormock. yir.ai is an image and video API router. To use it, setVIDEO_PROVIDER=yirand theYIR_API_KEYsecret, and adjust the model and prompt insite.video.yir(src/server/video/yir.ts).- yir has no character-replacement model like the one the template uses (
wan/v2.2-14b/animate/replace). The integration uses Kling 2.6 Motion Control (reference mode: the photo plus the template's source video). The template ships the integration code only and it hasn't been tested with a real key (submit, status and error mapping are covered by unit tests), so verify output quality and price yourself;costCentsPerSecond: 7is an estimate, so update it from the yir model page before launch. - Jobs are submitted with the generation id as
Idempotency-Key, so a resubmit after a queue redelivery or an interrupted Worker gets the same yir job back and isn't billed twice. yir matches requests by body, so the signed photo URL is derived from the job's creation time (valid for 2 hours) and stays the same on resubmit. - There is no per-job
max_cost: yir rejectsmax_coston requests that reference media by URL. Motion Control is billed by the reference video's length, and the template's source videos have fixed lengths, so the price is already bounded. To cap total spend, set a monthly limit on the API key in the yir Console; past it, submits fail withYIR_SPEND_LIMIT_EXCEEDED. - yir can route a job to third-party channels as well as official ones, and users' photos are sent to them.
site.video.yir.routingOnlydefaults tonull: the API key's Routing Profile picks the channel and a failed attempt can fall back to another. With channel IDs (such as['official']) only those channels are used, and the job fails if they all fail. Either way, list the channels that actually handle photos in your privacy policy. - A missing or revoked key, an empty yir balance or an exhausted key limit fails the job with a refund and fires the "Video provider account" alert (see "Monitoring and alerts") right away, without waiting for the failure rate to build up.
- Upstream attempts that were billed are charged even if the job fails, and the wallet balance can go negative. yir keeps results for only 7 days; the template copies each video to R2 as soon as it's ready.
- Disclosure: the yir links in this README are the author's referral link; per yir's terms the author earns a share of yir's margin on a referred account's spending for its first 6 months. To sign up without it, use yir.ai. Links to fal carry no referral.
- yir has no character-replacement model like the one the template uses (
- Security: non-GET
/api/*requests (except webhooks, CSP reports and browser error reports) must have anOriginmatching the site; uploads are type-checked by magic bytes; uploads and generation are rate-limited per user; banning a user invalidates their sessions immediately; admin actions are written toadmin_audit.- The
redirectafter sign-in (sign-in page and Google callback) only accepts same-site paths:src/lib/redirect.tsrejects backslashes, control characters and anything that normalizes to a leading//, so links like/sign-in?redirect=/\evil.comland on the dashboard. - For 5xx errors only admins get the
message; everyone else gets just the error code, and the details go to Workers Logs as[api] <method> <path> <code>: <message>. - Google sign-in: the authorization code flow uses PKCE (S256) and a nonce. Before One Tap initializes it fetches a nonce from
/api/auth/google/nonce, which is also stored in a cookie, and the two must match when the credential is submitted. - Accounts are keyed on Google's
sub: if the same email comes back with a different Google account (a differentsub), sign-in is refused and the sign-in page asks the user to contact support (account_conflict). Accounts created by dev login (nosub) are bound on their first Google sign-in. - Deleting an account keeps only a salted hash of the email (
deleted_accountstable, salted withSESSION_SECRET), so signing up again with the same email gets no sign-up bonus and no referral binding. RotatingSESSION_SECRETinvalidates the old hashes. - Uploads must send
Content-Length(otherwise 411); oversized uploads get 413 before the body is read. Provider outputs over 100 MB fail the generation and refund credits; with a length they stream straight into R2, without one the download is counted as it is read and stops at 48 MB. - When the payment result page has parameters other than
order(Dodo's return URL adds the buyer's email), the Worker 302s to a clean URL before rendering, so the email never reaches the page, analytics, error reports or browser history. The first request's URL still appears in Workers Logs. - The takedown form doesn't require sign-in, so a receipt is only sent to signed-in reporters who enter their own account email; otherwise the form could be used to email arbitrary addresses. The report ID is shown after submitting, and the outcome is still emailed to the address given.
- To report a vulnerability, see SECURITY.md.
- The
- Sharing videos: on the dashboard, users can share one of their own videos, which creates a public link
/v/<random id>(16 characters, not enumerable;/zh/v/…works too). On by default; setsite.share.enabledtofalseto hide the button and the page. Face-swap or real-person sites should consider turning it off.- The share page only has the video, the AI badge and disclosure, a "Make your own" button and a report link. It shows nothing about the creator, and neither the HTML nor the API exposes the video id. Both the response header and the meta tag say
noindex, and share pages aren't in the sitemap. - Link previews: when sharing, the browser grabs a frame from the video, draws a 1200×630 JPEG with an AI label in the top-left corner and uploads it as the
og:image; the video URL goes intoog:video. The poster goes through the same image moderation as uploads (422share_rejectedif it fails). If capturing fails (the browser can't decode the video, or it times out), the video is shared without a poster and the site's default OG image is used. - Media is served from
/media/shares/<id>/videoand/media/shares/<id>/poster.jpgwith Range support andCache-Control: public, max-age=300, so a visitor who already opened it may keep playing from cache for up to 5 minutes after sharing stops. - Expiry: after sharing stops the old link returns 404, and sharing again creates a new link; once the video expires or is removed after a report it returns 410, the poster is deleted and the video can't be shared again; deleting the video also deletes the poster.
- Migration
0015_shares.sqladdsshare_id,share_poster_keyandshared_attogenerations. Data export includes the share link and when sharing started; while the feature is on, the privacy policy has a "Shared videos" section.
- The share page only has the video, the AI badge and disclosure, a "Make your own" button and a report link. It shows nothing about the creator, and neither the HTML nor the API exposes the video id. Both the response header and the meta tag say
- CSP: pages are served with
Content-Security-Policy-Report-Only; the policy lives insrc/server/csp.ts.- A nonce is generated per request (
src/server.ts→ssr.nonceinsrc/router.tsx) and added to every inline script the framework emits;script-srcdoes not allow'unsafe-inline'. Put custom inline scripts inheadScripts()in__root.tsxinstead of writing<script>directly. - GA4, Cloudflare Web Analytics, Turnstile and Google sign-in are allowed per their official docs; add sources to
csp.tswhen adding third-party scripts, iframes or APIs. - Violation reports go to
/api/csp-reportand are only written to Workers Logs as[csp] <directive> blocked=… page=…(URLs without query, rate-limited per IP). Once production has run for a while without false positives, change the header name inserver.tstoContent-Security-Policyto enforce it. - Every response has a
Permissions-Policythat disables camera, microphone, geolocation, payment and USB.
- A nonce is generated per request (
- AI label: the generator and dashboard players show an "AI-generated" badge, with a note to keep the label when sharing. Downloads are named
<brand>-ai-video-<video id>.mp4, so a reporter can identify a video by its file name. Videos have no burned-in watermark and no C2PA manifest yet (requires Containers; see FEATURES P0-1). Share pages show the same badge and an AI disclosure, and link preview images carry an AI label in the top-left corner. - Retention: controlled by
site.retention; photos are kept 7 days and videos 30 by default, and the privacy policy reads both numbers.- The 5-minute Cron deletes expired R2 objects and sets
expired_atin D1, up to 90 per type per run. Photos used by in-progress jobs are kept. - Expired items show as "Expired" in the dashboard and the video API returns 410
media_expired. Orders and the credit ledger are unaffected. - R2 lifecycle rules act as a backstop, one day longer than the code so they never delete before D1 is marked. Don't add a rule for
templates/.npx wrangler r2 bucket lifecycle add <name>-media expire-uploads uploads/ --expire-days 8 npx wrangler r2 bucket lifecycle add <name>-media expire-outputs outputs/ --expire-days 31
- The 5-minute Cron deletes expired R2 objects and sets
- Takedowns: "Report content" in the footer opens
/report; no account needed.- Fields: signature, email, content locator, reason, non-consent statement. Submissions go to
takedown_requestswith a deadline 48 hours later (site.takedownSlaHours). Agen_video id or a share link (/v/<id>, with or without a language prefix) in the locator is linked automatically; the report link on a share page pre-fills the share URL. - Admins handle them at
/admin/takedowns; the overview shows pending and overdue counts. - Removal finds every upload and video made from the same photo by its SHA-256, then:
- fails in-progress jobs and refunds credits;
- deletes the R2 objects (including share posters) and sets
removed_at, so share links return 410; - adds the hash to
blocked_media, so uploading the same image again returns 422content_rejected.
- Rejection requires a reason.
- Both outcomes are written to
admin_audit.ADMIN_EMAILSgets an email for new reports, 12 hours before the deadline and when overdue (see "Transactional email"), but someone still needs to check the queue daily.
- Fields: signature, email, content locator, reason, non-consent statement. Submissions go to
- Upload moderation: chosen with
MODERATION_PROVIDER. Moderation runs before writing to R2; rejected images are never stored and no credits are spent.openai: OpenAIomni-moderation-latest(free), requiresOPENAI_API_KEY. Blocks the sexual, violence/graphic and self-harm categories. Uploads are refused with 503 if the API fails.none: no moderation.mock: non-production only.- Minors and public figures are not OpenAI image categories; those still rely on the consent statement and takedowns.
- Turnstile: create a widget in the Cloudflare dashboard, put the site key in
vars.TURNSTILE_SITE_KEY, then runnpx wrangler secret put TURNSTILE_SECRET_KEY.- It only protects the anonymous contact and report forms. Sign-in and generation rely on Google sign-in plus rate limits; add it there when offering a free trial.
- With a site key but no secret, both forms return 503 instead of silently letting requests through.
- Analytics and cookies: configured in
site.analytics; the "Cookies and analytics" section of the privacy policy adapts to it.- Recommended: only set
cfBeaconToken(Cloudflare Web Analytics), which sets no cookies and needs no consent banner. - GA4 is only enabled when
ga4Idis set, together with a consent banner and a "Cookie settings" footer link. It uses Consent Mode v2 basic: gtag.js isn't loaded until the visitor accepts, events only go todataLayer, and ad consent is always denied. Declining or withdrawing deletes_ga,_ga_*and_gid(on the current host and each parent domain). The choice is stored asconsent.analyticsinlocalStorage; seesrc/lib/consent.ts. - Attribution uses a first-party cookie
vt_attr(90 days). Withga4Idset it waits for consent like GA4: accepting records the current visit's source, declining or withdrawing deletes it, and visitors without the cookie are attributed as direct on sign-up and orders (visits are still counted). Withoutga4Idthere is no banner andvt_attris written directly; under a strict reading of the EU ePrivacy Directive that also needs prior consent, so enable the banner if you mainly serve EU users. Seesrc/lib/attribution.ts.
- Recommended: only set
- Export my data (GDPR Art. 15 and 20): the dashboard settings page downloads JSON (
GET /api/account/export) with the account, sessions, orders, credit ledger and grants, uploads and generations, contact messages, the user's own takedown reports and email log; rate-limited per user. It excludes R2 paths, provider request ids, internal errors and costs, and reports others filed against the user's videos. Media files aren't bundled; those still within retention can be downloaded from the dashboard. Field list:src/server/account-export.ts. - Database backups: when the 5-minute Cron finds no backup for the current UTC day, it exports D1 as SQL (schema plus all data;
sessionsis schema only), gzips it tobackups/d1/<date>.sql.gzin MEDIA and keeps 30 days (src/server/backup.ts). It tries at most 3 times a day and emailsADMIN_EMAILSabackup_failedalert if the third attempt fails. There is no public route tobackups/, and R2 lifecycle rules shouldn't cover it.- Restore into a newly created, empty database (don't run migrations first; the backup includes the
d1_migrationsrecords). Without--applythe script only plans: it downloads the backup, counts rows per table and checks that the target is empty. Then import:Then pointnode scripts/restore-d1.mjs --date 2026-10-21 --remote --database <new-db> node scripts/restore-d1.mjs --date 2026-10-21 --remote --database <new-db> --apply
database_idinwrangler.jsoncat the new database and redeploy. Sessions aren't backed up, so everyone has to sign in again. - D1 Time Travel restores to any point in the last 30 days (7 on Workers Free), but overwrites the database in place:
npx wrangler d1 time-travel restore <db> --timestamp=<unix>. Use Time Travel for mistakes; the R2 backups are for longer retention, a deleted database or moving accounts. - Locally,
POST /api/dev/backupexports once immediately (localhost only); restore it into a separate local state with--file <path> --local --persist-to <dir>to check it.
- Restore into a newly created, empty database (don't run migrations first; the backup includes the
Sent directly from the Worker with Cloudflare Email Service (public beta): the send_email binding is EMAIL and the sender is vars.EMAIL_FROM.
- To users: credits added, video ready (with the retention date), generation failed and refunded, takedown receipt (only to signed-in reporters using their own account email), takedown outcome, video removed after a report. The language follows the UI language of the user's latest order or generation; takedown emails use the language of the report page. Deleted or banned users get nothing.
- To admins (
ADMIN_EMAILS, in Chinese): new takedown; one reminder 12 hours before the deadline and one when overdue; new contact message (Reply-To is the sender). Senders don't get an auto-reply, so the form can't be used to email arbitrary addresses. - Delivery: business events are first written to the D1
email_outboxtable with a uniquededupe_key(e.g.order_paid:<order id>), so webhook replays, queue redelivery and overlapping Crons send one email.- Emails are sent right after being written. On rate limits, daily quota or server errors they back off 5, 15, 45… minutes, up to 5 attempts, retried by the 5-minute Cron; permanent errors such as an unverified sender fail immediately.
- Delivery is at-least-once: if the Worker stops after sending but before saving the status, an email may be sent twice.
- The outbox is kept 30 days; a user's records are deleted with their account.
- Admin:
/admin/emailssearches by recipient, order id or takedown id and can resend failed or unsent emails; the overview shows the failure count. - Going live:
- Cloudflare dashboard → Compute > Email Service → Onboard Domain, add the site's domain (DNS must be on Cloudflare) and add the SPF/DKIM records it shows.
- Sending to arbitrary recipients requires Workers Paid. 3,000 emails a month are included, then $0.35 per thousand (pricing).
- Set
vars.EMAIL_FROMtonoreply@<domain>(new-site.mjsdoes this), then runnpm run db:migrate:remote.
- With
EMAIL_FROMempty or no binding, nothing is sent and emails are marked "not sent"; resend them from the admin once configured. Localwrangler devonly simulates sending; don't add"remote": trueto the binding.
A health check (src/server/alerts.ts) runs on the 5-minute Cron. When a threshold is crossed it emails ADMIN_EMAILS (in Chinese) and writes console.error('[alert] …') to Workers Logs:
| Alert | Condition | Admin page |
|---|---|---|
| Generation failure rate | In the last hour, ≥ 3 failed jobs and ≥ 30% of finished jobs (excluding manual admin failures and takedowns) | /admin/generations |
| Dead letters | A job was failed in the last hour because its queue message ran out of retries | /admin/generations |
| Video provider account | In the last hour a job failed because the key is missing or revoked, the yir balance is empty or the key's monthly limit is used up (alerts on the first one) | /admin/generations |
| Backlog | A job created more than 30 minutes ago is still unfinished | /admin/generations |
| Payment webhooks | A payment webhook failed to process in the last 24 hours | /admin/events |
| Payments to review | In the last 24 hours a paid webhook had no matching order, or its amount, currency or product didn't match the order (until the order is marked paid or refunded) | /admin/events |
| In the last 6 hours, ≥ 3 emails failed or are still unsent after 3 attempts (alert emails excluded) | /admin/emails |
- Dedupe: each alert type is sent at most once per 6-hour UTC bucket, with
dedupe_keyadmin_alert:<type>:<bucket>:<admin email>. If the problem persists, it alerts again in the next bucket. - Dead-letter queue: a message that still fails after 10 retries (about 35 minutes) goes to
<name>-jobs-dlq. The same Worker consumes it (src/server.tsroutes by the-dlqsuffix), fails the job (error_code = dead_letter) and refunds credits; the user gets a generation-failed email. New sites need both queues (see "Deploying to Cloudflare");new-site.mjsrenames both. - D1 backup failures aren't part of the health check above; a
backup_failedalert is emailed when the third export of the day fails. See "Database backups" under "Compliance and content safety". - Instant payment notifications: set the secret
PAYMENT_NOTIFY_WEBHOOK_URLand every order's first credit (including renewals and admin mark-paid) posts a message to a Telegram, Feishu / Lark, Discord or Slack chat with the amount, package, credits, source and order id. URL formats are in docs/env.md (in Chinese). Failures are only logged and never block crediting; the webhook URL (which contains a token) never appears in logs or errors. - If email itself is broken (unverified domain, quota exhausted), alert emails may not go out either; Workers Logs
[alert]lines are the source of truth. For a second channel, push logs to an external monitoring service with Workers Logpush and alert on[alert]. - Browser errors: uncaught exceptions, unhandled promise rejections and error-page render failures are sent by
src/lib/error-report.tswithsendBeaconto/api/client-error, which only writes to Workers Logs:[client-error] id=… kind=error|rejection|boundary page=… message=… stack=….- The error page shows an error ID; search Workers Logs for
id=<ID>to find the stack trace when a user reports it. - Emails are masked, URLs lose credentials, query and hash, and stacks keep the first 6 frames. Each distinct error is reported once per page, at most 5 per page, and the endpoint is rate-limited per IP. Cross-origin
Script error.is ignored. - Production code is bundled, so stack traces point at minified line/column numbers. To map them to source or aggregate alerts, swap the sending code for an SDK such as Sentry (and allow it in
connect-srcincsp.ts).
- The error page shows an error ID; search Workers Logs for
npm run typecheck
npm test # vitest: Waffo/Dodo signatures and event mapping, refund ratios and refund id rules, attribution parsing, mocks disabled in production, Turnstile and moderation error paths, email templates and retry decisions, alert thresholds and buckets, GA4 not loading before consent and cookie removal on withdrawal, CSP report and browser error summaries and redaction, sign-in redirect validation, PKCE and nonce for Google sign-in, binding accounts by sub and no second sign-up bonus for deleted emails, output download size limits, SQL escaping in D1 backups, payment notification formats and URL redaction, the attribution cookie waiting for consent, 5xx details hidden from non-admins, weak SESSION_SECRET refused in production, launch config check and doctor rules and wrangler output parsing, template sync plan layers, secret sync plan validation and redaction, credit accounting, redeem codes, referral rewards and subscriptions (calendar months, yearly grants, renewals, out-of-order events, aligning the current grant's expiry when the period is corrected, refunds, Dodo/Waffo event mapping) on SQLite, runtime settings validation, cache and daily cap, connection probes and their redaction, the e2e catalog matching the tests
npm run test:e2e # Playwright: run npm run seed:local first; reuses a dev server already running on 127.0.0.1:3000
E2E_PORT=3200 npm run test:e2e # use another port when 3000 is taken
npm run seo:check # needs the dev server running; writes its report to .tmp/seo-check/
SMOKE_BASE_URL=https://<domain> npm run test:smoke # read-only production smoke, see "Deploying to Cloudflare"
SANDBOX_PROVIDER=dodo E2E_PORT=3200 npm run test:sandbox # real test-mode checkout (dodo or waffo); needs test credentials in .dev.vars, not run in CIThe risk-ranked e2e acceptance catalog and known gaps are in tests/e2e/CATALOG.md (in Chinese). Update it when you add or rename a test; npm test checks that the two match. For sandbox e2e prerequisites, webhook mode and test cards, see "sandbox e2e" in docs/payments.md (in Chinese).
e2e coverage (64 tests):
- Purchase and generation: the full main path (including the Chinese site and the AI badge), failed payment, returning to the result page signed out (the email is removed by a 302 before rendering), insufficient credits, refund on failed generation; three pollers handling the same job concurrently store it once without deleting the video, and the admin shows cost and margin.
- Compliance:
- Retention: photos used by in-progress jobs are kept; expired photos can't be reused; expired videos return 410 and show as expired in the dashboard; repeated cleanup is idempotent.
- Takedowns: an anonymous report at
/report, removed by an admin. Both videos from the same photo are deleted, unrelated videos are kept, and re-uploading the photo is blocked; a handled report can't be handled again, and the outcome is audited. - Moderation: rejected uploads return 422.
- Data export: the JSON downloaded from settings contains all of the user's records and none of other users' data or internal fields; 401 when signed out or after account deletion.
- Sharing: after sharing from the dashboard, an anonymous visitor sees a noindex share page with the AI badge and no video id; other users can't start or stop sharing; the old link returns 404 after sharing stops and 410 once the video expires or is removed after a report; non-JPEG or rejected posters are refused; a report that only has a share link with a language prefix still finds the video.
- Credit grants: after an admin grants time-limited credits, a render spends them first and a failed render returns them; once they expire the billing notice disappears. With credits for only one render, two concurrent renders give one success and one 402. Unit tests run the accounting rules on SQLite with every migration applied.
- Redeem codes: the admin generates a batch and exports CSV, disables one; a user redeems on the settings page and gets credits with an expiry; redeeming twice, a used-up code, a disabled or malformed code and non-admin access are refused; five users redeeming a 3-use campaign code at once give three successes.
- Referral rewards: a friend signs up through the settings-page link and the first paid order rewards the inviter once; a second order, other users' orders and invalid codes or existing accounts don't; refunding the first order claws the reward back once; sign-ups over the per-IP limit are rejected and shown with the reason in the admin; data export only shows each side's own records.
- Runtime settings: admin-only, and an unknown or out-of-range value rejects the whole save; a new sign-up bonus and validity apply to the next sign-up and are audited with before and after values; maintenance mode blocks generation and checkout with 503 while an existing order's payment is still credited and admins are exempt, with a banner in both languages; announcements per language; the daily cap returns 429 without charging; the admin page saves only changed fields, and every connection test passes locally.
- Subscriptions: monthly: subscribe from the pricing page, credits expire with the period, a replayed renewal is credited once and an underpaid one is held, past due and recovery, cancel at period end and undo on the billing page, the portal, a second subscription and account deletion refused while it renews, re-subscribing after it ends; yearly: monthly grants that a repeated Cron run doesn't duplicate, an older status event arriving late is ignored, and a refund claws back only the subscription credits and stops the grants.
- Alerts and dead letters: a dead-lettered job is failed and refunded once; the health check raises failure-rate and dead-letter alerts and doesn't resend within the same bucket.
- Transactional email: replayed payment webhooks send one email in the user's language; one email each for success and failure, none for a repeated failure; no takedown receipt for signed-out reporters and a receipt only when a signed-in user enters their own email, one deadline and one overdue reminder, notifying the reporter and the creator after removal and no further reminders; contact messages only notify admins; account deletion clears the email log; the admin email page.
- Payment events and refunds: concurrent and replayed payment events, refunds after partial use, two partial refunds, a late webhook with the same refund id after a manual record doesn't claw back twice; a refund arriving before its payment is retried and still claws back; underpaid or wrong-package payments are not credited and raise an alert until refunded, discounted or other-currency payments are credited, orphan events alert and other sites' events are ignored; a lost webhook is recovered by the success page and by the Cron (backoff, five concurrent checks credit once, a late webhook isn't credited again, other users get 404, underpaid payments aren't re-checked), and the admin check action credits or points to manual review.
- Order and user data: billing and the order detail page show amounts paid and partial refunds; another user's order returns 404 from the API and "not found" on the page; the admin searches orders, an invalid refund amount is blocked and can be retried, and the timeline is complete after a partial refund and a mark-paid; users filter by paying and banned and sort by credits, and invalid sort values never reach SQL; a ledger longer than one page loads more without duplicates or gaps.
- Security and permissions: CSRF, forged uploads and access to other users' data, admin permissions, no secret values in the admin config check, credit adjustments and bans, dev login and mock payments only accepted from localhost; off-site
redirectvalues after sign-in fall back to the dashboard, and unknown error codes on the sign-in page (such as__proto__) show the generic message; the email parameter on the payment result page is removed by a 302 before rendering. - Security headers: every inline script carries the request's nonce; zero CSP violations across public pages, a signed-in generation and the admin; an injected script without a nonce is reported; report endpoints aren't subject to the same-origin check.
- Browser error reporting: global errors and unhandled rejections are reported once each without the query string, duplicates aren't re-sent, at most 5 per page; on a render error, the ID on the error page matches the report, and "Try again" recovers once the API is back.
- Attribution and reports: UTM attribution persists across sessions; reports and CSV.
- SEO: every seo-check rule, hydration of the English and Chinese home pages, head updates after client-side navigation, crawlable language links.
Artifacts go to .tmp/video-template-e2e/: report/ (HTML report with screenshots and traces for every step), results.json and test-results/.
src/config/site.ts per-site config
src/content/ English and Chinese copy (one chunk per locale)
src/routes/ pages ({-$locale} is the optional locale prefix, admin/ is the admin)
src/server/ API: auth, credits, orders, payments/, video/, jobs (queue, dead letters, Cron), alerts, admin, seo, retention, takedown, moderation, turnstile, email (outbox), notifications (events → email), settings (runtime settings), probes (connection tests), csp, client-error
migrations/ D1 migrations
containers/postprocess/ post-processing container: burned-in watermark, C2PA signing, cover and preview (experimental, not wired into the Worker yet; see ROADMAP)
seed/templates/ source videos for generation
.claude/skills/ Claude Code skills: new-hot-site (launch a keyword site), sync-upstream (merge template updates), launch-audit (launch checks)
scripts/ new-site, doctor (pre-deploy checks), sync-plan (template sync plan), sync-secrets (secret sync), seo-check, indexnow, waffo-setup, dodo-setup, webhook-proxy, seed-local, placeholder media
tests/ unit, e2e (mock providers), smoke (post-deploy), sandbox (payment provider test mode)