create-ailk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +50 -53
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +2 -2
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +33 -5
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +459 -135
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +229 -4
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +14 -13
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +0 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/database/CHANGELOG.md +33 -0
- package/templates/database/inbox/schema.prisma +67 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1296
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/listing-promotion.ts +0 -342
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
* @description The deliverable-fulfillment service (#3961 D3/D4/D5/D7;
|
|
4
4
|
* extended #3998 D1/D3/D5; extended FEAT-074 U4b, #4718 D4).
|
|
5
5
|
*
|
|
6
|
+
* NOT marked `oss: false` and NOT in signal-collectors's `ownedFiles`
|
|
7
|
+
* (module-architecture.ts), despite D-48/D6 placing this file with
|
|
8
|
+
* signal-collectors — `services/recommender-capture.ts` imports it directly
|
|
9
|
+
* and is itself reached from `server.ts` by a plain, unconditional static
|
|
10
|
+
* import today (commerce/#5364 has not yet landed the gate around it).
|
|
11
|
+
* Marking or excluding this file broke both the OSS-strip and the create-ailk
|
|
12
|
+
* starter snapshot for EVERY module, not just signal-collectors (spec §5.4
|
|
13
|
+
* escalation boundary; see module-architecture.ts's comment on this file).
|
|
14
|
+
* Revert once #5364 lands.
|
|
15
|
+
*
|
|
6
16
|
* `fulfillDeliverable` is the one entry point, called from flow-engine's
|
|
7
17
|
* completion branch after `routeLead` succeeds and only when the session's
|
|
8
18
|
* `configSnapshot` carries a `deliverables` block (D4 — the block is opt-in
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* NOT marked `oss: false` (D-48/#5366 escalation — see
|
|
3
|
+
* `routes/project-listings/me.ts`'s header comment): imported by
|
|
4
|
+
* `routes/project-listings/{start,public}.ts`, each of which ships
|
|
5
|
+
* unconditionally because `routes/project-listings/index.ts`'s multi-tenant
|
|
6
|
+
* literal `import()` forces it. Registration stays gated.
|
|
7
|
+
*
|
|
2
8
|
* @file listing-config.ts
|
|
3
9
|
* @description The per-site project-listings config read (#5033) — one
|
|
4
10
|
* function, `listingConfigFor`, that resolves a site's window bounds and
|
|
@@ -53,9 +59,61 @@ export async function listingConfigFor(siteId: string): Promise<SiteListingConfi
|
|
|
53
59
|
);
|
|
54
60
|
return SITE_LISTING_CONFIG_DEFAULTS;
|
|
55
61
|
}
|
|
62
|
+
warnOnDroppedField(siteId, row.data, parsed.data);
|
|
56
63
|
return parsed.data;
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The two `.catch(undefined)` members of `siteListingConfigSchema` are silent
|
|
68
|
+
* by construction (#5406): a malformed `resumeEmail` or `siteName` is DROPPED
|
|
69
|
+
* rather than failing the whole blob, so the site keeps its own window bounds
|
|
70
|
+
* and collected fields instead of silently reverting to the platform's more
|
|
71
|
+
* permissive defaults.
|
|
72
|
+
*
|
|
73
|
+
* Silent is the right BEHAVIOUR and the wrong OBSERVABILITY. An operator who
|
|
74
|
+
* typos a subject key gets a site that quietly stops sending the resume email
|
|
75
|
+
* with nothing anywhere saying why. This closes that: when the stored blob has
|
|
76
|
+
* the key and the parsed config does not, the drop is logged.
|
|
77
|
+
*
|
|
78
|
+
* Field NAMES only, never values — `resumeEmail` carries the site's addresses
|
|
79
|
+
* and the founder-facing copy, and this module sits on the anonymous path.
|
|
80
|
+
*/
|
|
81
|
+
function warnOnDroppedField(
|
|
82
|
+
siteId: string,
|
|
83
|
+
stored: unknown,
|
|
84
|
+
parsed: SiteListingConfig,
|
|
85
|
+
): void {
|
|
86
|
+
if (typeof stored !== "object" || stored === null) return;
|
|
87
|
+
const raw = stored as Record<string, unknown>;
|
|
88
|
+
for (const field of ["resumeEmail", "siteName"] as const) {
|
|
89
|
+
if (raw[field] !== undefined && parsed[field] === undefined) {
|
|
90
|
+
logger.warn(
|
|
91
|
+
{ siteId, field },
|
|
92
|
+
"[listing-config] stored field failed its schema and was DROPPED — the rest of this site's config still applies, but this field is not in effect",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `resumeEmail.maxSendsPerHour` carries its OWN `.catch(undefined)` (#5414),
|
|
98
|
+
// so a bad cap is dropped while the block around it survives — which the
|
|
99
|
+
// loop above, comparing whole members, cannot see. Checked only when the
|
|
100
|
+
// block itself parsed: a dropped block is already reported once above, and
|
|
101
|
+
// reporting it twice would make the narrower line the less trustworthy one.
|
|
102
|
+
const storedEmail = raw["resumeEmail"];
|
|
103
|
+
if (
|
|
104
|
+
typeof storedEmail === "object" &&
|
|
105
|
+
storedEmail !== null &&
|
|
106
|
+
(storedEmail as Record<string, unknown>)["maxSendsPerHour"] !== undefined &&
|
|
107
|
+
parsed.resumeEmail !== undefined &&
|
|
108
|
+
parsed.resumeEmail.maxSendsPerHour === undefined
|
|
109
|
+
) {
|
|
110
|
+
logger.warn(
|
|
111
|
+
{ siteId, field: "resumeEmail.maxSendsPerHour" },
|
|
112
|
+
"[listing-config] stored field failed its schema and was DROPPED — the rest of this site's config still applies, but this field is not in effect",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
59
117
|
/**
|
|
60
118
|
* Resolve a site from its PUBLIC SITE KEY (#5048 D2).
|
|
61
119
|
*
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file project-listing-resume-email.ts
|
|
3
|
+
* @description The founder's resume-link email, sent the moment a listing
|
|
4
|
+
* draft is created (#5406) — the API side of `sendListingResumeEmail`.
|
|
5
|
+
*
|
|
6
|
+
* It is the `onDraftStarted` email every consuming site needs and every
|
|
7
|
+
* consuming site was building for itself: salessmyth/samhenry shipped a
|
|
8
|
+
* plain-text one with its sender hardcoded in `apps/web/src/lib/resend.ts` and
|
|
9
|
+
* its wording in `messages/en.json`, so every reword needed a rebuild. Here
|
|
10
|
+
* there is nothing to rebuild — every site-facing value is read from the
|
|
11
|
+
* site's `SiteListingConfig` row on each send.
|
|
12
|
+
*
|
|
13
|
+
* OPT-IN. A site whose config carries no `resumeEmail.fromAddress` (or no
|
|
14
|
+
* `resumeEmail.baseUrl`) gets NO email. Both are deliberate rather than
|
|
15
|
+
* defaulted: Resend only sends from a domain the site has verified, and the
|
|
16
|
+
* platform does not know where a site mounted its application. Together they
|
|
17
|
+
* mean a site already sending its own resume email does not start sending two
|
|
18
|
+
* merely because it upgraded.
|
|
19
|
+
*
|
|
20
|
+
* TENANT ISOLATION. The `siteId` this reads config for comes off the DRAFT
|
|
21
|
+
* ROW, never from the request body and never from a caller argument — so the
|
|
22
|
+
* email a founder receives is always addressed and worded by the site their
|
|
23
|
+
* application was actually filed against. `start` already resolves the site
|
|
24
|
+
* server-side from a public key (#5048 D2); reading it back off the row is the
|
|
25
|
+
* belt to that braces, and is why this function takes only a listing id.
|
|
26
|
+
*
|
|
27
|
+
* RATE-BOUNDED PER RECIPIENT (PR #5411 security review, finding 1). `start` is
|
|
28
|
+
* anonymous and its IP limiter is evadable (`trustProxy` is on — see the note
|
|
29
|
+
* beside the route registration in `server.ts`), so before this change the
|
|
30
|
+
* cost of an unbounded call was one row and no outbound anything. Sending mail
|
|
31
|
+
* from the site's own verified domain to an address the CALLER typed changes
|
|
32
|
+
* that: looped, it is a mail bomb aimed at a named victim and a deliverability
|
|
33
|
+
* attack on the site's sending domain. `RESUME_EMAILS_PER_RECIPIENT_PER_WINDOW`
|
|
34
|
+
* bounds it.
|
|
35
|
+
*
|
|
36
|
+
* The cap is keyed on an UNVERIFIED identifier, which lesson 14
|
|
37
|
+
* (`docs/lessons/project-listings/14-…`) warns is a lockout weapon. Two things
|
|
38
|
+
* make it a proportionate one here, and both are deliberate: the window is one
|
|
39
|
+
* HOUR rather than forever, so any lockout clears itself; and what a
|
|
40
|
+
* suppressed send denies is only the EMAIL — `start` still returns the resume
|
|
41
|
+
* token in its 201 body, so the founder in front of the form loses nothing and
|
|
42
|
+
* completes their application as normal.
|
|
43
|
+
*
|
|
44
|
+
* RATE-BOUNDED PER SITE TOO (#5414). The bound above is per RECIPIENT, which
|
|
45
|
+
* stops a loop aimed at one victim's inbox and does nothing about a loop over a
|
|
46
|
+
* thousand strangers — each of whom receives one message from the site's own
|
|
47
|
+
* verified domain. That is the send pattern a mailbox provider reads as a list
|
|
48
|
+
* blast, and the cost lands on the site rather than on the caller: a flagged
|
|
49
|
+
* sending domain and a Resend account that can be suspended, which stops EVERY
|
|
50
|
+
* email the site sends and not only this one.
|
|
51
|
+
* `SITE_RESUME_EMAILS_PER_WINDOW` bounds the site's total over the same hour,
|
|
52
|
+
* and a site raises it for itself with `resumeEmail.maxSendsPerHour`.
|
|
53
|
+
*
|
|
54
|
+
* WHAT THE SITE BUDGET COUNTS, and its one honest cost: the DRAFTS created for
|
|
55
|
+
* the site inside the window, the same shape the per-recipient bound counts and
|
|
56
|
+
* for the same reason — this function runs exactly once per created draft, so
|
|
57
|
+
* drafts are one-to-one with emails ATTEMPTED. No new table, no migration, and
|
|
58
|
+
* the count holds across server instances because it is a database read rather
|
|
59
|
+
* than process memory.
|
|
60
|
+
*
|
|
61
|
+
* Its cost, stated at full strength because the weak form of it is misleading:
|
|
62
|
+
* a flood of drafts spends the budget even though those drafts sent no mail, so
|
|
63
|
+
* at the cap a GENUINE founder's email is suppressed too — and the cheapest way
|
|
64
|
+
* to cause that is not a mail flood at all. The per-recipient bound above
|
|
65
|
+
* returns BEFORE this count is reached, so one caller using ONE address can
|
|
66
|
+
* open cap+1 drafts, have three messages actually delivered (to their own
|
|
67
|
+
* inbox), and silence the site's resume email for the rest of the hour. The
|
|
68
|
+
* denial is cheaper than the abuse it prevents.
|
|
69
|
+
*
|
|
70
|
+
* It is accepted rather than overlooked, for the reasons lesson 14 already
|
|
71
|
+
* accepted for the per-recipient bound: the window is an hour rather than
|
|
72
|
+
* forever, so it clears itself; and what a suppressed send denies is only the
|
|
73
|
+
* EMAIL — the draft is created and the 201 still carries the resume token, so
|
|
74
|
+
* the founder in front of the form completes their application as normal. What
|
|
75
|
+
* it denies is the founder who closes the tab, for at most an hour. Narrowing
|
|
76
|
+
* it means counting SENDS rather than drafts, which needs somewhere to record a
|
|
77
|
+
* send — a schema change this issue deliberately stops short of (see the PR).
|
|
78
|
+
*
|
|
79
|
+
* PII boundary (the rule `routes/project-listings/start.ts` states in its own
|
|
80
|
+
* header): this module logs `siteId`, `listingId`, a reason, an error KIND, and
|
|
81
|
+
* the link's HOST. Never the founder's address or name, never the resume
|
|
82
|
+
* token, never the #5407 verification NONCE, and never the assembled link —
|
|
83
|
+
* which carries both. The nonce is the stricter of the two: the resume token
|
|
84
|
+
* also went out in `start`'s 201 body, but this message is the ONLY place the
|
|
85
|
+
* nonce ever leaves the server, so a log line quoting the link would not just
|
|
86
|
+
* disclose a credential, it would destroy the property the nonce exists to
|
|
87
|
+
* establish. Resend's own `error.message` is untrusted (it can quote an
|
|
88
|
+
* address), so only `error.kind` is logged.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
import { sendListingResumeEmail } from "@working-theory/email";
|
|
92
|
+
import { logger } from "@working-theory/observability";
|
|
93
|
+
|
|
94
|
+
import { prisma } from "../lib/prisma.js";
|
|
95
|
+
|
|
96
|
+
import { listingConfigFor } from "./listing-config.js";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How many resume emails one address may receive from one site within
|
|
100
|
+
* `RESUME_EMAIL_WINDOW_MS`.
|
|
101
|
+
*
|
|
102
|
+
* Three, not one: a founder who closes the tab and starts again has genuinely
|
|
103
|
+
* begun a second draft and genuinely needs a second link. Three of those in an
|
|
104
|
+
* hour is generous for a person and useless for a flood.
|
|
105
|
+
*/
|
|
106
|
+
const RESUME_EMAILS_PER_RECIPIENT_PER_WINDOW = 3;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* How many resume emails ONE SITE may send in total across every recipient
|
|
110
|
+
* within `RESUME_EMAIL_WINDOW_MS`, when the site has not set its own
|
|
111
|
+
* `resumeEmail.maxSendsPerHour` (#5414).
|
|
112
|
+
*
|
|
113
|
+
* Twenty, sized against a small site's real application volume rather than
|
|
114
|
+
* against an attacker: a site running this feature takes a handful of listing
|
|
115
|
+
* applications a day, so twenty inside a single HOUR is already a launch-day
|
|
116
|
+
* spike, and a site with genuine volume raises the number for itself. Sized
|
|
117
|
+
* from the other end it is a brake rather than a wall — it bounds a flood at
|
|
118
|
+
* a few hundred messages a day instead of thousands, which is the difference
|
|
119
|
+
* between a sending domain an operator can still explain and one a mailbox
|
|
120
|
+
* provider has already decided about. The operator is told when it binds.
|
|
121
|
+
*
|
|
122
|
+
* It is a platform default and deliberately not any site's terms — the same
|
|
123
|
+
* posture `siteListingConfigSchema`'s own defaults take.
|
|
124
|
+
*/
|
|
125
|
+
const SITE_RESUME_EMAILS_PER_WINDOW = 20;
|
|
126
|
+
|
|
127
|
+
/** One hour. The cap is a rolling window so any lockout expires on its own. */
|
|
128
|
+
const RESUME_EMAIL_WINDOW_MS = 60 * 60 * 1000;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* When each site's budget was last reported, so the cap logs ONCE per window
|
|
132
|
+
* rather than once per suppressed send.
|
|
133
|
+
*
|
|
134
|
+
* A line per suppressed send would hand the same caller a log-flooding
|
|
135
|
+
* primitive on top of the mail flood, which is the opposite of what this
|
|
136
|
+
* control is for. Per PROCESS rather than per deployment, so N instances emit
|
|
137
|
+
* at most N lines an hour — the CAP is what has to hold across instances (it
|
|
138
|
+
* is counted in the database), and this is only its volume knob.
|
|
139
|
+
*
|
|
140
|
+
* The key space is the deployment's configured sites, so it does not grow with
|
|
141
|
+
* traffic.
|
|
142
|
+
*/
|
|
143
|
+
const siteCapLastLoggedAt = new Map<string, number>();
|
|
144
|
+
|
|
145
|
+
function logSiteCapOncePerWindow(siteId: string, count: number): void {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const last = siteCapLastLoggedAt.get(siteId);
|
|
148
|
+
if (last !== undefined && now - last < RESUME_EMAIL_WINDOW_MS) return;
|
|
149
|
+
siteCapLastLoggedAt.set(siteId, now);
|
|
150
|
+
// `siteId` and the count, and a constant naming which bound fired. No
|
|
151
|
+
// address, no founder name, no resume token, no link and no LISTING ID —
|
|
152
|
+
// this line is about a site's aggregate budget, and the draft that happened
|
|
153
|
+
// to trip it identifies a person the sibling lines deliberately do not name.
|
|
154
|
+
logger.warn({ siteId, count, reason: "site_rate_limited" }, "listing-resume-email-skipped");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The link back into the application.
|
|
159
|
+
*
|
|
160
|
+
* `?draft=<id>&token=<resumeToken>&verify=<verifyNonce>` is the exact shape
|
|
161
|
+
* `ListingApplicationFlow`'s `resume` prop reads. Assembled through `URL` so a
|
|
162
|
+
* `baseUrl` that already carries a query string gains three parameters rather
|
|
163
|
+
* than a second `?`, and so every value is percent-encoded.
|
|
164
|
+
*
|
|
165
|
+
* `verify` (#5407) is the one credential THIS MESSAGE IS THE ONLY CARRIER OF.
|
|
166
|
+
* The draft id and the resume token were both in `start`'s 201 body, so the
|
|
167
|
+
* browser that filled the form already holds them; the nonce was not, and is
|
|
168
|
+
* what lets the server distinguish "someone opened this draft" from "someone
|
|
169
|
+
* read mail at this address". That makes this link the sole path between the
|
|
170
|
+
* nonce and the outside world — which is why nothing in this module logs it,
|
|
171
|
+
* and why the log line below carries the link's HOST and never its query.
|
|
172
|
+
*/
|
|
173
|
+
function resumeLinkFor(
|
|
174
|
+
baseUrl: string,
|
|
175
|
+
draftId: string,
|
|
176
|
+
resumeToken: string,
|
|
177
|
+
verifyNonce: string,
|
|
178
|
+
): string {
|
|
179
|
+
const url = new URL(baseUrl);
|
|
180
|
+
url.searchParams.set("draft", draftId);
|
|
181
|
+
url.searchParams.set("token", resumeToken);
|
|
182
|
+
url.searchParams.set("verify", verifyNonce);
|
|
183
|
+
return url.toString();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Send the resume email for a just-created draft. Never throws, and never
|
|
188
|
+
* reports success — the caller does not wait on it (see
|
|
189
|
+
* `sendListingResumeLink` below, which is what routes actually call).
|
|
190
|
+
*/
|
|
191
|
+
async function send(
|
|
192
|
+
listingId: string,
|
|
193
|
+
resumeToken: string,
|
|
194
|
+
verifyNonce: string,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
const row = await prisma.projectListing.findFirst({
|
|
197
|
+
where: { id: listingId },
|
|
198
|
+
// `name` is null at step one, so `{projectName}` renders empty there. It
|
|
199
|
+
// is selected anyway because this function is about a ROW, not about a
|
|
200
|
+
// moment: a later caller on a named draft substitutes a real name with no
|
|
201
|
+
// change here.
|
|
202
|
+
select: { siteId: true, founderEmail: true, name: true, founderFirstName: true },
|
|
203
|
+
});
|
|
204
|
+
if (!row) return;
|
|
205
|
+
|
|
206
|
+
const config = await listingConfigFor(row.siteId);
|
|
207
|
+
const settings = config.resumeEmail;
|
|
208
|
+
|
|
209
|
+
if (settings?.fromAddress === undefined || settings.baseUrl === undefined) {
|
|
210
|
+
logger.info(
|
|
211
|
+
{
|
|
212
|
+
siteId: row.siteId,
|
|
213
|
+
listingId,
|
|
214
|
+
reason: settings?.fromAddress === undefined ? "no_from_address" : "no_base_url",
|
|
215
|
+
},
|
|
216
|
+
"listing-resume-email-skipped",
|
|
217
|
+
);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const windowStart = new Date(Date.now() - RESUME_EMAIL_WINDOW_MS);
|
|
222
|
+
|
|
223
|
+
// Counts the DRAFTS this address has started on this site in the window,
|
|
224
|
+
// which is one-to-one with the emails it has been sent: this function runs
|
|
225
|
+
// exactly once per created draft. `founderEmail` is already normalized
|
|
226
|
+
// (trimmed and lowercased) at write time, so the count cannot be evaded with
|
|
227
|
+
// the shift key — the fix lesson 14 records.
|
|
228
|
+
const recentForRecipient = await prisma.projectListing.count({
|
|
229
|
+
where: {
|
|
230
|
+
siteId: row.siteId,
|
|
231
|
+
founderEmail: row.founderEmail,
|
|
232
|
+
createdAt: { gt: windowStart },
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
// `>` not `>=`: the draft that triggered this send is itself in the count.
|
|
236
|
+
if (recentForRecipient > RESUME_EMAILS_PER_RECIPIENT_PER_WINDOW) {
|
|
237
|
+
logger.warn(
|
|
238
|
+
{ siteId: row.siteId, listingId, reason: "recipient_rate_limited" },
|
|
239
|
+
"listing-resume-email-skipped",
|
|
240
|
+
);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The same count without the address clause — the site's whole outbound
|
|
245
|
+
// budget for the window (#5414). `siteId` comes off the DRAFT ROW, as every
|
|
246
|
+
// read in this function does, so one site's flood can never spend another's
|
|
247
|
+
// budget however the caller addressed the request.
|
|
248
|
+
//
|
|
249
|
+
// An invalid `maxSendsPerHour` is already `undefined` by the time it reaches
|
|
250
|
+
// here (its own `.catch(undefined)` in the schema), so a bad cap lands on the
|
|
251
|
+
// platform default rather than on zero — and `listingConfigFor` has already
|
|
252
|
+
// logged that the operator's value is not in effect.
|
|
253
|
+
const siteCap = settings.maxSendsPerHour ?? SITE_RESUME_EMAILS_PER_WINDOW;
|
|
254
|
+
const recentForSite = await prisma.projectListing.count({
|
|
255
|
+
where: { siteId: row.siteId, createdAt: { gt: windowStart } },
|
|
256
|
+
});
|
|
257
|
+
if (recentForSite > siteCap) {
|
|
258
|
+
logSiteCapOncePerWindow(row.siteId, recentForSite);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// `maxSendsPerHour` is the API's to consume, like `baseUrl` beside it — the
|
|
263
|
+
// email package is handed COPY, and a budget is not copy.
|
|
264
|
+
const { baseUrl, maxSendsPerHour: _cap, ...copy } = settings;
|
|
265
|
+
// The HOST only (security review, finding 2). An operator who repoints
|
|
266
|
+
// `baseUrl` at a third party would otherwise exfiltrate every founder's
|
|
267
|
+
// resume token — the sole credential on their draft — with nothing anywhere
|
|
268
|
+
// recording it. The host is not a secret; the path and query are, because
|
|
269
|
+
// the query carries the token.
|
|
270
|
+
logger.info(
|
|
271
|
+
{ siteId: row.siteId, listingId, linkHost: new URL(baseUrl).host },
|
|
272
|
+
"listing-resume-email-sending",
|
|
273
|
+
);
|
|
274
|
+
const result = await sendListingResumeEmail({
|
|
275
|
+
to: row.founderEmail,
|
|
276
|
+
resumeLink: resumeLinkFor(baseUrl, listingId, resumeToken, verifyNonce),
|
|
277
|
+
settings: copy,
|
|
278
|
+
variables: {
|
|
279
|
+
// The site's own display name, or absent — `{siteName}` then renders as
|
|
280
|
+
// empty text rather than as a platform name the site never chose.
|
|
281
|
+
...(config.siteName !== undefined && { siteName: config.siteName }),
|
|
282
|
+
...(row.name !== null && { projectName: row.name }),
|
|
283
|
+
// #5407 — `{firstName}` now has a source, and only where the founder
|
|
284
|
+
// actually gave one. `founderName` is still deliberately NOT split to
|
|
285
|
+
// guess a first name: a split on the first space is wrong for most of
|
|
286
|
+
// the world's names, and getting a founder's name wrong in the first
|
|
287
|
+
// email they receive is worse than omitting it. A row whose site asks
|
|
288
|
+
// for one display line renders `{firstName}` empty, exactly as before.
|
|
289
|
+
...(row.founderFirstName !== null && { firstName: row.founderFirstName }),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (!result.ok) {
|
|
294
|
+
logger.warn(
|
|
295
|
+
{ siteId: row.siteId, listingId, errorKind: result.error.kind },
|
|
296
|
+
"listing resume email send failed",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Fire-and-forget entry point — what `start` calls, and what makes the 201 on
|
|
303
|
+
* that route independent of Resend.
|
|
304
|
+
*
|
|
305
|
+
* Returns `void` rather than a promise so a caller cannot accidentally
|
|
306
|
+
* `await` it, and `.catch`-guards everything: `send` above can reject (a
|
|
307
|
+
* Prisma read, a malformed `baseUrl` reaching `new URL`) where
|
|
308
|
+
* `sendListingResumeEmail` itself never does, and nothing from either may
|
|
309
|
+
* reach a route whose row is already committed.
|
|
310
|
+
*
|
|
311
|
+
* Mirrors `project-listing-decision.ts#fileIssueOnApprove`'s shape, which is
|
|
312
|
+
* the same promise on the same feature: a best-effort side effect of a write
|
|
313
|
+
* that has already landed.
|
|
314
|
+
*/
|
|
315
|
+
export function sendListingResumeLink(
|
|
316
|
+
listingId: string,
|
|
317
|
+
resumeToken: string,
|
|
318
|
+
verifyNonce: string,
|
|
319
|
+
): void {
|
|
320
|
+
send(listingId, resumeToken, verifyNonce).catch((err: unknown) => {
|
|
321
|
+
logger.warn(
|
|
322
|
+
{ listingId, errName: err instanceof Error ? err.name : "UnknownError" },
|
|
323
|
+
"listing resume email rejected unexpectedly",
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
}
|