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
|
@@ -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
|
|
@@ -43,6 +50,7 @@ import {
|
|
|
43
50
|
buildProjectListingDraftPatchSchema,
|
|
44
51
|
buildProjectListingSubmitSchema,
|
|
45
52
|
composeAddressLine,
|
|
53
|
+
composeFounderName,
|
|
46
54
|
flowAddressValueSchema,
|
|
47
55
|
publicSiteAnswers,
|
|
48
56
|
PROJECT_LISTING_CONCURRENT_LIMIT,
|
|
@@ -230,7 +238,13 @@ const PROJECT_LISTING_SELECT = {
|
|
|
230
238
|
siteAnswers: true,
|
|
231
239
|
windowDays: true,
|
|
232
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,
|
|
233
245
|
founderEmail: true,
|
|
246
|
+
founderPhone: true,
|
|
247
|
+
emailVerifiedAt: true,
|
|
234
248
|
founderCompany: true,
|
|
235
249
|
headline: true,
|
|
236
250
|
logoMarkUrl: true,
|
|
@@ -357,7 +371,13 @@ function toResponseRow(
|
|
|
357
371
|
address: row.address,
|
|
358
372
|
addressParts: addressPartsFromRow(row),
|
|
359
373
|
founderName: row.founderName,
|
|
374
|
+
founderFirstName: row.founderFirstName,
|
|
375
|
+
founderLastName: row.founderLastName,
|
|
360
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,
|
|
361
381
|
founderCompany: row.founderCompany,
|
|
362
382
|
questions: row.questions as unknown as ProjectListingQuestions | null,
|
|
363
383
|
logoMarkUrl: row.logoMarkUrl,
|
|
@@ -489,6 +509,42 @@ function hashResumeToken(token: string): string {
|
|
|
489
509
|
return createHash("sha256").update(token).digest("hex");
|
|
490
510
|
}
|
|
491
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
|
+
|
|
492
548
|
/**
|
|
493
549
|
* Resolve the ONE draft a token grants access to (#5036 D1).
|
|
494
550
|
*
|
|
@@ -532,6 +588,19 @@ export type StartProjectListingDraftResult =
|
|
|
532
588
|
ok: true;
|
|
533
589
|
id: string;
|
|
534
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;
|
|
535
604
|
}
|
|
536
605
|
| { ok: false; error: { kind: "invalid_input"; message: string } };
|
|
537
606
|
|
|
@@ -557,7 +626,14 @@ export async function startProjectListingDraft(
|
|
|
557
626
|
siteId: string,
|
|
558
627
|
input: ProjectListingStartInput,
|
|
559
628
|
): Promise<StartProjectListingDraftResult> {
|
|
560
|
-
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);
|
|
561
637
|
const founderEmail = normalizeFounderEmail(input.founderEmail);
|
|
562
638
|
|
|
563
639
|
const listingConfig = await listingConfigFor(siteId);
|
|
@@ -590,6 +666,13 @@ export async function startProjectListingDraft(
|
|
|
590
666
|
}
|
|
591
667
|
|
|
592
668
|
const resumeToken = mintResumeToken();
|
|
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();
|
|
593
676
|
// #5275 — the row and its grant record land in ONE transaction: from here
|
|
594
677
|
// on `consentAt` and the ConsentGrant row are stamped by the SAME
|
|
595
678
|
// affirmative act, and a grant write that fails takes the row with it
|
|
@@ -606,10 +689,21 @@ export async function startProjectListingDraft(
|
|
|
606
689
|
data: {
|
|
607
690
|
siteId,
|
|
608
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
|
+
}),
|
|
609
701
|
founderEmail,
|
|
702
|
+
...(input.founderPhone !== undefined && { founderPhone: input.founderPhone }),
|
|
610
703
|
status: "draft",
|
|
611
704
|
consentAt: new Date(),
|
|
612
705
|
resumeTokenHash: hashResumeToken(resumeToken),
|
|
706
|
+
emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
|
|
613
707
|
windowDays,
|
|
614
708
|
},
|
|
615
709
|
select: { id: true },
|
|
@@ -623,7 +717,120 @@ export async function startProjectListingDraft(
|
|
|
623
717
|
return created;
|
|
624
718
|
});
|
|
625
719
|
|
|
626
|
-
return { ok: true, id: row.id, resumeToken };
|
|
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 },
|
|
818
|
+
});
|
|
819
|
+
if (stamped.count > 0) return { ok: true, value: { id, emailVerifiedAt } };
|
|
820
|
+
|
|
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" } };
|
|
627
834
|
}
|
|
628
835
|
|
|
629
836
|
// ─── patch (PATCH, anonymous, token-gated) ──────────────────────────────────
|
|
@@ -1010,7 +1217,15 @@ export async function submitProjectListingDraft(
|
|
|
1010
1217
|
try {
|
|
1011
1218
|
const updated = await prisma.projectListing.updateMany({
|
|
1012
1219
|
where: { id, status: "draft" },
|
|
1013
|
-
|
|
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
|
+
},
|
|
1014
1229
|
});
|
|
1015
1230
|
return updated.count;
|
|
1016
1231
|
} catch (err) {
|
|
@@ -1451,7 +1666,17 @@ export async function publicProjectListings(siteId: string): Promise<ProjectList
|
|
|
1451
1666
|
|
|
1452
1667
|
const now = new Date();
|
|
1453
1668
|
return [...top3, ...rest].map(({ row, waitlistCount }) => {
|
|
1454
|
-
|
|
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(
|
|
1455
1680
|
row,
|
|
1456
1681
|
waitlistCount,
|
|
1457
1682
|
now,
|
|
@@ -31,39 +31,70 @@ async function getBuildFunction(): Promise<typeof buildServer> {
|
|
|
31
31
|
|
|
32
32
|
// Initialize build function
|
|
33
33
|
const buildPromise = getBuildFunction();
|
|
34
|
-
let serverInstance: FastifyServer | null = null;
|
|
35
34
|
|
|
36
35
|
/**
|
|
37
|
-
*
|
|
36
|
+
* The in-flight (or settled) build. Caching the PROMISE rather than the
|
|
37
|
+
* resolved instance is what makes this safe: a warm Vercel container serves
|
|
38
|
+
* concurrent invocations on one Node process, and the old check-then-set
|
|
39
|
+
* (`if (!serverInstance)` followed by two awaits before the assignment) let
|
|
40
|
+
* two requests arriving during a cold start both see null and both construct
|
|
41
|
+
* a complete Fastify server — every plugin registered, every route wired, the
|
|
42
|
+
* Prisma client initialised — with one of the two then discarded. Nothing
|
|
43
|
+
* failed; the only symptoms were cold-start latency and memory (#5332).
|
|
38
44
|
*/
|
|
39
|
-
|
|
40
|
-
if (!serverInstance) {
|
|
41
|
-
logger.info('[Vercel Handler] Building server instance...');
|
|
42
|
-
const startTime = Date.now();
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
const buildTimeout = new Promise<never>((_, reject) => {
|
|
46
|
-
setTimeout(() => reject(new Error('Server build timeout after 10s')), 10000);
|
|
47
|
-
});
|
|
45
|
+
let serverPromise: Promise<FastifyServer> | null = null;
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
async function buildServerInstance(): Promise<FastifyServer> {
|
|
48
|
+
logger.info('[Vercel Handler] Building server instance...');
|
|
49
|
+
const startTime = Date.now();
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
try {
|
|
52
|
+
const buildTimeout = new Promise<never>((_, reject) => {
|
|
53
|
+
setTimeout(() => reject(new Error('Server build timeout after 10s')), 10000);
|
|
54
|
+
});
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
const build = await Promise.race([buildPromise, buildTimeout]);
|
|
57
|
+
logger.info('[Vercel Handler] Build function obtained, creating server...');
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
const initTimeout = new Promise<never>((_, reject) => {
|
|
60
|
+
setTimeout(() => reject(new Error('Server initialization timeout after 10s')), 10000);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const serverInstance = await Promise.race([build(), initTimeout]);
|
|
64
|
+
|
|
65
|
+
const buildTime = Date.now() - startTime;
|
|
66
|
+
logger.info({ buildTime }, '[Vercel Handler] Server built successfully');
|
|
67
|
+
return serverInstance;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const buildTime = Date.now() - startTime;
|
|
70
|
+
logger.error({ err: error, buildTime }, '[Vercel Handler] Failed to build server');
|
|
71
|
+
throw new Error(`Server build failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
65
72
|
}
|
|
66
|
-
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get or create the Fastify server instance
|
|
77
|
+
*/
|
|
78
|
+
function getServer(): Promise<FastifyServer> {
|
|
79
|
+
if (serverPromise) {
|
|
80
|
+
return serverPromise;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const attempt = buildServerInstance();
|
|
84
|
+
serverPromise = attempt;
|
|
85
|
+
// Evict a FAILED build from the cache so the next invocation retries —
|
|
86
|
+
// a rejected promise left in place would serve every later request the
|
|
87
|
+
// same cold-start failure for the life of the container. The guard keeps
|
|
88
|
+
// this from clearing a newer attempt; `attempt` is returned as-is, so the
|
|
89
|
+
// rejection still reaches the caller (this handler only silences the
|
|
90
|
+
// unhandled-rejection warning on its own bookkeeping branch).
|
|
91
|
+
attempt.catch(() => {
|
|
92
|
+
if (serverPromise === attempt) {
|
|
93
|
+
serverPromise = null;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return attempt;
|
|
67
98
|
}
|
|
68
99
|
|
|
69
100
|
/**
|
|
@@ -156,9 +187,12 @@ export default async function handler(
|
|
|
156
187
|
logger.error({ err: error }, '[Vercel Handler] Error handling request');
|
|
157
188
|
|
|
158
189
|
if (!res.headersSent) {
|
|
190
|
+
// Generic body only (security review on #146): this catch sits above
|
|
191
|
+
// Fastify's error serializer, so error.message could carry hosts,
|
|
192
|
+
// paths, or driver detail. The logger.error above keeps the detail.
|
|
159
193
|
res.status(500).json({
|
|
160
194
|
error: 'Internal Server Error',
|
|
161
|
-
message:
|
|
195
|
+
message: 'Something went wrong on our end.',
|
|
162
196
|
});
|
|
163
197
|
}
|
|
164
198
|
}
|
|
@@ -40,6 +40,14 @@ MCP_RATE_LIMIT_WRITE_PER_MIN=10
|
|
|
40
40
|
# Example: https://app.yourdomain.com,https://other.yourdomain.com
|
|
41
41
|
MCP_CORS_WRITE_ALLOWED_ORIGINS=
|
|
42
42
|
|
|
43
|
+
# ── Paid modules (D-44, #5368) ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
# Comma-separated allowlist of installed paid modules, e.g.
|
|
46
|
+
# "multi-tenant,scheduling". Unset (the default) or "" means NO paid modules
|
|
47
|
+
# — installed modules enable themselves explicitly. Recognized keys: docs,
|
|
48
|
+
# multi-tenant, scheduling, content-write. Must match apps/api's value.
|
|
49
|
+
# AILK_ENABLED_MODULES=
|
|
50
|
+
|
|
43
51
|
# ── Abuse protection ───────────────────────────────────────────────────────────
|
|
44
52
|
|
|
45
53
|
# Maximum number of capture_lead submissions per IP per day.
|
|
@@ -25,8 +25,8 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
|
|
|
25
25
|
**Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`
|
|
26
26
|
**Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback`
|
|
27
27
|
**Maintenance (2):** `audit`, `scaffold`
|
|
28
|
-
**Flow (2):** `get_flow`, `submit_flow_step`
|
|
29
|
-
**Booking (3):** `list_availability`, `get_event_meta`, `create_booking`
|
|
28
|
+
**Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366): identity-blind and free of any `requirePaidTier()` call like every tool in this list, but registered only when the signal-collectors module is present, the same shape Booking below already has
|
|
29
|
+
**Booking (3):** `list_availability`, `get_event_meta`, `create_booking` — module-gated (`module: "scheduling"`)
|
|
30
30
|
|
|
31
31
|
### Why the catalog reports 23 and the server registers 22
|
|
32
32
|
|
|
@@ -24,7 +24,28 @@ interface RegisteredTool {
|
|
|
24
24
|
description?: string;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// The catalog includes module-gated tools (scheduling, content-write,
|
|
28
|
+
// signal-collectors); an unset AILK_ENABLED_MODULES no longer registers them
|
|
29
|
+
// by default (D-44, #5368), so this drift check enables every paid module
|
|
30
|
+
// explicitly — it is comparing the full catalog, not what a default build
|
|
31
|
+
// registers.
|
|
32
|
+
const PAID_MODULES = "scheduling,content-write,signal-collectors";
|
|
33
|
+
|
|
27
34
|
describe("MCP catalog ↔ server drift", () => {
|
|
35
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
process.env.AILK_ENABLED_MODULES = PAID_MODULES;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
if (priorEnabledModules === undefined) {
|
|
43
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
44
|
+
} else {
|
|
45
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
28
49
|
it("registered tool names equal catalog tool names", () => {
|
|
29
50
|
const server = createMcpServer();
|
|
30
51
|
const registered = (
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `isModuleEnabled()` default-flip regression test (D-44, #5368).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `apps/api/src/config/__tests__/modules.test.ts` — apps/* may not
|
|
5
|
+
* import apps/*, so this reader (and its test) are duplicated rather than
|
|
6
|
+
* shared. Proves the mechanical claim #5368 exists to make true: an UNSET
|
|
7
|
+
* `AILK_ENABLED_MODULES` now gates every paid module OFF, the same as an
|
|
8
|
+
* explicit empty string — only an explicit allowlist turns modules on, and
|
|
9
|
+
* turns on exactly the modules it names.
|
|
10
|
+
*
|
|
11
|
+
* Reverting `enabledModules()`'s unset branch back to the `undefined`
|
|
12
|
+
* sentinel (S1, #5088's original shape) makes the "unset" cases below fail
|
|
13
|
+
* RED — see #5368 verification notes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { isModuleEnabled } from "../../src/config/modules.js";
|
|
17
|
+
|
|
18
|
+
const RECOGNIZED_MODULES = [
|
|
19
|
+
"docs",
|
|
20
|
+
"multi-tenant",
|
|
21
|
+
"scheduling",
|
|
22
|
+
"content-write",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
describe("isModuleEnabled", () => {
|
|
26
|
+
const originalEnv = process.env.AILK_ENABLED_MODULES;
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
if (originalEnv === undefined) {
|
|
30
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
31
|
+
} else {
|
|
32
|
+
process.env.AILK_ENABLED_MODULES = originalEnv;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("AILK_ENABLED_MODULES unset", () => {
|
|
37
|
+
it.each(RECOGNIZED_MODULES)("returns false for %s", (moduleName) => {
|
|
38
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
39
|
+
expect(isModuleEnabled(moduleName)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('AILK_ENABLED_MODULES=""', () => {
|
|
44
|
+
it.each(RECOGNIZED_MODULES)("returns false for %s", (moduleName) => {
|
|
45
|
+
process.env.AILK_ENABLED_MODULES = "";
|
|
46
|
+
expect(isModuleEnabled(moduleName)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("AILK_ENABLED_MODULES as an explicit allowlist", () => {
|
|
51
|
+
it.each(RECOGNIZED_MODULES)(
|
|
52
|
+
"enables exactly the listed modules — %s on when named",
|
|
53
|
+
(moduleName) => {
|
|
54
|
+
process.env.AILK_ENABLED_MODULES = moduleName;
|
|
55
|
+
expect(isModuleEnabled(moduleName)).toBe(true);
|
|
56
|
+
for (const other of RECOGNIZED_MODULES) {
|
|
57
|
+
if (other === moduleName) continue;
|
|
58
|
+
expect(isModuleEnabled(other)).toBe(false);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
it("enables every module named in a comma list, and no others", () => {
|
|
64
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant,scheduling";
|
|
65
|
+
expect(isModuleEnabled("multi-tenant")).toBe(true);
|
|
66
|
+
expect(isModuleEnabled("scheduling")).toBe(true);
|
|
67
|
+
expect(isModuleEnabled("docs")).toBe(false);
|
|
68
|
+
expect(isModuleEnabled("content-write")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("trims whitespace and drops empty entries, same as before the flip", () => {
|
|
72
|
+
process.env.AILK_ENABLED_MODULES = " multi-tenant , , scheduling ";
|
|
73
|
+
expect(isModuleEnabled("multi-tenant")).toBe(true);
|
|
74
|
+
expect(isModuleEnabled("scheduling")).toBe(true);
|
|
75
|
+
expect(isModuleEnabled("docs")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -18,7 +18,7 @@ import { getMcpToolRegistrations } from "@working-theory/metadata";
|
|
|
18
18
|
|
|
19
19
|
import { createMcpServer, listRegisteredTools } from "../src/server.js";
|
|
20
20
|
|
|
21
|
-
const PAID_MODULES = ["scheduling", "content-write"] as const;
|
|
21
|
+
const PAID_MODULES = ["scheduling", "content-write", "signal-collectors"] as const;
|
|
22
22
|
|
|
23
23
|
function withEnabledModules<T>(enabled: string, fn: () => T): T {
|
|
24
24
|
const prior = process.env.AILK_ENABLED_MODULES;
|
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Paid-module presence — the registration-grain signal used by `server.ts`
|
|
3
3
|
* to decide whether a paid module's tools are wired into this build
|
|
4
|
-
* (S1, #5088
|
|
5
|
-
*
|
|
4
|
+
* (S1, #5088; defaulted off by D-44, #5368). Mirrors
|
|
5
|
+
* `apps/api/src/config/modules.ts` — apps/* may not import apps/*, so this
|
|
6
|
+
* tiny reader is duplicated rather than shared.
|
|
6
7
|
*
|
|
7
8
|
* `AILK_ENABLED_MODULES` is a comma-separated allowlist (e.g.
|
|
8
|
-
* `"multi-tenant,scheduling"`). Unset means
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* `"multi-tenant,scheduling"`). Unset means **no paid modules** — installed
|
|
10
|
+
* modules enable themselves explicitly; an explicit empty string (`""`)
|
|
11
|
+
* means the same (the OSS-strip build target); a list enables exactly the
|
|
12
|
+
* modules it names.
|
|
11
13
|
*
|
|
12
|
-
* Recognized module keys: `docs`, `multi-tenant`, `scheduling
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
14
|
+
* Recognized module keys: `docs`, `multi-tenant`, `scheduling`,
|
|
15
|
+
* `signal-collectors` (the `ModuleName` vocabulary
|
|
16
|
+
* `packages/create-ailk/src/module-architecture.ts` already models via
|
|
17
|
+
* `MODULE_SLICES`) plus `content-write` — the Pro
|
|
15
18
|
* content-write surface (`create_page`/`update_page`/`delete_page`).
|
|
16
19
|
* `content-write` is intentionally NOT a `MODULE_SLICES` entry: it is not
|
|
17
20
|
* (yet) a create-ailk `--modules` selector, so it stays a local flag here
|
|
18
21
|
* rather than widening the create-ailk module vocabulary.
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
|
-
function enabledModules(): Set<string>
|
|
24
|
+
function enabledModules(): Set<string> {
|
|
22
25
|
const raw = process.env.AILK_ENABLED_MODULES;
|
|
23
|
-
if (raw === undefined) return
|
|
26
|
+
if (raw === undefined) return new Set(); // unset = no paid modules
|
|
24
27
|
return new Set(
|
|
25
28
|
raw
|
|
26
29
|
.split(",")
|
|
@@ -31,7 +34,5 @@ function enabledModules(): Set<string> | undefined {
|
|
|
31
34
|
|
|
32
35
|
/** Whether the named paid module's tools should register on this build/boot. */
|
|
33
36
|
export function isModuleEnabled(moduleName: string): boolean {
|
|
34
|
-
|
|
35
|
-
if (enabled === undefined) return true;
|
|
36
|
-
return enabled.has(moduleName);
|
|
37
|
+
return enabledModules().has(moduleName);
|
|
37
38
|
}
|
|
@@ -20,7 +20,6 @@ import { ailkAudit } from "./tools/audit.js";
|
|
|
20
20
|
import { ailkCaptureLead } from "./tools/capture_lead.js";
|
|
21
21
|
import { ailkGetBusinessHours } from "./tools/get_business_hours.js";
|
|
22
22
|
import { ailkGetFaq } from "./tools/get_faq.js";
|
|
23
|
-
import { ailkGetFlow } from "./tools/get_flow.js";
|
|
24
23
|
import { ailkGetLocations } from "./tools/get_locations.js";
|
|
25
24
|
import { ailkGetOfferings } from "./tools/get_offerings.js";
|
|
26
25
|
import { ailkGetPricing } from "./tools/get_pricing.js";
|
|
@@ -31,7 +30,6 @@ import { ailkReadContent } from "./tools/read_content.js";
|
|
|
31
30
|
import { ailkRequestCallback } from "./tools/request_callback.js";
|
|
32
31
|
import { ailkScaffold } from "./tools/scaffold.js";
|
|
33
32
|
import { ailkSearchContent } from "./tools/search_content.js";
|
|
34
|
-
import { ailkSubmitFlowStep } from "./tools/submit_flow_step.js";
|
|
35
33
|
import { ailkSubscribeNewsletter } from "./tools/subscribe_newsletter.js";
|
|
36
34
|
|
|
37
35
|
/**
|
|
@@ -65,8 +63,6 @@ const HANDLERS: Record<string, (input: unknown) => Promise<unknown>> = {
|
|
|
65
63
|
subscribe_newsletter: (input) => ailkSubscribeNewsletter(input as never),
|
|
66
64
|
request_callback: (input) => ailkRequestCallback(input as never),
|
|
67
65
|
scaffold: (input) => ailkScaffold(input as never),
|
|
68
|
-
get_flow: (input) => ailkGetFlow(input as never),
|
|
69
|
-
submit_flow_step: (input) => ailkSubmitFlowStep(input as never),
|
|
70
66
|
};
|
|
71
67
|
|
|
72
68
|
type ToolHandler = (input: unknown) => Promise<unknown>;
|
|
@@ -119,6 +115,14 @@ const MODULE_TOOL_ENTRIES: Record<
|
|
|
119
115
|
create_page: { specifier: "./tools/create_page.js", exportName: "ailkCreatePage" },
|
|
120
116
|
update_page: { specifier: "./tools/update_page.js", exportName: "ailkUpdatePage" },
|
|
121
117
|
delete_page: { specifier: "./tools/delete_page.js", exportName: "ailkDeletePage" },
|
|
118
|
+
// D-48 signal-collectors — the flow engine's MCP twins of GET
|
|
119
|
+
// /v1/flows/:slug and POST /v1/flows/:slug/steps. Anonymous, identity-blind;
|
|
120
|
+
// free-anonymous status is now module-gated rather than unconditional.
|
|
121
|
+
get_flow: { specifier: "./tools/get_flow.js", exportName: "ailkGetFlow" },
|
|
122
|
+
submit_flow_step: {
|
|
123
|
+
specifier: "./tools/submit_flow_step.js",
|
|
124
|
+
exportName: "ailkSubmitFlowStep",
|
|
125
|
+
},
|
|
122
126
|
};
|
|
123
127
|
|
|
124
128
|
/**
|