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
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file founder-identity.test.ts
|
|
3
|
+
*
|
|
4
|
+
* THE ACCEPTANCE GATE for the founder's identity in parts and the resume
|
|
5
|
+
* link's email verification (#5407).
|
|
6
|
+
*
|
|
7
|
+
* Two properties carry this change, and both look fine when broken:
|
|
8
|
+
*
|
|
9
|
+
* 1. `founder_name` STILL HOLDS A DISPLAY NAME, composed server-side from
|
|
10
|
+
* the parts wherever they are written. Every existing reader of it — the
|
|
11
|
+
* submit-time slug fallback, the approve/reject email's greeting, the
|
|
12
|
+
* Slack review card, the build issue, the `routeLead` identity — reads
|
|
13
|
+
* that column and nothing else, so a `start` that stored the parts and
|
|
14
|
+
* left the display line to the client would break all of them at once,
|
|
15
|
+
* silently, one release later. The stored line and the stored parts must
|
|
16
|
+
* also never DISAGREE, which is why the parts win over a `founderName`
|
|
17
|
+
* sent beside them.
|
|
18
|
+
*
|
|
19
|
+
* 2. VERIFICATION IS WRITE-ONCE AND TOKEN-GATED. The stamp says the resume
|
|
20
|
+
* token reached `founder_email` and came back; a second opening must not
|
|
21
|
+
* move it, and a wrong token must change nothing and be indistinguishable
|
|
22
|
+
* from an unknown draft. An anonymous route whose entire input is an id
|
|
23
|
+
* and a token is an oracle for which drafts exist the moment its answers
|
|
24
|
+
* differ.
|
|
25
|
+
*
|
|
26
|
+
* Same harness as `structured-address.test.ts`: real routes, real services,
|
|
27
|
+
* mocked `lib/prisma.js` over a small in-memory row store, `routeLead`
|
|
28
|
+
* running for real against a per-test temp path.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock
|
|
32
|
+
jest.mock("../../../lib/prisma.js");
|
|
33
|
+
|
|
34
|
+
jest.mock("@working-theory/email", () => ({
|
|
35
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
36
|
+
// #5406/#5407 — the resume email is the ONLY carrier of the verification
|
|
37
|
+
// nonce, so this mock is not a stub to keep the suite quiet: it is the
|
|
38
|
+
// founder's mailbox. `nonceFromEmail` below reads the nonce back out of the
|
|
39
|
+
// link this was asked to send, which is the only way any test here can
|
|
40
|
+
// obtain one.
|
|
41
|
+
sendListingResumeEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-resume" }),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
jest.mock("@working-theory/license", () => ({
|
|
45
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
46
|
+
tier: "free",
|
|
47
|
+
plan: null,
|
|
48
|
+
features: [],
|
|
49
|
+
expiresAt: null,
|
|
50
|
+
revoked: false,
|
|
51
|
+
}),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
import { sendListingResumeEmail } from "@working-theory/email";
|
|
55
|
+
import Fastify from "fastify";
|
|
56
|
+
import type { FastifyInstance } from "fastify";
|
|
57
|
+
|
|
58
|
+
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
59
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
60
|
+
import { patchProjectListingDraftRoute } from "../patch-draft.js";
|
|
61
|
+
import { startProjectListingRoute } from "../start.js";
|
|
62
|
+
import { submitProjectListingRoute } from "../submit.js";
|
|
63
|
+
import { verifyProjectListingDraftEmailRoute } from "../verify-email.js";
|
|
64
|
+
|
|
65
|
+
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const SITE_ID = "site-1";
|
|
68
|
+
const SITE_KEY = "pk-site-1";
|
|
69
|
+
|
|
70
|
+
const opt = (value: string, label: string) => ({ value, label });
|
|
71
|
+
|
|
72
|
+
const VALID_QUESTIONS = {
|
|
73
|
+
q2: {
|
|
74
|
+
prompt: "Why are you here?",
|
|
75
|
+
options: [opt("scale", "Scaling my team"), opt("just-curious", "Just curious")],
|
|
76
|
+
},
|
|
77
|
+
q3: { prompt: "Stage?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
|
|
78
|
+
q4: { prompt: "Team size?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
|
|
79
|
+
q5: {
|
|
80
|
+
prompt: "Urgency?",
|
|
81
|
+
options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")],
|
|
82
|
+
},
|
|
83
|
+
q6: {
|
|
84
|
+
prompt: "Pain?",
|
|
85
|
+
options: [
|
|
86
|
+
opt("low", "Low"),
|
|
87
|
+
opt("medium", "Medium"),
|
|
88
|
+
opt("high", "High"),
|
|
89
|
+
opt("critical", "Critical"),
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
q7: { prompt: "Anything else?" },
|
|
93
|
+
extras: [],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** The #5407 contact screen's body: the name in PARTS, plus a phone. */
|
|
97
|
+
const START_PAYLOAD = {
|
|
98
|
+
siteKey: SITE_KEY,
|
|
99
|
+
founderFirstName: "Ada",
|
|
100
|
+
founderLastName: "Lovelace",
|
|
101
|
+
founderEmail: "ada@example.com",
|
|
102
|
+
founderPhone: "+44 20 7946 0958",
|
|
103
|
+
consent: true,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** What every caller that predates #5407 sends, and must go on sending. */
|
|
107
|
+
const LEGACY_START_PAYLOAD = {
|
|
108
|
+
siteKey: SITE_KEY,
|
|
109
|
+
founderName: "Ada van der Berg",
|
|
110
|
+
founderEmail: "ada@example.com",
|
|
111
|
+
consent: true,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Everything the steps after step one collect, minus the address. */
|
|
115
|
+
const APPLICATION_WITHOUT_ADDRESS = {
|
|
116
|
+
name: "My Project",
|
|
117
|
+
headline: "Ten words or fewer for the tile title here",
|
|
118
|
+
oneLiner: "A short sentence describing what the project does for its people.",
|
|
119
|
+
problem: "The problem is real and this describes it.",
|
|
120
|
+
product: "wireframes",
|
|
121
|
+
commitment: "part-time",
|
|
122
|
+
teamComposition: "co-founders",
|
|
123
|
+
faqs: [
|
|
124
|
+
{ question: "Q one?", answer: "A one." },
|
|
125
|
+
{ question: "Q two?", answer: "A two." },
|
|
126
|
+
{ question: "Q three?", answer: "A three." },
|
|
127
|
+
],
|
|
128
|
+
questions: VALID_QUESTIONS,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** A complete address, so a draft can be driven all the way to `submit`. */
|
|
132
|
+
const ADDRESS_PARTS = {
|
|
133
|
+
addressLine1: "221B Baker Street",
|
|
134
|
+
city: "London",
|
|
135
|
+
region: "Greater London",
|
|
136
|
+
postalCode: "NW1 6XE",
|
|
137
|
+
country: "GB",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// ─── The in-memory row store the Prisma mock is backed by ───────────────────
|
|
141
|
+
|
|
142
|
+
type Row = Record<string, unknown>;
|
|
143
|
+
|
|
144
|
+
let rows: Row[];
|
|
145
|
+
let nextId: number;
|
|
146
|
+
/** Whether the site has switched the #5406 resume email on for this test. */
|
|
147
|
+
let resumeEmailEnabled = true;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The site's `SiteListingConfig` row, carrying the two members #5406 requires
|
|
151
|
+
* before it will send anything. Everything else falls back to the platform
|
|
152
|
+
* defaults, exactly as it does for a site that configures only these.
|
|
153
|
+
*/
|
|
154
|
+
const SITE_CONFIG_ROW = {
|
|
155
|
+
// `listingConfigFor` selects `data` and parses that blob — the config is one
|
|
156
|
+
// JSON column, not a row of columns.
|
|
157
|
+
data: {
|
|
158
|
+
resumeEmail: {
|
|
159
|
+
fromAddress: "hello@site.example",
|
|
160
|
+
baseUrl: "https://site.example/apply",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function matches(row: Row, where: Record<string, unknown>): boolean {
|
|
166
|
+
return Object.entries(where).every(([key, value]) => {
|
|
167
|
+
if (key === "OR") {
|
|
168
|
+
return (value as Record<string, unknown>[]).some((clause) => matches(row, clause));
|
|
169
|
+
}
|
|
170
|
+
if (value !== null && typeof value === "object" && !(value instanceof Date)) {
|
|
171
|
+
const clause = value as Record<string, unknown>;
|
|
172
|
+
if ("in" in clause) return (clause["in"] as unknown[]).includes(row[key]);
|
|
173
|
+
if ("not" in clause) return row[key] !== clause["not"];
|
|
174
|
+
if ("gt" in clause) return (row[key] as Date) > (clause["gt"] as Date);
|
|
175
|
+
if ("startsWith" in clause) {
|
|
176
|
+
return (
|
|
177
|
+
typeof row[key] === "string" &&
|
|
178
|
+
(row[key] as string).startsWith(clause["startsWith"] as string)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return row[key] === value;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function installStore(): void {
|
|
187
|
+
rows = [];
|
|
188
|
+
nextId = 1;
|
|
189
|
+
resumeEmailEnabled = true;
|
|
190
|
+
|
|
191
|
+
(prisma.lead.create as jest.Mock).mockImplementation(() =>
|
|
192
|
+
Promise.resolve({ id: `lead_00000000-0000-7000-8000-00000000000${nextId}` }),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
|
|
196
|
+
({ where }: { where: { siteId?: string; publicKey?: string } }) => {
|
|
197
|
+
if (where.publicKey !== undefined) {
|
|
198
|
+
return Promise.resolve(where.publicKey === SITE_KEY ? { siteId: SITE_ID } : null);
|
|
199
|
+
}
|
|
200
|
+
// #5406 — the resume email is OPT-IN, and this suite opts in: without
|
|
201
|
+
// `fromAddress` and `baseUrl` no message is sent, so no nonce is ever
|
|
202
|
+
// delivered and nothing here could verify. The `resume-email-off` block
|
|
203
|
+
// at the end of the file asserts exactly that state, with this returning
|
|
204
|
+
// null instead.
|
|
205
|
+
return Promise.resolve(resumeEmailEnabled ? SITE_CONFIG_ROW : null);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// #5275 — the grant write `start` makes after the row. Append-only; it only
|
|
210
|
+
// needs to resolve here (drafts.test.ts asserts on what it is called with).
|
|
211
|
+
(prisma.consentGrant.createMany as jest.Mock).mockImplementation(
|
|
212
|
+
({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// #5275 — `start` writes the row and its grant in one interactive
|
|
216
|
+
// transaction; the mock hands the same store-backed client through.
|
|
217
|
+
(prisma.$transaction as jest.Mock).mockImplementation(
|
|
218
|
+
async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
(prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
|
|
222
|
+
const now = new Date();
|
|
223
|
+
const row: Row = {
|
|
224
|
+
id: `prjl_00000000-0000-7000-8000-00000000000${nextId++}`,
|
|
225
|
+
slug: null,
|
|
226
|
+
name: null,
|
|
227
|
+
oneLiner: null,
|
|
228
|
+
problem: null,
|
|
229
|
+
url: null,
|
|
230
|
+
product: null,
|
|
231
|
+
commitment: null,
|
|
232
|
+
teamComposition: null,
|
|
233
|
+
faqs: null,
|
|
234
|
+
anythingElse: null,
|
|
235
|
+
address: null,
|
|
236
|
+
// #5279 — a freshly-started draft has no parts, exactly like every row
|
|
237
|
+
// written before the columns existed.
|
|
238
|
+
addressLine1: null,
|
|
239
|
+
addressLine2: null,
|
|
240
|
+
addressCity: null,
|
|
241
|
+
addressRegion: null,
|
|
242
|
+
addressPostalCode: null,
|
|
243
|
+
addressCountry: null,
|
|
244
|
+
questions: null,
|
|
245
|
+
// #5407 — a row written before the columns existed, and a freshly
|
|
246
|
+
// started draft on a site that asks for one name line: parts null,
|
|
247
|
+
// no phone, no verification stamp.
|
|
248
|
+
founderFirstName: null,
|
|
249
|
+
founderLastName: null,
|
|
250
|
+
founderPhone: null,
|
|
251
|
+
emailVerifiedAt: null,
|
|
252
|
+
founderCompany: null,
|
|
253
|
+
leadId: null,
|
|
254
|
+
headline: null,
|
|
255
|
+
logoMarkUrl: null,
|
|
256
|
+
logoWordmarkUrl: null,
|
|
257
|
+
featured: false,
|
|
258
|
+
decidedAt: null,
|
|
259
|
+
decidedBy: null,
|
|
260
|
+
issueUrl: null,
|
|
261
|
+
experimentKey: null,
|
|
262
|
+
windowEndsAt: null,
|
|
263
|
+
createdAt: now,
|
|
264
|
+
updatedAt: now,
|
|
265
|
+
...data,
|
|
266
|
+
};
|
|
267
|
+
rows.push(row);
|
|
268
|
+
return Promise.resolve(row);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
(prisma.projectListing.findFirst as jest.Mock).mockImplementation(
|
|
272
|
+
({ where }: { where: Record<string, unknown> }) =>
|
|
273
|
+
Promise.resolve(rows.find((row) => matches(row, where)) ?? null),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
(prisma.projectListing.findMany as jest.Mock).mockImplementation(
|
|
277
|
+
({ where }: { where: Record<string, unknown> }) =>
|
|
278
|
+
Promise.resolve(rows.filter((row) => matches(row, where))),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
(prisma.projectListing.update as jest.Mock).mockImplementation(
|
|
282
|
+
({ where, data }: { where: Record<string, unknown>; data: Row }) => {
|
|
283
|
+
const row = rows.find((r) => matches(r, where));
|
|
284
|
+
Object.assign(row!, data, { updatedAt: new Date() });
|
|
285
|
+
return Promise.resolve(row);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
(prisma.projectListing.updateMany as jest.Mock).mockImplementation(
|
|
290
|
+
({ where, data }: { where: Record<string, unknown>; data: Row }) => {
|
|
291
|
+
const matched = rows.filter((r) => matches(r, where));
|
|
292
|
+
matched.forEach((r) => Object.assign(r, data, { updatedAt: new Date() }));
|
|
293
|
+
return Promise.resolve({ count: matched.length });
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
|
|
301
|
+
|
|
302
|
+
async function buildApp(): Promise<FastifyInstance> {
|
|
303
|
+
const app = Fastify();
|
|
304
|
+
await app.register(startProjectListingRoute);
|
|
305
|
+
await app.register(patchProjectListingDraftRoute);
|
|
306
|
+
await app.register(submitProjectListingRoute);
|
|
307
|
+
await app.register(verifyProjectListingDraftEmailRoute);
|
|
308
|
+
await app.ready();
|
|
309
|
+
return app;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function startDraft(
|
|
313
|
+
app: FastifyInstance,
|
|
314
|
+
payload: Record<string, unknown> = START_PAYLOAD,
|
|
315
|
+
): Promise<{ id: string; resumeToken: string }> {
|
|
316
|
+
const res = await app.inject({
|
|
317
|
+
method: "POST",
|
|
318
|
+
url: "/v1/project-listings",
|
|
319
|
+
payload,
|
|
320
|
+
});
|
|
321
|
+
expect(res.statusCode).toBe(201);
|
|
322
|
+
return (res.json() as { data: { id: string; resumeToken: string } }).data;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Call the verify route the way a founder's browser does when it lands on the
|
|
327
|
+
* emailed link: the resume token in the header, the nonce in the body.
|
|
328
|
+
*
|
|
329
|
+
* `nonce` is deliberately a separate argument rather than something derived
|
|
330
|
+
* from `draft`, because the WHOLE POINT of #5407's fix is that the nonce is
|
|
331
|
+
* not derivable from anything `start` hands back. A test that could reach it
|
|
332
|
+
* without going through the email would be testing the forgeable design.
|
|
333
|
+
*/
|
|
334
|
+
const verifyEmail = (
|
|
335
|
+
app: FastifyInstance,
|
|
336
|
+
id: string,
|
|
337
|
+
token?: string,
|
|
338
|
+
nonce?: string,
|
|
339
|
+
) =>
|
|
340
|
+
app.inject({
|
|
341
|
+
method: "POST",
|
|
342
|
+
url: `/v1/project-listings/drafts/${id}/verify-email`,
|
|
343
|
+
...(token === undefined ? {} : { headers: bearer(token) }),
|
|
344
|
+
...(nonce === undefined ? {} : { payload: { verifyNonce: nonce } }),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Wait for the fire-and-forget resume email to have been dispatched.
|
|
349
|
+
*
|
|
350
|
+
* `start` returns its 201 without awaiting the send, so the mock is called a
|
|
351
|
+
* microtask or two later. Polling the mock is what makes every test below
|
|
352
|
+
* deterministic without reaching into the service's internals.
|
|
353
|
+
*/
|
|
354
|
+
async function waitForEmail(listingId: string): Promise<void> {
|
|
355
|
+
for (let i = 0; i < 50; i += 1) {
|
|
356
|
+
const sent = (sendListingResumeEmail as jest.Mock).mock.calls.some(([arg]) =>
|
|
357
|
+
String((arg as { resumeLink: string }).resumeLink).includes(listingId),
|
|
358
|
+
);
|
|
359
|
+
if (sent) return;
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`no resume email dispatched for ${listingId}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The nonce as it reached the FOUNDER — read out of the link the resume email
|
|
367
|
+
* was asked to send, which is the only place it exists outside the server.
|
|
368
|
+
*
|
|
369
|
+
* `sendListingResumeEmail` is mocked at the top of this file, so this reads
|
|
370
|
+
* the argument `project-listing-resume-email.ts` passed it. That indirection
|
|
371
|
+
* is the test's whole value: it proves the nonce travels by mail and by
|
|
372
|
+
* nothing else.
|
|
373
|
+
*/
|
|
374
|
+
function nonceFromEmail(listingId: string): string {
|
|
375
|
+
const calls = (sendListingResumeEmail as jest.Mock).mock.calls;
|
|
376
|
+
const call = calls.find(([arg]) =>
|
|
377
|
+
String((arg as { resumeLink: string }).resumeLink).includes(listingId),
|
|
378
|
+
);
|
|
379
|
+
if (!call) throw new Error(`no resume email sent for ${listingId}`);
|
|
380
|
+
const link = new URL((call[0] as { resumeLink: string }).resumeLink);
|
|
381
|
+
const nonce = link.searchParams.get("verify");
|
|
382
|
+
if (!nonce) throw new Error("the emailed link carried no verify nonce");
|
|
383
|
+
return nonce;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const patch = (
|
|
387
|
+
app: FastifyInstance,
|
|
388
|
+
draft: { id: string; resumeToken: string },
|
|
389
|
+
payload: Record<string, unknown>,
|
|
390
|
+
) =>
|
|
391
|
+
app.inject({
|
|
392
|
+
method: "PATCH",
|
|
393
|
+
url: `/v1/project-listings/drafts/${draft.id}`,
|
|
394
|
+
headers: bearer(draft.resumeToken),
|
|
395
|
+
payload,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const submit = (app: FastifyInstance, draft: { id: string; resumeToken: string }) =>
|
|
399
|
+
app.inject({
|
|
400
|
+
method: "POST",
|
|
401
|
+
url: `/v1/project-listings/drafts/${draft.id}/submit`,
|
|
402
|
+
headers: bearer(draft.resumeToken),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const storedRow = (id: string): Row => {
|
|
406
|
+
const row = rows.find((r) => r["id"] === id);
|
|
407
|
+
expect(row).toBeDefined();
|
|
408
|
+
return row!;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const draftBody = (res: { json: () => unknown }) =>
|
|
412
|
+
(res.json() as { data: Record<string, unknown> }).data;
|
|
413
|
+
|
|
414
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
describe("the founder's identity in parts (#5407)", () => {
|
|
417
|
+
setupLeadFileEnv("project-listing-founder-identity");
|
|
418
|
+
let app: FastifyInstance;
|
|
419
|
+
|
|
420
|
+
beforeEach(async () => {
|
|
421
|
+
jest.clearAllMocks();
|
|
422
|
+
installStore();
|
|
423
|
+
app = await buildApp();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
afterEach(async () => {
|
|
427
|
+
await app.close();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe("`start` stores the parts AND composes the display name", () => {
|
|
431
|
+
it("writes first name, last name and phone to their own columns", async () => {
|
|
432
|
+
const draft = await startDraft(app);
|
|
433
|
+
|
|
434
|
+
const row = storedRow(draft.id);
|
|
435
|
+
expect(row["founderFirstName"]).toBe("Ada");
|
|
436
|
+
expect(row["founderLastName"]).toBe("Lovelace");
|
|
437
|
+
expect(row["founderPhone"]).toBe("+44 20 7946 0958");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("writes `founder_name` as the composed display string", async () => {
|
|
441
|
+
// The column every existing reader reads. Written out literally rather
|
|
442
|
+
// than by calling `composeFounderName`, so a change to the joiner fails
|
|
443
|
+
// this test instead of travelling through it.
|
|
444
|
+
const draft = await startDraft(app);
|
|
445
|
+
expect(storedRow(draft.id)["founderName"]).toBe("Ada Lovelace");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("composes from the PARTS even when the body also sends a display line", async () => {
|
|
449
|
+
// The stored line and the stored parts must never disagree — a row
|
|
450
|
+
// saying "A. Lovelace" beside parts saying "Ada" + "Lovelace" has no
|
|
451
|
+
// reader that can tell which is right.
|
|
452
|
+
const draft = await startDraft(app, {
|
|
453
|
+
...START_PAYLOAD,
|
|
454
|
+
founderName: "A. Lovelace",
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const row = storedRow(draft.id);
|
|
458
|
+
expect(row["founderName"]).toBe("Ada Lovelace");
|
|
459
|
+
expect(row["founderFirstName"]).toBe("Ada");
|
|
460
|
+
expect(row["founderLastName"]).toBe("Lovelace");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("carries the composed name through SUBMIT, which is where every reader picks it up", async () => {
|
|
464
|
+
// `submit` is the moment the slug is derived, the Lead is written and
|
|
465
|
+
// the Slack card is posted — all three read `founderName` off the row
|
|
466
|
+
// and none of them knows the parts exist.
|
|
467
|
+
const draft = await startDraft(app);
|
|
468
|
+
await patch(app, draft, { ...APPLICATION_WITHOUT_ADDRESS, address: ADDRESS_PARTS });
|
|
469
|
+
|
|
470
|
+
const res = await submit(app, draft);
|
|
471
|
+
|
|
472
|
+
expect(res.statusCode).toBe(200);
|
|
473
|
+
expect(storedRow(draft.id)["founderName"]).toBe("Ada Lovelace");
|
|
474
|
+
// The parts survive the transition beside it, unaltered.
|
|
475
|
+
expect(storedRow(draft.id)["founderFirstName"]).toBe("Ada");
|
|
476
|
+
expect(storedRow(draft.id)["founderLastName"]).toBe("Lovelace");
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("a caller that predates #5407 is untouched", () => {
|
|
481
|
+
it("stores a `founderName` line VERBATIM, with both parts null", async () => {
|
|
482
|
+
const draft = await startDraft(app, LEGACY_START_PAYLOAD);
|
|
483
|
+
|
|
484
|
+
const row = storedRow(draft.id);
|
|
485
|
+
// Nothing splits it. "Ada van der Berg" has no last space that means
|
|
486
|
+
// anything, and a wrong split would put an invented surname on the row
|
|
487
|
+
// the operator reviews with nothing to mark it as invented.
|
|
488
|
+
expect(row["founderName"]).toBe("Ada van der Berg");
|
|
489
|
+
expect(row["founderFirstName"]).toBeNull();
|
|
490
|
+
expect(row["founderLastName"]).toBeNull();
|
|
491
|
+
expect(row["founderPhone"]).toBeNull();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("refuses a body carrying no name at all, rather than writing a blank one", async () => {
|
|
495
|
+
const { founderName: _line, ...nameless } = LEGACY_START_PAYLOAD;
|
|
496
|
+
|
|
497
|
+
const res = await app.inject({
|
|
498
|
+
method: "POST",
|
|
499
|
+
url: "/v1/project-listings",
|
|
500
|
+
payload: nameless,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(res.statusCode).toBe(400);
|
|
504
|
+
expect(rows).toHaveLength(0);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("refuses whitespace-only parts, rather than storing a blank-looking name", async () => {
|
|
508
|
+
const res = await app.inject({
|
|
509
|
+
method: "POST",
|
|
510
|
+
url: "/v1/project-listings",
|
|
511
|
+
payload: { ...START_PAYLOAD, founderFirstName: " ", founderLastName: " " },
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
expect(res.statusCode).toBe(400);
|
|
515
|
+
expect(rows).toHaveLength(0);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
describe("the founder's own draft read echoes their own input back", () => {
|
|
520
|
+
it("carries the phone and an unverified stamp", async () => {
|
|
521
|
+
// The founder's own echo DOES carry the phone — it is their own step-one
|
|
522
|
+
// input, returned to the person who typed it. What must never carry it
|
|
523
|
+
// is the PUBLIC read, which `public.test.ts` asserts against a row with
|
|
524
|
+
// a real phone on it rather than a null one.
|
|
525
|
+
const draft = await startDraft(app);
|
|
526
|
+
const body = draftBody(await patch(app, draft, { headline: "A headline here" }));
|
|
527
|
+
expect(body["founderPhone"]).toBe("+44 20 7946 0958");
|
|
528
|
+
expect(body["emailVerifiedAt"]).toBeNull();
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe("the resume link's email verification (#5407)", () => {
|
|
534
|
+
setupLeadFileEnv("project-listing-verify-email");
|
|
535
|
+
let app: FastifyInstance;
|
|
536
|
+
|
|
537
|
+
beforeEach(async () => {
|
|
538
|
+
jest.clearAllMocks();
|
|
539
|
+
installStore();
|
|
540
|
+
app = await buildApp();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
afterEach(async () => {
|
|
544
|
+
await app.close();
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
/** Start a draft and wait for its resume email to have been dispatched. */
|
|
548
|
+
async function startAndMail(): Promise<{
|
|
549
|
+
id: string;
|
|
550
|
+
resumeToken: string;
|
|
551
|
+
nonce: string;
|
|
552
|
+
}> {
|
|
553
|
+
const draft = await startDraft(app);
|
|
554
|
+
// The send is fire-and-forget, so it lands a microtask or two after the
|
|
555
|
+
// 201. Waiting on the mock is what makes this deterministic.
|
|
556
|
+
await waitForEmail(draft.id);
|
|
557
|
+
return { ...draft, nonce: nonceFromEmail(draft.id) };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── THE LOAD-BEARING TEST ────────────────────────────────────────────────
|
|
561
|
+
//
|
|
562
|
+
// The first design of this route stamped on the resume token alone, and the
|
|
563
|
+
// resume token is in `start`'s own 201 body. So the forge was two calls:
|
|
564
|
+
// open a draft under a stranger's address, take the token out of the
|
|
565
|
+
// response, present it back. Everything else in this describe block can
|
|
566
|
+
// pass with that design; only this fails.
|
|
567
|
+
describe("the forge path — `start`'s own response is not enough", () => {
|
|
568
|
+
it("REFUSES a caller holding only what `start` returned", async () => {
|
|
569
|
+
const draft = await startDraft(app, {
|
|
570
|
+
...START_PAYLOAD,
|
|
571
|
+
founderEmail: "victim@example.com",
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Everything an attacker has after two HTTP calls: the id and the
|
|
575
|
+
// resume token, straight out of the 201.
|
|
576
|
+
const res = await verifyEmail(app, draft.id, draft.resumeToken, draft.resumeToken);
|
|
577
|
+
|
|
578
|
+
expect(res.statusCode).toBe(404);
|
|
579
|
+
expect(storedRow(draft.id)["emailVerifiedAt"]).toBeNull();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("never puts the nonce in `start`'s response, where the forge would find it", async () => {
|
|
583
|
+
const res = await app.inject({
|
|
584
|
+
method: "POST",
|
|
585
|
+
url: "/v1/project-listings",
|
|
586
|
+
payload: START_PAYLOAD,
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
expect(res.statusCode).toBe(201);
|
|
590
|
+
// The response is the id and the token, and nothing else — the route's
|
|
591
|
+
// hard-floor contract, unchanged by #5407.
|
|
592
|
+
const data = (res.json() as { data: Record<string, unknown> }).data;
|
|
593
|
+
expect(Object.keys(data).sort()).toEqual(["id", "resumeToken"]);
|
|
594
|
+
|
|
595
|
+
// And the nonce is nowhere in the body at all, under any key.
|
|
596
|
+
await waitForEmail(String(data["id"]));
|
|
597
|
+
expect(res.body).not.toContain(nonceFromEmail(String(data["id"])));
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("stores only a DIGEST — the nonce is not readable off the row", async () => {
|
|
601
|
+
const { id, nonce } = await startAndMail();
|
|
602
|
+
|
|
603
|
+
const row = storedRow(id);
|
|
604
|
+
expect(row["emailVerifyNonceHash"]).toEqual(expect.any(String));
|
|
605
|
+
expect(row["emailVerifyNonceHash"]).not.toBe(nonce);
|
|
606
|
+
// sha-256, hex.
|
|
607
|
+
expect(row["emailVerifyNonceHash"]).toMatch(/^[0-9a-f]{64}$/);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
describe("the emailed nonce is what verifies", () => {
|
|
612
|
+
it("stamps `email_verified_at` when the link's own nonce is presented", async () => {
|
|
613
|
+
const { id, resumeToken, nonce } = await startAndMail();
|
|
614
|
+
expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
|
|
615
|
+
|
|
616
|
+
const res = await verifyEmail(app, id, resumeToken, nonce);
|
|
617
|
+
|
|
618
|
+
expect(res.statusCode).toBe(200);
|
|
619
|
+
const stamped = storedRow(id)["emailVerifiedAt"];
|
|
620
|
+
expect(stamped).toBeInstanceOf(Date);
|
|
621
|
+
expect((res.json() as { data: { emailVerifiedAt: string } }).data.emailVerifiedAt).toBe(
|
|
622
|
+
(stamped as Date).toISOString(),
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("is WRITE-ONCE — a second call answers the FIRST stamp and moves nothing", async () => {
|
|
627
|
+
const { id, resumeToken, nonce } = await startAndMail();
|
|
628
|
+
|
|
629
|
+
const first = await verifyEmail(app, id, resumeToken, nonce);
|
|
630
|
+
const firstStamp = storedRow(id)["emailVerifiedAt"] as Date;
|
|
631
|
+
|
|
632
|
+
const second = await verifyEmail(app, id, resumeToken, nonce);
|
|
633
|
+
|
|
634
|
+
expect(second.statusCode).toBe(200);
|
|
635
|
+
expect((storedRow(id)["emailVerifiedAt"] as Date).getTime()).toBe(firstStamp.getTime());
|
|
636
|
+
expect(second.json()).toEqual(first.json());
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("refuses a WRONG nonce, with the same 404 as everything else", async () => {
|
|
640
|
+
const { id, resumeToken } = await startAndMail();
|
|
641
|
+
|
|
642
|
+
const res = await verifyEmail(app, id, resumeToken, "not-the-nonce");
|
|
643
|
+
|
|
644
|
+
expect(res.statusCode).toBe(404);
|
|
645
|
+
expect(res.json()).toEqual({
|
|
646
|
+
ok: false,
|
|
647
|
+
error: { code: "not_found", message: "Draft not found" },
|
|
648
|
+
});
|
|
649
|
+
expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("refuses another listing's nonce", async () => {
|
|
653
|
+
const mine = await startAndMail();
|
|
654
|
+
const theirs = await startDraft(app, {
|
|
655
|
+
...START_PAYLOAD,
|
|
656
|
+
founderEmail: "grace@example.com",
|
|
657
|
+
});
|
|
658
|
+
await waitForEmail(theirs.id);
|
|
659
|
+
|
|
660
|
+
const res = await verifyEmail(
|
|
661
|
+
app,
|
|
662
|
+
theirs.id,
|
|
663
|
+
theirs.resumeToken,
|
|
664
|
+
nonceFromEmail(mine.id),
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
expect(res.statusCode).toBe(404);
|
|
668
|
+
expect(storedRow(theirs.id)["emailVerifiedAt"]).toBeNull();
|
|
669
|
+
expect(storedRow(mine.id)["emailVerifiedAt"]).toBeNull();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("refuses a WRONG resume token even with the right nonce", async () => {
|
|
673
|
+
// The nonce carries the proof, but the token stays in the guard — a
|
|
674
|
+
// nonce that leaked on its own still opens nothing.
|
|
675
|
+
const { id, nonce } = await startAndMail();
|
|
676
|
+
|
|
677
|
+
const res = await verifyEmail(app, id, "not-the-token", nonce);
|
|
678
|
+
|
|
679
|
+
expect(res.statusCode).toBe(404);
|
|
680
|
+
expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
describe("the error surface is `patch`'s and `submit`'s", () => {
|
|
685
|
+
it("gives an UNKNOWN id the identical answer to a wrong nonce", async () => {
|
|
686
|
+
const { id, resumeToken, nonce } = await startAndMail();
|
|
687
|
+
const wrongNonce = await verifyEmail(app, id, resumeToken, "not-the-nonce");
|
|
688
|
+
const unknownId = await verifyEmail(
|
|
689
|
+
app,
|
|
690
|
+
"prjl_00000000-0000-7000-8000-0000000000ff",
|
|
691
|
+
resumeToken,
|
|
692
|
+
nonce,
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
expect(unknownId.statusCode).toBe(wrongNonce.statusCode);
|
|
696
|
+
expect(unknownId.json()).toEqual(wrongNonce.json());
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it("refuses a SUBMITTED row — submit clears both digests", async () => {
|
|
700
|
+
const { id, resumeToken, nonce } = await startAndMail();
|
|
701
|
+
const draft = { id, resumeToken };
|
|
702
|
+
await patch(app, draft, { ...APPLICATION_WITHOUT_ADDRESS, address: ADDRESS_PARTS });
|
|
703
|
+
expect((await submit(app, draft)).statusCode).toBe(200);
|
|
704
|
+
|
|
705
|
+
expect(storedRow(id)["emailVerifyNonceHash"]).toBeNull();
|
|
706
|
+
const res = await verifyEmail(app, id, resumeToken, nonce);
|
|
707
|
+
|
|
708
|
+
expect(res.statusCode).toBe(404);
|
|
709
|
+
expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("answers 401 when no token is presented at all", async () => {
|
|
713
|
+
const { id, nonce } = await startAndMail();
|
|
714
|
+
|
|
715
|
+
const res = await verifyEmail(app, id, undefined, nonce);
|
|
716
|
+
|
|
717
|
+
expect(res.statusCode).toBe(401);
|
|
718
|
+
expect(res.json()).toEqual({
|
|
719
|
+
ok: false,
|
|
720
|
+
error: { code: "unauthorized", message: "A resume token is required" },
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("answers 400 when the body carries no nonce", async () => {
|
|
725
|
+
const { id, resumeToken } = await startAndMail();
|
|
726
|
+
|
|
727
|
+
const res = await verifyEmail(app, id, resumeToken);
|
|
728
|
+
|
|
729
|
+
expect(res.statusCode).toBe(400);
|
|
730
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe("validation_failed");
|
|
731
|
+
expect(storedRow(id)["emailVerifiedAt"]).toBeNull();
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("answers 400 on a malformed id, before any credential is consulted", async () => {
|
|
735
|
+
const res = await app.inject({
|
|
736
|
+
method: "POST",
|
|
737
|
+
url: "/v1/project-listings/drafts/not-an-id/verify-email",
|
|
738
|
+
headers: bearer("anything"),
|
|
739
|
+
payload: { verifyNonce: "anything" },
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
expect(res.statusCode).toBe(400);
|
|
743
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe("validation_failed");
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("never returns either credential, on success or failure", async () => {
|
|
747
|
+
const { id, resumeToken, nonce } = await startAndMail();
|
|
748
|
+
const ok = await verifyEmail(app, id, resumeToken, nonce);
|
|
749
|
+
const bad = await verifyEmail(app, id, resumeToken, "not-the-nonce");
|
|
750
|
+
|
|
751
|
+
for (const res of [ok, bad]) {
|
|
752
|
+
expect(res.body).not.toContain(resumeToken);
|
|
753
|
+
expect(res.body).not.toContain(nonce);
|
|
754
|
+
}
|
|
755
|
+
// And nothing but the id and the stamp rides the success body.
|
|
756
|
+
expect(Object.keys((ok.json() as { data: Record<string, unknown> }).data).sort()).toEqual([
|
|
757
|
+
"emailVerifiedAt",
|
|
758
|
+
"id",
|
|
759
|
+
]);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("surfaces the stamp on the founder's own draft read", async () => {
|
|
763
|
+
const { id, resumeToken, nonce } = await startAndMail();
|
|
764
|
+
await verifyEmail(app, id, resumeToken, nonce);
|
|
765
|
+
|
|
766
|
+
const body = draftBody(
|
|
767
|
+
await patch(app, { id, resumeToken }, { headline: "A headline here" }),
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
expect(body["emailVerifiedAt"]).toBe(
|
|
771
|
+
(storedRow(id)["emailVerifiedAt"] as Date).toISOString(),
|
|
772
|
+
);
|
|
773
|
+
// The nonce digest is an internal column and never rides a response.
|
|
774
|
+
expect(body["emailVerifyNonceHash"]).toBeUndefined();
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe("a site that sends no resume email can never verify", () => {
|
|
779
|
+
it("mints a nonce, delivers none, and leaves the row unverifiable", async () => {
|
|
780
|
+
resumeEmailEnabled = false;
|
|
781
|
+
|
|
782
|
+
const draft = await startDraft(app);
|
|
783
|
+
await Promise.resolve();
|
|
784
|
+
|
|
785
|
+
// The nonce exists — `start` cannot know whether the send will happen.
|
|
786
|
+
expect(storedRow(draft.id)["emailVerifyNonceHash"]).toEqual(expect.any(String));
|
|
787
|
+
// But nothing was mailed, so nothing outside the server holds it.
|
|
788
|
+
expect(sendListingResumeEmail).not.toHaveBeenCalled();
|
|
789
|
+
// And the only credentials anyone has do not verify. That is the
|
|
790
|
+
// correct outcome, not a gap: nobody has read anything at that address.
|
|
791
|
+
const res = await verifyEmail(app, draft.id, draft.resumeToken, draft.resumeToken);
|
|
792
|
+
expect(res.statusCode).toBe(404);
|
|
793
|
+
expect(storedRow(draft.id)["emailVerifiedAt"]).toBeNull();
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
});
|