create-ailk 0.1.1 → 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 +210 -52
- package/dist/cli.js +0 -0
- 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 +14 -15
- 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 +35 -4
- 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 +13 -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 +28 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -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 +478 -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 +717 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +86 -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 +38 -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 +471 -133
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- 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 +602 -29
- 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/[locale]/layout.tsx +13 -1
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +20 -13
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -1
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +1 -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/content/_site.mdx +12 -0
- package/templates/database/CHANGELOG.md +94 -0
- package/templates/database/inbox/schema.prisma +165 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/db-generate-locked.sh +0 -0
- 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__/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__/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 -1278
- 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/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 -490
- 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
|
@@ -1,4 +1,11 @@
|
|
|
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,patch-draft,submit,public,me}.ts`, each of
|
|
5
|
+
* which ships unconditionally because `routes/project-listings/index.ts`'s
|
|
6
|
+
* multi-tenant literal `import()` forces it. Registration of the routes that
|
|
7
|
+
* call into this service stays gated (`isModuleEnabled("signal-collectors")`).
|
|
8
|
+
*
|
|
2
9
|
* @file project-listings.ts
|
|
3
10
|
* @description The founder's-application-row service (#4998, reshaped by
|
|
4
11
|
* #5036) — writes, reads, and transitions `ProjectListing` (ADR 0009 D3
|
|
@@ -42,10 +49,15 @@ import {
|
|
|
42
49
|
clampWindowDays,
|
|
43
50
|
buildProjectListingDraftPatchSchema,
|
|
44
51
|
buildProjectListingSubmitSchema,
|
|
52
|
+
composeAddressLine,
|
|
53
|
+
composeFounderName,
|
|
54
|
+
flowAddressValueSchema,
|
|
55
|
+
publicSiteAnswers,
|
|
45
56
|
PROJECT_LISTING_CONCURRENT_LIMIT,
|
|
46
57
|
PROJECT_LISTING_NON_TERMINAL_STATUSES,
|
|
47
58
|
} from "@working-theory/validation";
|
|
48
59
|
import type {
|
|
60
|
+
FlowAddressValue,
|
|
49
61
|
ProjectListing,
|
|
50
62
|
ProjectListingDraftPatchInput,
|
|
51
63
|
ProjectListingFaqs,
|
|
@@ -56,6 +68,13 @@ import type {
|
|
|
56
68
|
|
|
57
69
|
import { prisma } from "../lib/prisma.js";
|
|
58
70
|
|
|
71
|
+
import {
|
|
72
|
+
consentConfigForSite,
|
|
73
|
+
MAX_CONSENT_KEYS,
|
|
74
|
+
recordConsent,
|
|
75
|
+
recordLegacyConsent,
|
|
76
|
+
requiredConsentUnmet,
|
|
77
|
+
} from "./consent.js";
|
|
59
78
|
import { routeLead, type SourceMeta } from "./lead-routing.js";
|
|
60
79
|
import { listingConfigFor } from "./listing-config.js";
|
|
61
80
|
|
|
@@ -205,9 +224,27 @@ const PROJECT_LISTING_SELECT = {
|
|
|
205
224
|
faqs: true,
|
|
206
225
|
anythingElse: true,
|
|
207
226
|
address: true,
|
|
227
|
+
// #5279 — the structured parts beside the display string.
|
|
228
|
+
addressLine1: true,
|
|
229
|
+
addressLine2: true,
|
|
230
|
+
addressCity: true,
|
|
231
|
+
addressRegion: true,
|
|
232
|
+
addressPostalCode: true,
|
|
233
|
+
addressCountry: true,
|
|
208
234
|
questions: true,
|
|
235
|
+
// #5285 — the site's own answers, and the window the applicant asked for.
|
|
236
|
+
// `windowDays` rides the select because `submit` re-validates the assembled
|
|
237
|
+
// application, and `requestedWindowDays` is one of its fields now.
|
|
238
|
+
siteAnswers: true,
|
|
239
|
+
windowDays: true,
|
|
209
240
|
founderName: true,
|
|
241
|
+
// #5407 — the name parts beside the composed display string, the phone, and
|
|
242
|
+
// the resume-link verification stamp.
|
|
243
|
+
founderFirstName: true,
|
|
244
|
+
founderLastName: true,
|
|
210
245
|
founderEmail: true,
|
|
246
|
+
founderPhone: true,
|
|
247
|
+
emailVerifiedAt: true,
|
|
211
248
|
founderCompany: true,
|
|
212
249
|
headline: true,
|
|
213
250
|
logoMarkUrl: true,
|
|
@@ -228,6 +265,81 @@ type ProjectListingRow = Prisma.ProjectListingGetPayload<{
|
|
|
228
265
|
select: typeof PROJECT_LISTING_SELECT;
|
|
229
266
|
}>;
|
|
230
267
|
|
|
268
|
+
/**
|
|
269
|
+
* The structured address parts of a row, or null (#5279).
|
|
270
|
+
*
|
|
271
|
+
* Exported because `project-listing-decision.ts` renders the same row shape
|
|
272
|
+
* and must answer the same way — two readers guessing separately at what
|
|
273
|
+
* "has parts" means is exactly how one of them starts emitting a half-filled
|
|
274
|
+
* object.
|
|
275
|
+
*
|
|
276
|
+
* The rule is the composite's REQUIRED set: the five non-optional parts are
|
|
277
|
+
* all present, or there are no parts. `addressLine2` is optional in the
|
|
278
|
+
* fieldset, so it rides along when present and is simply absent when not. A
|
|
279
|
+
* row written before #5279 has all six columns null and answers null here —
|
|
280
|
+
* never a partial object reconstructed from `address`, which nothing in this
|
|
281
|
+
* repo parses.
|
|
282
|
+
*
|
|
283
|
+
* `.safeParse` rather than a cast: these are six nullable TEXT columns, and
|
|
284
|
+
* the emitted object must satisfy `flowAddressValueSchema` or the row fails
|
|
285
|
+
* its own response schema downstream. A shape that somehow does not (a
|
|
286
|
+
* `country` that is not an alpha-2 code, say) answers null rather than
|
|
287
|
+
* putting an invalid address on the PUBLIC read.
|
|
288
|
+
*/
|
|
289
|
+
export function addressPartsFromRow(row: {
|
|
290
|
+
addressLine1: string | null;
|
|
291
|
+
addressLine2: string | null;
|
|
292
|
+
addressCity: string | null;
|
|
293
|
+
addressRegion: string | null;
|
|
294
|
+
addressPostalCode: string | null;
|
|
295
|
+
addressCountry: string | null;
|
|
296
|
+
}): FlowAddressValue | null {
|
|
297
|
+
if (
|
|
298
|
+
row.addressLine1 === null ||
|
|
299
|
+
row.addressCity === null ||
|
|
300
|
+
row.addressRegion === null ||
|
|
301
|
+
row.addressPostalCode === null ||
|
|
302
|
+
row.addressCountry === null
|
|
303
|
+
) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const parsed = flowAddressValueSchema.safeParse({
|
|
307
|
+
addressLine1: row.addressLine1,
|
|
308
|
+
...(row.addressLine2 !== null && { addressLine2: row.addressLine2 }),
|
|
309
|
+
city: row.addressCity,
|
|
310
|
+
region: row.addressRegion,
|
|
311
|
+
postalCode: row.addressPostalCode,
|
|
312
|
+
country: row.addressCountry,
|
|
313
|
+
});
|
|
314
|
+
return parsed.success ? parsed.data : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The site-answers object of a row, or null (#5285).
|
|
319
|
+
*
|
|
320
|
+
* Exported for the same reason `addressPartsFromRow` is: the operator's
|
|
321
|
+
* decision payload renders the same row shape and must answer the same way.
|
|
322
|
+
*
|
|
323
|
+
* The column is `Json?`, so Prisma types it as `JsonValue` — an array, a
|
|
324
|
+
* number and a string all satisfy it. Only ever written by
|
|
325
|
+
* `draftUpdateData` from a body the site's own declarations validated, so a
|
|
326
|
+
* row holding anything else is a row somebody wrote around this service.
|
|
327
|
+
* A non-object answers null rather than being cast: a value of the wrong
|
|
328
|
+
* SHAPE reaching the response schema would fail the whole read, and one
|
|
329
|
+
* malformed row must not take a listing page down.
|
|
330
|
+
*
|
|
331
|
+
* Non-string members are dropped for the same reason — the store is
|
|
332
|
+
* `Record<string, string>`, and what is handed back has to actually be one.
|
|
333
|
+
*/
|
|
334
|
+
export function siteAnswersFromRow(value: Prisma.JsonValue | null): Record<string, string> | null {
|
|
335
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
336
|
+
return Object.fromEntries(
|
|
337
|
+
Object.entries(value).filter(
|
|
338
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
231
343
|
/**
|
|
232
344
|
* `now` is passed rather than read inside so one read renders every row of a
|
|
233
345
|
* list against a single instant. `featuredEnabled` is the site's config
|
|
@@ -257,12 +369,25 @@ function toResponseRow(
|
|
|
257
369
|
faqs: row.faqs as unknown as ProjectListingFaqs | null,
|
|
258
370
|
anythingElse: row.anythingElse,
|
|
259
371
|
address: row.address,
|
|
372
|
+
addressParts: addressPartsFromRow(row),
|
|
260
373
|
founderName: row.founderName,
|
|
374
|
+
founderFirstName: row.founderFirstName,
|
|
375
|
+
founderLastName: row.founderLastName,
|
|
261
376
|
founderEmail: row.founderEmail,
|
|
377
|
+
founderPhone: row.founderPhone,
|
|
378
|
+
// #5407 — the operator's verification filter at review. Null means "no
|
|
379
|
+
// opening of the resume link recorded", never "verification failed".
|
|
380
|
+
emailVerifiedAt: row.emailVerifiedAt ? row.emailVerifiedAt.toISOString() : null,
|
|
262
381
|
founderCompany: row.founderCompany,
|
|
263
382
|
questions: row.questions as unknown as ProjectListingQuestions | null,
|
|
264
383
|
logoMarkUrl: row.logoMarkUrl,
|
|
265
384
|
logoWordmarkUrl: row.logoWordmarkUrl,
|
|
385
|
+
// #5285 — UNFILTERED here, deliberately. This row shape serves the tenant
|
|
386
|
+
// dashboard and the founder's own draft read, both of which are entitled
|
|
387
|
+
// to every answer. The PUBLIC read is the one that must not carry an
|
|
388
|
+
// `operator-only` answer, and it narrows this through `publicSiteAnswers`
|
|
389
|
+
// in `publicProjectListings` — one filter, on the one read that publishes.
|
|
390
|
+
siteAnswers: siteAnswersFromRow(row.siteAnswers),
|
|
266
391
|
featured: featuredEnabled && row.featured,
|
|
267
392
|
// The row's OWN flag decides the closed exemption (#5036 D6b), not the
|
|
268
393
|
// site's `featuredEnabled` switch: that switch governs whether the flag
|
|
@@ -384,6 +509,42 @@ function hashResumeToken(token: string): string {
|
|
|
384
509
|
return createHash("sha256").update(token).digest("hex");
|
|
385
510
|
}
|
|
386
511
|
|
|
512
|
+
// ─── The email-verification nonce (#5407) ──────────────────────────────────
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Mint the nonce that PROVES the founder read mail sent to `founderEmail`.
|
|
516
|
+
*
|
|
517
|
+
* WHY A SECOND CREDENTIAL EXISTS AT ALL, since the obvious design is to reuse
|
|
518
|
+
* the resume token the emailed link already carries. That design cannot work:
|
|
519
|
+
* `start` returns the resume token in its own 201 body, to whoever posted the
|
|
520
|
+
* form. The emailed link is a SECOND COPY of a credential the applicant
|
|
521
|
+
* already holds, so keying verification on it would let two HTTP calls — open
|
|
522
|
+
* a draft under a stranger's address, take the token out of the response,
|
|
523
|
+
* present it back — forge the stamp with no mailbox involved. Found by this
|
|
524
|
+
* change's own security review, before it shipped.
|
|
525
|
+
*
|
|
526
|
+
* This nonce closes that because of where it does NOT go. It is minted here,
|
|
527
|
+
* digested into `emailVerifyNonceHash`, and returned to exactly one caller —
|
|
528
|
+
* `start`, which hands it to `sendListingResumeLink` and to nothing else. It
|
|
529
|
+
* is in no response body, no log line, and no browser that filled the form.
|
|
530
|
+
* The only way to hold it is to have read the email.
|
|
531
|
+
*
|
|
532
|
+
* Same 256 bits as the resume token, for the same reason.
|
|
533
|
+
*/
|
|
534
|
+
function mintVerifyNonce(): string {
|
|
535
|
+
return randomBytes(RESUME_TOKEN_BYTES).toString("base64url");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* What `emailVerifyNonceHash` holds. The same plain sha-256 as
|
|
540
|
+
* `hashResumeToken`, for the same reason — the input is CSPRNG output, not a
|
|
541
|
+
* human-chosen secret — and kept as its own function rather than aliased so
|
|
542
|
+
* no call site has to read "resume token" while meaning the nonce.
|
|
543
|
+
*/
|
|
544
|
+
function hashVerifyNonce(nonce: string): string {
|
|
545
|
+
return createHash("sha256").update(nonce).digest("hex");
|
|
546
|
+
}
|
|
547
|
+
|
|
387
548
|
/**
|
|
388
549
|
* Resolve the ONE draft a token grants access to (#5036 D1).
|
|
389
550
|
*
|
|
@@ -410,17 +571,38 @@ async function findDraftByToken(
|
|
|
410
571
|
// ─── start (POST, anonymous) ────────────────────────────────────────────────
|
|
411
572
|
|
|
412
573
|
/**
|
|
413
|
-
* `start` has
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
574
|
+
* `start` has one failure mode of its own to report (#5275): a `consents`
|
|
575
|
+
* map that does not GRANT every purpose the site's registry marks required —
|
|
576
|
+
* declined, or simply absent. The legacy literal says the founder consented;
|
|
577
|
+
* a `false` (or nothing) beside it for the required purpose says they did
|
|
578
|
+
* not — the body contradicts itself, and the row must not be created on it
|
|
579
|
+
* (it would go on to publish the founder's address against a recorded
|
|
580
|
+
* decline, or with no evidence row at all). An oversized map is the same
|
|
581
|
+
* 400. Everything else is as before: the site's config falls back to the
|
|
582
|
+
* platform defaults rather than erroring, and a Prisma failure is a 500
|
|
583
|
+
* through the framework. The Lead write — the step that used to give this
|
|
584
|
+
* operation a `persist_failed`/`config_missing` surface — moved to `submit`.
|
|
418
585
|
*/
|
|
419
|
-
export type StartProjectListingDraftResult =
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
586
|
+
export type StartProjectListingDraftResult =
|
|
587
|
+
| {
|
|
588
|
+
ok: true;
|
|
589
|
+
id: string;
|
|
590
|
+
resumeToken: string;
|
|
591
|
+
/**
|
|
592
|
+
* #5407 — the email-verification nonce, for the RESUME-EMAIL BUILDER
|
|
593
|
+
* and for nothing else.
|
|
594
|
+
*
|
|
595
|
+
* A sibling of the result rather than a field of anything the route
|
|
596
|
+
* hands to a response schema, deliberately (lesson 05): `start`'s 201
|
|
597
|
+
* body is `projectListingStartResponseSchema`, which is `.strict()` and
|
|
598
|
+
* carries `id` and `resumeToken` only. That route's request and
|
|
599
|
+
* response shapes are a declared hard floor and are unchanged — this
|
|
600
|
+
* value reaches `sendListingResumeLink` from the route's local
|
|
601
|
+
* variable and never touches the wire.
|
|
602
|
+
*/
|
|
603
|
+
verifyNonce: string;
|
|
604
|
+
}
|
|
605
|
+
| { ok: false; error: { kind: "invalid_input"; message: string } };
|
|
424
606
|
|
|
425
607
|
/**
|
|
426
608
|
* Create a `draft` from step one — the founder's name, email and consent.
|
|
@@ -444,27 +626,211 @@ export async function startProjectListingDraft(
|
|
|
444
626
|
siteId: string,
|
|
445
627
|
input: ProjectListingStartInput,
|
|
446
628
|
): Promise<StartProjectListingDraftResult> {
|
|
447
|
-
const {
|
|
629
|
+
const { requestedWindowDays } = input;
|
|
630
|
+
// #5407 — the NOT NULL display column. `composeFounderName` builds it from
|
|
631
|
+
// the PARTS wherever both are written (even beside a `founderName` the body
|
|
632
|
+
// also sent, so the stored line and the stored parts can never disagree),
|
|
633
|
+
// and stores a line sent on its own verbatim. ONE WAY ONLY: nothing here or
|
|
634
|
+
// anywhere else derives parts back out of a line. The schema has already
|
|
635
|
+
// refused a body carrying neither shape, so this always produces a string.
|
|
636
|
+
const founderName = composeFounderName(input);
|
|
448
637
|
const founderEmail = normalizeFounderEmail(input.founderEmail);
|
|
449
638
|
|
|
450
639
|
const listingConfig = await listingConfigFor(siteId);
|
|
451
640
|
const windowDays = clampWindowDays(requestedWindowDays, listingConfig);
|
|
452
641
|
|
|
642
|
+
// #5275 — refuse BEFORE the row exists: a required purpose not granted
|
|
643
|
+
// (declined or absent) is a body that contradicts its own `consent: true`,
|
|
644
|
+
// and an oversized map is not a form's answer.
|
|
645
|
+
const consentConfig = await consentConfigForSite(siteId);
|
|
646
|
+
if (input.consents) {
|
|
647
|
+
if (Object.keys(input.consents).length > MAX_CONSENT_KEYS) {
|
|
648
|
+
return {
|
|
649
|
+
ok: false,
|
|
650
|
+
error: {
|
|
651
|
+
kind: "invalid_input",
|
|
652
|
+
message: `consents carries more than ${MAX_CONSENT_KEYS} purposes`,
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
const unmet = requiredConsentUnmet(consentConfig, input.consents);
|
|
657
|
+
if (unmet !== undefined) {
|
|
658
|
+
return {
|
|
659
|
+
ok: false,
|
|
660
|
+
error: {
|
|
661
|
+
kind: "invalid_input",
|
|
662
|
+
message: `required consent purpose "${unmet}" was not granted`,
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
453
668
|
const resumeToken = mintResumeToken();
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
669
|
+
// #5407 — minted on EVERY draft, whether or not the site sends the resume
|
|
670
|
+
// email. `start` cannot know: the send is decided asynchronously inside
|
|
671
|
+
// `sendListingResumeLink`, against the site's own config. A site running no
|
|
672
|
+
// resume email therefore stores a nonce nobody is ever handed, and nothing
|
|
673
|
+
// on that row can ever verify — which is the correct outcome rather than a
|
|
674
|
+
// gap, because no one has read anything at that address.
|
|
675
|
+
const verifyNonce = mintVerifyNonce();
|
|
676
|
+
// #5275 — the row and its grant record land in ONE transaction: from here
|
|
677
|
+
// on `consentAt` and the ConsentGrant row are stamped by the SAME
|
|
678
|
+
// affirmative act, and a grant write that fails takes the row with it
|
|
679
|
+
// rather than stranding a "consented" listing with no evidence (security
|
|
680
|
+
// review, Medium 2). A body carrying `consents` records the wording the
|
|
681
|
+
// founder read (resolved server-side from the same registry the renderer
|
|
682
|
+
// used, D7); a body carrying only the legacy `consent: true` literal
|
|
683
|
+
// records one grant marked unrecoverable (D11) rather than today's
|
|
684
|
+
// wording, which nobody was shown.
|
|
685
|
+
const consents = input.consents;
|
|
686
|
+
const locale = input.locale ?? "en";
|
|
687
|
+
const row = await prisma.$transaction(async (tx) => {
|
|
688
|
+
const created = await tx.projectListing.create({
|
|
689
|
+
data: {
|
|
690
|
+
siteId,
|
|
691
|
+
founderName,
|
|
692
|
+
// #5407 — the parts as sent, beside the composed line. Absent where a
|
|
693
|
+
// caller sent only a display line, which is the shape every row
|
|
694
|
+
// written before #5407 carries; nothing here ever splits that line.
|
|
695
|
+
...(input.founderFirstName !== undefined && {
|
|
696
|
+
founderFirstName: input.founderFirstName,
|
|
697
|
+
}),
|
|
698
|
+
...(input.founderLastName !== undefined && {
|
|
699
|
+
founderLastName: input.founderLastName,
|
|
700
|
+
}),
|
|
701
|
+
founderEmail,
|
|
702
|
+
...(input.founderPhone !== undefined && { founderPhone: input.founderPhone }),
|
|
703
|
+
status: "draft",
|
|
704
|
+
consentAt: new Date(),
|
|
705
|
+
resumeTokenHash: hashResumeToken(resumeToken),
|
|
706
|
+
emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
|
|
707
|
+
windowDays,
|
|
708
|
+
},
|
|
709
|
+
select: { id: true },
|
|
710
|
+
});
|
|
711
|
+
const subject = { siteId, subjectType: "project_listing", subjectId: created.id };
|
|
712
|
+
if (consents) {
|
|
713
|
+
await recordConsent({ ...subject, locale, config: consentConfig, consents }, tx);
|
|
714
|
+
} else {
|
|
715
|
+
await recordLegacyConsent({ ...subject, config: consentConfig }, tx);
|
|
716
|
+
}
|
|
717
|
+
return created;
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
return { ok: true, id: row.id, resumeToken, verifyNonce };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ─── verify-email (POST, anonymous, nonce-gated) ───────────────────────────
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* THE NONCE HAS NO EXPIRY, and shares the resume token's lifetime exactly: it
|
|
727
|
+
* lives until `submit` clears both digests, and an unsubmitted draft is never
|
|
728
|
+
* swept (`DRAFT_HOLDS_A_SLOT_DAYS` bounds the QUOTA a draft occupies, not the
|
|
729
|
+
* row). A resume email sitting in a mailbox for a year still verifies that
|
|
730
|
+
* draft a year later.
|
|
731
|
+
*
|
|
732
|
+
* That is acceptable rather than overlooked, and for one reason: the same
|
|
733
|
+
* message carries the resume token, so anyone who can read it can already
|
|
734
|
+
* resume and submit the draft outright. A TTL would have to cover both
|
|
735
|
+
* credentials to mean anything, and that is a change to #5048's resume
|
|
736
|
+
* contract rather than to this column.
|
|
737
|
+
*
|
|
738
|
+
* It is also NOT cleared on a successful stamp — the fallback read below
|
|
739
|
+
* needs it to answer a repeat call idempotently.
|
|
740
|
+
*/
|
|
741
|
+
export type VerifyProjectListingDraftEmailResult =
|
|
742
|
+
| { ok: true; value: { id: string; emailVerifiedAt: Date } }
|
|
743
|
+
| { ok: false; error: { kind: "not_found"; message: string } };
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Record that somebody who can read mail at `founderEmail` opened this
|
|
747
|
+
* draft's emailed link (#5407).
|
|
748
|
+
*
|
|
749
|
+
* THE PRECISION IN THAT SENTENCE IS DELIBERATE. It is evidence about the
|
|
750
|
+
* ADDRESS, not about the person driving the draft. Whoever posted `start`
|
|
751
|
+
* chose the address and holds the resume token, so an attacker can open a
|
|
752
|
+
* draft under a stranger's address and the platform will mail that stranger a
|
|
753
|
+
* link; if they click it, the attacker's draft is stamped. Binding the stamp
|
|
754
|
+
* to the application's CONTENT, or to `ownerUserId`, is what would close that,
|
|
755
|
+
* and neither is in this issue. What the stamp rules out is the case it was
|
|
756
|
+
* built for: a stamp obtained with no mailbox involved at all.
|
|
757
|
+
*
|
|
758
|
+
* WHAT IT PROVES, and why it takes TWO credentials. The caller must present
|
|
759
|
+
* both the draft's resume token and its verification NONCE, and only the
|
|
760
|
+
* second one carries the proof:
|
|
761
|
+
*
|
|
762
|
+
* - the RESUME TOKEN says "you opened this draft". `start` returns it in
|
|
763
|
+
* its own 201 body, so anyone who posted the form has it. On its own it
|
|
764
|
+
* proves nothing about an address, and a design that stamped on it alone
|
|
765
|
+
* would be forgeable in two HTTP calls — open a draft under a stranger's
|
|
766
|
+
* address, take the token out of the response, present it back. That is
|
|
767
|
+
* the design this one replaced, caught by its own security review.
|
|
768
|
+
* - the NONCE says "you read mail sent to `founderEmail`". It is minted at
|
|
769
|
+
* `start`, digested onto the row, and handed to nothing but
|
|
770
|
+
* `sendListingResumeLink`. It is in no response body and no log line, and
|
|
771
|
+
* the only place it exists outside this server is the `?verify=`
|
|
772
|
+
* parameter of the link inside the email.
|
|
773
|
+
*
|
|
774
|
+
* The token stays in the guard anyway: it costs one term in a `where` and it
|
|
775
|
+
* means a nonce that somehow leaked alone still opens nothing.
|
|
776
|
+
*
|
|
777
|
+
* What it does NOT say is who the founder is. `ownerUserId` (#5295) is the
|
|
778
|
+
* column that carries an identity claim and still needs a signed-in user
|
|
779
|
+
* whose own address the auth system verified.
|
|
780
|
+
*
|
|
781
|
+
* WRITE-ONCE, BY COMPARE-AND-SWAP. `emailVerifiedAt: null` is in the `where`,
|
|
782
|
+
* so the stamp is the atomic transition and two concurrent openings of the
|
|
783
|
+
* same link produce one stamp between them rather than a last-writer-wins
|
|
784
|
+
* race. A repeat opening reads back the FIRST stamp, so the call is
|
|
785
|
+
* idempotent in its effect and in its answer. It is not indistinguishable —
|
|
786
|
+
* a repeat answers an older timestamp than "now" — and does not need to be:
|
|
787
|
+
* only a holder of both credentials can make the call at all, and they are
|
|
788
|
+
* the one person entitled to know when their own link was first opened.
|
|
789
|
+
*
|
|
790
|
+
* ONE ANSWER FOR EVERY FAILURE. A wrong token, a wrong nonce, a credential
|
|
791
|
+
* bound to a different listing, an unknown id and an already-submitted row
|
|
792
|
+
* are all `not_found` — exactly what `patch` and `submit` answer. The
|
|
793
|
+
* anonymous surface must not distinguish "exists but not yours" from "does
|
|
794
|
+
* not exist", and a verification route least of all: its whole input is a
|
|
795
|
+
* guessable-in-principle id and two secrets, so a distinguishable response
|
|
796
|
+
* would turn it into an oracle for which drafts exist and, worse, into a
|
|
797
|
+
* nonce-guessing feedback channel.
|
|
798
|
+
*
|
|
799
|
+
* Neither credential is ever logged, returned, or compared in application
|
|
800
|
+
* code — only their sha-256 digests reach the query.
|
|
801
|
+
*/
|
|
802
|
+
export async function verifyProjectListingDraftEmail(
|
|
803
|
+
id: string,
|
|
804
|
+
resumeToken: string,
|
|
805
|
+
verifyNonce: string,
|
|
806
|
+
): Promise<VerifyProjectListingDraftEmailResult> {
|
|
807
|
+
const guard = {
|
|
808
|
+
id,
|
|
809
|
+
status: "draft",
|
|
810
|
+
resumeTokenHash: hashResumeToken(resumeToken),
|
|
811
|
+
emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
|
|
812
|
+
};
|
|
813
|
+
const emailVerifiedAt = new Date();
|
|
814
|
+
|
|
815
|
+
const stamped = await prisma.projectListing.updateMany({
|
|
816
|
+
where: { ...guard, emailVerifiedAt: null },
|
|
817
|
+
data: { emailVerifiedAt },
|
|
465
818
|
});
|
|
819
|
+
if (stamped.count > 0) return { ok: true, value: { id, emailVerifiedAt } };
|
|
466
820
|
|
|
467
|
-
|
|
821
|
+
// Zero rows means one of two things, and they answer differently: the draft
|
|
822
|
+
// is already stamped (idempotent success, carrying the FIRST stamp), or the
|
|
823
|
+
// credentials do not open this draft (`not_found`). This read is what tells
|
|
824
|
+
// them apart, and it is gated by the same four terms the write was — so it
|
|
825
|
+
// cannot report a draft the caller's credentials do not open.
|
|
826
|
+
const row = await prisma.projectListing.findFirst({
|
|
827
|
+
where: guard,
|
|
828
|
+
select: { id: true, emailVerifiedAt: true },
|
|
829
|
+
});
|
|
830
|
+
if (row?.emailVerifiedAt) {
|
|
831
|
+
return { ok: true, value: { id: row.id, emailVerifiedAt: row.emailVerifiedAt } };
|
|
832
|
+
}
|
|
833
|
+
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
468
834
|
}
|
|
469
835
|
|
|
470
836
|
// ─── patch (PATCH, anonymous, token-gated) ──────────────────────────────────
|
|
@@ -478,15 +844,47 @@ export type PatchProjectListingDraftResult =
|
|
|
478
844
|
| { kind: "invalid_input"; message: string };
|
|
479
845
|
};
|
|
480
846
|
|
|
847
|
+
/**
|
|
848
|
+
* What `draftUpdateData` cannot read off the patch body (#5285).
|
|
849
|
+
*
|
|
850
|
+
* Both members are the SERVER's, and that is why they are a second argument
|
|
851
|
+
* rather than two more payload fields: `windowDays` is the applicant's
|
|
852
|
+
* request already clamped against the site's own maximum, and
|
|
853
|
+
* `storedSiteAnswers` is what is on the row, which the merge needs and a
|
|
854
|
+
* request body must never be able to supply.
|
|
855
|
+
*/
|
|
856
|
+
interface DraftUpdateContext {
|
|
857
|
+
/** The clamped window, when the patch asked for one. */
|
|
858
|
+
windowDays?: number;
|
|
859
|
+
/** The answers already stored, for the merge. */
|
|
860
|
+
storedSiteAnswers?: Record<string, string>;
|
|
861
|
+
}
|
|
862
|
+
|
|
481
863
|
/**
|
|
482
864
|
* Translate a validated partial application into a Prisma update.
|
|
483
865
|
*
|
|
484
866
|
* Only the keys the caller actually sent are written: an absent key is
|
|
485
867
|
* `undefined`, which Prisma omits from the UPDATE, so patching one field
|
|
486
868
|
* cannot blank the nine the founder filled in on earlier steps.
|
|
869
|
+
*
|
|
870
|
+
* #5279 — THIS IS WHERE THE DISPLAY ADDRESS IS COMPOSED, and the only place.
|
|
871
|
+
* `address` is a union of the legacy display STRING and the structured PARTS
|
|
872
|
+
* (`flowAddressValueSchema`), and the two branches write different columns:
|
|
873
|
+
*
|
|
874
|
+
* string → `address` verbatim, no part column touched. A founder who typed
|
|
875
|
+
* a line gets that line stored exactly as typed, and a draft
|
|
876
|
+
* written before #5279 keeps patching as it always did.
|
|
877
|
+
* parts → the six part columns, PLUS `address` composed from them by
|
|
878
|
+
* `composeAddressLine`.
|
|
879
|
+
*
|
|
880
|
+
* The derivation runs ONE WAY ONLY. Nothing anywhere derives parts from a
|
|
881
|
+
* line: address parsing guesses, and a wrong guess silently corrupts the
|
|
882
|
+
* party identification a listing's privacy notice points at (#5036 D6), with
|
|
883
|
+
* nothing on the row to mark it as invented.
|
|
487
884
|
*/
|
|
488
885
|
function draftUpdateData(
|
|
489
886
|
patch: ProjectListingDraftPatchInput,
|
|
887
|
+
context: DraftUpdateContext,
|
|
490
888
|
): Prisma.ProjectListingUpdateInput {
|
|
491
889
|
return {
|
|
492
890
|
...(patch.name !== undefined && { name: patch.name }),
|
|
@@ -499,11 +897,45 @@ function draftUpdateData(
|
|
|
499
897
|
...(patch.teamComposition !== undefined && { teamComposition: patch.teamComposition }),
|
|
500
898
|
...(patch.faqs !== undefined && { faqs: patch.faqs as unknown as Prisma.InputJsonValue }),
|
|
501
899
|
...(patch.anythingElse !== undefined && { anythingElse: patch.anythingElse }),
|
|
502
|
-
...(patch.address !== undefined &&
|
|
900
|
+
...(patch.address !== undefined &&
|
|
901
|
+
(typeof patch.address === "string"
|
|
902
|
+
? { address: patch.address }
|
|
903
|
+
: {
|
|
904
|
+
address: composeAddressLine(patch.address),
|
|
905
|
+
addressLine1: patch.address.addressLine1,
|
|
906
|
+
addressLine2: patch.address.addressLine2 ?? null,
|
|
907
|
+
addressCity: patch.address.city,
|
|
908
|
+
addressRegion: patch.address.region,
|
|
909
|
+
addressPostalCode: patch.address.postalCode,
|
|
910
|
+
addressCountry: patch.address.country,
|
|
911
|
+
})),
|
|
503
912
|
...(patch.founderCompany !== undefined && { founderCompany: patch.founderCompany }),
|
|
504
913
|
...(patch.questions !== undefined && {
|
|
505
914
|
questions: patch.questions as unknown as Prisma.InputJsonValue,
|
|
506
915
|
}),
|
|
916
|
+
...(patch.logoMarkUrl !== undefined && { logoMarkUrl: patch.logoMarkUrl }),
|
|
917
|
+
...(patch.logoWordmarkUrl !== undefined && {
|
|
918
|
+
logoWordmarkUrl: patch.logoWordmarkUrl,
|
|
919
|
+
}),
|
|
920
|
+
// #5285 — the applicant's REQUEST, already clamped by the caller against
|
|
921
|
+
// the site's own `maxWindowDays`. It is written to `windowDays`, the same
|
|
922
|
+
// column `start` writes, because there is one window per listing and the
|
|
923
|
+
// screen that asks for it is the site's choice of screen, not a second
|
|
924
|
+
// field. Clamped and not refused, for the reason `startProjectListingDraft`
|
|
925
|
+
// gives: a founder who could post their own bound could post an unbounded
|
|
926
|
+
// one.
|
|
927
|
+
...(context.windowDays !== undefined && { windowDays: context.windowDays }),
|
|
928
|
+
// #5285 — MERGED, never replaced. A founder patches one screen at a time,
|
|
929
|
+
// so a body carrying screen four's two answers must not blank screen
|
|
930
|
+
// two's. The incoming keys win; everything already stored is carried.
|
|
931
|
+
// Every incoming id was checked against the site's declarations by the
|
|
932
|
+
// compiled patch schema, so nothing undeclared can enter here.
|
|
933
|
+
...(patch.siteAnswers !== undefined && {
|
|
934
|
+
siteAnswers: {
|
|
935
|
+
...context.storedSiteAnswers,
|
|
936
|
+
...patch.siteAnswers,
|
|
937
|
+
} as Prisma.InputJsonValue,
|
|
938
|
+
}),
|
|
507
939
|
};
|
|
508
940
|
}
|
|
509
941
|
|
|
@@ -526,7 +958,13 @@ export async function patchProjectListingDraft(
|
|
|
526
958
|
// still answers `not_found` before any body is inspected.
|
|
527
959
|
const owner = await prisma.projectListing.findFirst({
|
|
528
960
|
where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
|
|
529
|
-
|
|
961
|
+
// #5285 — `siteAnswers` rides this read because the write MERGES into it
|
|
962
|
+
// (a founder patches one screen at a time). It is still not the write's
|
|
963
|
+
// guard: the compare-and-swap below is, and a concurrent patch of a
|
|
964
|
+
// DIFFERENT screen can still lose its answers to this one, exactly as it
|
|
965
|
+
// can already lose a column. The store is one row's worth of one
|
|
966
|
+
// founder's own form, not a shared document.
|
|
967
|
+
select: { siteId: true, siteAnswers: true },
|
|
530
968
|
});
|
|
531
969
|
if (!owner) {
|
|
532
970
|
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
@@ -536,6 +974,7 @@ export async function patchProjectListingDraft(
|
|
|
536
974
|
const parsed = buildProjectListingDraftPatchSchema(
|
|
537
975
|
listingConfig.wordCaps,
|
|
538
976
|
listingConfig.collectedFields,
|
|
977
|
+
listingConfig.siteAnswers,
|
|
539
978
|
).safeParse(body);
|
|
540
979
|
if (!parsed.success) {
|
|
541
980
|
return {
|
|
@@ -548,6 +987,21 @@ export async function patchProjectListingDraft(
|
|
|
548
987
|
}
|
|
549
988
|
const patch: ProjectListingDraftPatchInput = parsed.data;
|
|
550
989
|
|
|
990
|
+
const stored = siteAnswersFromRow(owner.siteAnswers);
|
|
991
|
+
const context: DraftUpdateContext = {
|
|
992
|
+
// THE CLAMP, and the reason `requestedWindowDays` is safe to accept from
|
|
993
|
+
// an anonymous body at all (#5285). The site's `maxWindowDays` is read
|
|
994
|
+
// server-side and caps everything; the payload carries a REQUEST. A
|
|
995
|
+
// founder who could post their own bound could post an unbounded one, and
|
|
996
|
+
// this is the only place a patched window reaches a column.
|
|
997
|
+
...(patch.requestedWindowDays !== undefined && {
|
|
998
|
+
windowDays: clampWindowDays(patch.requestedWindowDays, listingConfig),
|
|
999
|
+
}),
|
|
1000
|
+
// What is on the row already, so the merge below adds to it rather than
|
|
1001
|
+
// replacing it. It comes from the ROW and never from the body.
|
|
1002
|
+
...(stored !== null && { storedSiteAnswers: stored }),
|
|
1003
|
+
};
|
|
1004
|
+
|
|
551
1005
|
// A GUARDED `updateMany`, not a read-then-write: the token check and the
|
|
552
1006
|
// write are one statement, so a concurrent `submit` cannot flip the row to
|
|
553
1007
|
// `requested` in between and let this patch land on an application the
|
|
@@ -555,7 +1009,7 @@ export async function patchProjectListingDraft(
|
|
|
555
1009
|
// same compare-and-swap shape `submit` and `decideListing` already use.
|
|
556
1010
|
const updated = await prisma.projectListing.updateMany({
|
|
557
1011
|
where: { id, status: "draft", resumeTokenHash: hashResumeToken(resumeToken) },
|
|
558
|
-
data: draftUpdateData(patch),
|
|
1012
|
+
data: draftUpdateData(patch, context),
|
|
559
1013
|
});
|
|
560
1014
|
if (updated.count === 0) {
|
|
561
1015
|
return { ok: false, error: { kind: "not_found", message: "Draft not found" } };
|
|
@@ -613,6 +1067,15 @@ function applicationFromRow(
|
|
|
613
1067
|
address: row.address,
|
|
614
1068
|
...(row.founderCompany !== null && { founderCompany: row.founderCompany }),
|
|
615
1069
|
questions: row.questions,
|
|
1070
|
+
// #5285 — the three fields that became writable. Each is optional in the
|
|
1071
|
+
// application schema, so a row that never carried one simply omits it and
|
|
1072
|
+
// `submit` is unaffected. `requestedWindowDays` reads back the CLAMPED
|
|
1073
|
+
// `windowDays` the row actually holds, not what was asked for: the
|
|
1074
|
+
// request is not stored anywhere, and re-asserting it would be asserting
|
|
1075
|
+
// a number nothing ever agreed to.
|
|
1076
|
+
...(row.logoMarkUrl !== null && { logoMarkUrl: row.logoMarkUrl }),
|
|
1077
|
+
...(row.logoWordmarkUrl !== null && { logoWordmarkUrl: row.logoWordmarkUrl }),
|
|
1078
|
+
...(row.windowDays !== null && { requestedWindowDays: row.windowDays }),
|
|
616
1079
|
};
|
|
617
1080
|
return Object.fromEntries(
|
|
618
1081
|
Object.entries(all).filter(([key]) => collected.has(key)),
|
|
@@ -754,7 +1217,15 @@ export async function submitProjectListingDraft(
|
|
|
754
1217
|
try {
|
|
755
1218
|
const updated = await prisma.projectListing.updateMany({
|
|
756
1219
|
where: { id, status: "draft" },
|
|
757
|
-
|
|
1220
|
+
// #5407 — the verification nonce is cleared here too, for the reason
|
|
1221
|
+
// the resume-token digest is: a credential that leaks after the draft
|
|
1222
|
+
// stops being a draft is inert twice over.
|
|
1223
|
+
data: {
|
|
1224
|
+
status: "requested",
|
|
1225
|
+
slug: candidate,
|
|
1226
|
+
resumeTokenHash: null,
|
|
1227
|
+
emailVerifyNonceHash: null,
|
|
1228
|
+
},
|
|
758
1229
|
});
|
|
759
1230
|
return updated.count;
|
|
760
1231
|
} catch (err) {
|
|
@@ -879,6 +1350,88 @@ export async function getProjectListing(siteId: string, id: string): Promise<Get
|
|
|
879
1350
|
return { ok: true, value: toResponseRow(row, waitlistCountFor(row, counts), new Date(), true) };
|
|
880
1351
|
}
|
|
881
1352
|
|
|
1353
|
+
// ─── The founder's own listings (auth scope; #5295) ─────────────────────────
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* The listings the CALLING USER owns, newest first.
|
|
1357
|
+
*
|
|
1358
|
+
* The join is the owner link and ONLY the owner link:
|
|
1359
|
+
* `ownerUserId === userId`, the scalar `listing-promotion.ts` stamps once that
|
|
1360
|
+
* user's own email is verified, matches the row's `founderEmail`, and the row's
|
|
1361
|
+
* site resolves to one of the user's own workspaces. `founderEmail` is NEVER
|
|
1362
|
+
* compared here, at any stage — an un-linked listing that happens to carry the
|
|
1363
|
+
* caller's address is not theirs until a promotion said so, and a read-time
|
|
1364
|
+
* email match would reintroduce exactly the cross-founder capture the link
|
|
1365
|
+
* exists to remove. (`flow-checkouts.ts` draws the same line for `Lead`.)
|
|
1366
|
+
*
|
|
1367
|
+
* No `siteId` parameter and no `currentSiteId()` scope, deliberately — and the
|
|
1368
|
+
* reason is NOT that the two can never coincide. `siteIdForPublicKey`
|
|
1369
|
+
* (`listing-config.ts`) does fall back to `currentSiteId()`, so a listing's
|
|
1370
|
+
* `siteId` CAN be `AILK_SITE_ID` or the literal `"default"` on a single-tenant
|
|
1371
|
+
* deployment. The reason is that the owner link is already the narrower scope:
|
|
1372
|
+
* it is only ever stamped for a site inside the user's own tenancy
|
|
1373
|
+
* (`listing-promotion.ts`, condition 3), so adding a site term could only
|
|
1374
|
+
* remove rows the caller owns — and on the multi-tenant shape, where a
|
|
1375
|
+
* listing's `siteId` is a WORKSPACE id rather than `AILK_SITE_ID`, it would
|
|
1376
|
+
* remove all of them. The response omits `siteId` regardless, so no workspace
|
|
1377
|
+
* id leaves the read.
|
|
1378
|
+
*
|
|
1379
|
+
* The one thing this gives up against the sibling `/me` routes: on a database
|
|
1380
|
+
* shared by more than one site, a caller's rows from every such site come back
|
|
1381
|
+
* together. They are all the caller's own rows, which is why that is acceptable
|
|
1382
|
+
* here and not a disclosure.
|
|
1383
|
+
*
|
|
1384
|
+
* No verified-email re-check either. `flow-checkouts.ts` performs one as
|
|
1385
|
+
* defence in depth beside its email-matching sibling
|
|
1386
|
+
* (`waitlist-signups.ts`, where the gate is load-bearing because the match IS
|
|
1387
|
+
* an email). Here the link is the proof, and it was only issuable under
|
|
1388
|
+
* verification — so a re-check adds no safety, while failing a caller whose
|
|
1389
|
+
* address later changed to an unverified one would wrongly hide the rows they
|
|
1390
|
+
* already proved they own.
|
|
1391
|
+
*
|
|
1392
|
+
* `waitlistCount` is resolved PER SITE: the count is keyed by
|
|
1393
|
+
* `(siteId, experimentKey)`, and a founder may own listings in more than one of
|
|
1394
|
+
* their own workspaces, so counting them all under one site id would report
|
|
1395
|
+
* another site's number.
|
|
1396
|
+
*
|
|
1397
|
+
* An empty array for a caller who owns nothing is the normal answer, not an
|
|
1398
|
+
* error.
|
|
1399
|
+
*/
|
|
1400
|
+
export async function listMyProjectListings(userId: string): Promise<ProjectListing[]> {
|
|
1401
|
+
const rows = await prisma.projectListing.findMany({
|
|
1402
|
+
where: { ownerUserId: userId },
|
|
1403
|
+
orderBy: { createdAt: "desc" },
|
|
1404
|
+
select: PROJECT_LISTING_SELECT,
|
|
1405
|
+
});
|
|
1406
|
+
if (rows.length === 0) return [];
|
|
1407
|
+
|
|
1408
|
+
const rowsBySite = new Map<string, ProjectListingRow[]>();
|
|
1409
|
+
for (const row of rows) {
|
|
1410
|
+
const forSite = rowsBySite.get(row.siteId);
|
|
1411
|
+
if (forSite) forSite.push(row);
|
|
1412
|
+
else rowsBySite.set(row.siteId, [row]);
|
|
1413
|
+
}
|
|
1414
|
+
const countsBySite = new Map<string, Map<string, number>>();
|
|
1415
|
+
await Promise.all(
|
|
1416
|
+
[...rowsBySite].map(async ([siteId, siteRows]) => {
|
|
1417
|
+
countsBySite.set(siteId, await attachWaitlistCounts(siteId, siteRows));
|
|
1418
|
+
}),
|
|
1419
|
+
);
|
|
1420
|
+
|
|
1421
|
+
const now = new Date();
|
|
1422
|
+
// `featuredEnabled: true` — the founder's own row, like the operator's
|
|
1423
|
+
// dashboard row, reports the flag as stored. It is the PUBLIC shelf the
|
|
1424
|
+
// site's config switch governs, not what the row's owner may see.
|
|
1425
|
+
return rows.map((row) =>
|
|
1426
|
+
toResponseRow(
|
|
1427
|
+
row,
|
|
1428
|
+
waitlistCountFor(row, countsBySite.get(row.siteId) ?? new Map()),
|
|
1429
|
+
now,
|
|
1430
|
+
true,
|
|
1431
|
+
),
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
882
1435
|
// ─── Patch (tenant scope; the featured flag) ───────────────────────────────
|
|
883
1436
|
|
|
884
1437
|
export type SetListingFeaturedResult =
|
|
@@ -1113,12 +1666,32 @@ export async function publicProjectListings(siteId: string): Promise<ProjectList
|
|
|
1113
1666
|
|
|
1114
1667
|
const now = new Date();
|
|
1115
1668
|
return [...top3, ...rest].map(({ row, waitlistCount }) => {
|
|
1116
|
-
|
|
1669
|
+
// #5407 — `founderPhone` and `emailVerifiedAt` leave here with
|
|
1670
|
+
// `founderEmail`. `projectListingPublicSchema` omits all three and
|
|
1671
|
+
// `sendValidated` re-parses `.strict()`, so a key that stopped being
|
|
1672
|
+
// dropped here would 500 the public read rather than quietly publishing a
|
|
1673
|
+
// founder's phone number — which is the failure mode worth having.
|
|
1674
|
+
const {
|
|
1675
|
+
founderEmail: _founderEmail,
|
|
1676
|
+
founderPhone: _founderPhone,
|
|
1677
|
+
emailVerifiedAt: _emailVerifiedAt,
|
|
1678
|
+
...publicRow
|
|
1679
|
+
} = toResponseRow(
|
|
1117
1680
|
row,
|
|
1118
1681
|
waitlistCount,
|
|
1119
1682
|
now,
|
|
1120
1683
|
listingConfig.featuredEnabled,
|
|
1121
1684
|
);
|
|
1122
|
-
|
|
1685
|
+
// #5285 — the `public`-visibility answers ONLY. An `operator-only` answer
|
|
1686
|
+
// is something a site asked for its own triage, and a founder who was
|
|
1687
|
+
// told it would not be published has to be right about that; an answer
|
|
1688
|
+
// whose id the site no longer declares is dropped for the same reason,
|
|
1689
|
+
// because "no declaration" cannot be read as "public" on the read that
|
|
1690
|
+
// publishes. The tenant dashboard and the review payload are the audience
|
|
1691
|
+
// that sees the rest, and they do not come through here.
|
|
1692
|
+
return {
|
|
1693
|
+
...publicRow,
|
|
1694
|
+
siteAnswers: publicSiteAnswers(publicRow.siteAnswers, listingConfig.siteAnswers),
|
|
1695
|
+
};
|
|
1123
1696
|
});
|
|
1124
1697
|
}
|