create-ailk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +50 -53
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +2 -2
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +33 -5
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +459 -135
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +229 -4
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +14 -13
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +0 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/database/CHANGELOG.md +33 -0
- package/templates/database/inbox/schema.prisma +67 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1296
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/listing-promotion.ts +0 -342
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file cors.test.ts
|
|
3
|
+
* @description CORS preflight contract (#5397).
|
|
4
|
+
*
|
|
5
|
+
* The one test file that does NOT mock `@fastify/cors`. Its siblings
|
|
6
|
+
* (`server.test.ts`, `module-exclusion.test.ts`, `index.test.ts`) stub the
|
|
7
|
+
* plugin at the module boundary, which is why the defect this file pins was
|
|
8
|
+
* invisible to the whole suite: with the plugin mocked, the registration
|
|
9
|
+
* options are never read and no preflight is ever answered. The plugin needs
|
|
10
|
+
* no live infrastructure — it is a header writer — so the real one runs here.
|
|
11
|
+
*
|
|
12
|
+
* The defect: `@fastify/cors` defaults `methods` to `GET,HEAD,POST`
|
|
13
|
+
* (node_modules/@fastify/cors/index.js `defaultOptions`), and `server.ts`
|
|
14
|
+
* passed only `origin` + `credentials`. So the preflight for
|
|
15
|
+
* `PATCH /v1/project-listings/drafts/:id` answered
|
|
16
|
+
* `access-control-allow-methods: GET, HEAD, POST` and the browser refused the
|
|
17
|
+
* request — a split-origin site could create a listing draft but never save
|
|
18
|
+
* one. Observed against the salessmyth/samhenry deploy, 2026-09-13.
|
|
19
|
+
*
|
|
20
|
+
* Each case rebuilds the module graph (`jest.resetModules()`) because
|
|
21
|
+
* `config/index.ts` snapshots `FRONTEND_URL` and `NODE_ENV` at import time.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Mocked for the same reasons the sibling suites give: rate-limit wants a live
|
|
25
|
+
// store, and @working-theory/email pulls an ESM-only transitive dep that
|
|
26
|
+
// jest-resolve cannot load in CommonJS mode. `@fastify/cors` is deliberately
|
|
27
|
+
// NOT mocked — it is the subject.
|
|
28
|
+
jest.mock("@fastify/rate-limit", () => jest.fn().mockResolvedValue(undefined));
|
|
29
|
+
|
|
30
|
+
jest.mock("@working-theory/email", () => ({
|
|
31
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import { REGISTERED_ROUTES_DECORATOR } from "../openapi/spec";
|
|
35
|
+
import type { RegisteredRoute } from "../openapi/surface-drift";
|
|
36
|
+
|
|
37
|
+
const FRONTEND_URL = "https://site.example.com";
|
|
38
|
+
const FOREIGN_ORIGIN = "https://attacker.example.net";
|
|
39
|
+
|
|
40
|
+
// The route the defect was found on: ListingApplicationFlow's `patchDraft`
|
|
41
|
+
// runs in the browser on every advance after step one.
|
|
42
|
+
const DRAFT_PATCH_URL = "/v1/project-listings/drafts/listing_abc123";
|
|
43
|
+
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
|
|
45
|
+
type App = any;
|
|
46
|
+
|
|
47
|
+
const ENV_KEYS = ["FRONTEND_URL", "NODE_ENV"] as const;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a server whose `config` was resolved from `env`. Restores the prior
|
|
51
|
+
* environment through `restoreEnv()` in `afterEach`.
|
|
52
|
+
*/
|
|
53
|
+
async function buildWith(env: {
|
|
54
|
+
FRONTEND_URL?: string;
|
|
55
|
+
NODE_ENV: string;
|
|
56
|
+
}): Promise<App> {
|
|
57
|
+
for (const key of ENV_KEYS) {
|
|
58
|
+
const value = env[key];
|
|
59
|
+
if (value === undefined) {
|
|
60
|
+
delete process.env[key];
|
|
61
|
+
} else {
|
|
62
|
+
process.env[key] = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
jest.resetModules();
|
|
66
|
+
|
|
67
|
+
const { build } = require("../server") as typeof import("../server");
|
|
68
|
+
return build();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Issue a CORS preflight the way a browser would. */
|
|
72
|
+
function preflight(
|
|
73
|
+
app: App,
|
|
74
|
+
origin: string,
|
|
75
|
+
method: string,
|
|
76
|
+
url = DRAFT_PATCH_URL,
|
|
77
|
+
) {
|
|
78
|
+
return app.inject({
|
|
79
|
+
method: "OPTIONS",
|
|
80
|
+
url,
|
|
81
|
+
headers: {
|
|
82
|
+
origin,
|
|
83
|
+
"access-control-request-method": method,
|
|
84
|
+
"access-control-request-headers": "authorization,content-type",
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `Access-Control-Allow-Methods` as an uppercased, trimmed set. */
|
|
90
|
+
function allowedMethods(response: {
|
|
91
|
+
headers: Record<string, unknown>;
|
|
92
|
+
}): string[] {
|
|
93
|
+
const header = response.headers["access-control-allow-methods"];
|
|
94
|
+
if (typeof header !== "string") return [];
|
|
95
|
+
return header.split(",").map((method) => method.trim().toUpperCase());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("CORS preflight (#5397)", () => {
|
|
99
|
+
const priorEnv = new Map<string, string | undefined>(
|
|
100
|
+
ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
101
|
+
);
|
|
102
|
+
let app: App;
|
|
103
|
+
|
|
104
|
+
afterEach(async () => {
|
|
105
|
+
if (app) {
|
|
106
|
+
await app.close();
|
|
107
|
+
app = undefined;
|
|
108
|
+
}
|
|
109
|
+
for (const [key, value] of priorEnv) {
|
|
110
|
+
if (value === undefined) {
|
|
111
|
+
delete process.env[key];
|
|
112
|
+
} else {
|
|
113
|
+
process.env[key] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
jest.resetModules();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("allows PATCH and the authorization header for the configured FRONTEND_URL origin", async () => {
|
|
120
|
+
// ARRANGE
|
|
121
|
+
app = await buildWith({ FRONTEND_URL, NODE_ENV: "production" });
|
|
122
|
+
|
|
123
|
+
// ACT
|
|
124
|
+
const response = await preflight(app, FRONTEND_URL, "PATCH");
|
|
125
|
+
|
|
126
|
+
// ASSERT — the origin is allowed, with credentials, exactly as before.
|
|
127
|
+
expect(response.headers["access-control-allow-origin"]).toBe(FRONTEND_URL);
|
|
128
|
+
expect(response.headers["access-control-allow-credentials"]).toBe("true");
|
|
129
|
+
// ...and the method the browser asked about is in the answer. Without an
|
|
130
|
+
// explicit `methods` option this is `GET, HEAD, POST` and the browser
|
|
131
|
+
// refuses the PATCH.
|
|
132
|
+
expect(allowedMethods(response)).toEqual(
|
|
133
|
+
expect.arrayContaining(["GET", "HEAD", "POST", "PATCH"]),
|
|
134
|
+
);
|
|
135
|
+
// The bearer token ListingApplicationFlow sends must survive the preflight.
|
|
136
|
+
const allowHeaders = String(
|
|
137
|
+
response.headers["access-control-allow-headers"] ?? "",
|
|
138
|
+
).toLowerCase();
|
|
139
|
+
expect(allowHeaders).toContain("authorization");
|
|
140
|
+
expect(allowHeaders).toContain("content-type");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("refuses a preflight from an origin other than FRONTEND_URL in production", async () => {
|
|
144
|
+
// ARRANGE
|
|
145
|
+
app = await buildWith({ FRONTEND_URL, NODE_ENV: "production" });
|
|
146
|
+
|
|
147
|
+
// ACT
|
|
148
|
+
const response = await preflight(app, FOREIGN_ORIGIN, "PATCH");
|
|
149
|
+
|
|
150
|
+
// ASSERT — with a STRING `origin` option, @fastify/cors echoes the
|
|
151
|
+
// configured origin rather than the requester's
|
|
152
|
+
// (`getAccessControlAllowOriginHeader`), and the refusal is the browser's:
|
|
153
|
+
// it blocks any response whose allow-origin is not its own origin. So the
|
|
154
|
+
// contract to pin is that the foreign origin is never named back, and the
|
|
155
|
+
// wildcard never appears — `credentials: true` with `*` would let any site
|
|
156
|
+
// read credentialed responses (the security-review M1 posture server.ts
|
|
157
|
+
// already documents). Widening `methods` must not widen WHO may use them.
|
|
158
|
+
const allowOrigin = response.headers["access-control-allow-origin"];
|
|
159
|
+
expect(allowOrigin).not.toBe(FOREIGN_ORIGIN);
|
|
160
|
+
expect(allowOrigin).not.toBe("*");
|
|
161
|
+
expect(allowOrigin).toBe(FRONTEND_URL);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("advertises every method the registered route table actually serves", async () => {
|
|
165
|
+
// ARRANGE — every paid module on, so the widest route surface is measured.
|
|
166
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant,scheduling,content-write";
|
|
167
|
+
try {
|
|
168
|
+
app = await buildWith({ FRONTEND_URL, NODE_ENV: "production" });
|
|
169
|
+
await app.ready();
|
|
170
|
+
|
|
171
|
+
// ACT
|
|
172
|
+
const response = await preflight(app, FRONTEND_URL, "GET", "/health");
|
|
173
|
+
const advertised = allowedMethods(response);
|
|
174
|
+
const registered = new Set(
|
|
175
|
+
(app[REGISTERED_ROUTES_DECORATOR] as RegisteredRoute[]).map((route) =>
|
|
176
|
+
route.method.toUpperCase(),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// ASSERT — a route added in a method nobody added to the CORS list is
|
|
181
|
+
// unreachable from a browser on a split-origin deploy. This is the drift
|
|
182
|
+
// guard: add a DELETE route, and this fails until `methods` says DELETE.
|
|
183
|
+
// Reported as a set so a drift names every missing method at once.
|
|
184
|
+
const missing = [...registered].filter(
|
|
185
|
+
(method) => !advertised.includes(method),
|
|
186
|
+
);
|
|
187
|
+
expect(missing).toEqual([]);
|
|
188
|
+
// HEAD is never in the route table (Fastify synthesises it from GET, and
|
|
189
|
+
// server.ts's onRoute hook drops it), so assert it separately.
|
|
190
|
+
expect(advertised).toContain("HEAD");
|
|
191
|
+
} finally {
|
|
192
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -67,6 +67,43 @@ const MODULE_ROUTES: Record<string, { method: 'GET' | 'POST'; url: string }[]> =
|
|
|
67
67
|
],
|
|
68
68
|
scheduling: [{ method: 'GET', url: '/v1/schedule/availability' }],
|
|
69
69
|
'content-write': [{ method: 'POST', url: '/v1/content/BlogPosting' }],
|
|
70
|
+
// D-48 signal-collectors (#5366). Each route is chosen so a REGISTERED
|
|
71
|
+
// handler never itself answers 404 without seed data (an unregistered
|
|
72
|
+
// route is Fastify's own 404, so a route whose "not found" business
|
|
73
|
+
// outcome is also 404 — /v1/flows/:slug with a real slug,
|
|
74
|
+
// /v1/deliverables/:grantId, which is a deliberately uniform 404 by
|
|
75
|
+
// design — would be ambiguous evidence either way):
|
|
76
|
+
// - /v1/flows/INVALID_SLUG fails flowSlugSchema's kebab-case regex ->
|
|
77
|
+
// 400, before any flow lookup (flowsScope gate)
|
|
78
|
+
// - /v1/waitlist-signups/me is authScope-only (no tenant guard) -> 401
|
|
79
|
+
// with no session (one of two authScope gates, D2)
|
|
80
|
+
// - /v1/slack/actions with no signature -> 401/503, never 404
|
|
81
|
+
// (webhooksScope gate)
|
|
82
|
+
// NOT any /v1/project-listings/* static path (D4's authScope gate, or
|
|
83
|
+
// the leadsScope gate's four anonymous routes): whenever this static
|
|
84
|
+
// route is unregistered, multi-tenant's tenant-scoped parametric
|
|
85
|
+
// /v1/project-listings/:id (registered whenever multi-tenant is enabled,
|
|
86
|
+
// which every case below does) matches the last path segment as an :id
|
|
87
|
+
// value and answers 401 (auth required) instead of Fastify's own 404 —
|
|
88
|
+
// a real routing precedence effect (server.ts's own comment on
|
|
89
|
+
// projectListingMeRoute), confirmed for both /me and /public, not a
|
|
90
|
+
// gating defect but ambiguous evidence for this test's shape.
|
|
91
|
+
'signal-collectors': [
|
|
92
|
+
{ method: 'GET', url: '/v1/flows/INVALID_SLUG' },
|
|
93
|
+
{ method: 'GET', url: '/v1/waitlist-signups/me' },
|
|
94
|
+
{ method: 'POST', url: '/v1/slack/actions' },
|
|
95
|
+
],
|
|
96
|
+
// D-47 commerce (#5364): checkout, the Stripe webhook receiver, the
|
|
97
|
+
// flow-checkouts read and the recommender's two email-moment routes —
|
|
98
|
+
// one representative route per registration site server.ts gates on
|
|
99
|
+
// isModuleEnabled("commerce").
|
|
100
|
+
commerce: [
|
|
101
|
+
{ method: 'POST', url: '/v1/checkout/sessions' },
|
|
102
|
+
{ method: 'POST', url: '/v1/webhooks/stripe' },
|
|
103
|
+
{ method: 'GET', url: '/v1/flow-checkouts/me' },
|
|
104
|
+
{ method: 'POST', url: '/v1/flows/x/checkout' },
|
|
105
|
+
{ method: 'POST', url: '/v1/flows/x/save' },
|
|
106
|
+
],
|
|
70
107
|
};
|
|
71
108
|
|
|
72
109
|
const ALL_MODULES = Object.keys(MODULE_ROUTES);
|
|
@@ -103,8 +140,8 @@ describe('Module exclusion — registration vanishes with the module (S1, #5088)
|
|
|
103
140
|
},
|
|
104
141
|
);
|
|
105
142
|
|
|
106
|
-
it('with every module enabled
|
|
107
|
-
app = await build();
|
|
143
|
+
it('with every module explicitly enabled, every module route registers — no over-exclusion', async () => {
|
|
144
|
+
app = await withEnabledModules(ALL_MODULES.join(','), () => build());
|
|
108
145
|
for (const routes of Object.values(MODULE_ROUTES)) {
|
|
109
146
|
for (const { method, url } of routes) {
|
|
110
147
|
const res = await app.inject({ method, url });
|
|
@@ -138,4 +175,25 @@ describe('Module exclusion — registration vanishes with the module (S1, #5088)
|
|
|
138
175
|
}
|
|
139
176
|
}
|
|
140
177
|
});
|
|
178
|
+
|
|
179
|
+
// D-44 / #5368: an unset AILK_ENABLED_MODULES is now the same as an
|
|
180
|
+
// explicit empty string — no paid module registers by default.
|
|
181
|
+
it('with AILK_ENABLED_MODULES unset (the new default), every module route 404s', async () => {
|
|
182
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
183
|
+
app = await build();
|
|
184
|
+
const health = await app.inject({ method: 'GET', url: '/health' });
|
|
185
|
+
expect(health.statusCode).toBe(200);
|
|
186
|
+
const contentTypes = await app.inject({
|
|
187
|
+
method: 'GET',
|
|
188
|
+
url: '/v1/content/types',
|
|
189
|
+
});
|
|
190
|
+
expect(contentTypes.statusCode).not.toBe(404);
|
|
191
|
+
|
|
192
|
+
for (const routes of Object.values(MODULE_ROUTES)) {
|
|
193
|
+
for (const { method, url } of routes) {
|
|
194
|
+
const res = await app.inject({ method, url });
|
|
195
|
+
expect(res.statusCode).toBe(404);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
141
199
|
});
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
33
33
|
import Fastify from 'fastify';
|
|
34
34
|
|
|
35
|
+
jest.mock('@working-theory/observability', () => ({
|
|
36
|
+
logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
|
37
|
+
}));
|
|
38
|
+
|
|
35
39
|
// ── Typed request/response stubs ────────────────────────────────────────────
|
|
36
40
|
//
|
|
37
41
|
// Minimal stubs that satisfy only the VercelRequest/VercelResponse surface
|
|
@@ -209,3 +213,158 @@ describe('vercel-handler — response side: binary round-trip (#5168)', () => {
|
|
|
209
213
|
expect(Buffer.compare(res._sent as Buffer, rawPayload)).toBe(0);
|
|
210
214
|
});
|
|
211
215
|
});
|
|
216
|
+
|
|
217
|
+
// ── #5332: one build() per process ──────────────────────────────────────────
|
|
218
|
+
//
|
|
219
|
+
// getServer() used to cache the RESOLVED instance and test it before two
|
|
220
|
+
// awaits, so two invocations overlapping a cold start on one warm container
|
|
221
|
+
// both saw null and both built a complete Fastify server. The fix caches the
|
|
222
|
+
// in-flight promise instead. These tests drive getServer() through the default
|
|
223
|
+
// handler — the handler's exported shape is byte-synced into consuming sites,
|
|
224
|
+
// so getServer() stays module-private rather than being exported for the test.
|
|
225
|
+
|
|
226
|
+
const COLD_START_REQUEST: MockVercelRequest = {
|
|
227
|
+
method: 'GET',
|
|
228
|
+
url: '/health',
|
|
229
|
+
headers: {},
|
|
230
|
+
body: undefined,
|
|
231
|
+
query: {},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
function sentMessage(_res: MockRes): string {
|
|
235
|
+
// Re-required, not imported: jest.resetModules() gives each test a fresh
|
|
236
|
+
// mock instance, and the handler under test holds that fresh one.
|
|
237
|
+
const { logger } = require('@working-theory/observability') as {
|
|
238
|
+
logger: { error: jest.Mock };
|
|
239
|
+
};
|
|
240
|
+
const calls = logger.error.mock.calls.filter(
|
|
241
|
+
([, msg]) => msg === '[Vercel Handler] Error handling request',
|
|
242
|
+
);
|
|
243
|
+
const last = calls[calls.length - 1];
|
|
244
|
+
return last ? String((last[0] as { err?: Error }).err?.message ?? '') : '';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
describe('vercel-handler — one build per process (#5332)', () => {
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
jest.resetModules();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
afterEach(() => {
|
|
253
|
+
jest.useRealTimers();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('builds one server for two invocations that overlap a cold start', async () => {
|
|
257
|
+
// One shared deferred for both calls, so whichever build() calls happen
|
|
258
|
+
// all settle together — the assertion is on the CALL COUNT, and a test
|
|
259
|
+
// that hung when the second call won the cache would obscure it.
|
|
260
|
+
let resolveServer!: (instance: unknown) => void;
|
|
261
|
+
const serverReady = new Promise<unknown>((resolve) => {
|
|
262
|
+
resolveServer = resolve;
|
|
263
|
+
});
|
|
264
|
+
const inject = jest.fn().mockResolvedValue({
|
|
265
|
+
statusCode: 200,
|
|
266
|
+
headers: {},
|
|
267
|
+
body: 'ok',
|
|
268
|
+
rawPayload: Buffer.from('ok'),
|
|
269
|
+
});
|
|
270
|
+
const build = jest.fn(() => serverReady);
|
|
271
|
+
jest.doMock('../server', () => ({ build }));
|
|
272
|
+
const handler = loadHandler();
|
|
273
|
+
|
|
274
|
+
const resA = makeRes();
|
|
275
|
+
const resB = makeRes();
|
|
276
|
+
// Both started before either resolves: neither has a server yet.
|
|
277
|
+
const a = handler(asRequest(COLD_START_REQUEST), asResponse(resA));
|
|
278
|
+
const b = handler(asRequest(COLD_START_REQUEST), asResponse(resB));
|
|
279
|
+
|
|
280
|
+
// Drain the microtask queue so both calls have reached getServer() and
|
|
281
|
+
// awaited build() before the build is allowed to finish.
|
|
282
|
+
for (let i = 0; i < 10; i += 1) {
|
|
283
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
resolveServer({ inject });
|
|
287
|
+
await Promise.all([a, b]);
|
|
288
|
+
|
|
289
|
+
expect(build).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(resA._status).toBe(200);
|
|
291
|
+
expect(resB._status).toBe(200);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('retries the build on the next invocation after a failed cold start', async () => {
|
|
295
|
+
const inject = jest.fn().mockResolvedValue({
|
|
296
|
+
statusCode: 200,
|
|
297
|
+
headers: {},
|
|
298
|
+
body: 'ok',
|
|
299
|
+
rawPayload: Buffer.from('ok'),
|
|
300
|
+
});
|
|
301
|
+
const build = jest
|
|
302
|
+
.fn()
|
|
303
|
+
.mockRejectedValueOnce(new Error('prisma connect refused'))
|
|
304
|
+
.mockResolvedValue({ inject });
|
|
305
|
+
jest.doMock('../server', () => ({ build }));
|
|
306
|
+
const handler = loadHandler();
|
|
307
|
+
|
|
308
|
+
const failed = makeRes();
|
|
309
|
+
await handler(asRequest(COLD_START_REQUEST), asResponse(failed));
|
|
310
|
+
expect(failed._status).toBe(500);
|
|
311
|
+
expect(sentMessage(failed)).toBe('Server build failed: prisma connect refused');
|
|
312
|
+
|
|
313
|
+
// A rejected promise left in the cache would serve this request the same
|
|
314
|
+
// failure forever without calling build() again.
|
|
315
|
+
const retried = makeRes();
|
|
316
|
+
await handler(asRequest(COLD_START_REQUEST), asResponse(retried));
|
|
317
|
+
|
|
318
|
+
expect(build).toHaveBeenCalledTimes(2);
|
|
319
|
+
expect(retried._status).toBe(200);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('does not leak an absolute filesystem path in the 500 body (#5351)', async () => {
|
|
323
|
+
const leakyPath = '/home/deploy/apps/api/dist/list.js';
|
|
324
|
+
const build = jest.fn().mockRejectedValue(
|
|
325
|
+
new Error(`ERR_MODULE_NOT_FOUND file://${leakyPath}`)
|
|
326
|
+
);
|
|
327
|
+
jest.doMock('../server', () => ({ build }));
|
|
328
|
+
const handler = loadHandler();
|
|
329
|
+
|
|
330
|
+
const res = makeRes();
|
|
331
|
+
await handler(asRequest(COLD_START_REQUEST), asResponse(res));
|
|
332
|
+
|
|
333
|
+
expect(res._status).toBe(500);
|
|
334
|
+
expect(JSON.stringify(res._sent)).not.toContain(leakyPath);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('still fires the build timeout with its original message', async () => {
|
|
338
|
+
jest.useFakeTimers();
|
|
339
|
+
// A module object carrying its own `then` is assimilated when the
|
|
340
|
+
// handler's `await import('./server.js')` resolves with it, so the
|
|
341
|
+
// dynamic import never settles — the production condition the 10s build
|
|
342
|
+
// timeout guards. `__esModule` keeps ts-jest's __importStar from copying
|
|
343
|
+
// the object and dropping `then`.
|
|
344
|
+
jest.doMock('../server', () => ({ __esModule: true, then: () => {} }));
|
|
345
|
+
const handler = loadHandler();
|
|
346
|
+
|
|
347
|
+
const res = makeRes();
|
|
348
|
+
const pending = handler(asRequest(COLD_START_REQUEST), asResponse(res));
|
|
349
|
+
await jest.advanceTimersByTimeAsync(10_000);
|
|
350
|
+
await pending;
|
|
351
|
+
|
|
352
|
+
expect(res._status).toBe(500);
|
|
353
|
+
expect(sentMessage(res)).toBe('Server build failed: Server build timeout after 10s');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('still fires the init timeout with its original message', async () => {
|
|
357
|
+
jest.useFakeTimers();
|
|
358
|
+
const build = jest.fn(() => new Promise(() => {}));
|
|
359
|
+
jest.doMock('../server', () => ({ build }));
|
|
360
|
+
const handler = loadHandler();
|
|
361
|
+
|
|
362
|
+
const res = makeRes();
|
|
363
|
+
const pending = handler(asRequest(COLD_START_REQUEST), asResponse(res));
|
|
364
|
+
await jest.advanceTimersByTimeAsync(10_000);
|
|
365
|
+
await pending;
|
|
366
|
+
|
|
367
|
+
expect(res._status).toBe(500);
|
|
368
|
+
expect(sentMessage(res)).toBe('Server build failed: Server initialization timeout after 10s');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `isModuleEnabled()` default-flip regression test (D-44, #5368).
|
|
3
|
+
*
|
|
4
|
+
* Proves the mechanical claim #5368 exists to make true: an UNSET
|
|
5
|
+
* `AILK_ENABLED_MODULES` now gates every paid module OFF, the same as an
|
|
6
|
+
* explicit empty string — only an explicit allowlist turns modules on, and
|
|
7
|
+
* turns on exactly the modules it names.
|
|
8
|
+
*
|
|
9
|
+
* Reverting `enabledModules()`'s unset branch back to the `undefined`
|
|
10
|
+
* sentinel (S1, #5088's original shape) makes the "unset" cases below fail
|
|
11
|
+
* RED — see #5368 verification notes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isModuleEnabled } from '../modules';
|
|
15
|
+
|
|
16
|
+
const RECOGNIZED_MODULES = [
|
|
17
|
+
'docs',
|
|
18
|
+
'multi-tenant',
|
|
19
|
+
'scheduling',
|
|
20
|
+
'content-write',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
describe('isModuleEnabled', () => {
|
|
24
|
+
const originalEnv = process.env.AILK_ENABLED_MODULES;
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (originalEnv === undefined) {
|
|
28
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
29
|
+
} else {
|
|
30
|
+
process.env.AILK_ENABLED_MODULES = originalEnv;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('AILK_ENABLED_MODULES unset', () => {
|
|
35
|
+
it.each(RECOGNIZED_MODULES)('returns false for %s', (moduleName) => {
|
|
36
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
37
|
+
expect(isModuleEnabled(moduleName)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('AILK_ENABLED_MODULES=""', () => {
|
|
42
|
+
it.each(RECOGNIZED_MODULES)('returns false for %s', (moduleName) => {
|
|
43
|
+
process.env.AILK_ENABLED_MODULES = '';
|
|
44
|
+
expect(isModuleEnabled(moduleName)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('AILK_ENABLED_MODULES as an explicit allowlist', () => {
|
|
49
|
+
it.each(RECOGNIZED_MODULES)(
|
|
50
|
+
'enables exactly the listed modules — %s on when named',
|
|
51
|
+
(moduleName) => {
|
|
52
|
+
process.env.AILK_ENABLED_MODULES = moduleName;
|
|
53
|
+
expect(isModuleEnabled(moduleName)).toBe(true);
|
|
54
|
+
for (const other of RECOGNIZED_MODULES) {
|
|
55
|
+
if (other === moduleName) continue;
|
|
56
|
+
expect(isModuleEnabled(other)).toBe(false);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it('enables every module named in a comma list, and no others', () => {
|
|
62
|
+
process.env.AILK_ENABLED_MODULES = 'multi-tenant,scheduling';
|
|
63
|
+
expect(isModuleEnabled('multi-tenant')).toBe(true);
|
|
64
|
+
expect(isModuleEnabled('scheduling')).toBe(true);
|
|
65
|
+
expect(isModuleEnabled('docs')).toBe(false);
|
|
66
|
+
expect(isModuleEnabled('content-write')).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('trims whitespace and drops empty entries, same as before the flip', () => {
|
|
70
|
+
process.env.AILK_ENABLED_MODULES = ' multi-tenant , , scheduling ';
|
|
71
|
+
expect(isModuleEnabled('multi-tenant')).toBe(true);
|
|
72
|
+
expect(isModuleEnabled('scheduling')).toBe(true);
|
|
73
|
+
expect(isModuleEnabled('docs')).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -39,6 +39,21 @@ export interface Config {
|
|
|
39
39
|
// row's issueUrl stays null for a later retry via the PATCH route.
|
|
40
40
|
AILK_GITHUB_REPO?: string;
|
|
41
41
|
AILK_GITHUB_TOKEN?: string;
|
|
42
|
+
|
|
43
|
+
// The public origin this API is reachable at, e.g. https://api.example.com.
|
|
44
|
+
// Already consumed by the deliverable download link (#3961 D5); the OpenAPI
|
|
45
|
+
// `servers` block reads it too (#5306), since the spec is byte-synced into
|
|
46
|
+
// every consuming site and cannot name a host as a literal.
|
|
47
|
+
//
|
|
48
|
+
// This is the API's OWN origin, distinct from FRONTEND_URL (the site the
|
|
49
|
+
// browser loads) and from `_site.mdx`'s `baseUrl` (the site's public web
|
|
50
|
+
// origin). A deployment typically serves the site from example.com and this
|
|
51
|
+
// API from api.example.com, so neither of the other two can stand in.
|
|
52
|
+
//
|
|
53
|
+
// Optional: unset degrades the `servers` block to a document-relative
|
|
54
|
+
// origin, which stays correct for any agent that fetched the spec from this
|
|
55
|
+
// API. See resolveServers() in src/openapi/spec.ts.
|
|
56
|
+
AILK_PUBLIC_API_URL?: string;
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
function validateConfig(): Config {
|
|
@@ -85,6 +100,10 @@ function validateConfig(): Config {
|
|
|
85
100
|
...(process.env.AILK_GITHUB_TOKEN !== undefined && {
|
|
86
101
|
AILK_GITHUB_TOKEN: process.env.AILK_GITHUB_TOKEN,
|
|
87
102
|
}),
|
|
103
|
+
|
|
104
|
+
...(process.env.AILK_PUBLIC_API_URL !== undefined && {
|
|
105
|
+
AILK_PUBLIC_API_URL: process.env.AILK_PUBLIC_API_URL,
|
|
106
|
+
}),
|
|
88
107
|
};
|
|
89
108
|
}
|
|
90
109
|
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Paid-module presence — the registration-grain signal used by `server.ts`
|
|
3
3
|
* to decide whether a paid module's routes (and their imports) are wired
|
|
4
|
-
* into this build (S1, #5088).
|
|
4
|
+
* into this build (S1, #5088; defaulted off by D-44, #5368).
|
|
5
5
|
*
|
|
6
6
|
* `AILK_ENABLED_MODULES` is a comma-separated allowlist (e.g.
|
|
7
|
-
* `"multi-tenant,scheduling"`). Unset means
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* shape elsewhere in this file's sibling config.
|
|
7
|
+
* `"multi-tenant,scheduling"`). Unset means **no paid modules** — installed
|
|
8
|
+
* modules enable themselves explicitly; an explicit empty string (`""`)
|
|
9
|
+
* means the same (the OSS-strip build target); a list enables exactly the
|
|
10
|
+
* modules it names.
|
|
12
11
|
*
|
|
13
|
-
* Recognized module keys: `docs`, `multi-tenant`, `scheduling
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
12
|
+
* Recognized module keys: `docs`, `multi-tenant`, `scheduling`,
|
|
13
|
+
* `signal-collectors`, `commerce` (the `ModuleName` vocabulary
|
|
14
|
+
* `packages/create-ailk/src/module-architecture.ts` already models via
|
|
15
|
+
* `MODULE_SLICES`) plus `content-write` — the Pro
|
|
16
16
|
* content-write surface (`create`/`update`/`delete` under
|
|
17
17
|
* `apps/api/src/routes/content/`). `content-write` is intentionally NOT a
|
|
18
18
|
* `MODULE_SLICES` entry: it is not (yet) a `create-ailk --modules`
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
* create-ailk's module types (outside its package allowlist anyway).
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
function enabledModules(): Set<string>
|
|
27
|
+
function enabledModules(): Set<string> {
|
|
28
28
|
const raw = process.env.AILK_ENABLED_MODULES;
|
|
29
|
-
if (raw === undefined) return
|
|
29
|
+
if (raw === undefined) return new Set(); // unset = no paid modules
|
|
30
30
|
return new Set(
|
|
31
31
|
raw
|
|
32
32
|
.split(',')
|
|
@@ -37,7 +37,5 @@ function enabledModules(): Set<string> | undefined {
|
|
|
37
37
|
|
|
38
38
|
/** Whether the named paid module's routes should register on this build/boot. */
|
|
39
39
|
export function isModuleEnabled(moduleName: string): boolean {
|
|
40
|
-
|
|
41
|
-
if (enabled === undefined) return true;
|
|
42
|
-
return enabled.has(moduleName);
|
|
40
|
+
return enabledModules().has(moduleName);
|
|
43
41
|
}
|
|
@@ -194,6 +194,9 @@ export const prisma = {
|
|
|
194
194
|
findMany: jest.fn(),
|
|
195
195
|
update: jest.fn(),
|
|
196
196
|
updateMany: jest.fn(),
|
|
197
|
+
// `count` — the resume email's per-recipient send cap (#5406): how many
|
|
198
|
+
// drafts this address has started on this site inside the rolling window.
|
|
199
|
+
count: jest.fn(),
|
|
197
200
|
},
|
|
198
201
|
// ── Inbox namespace (WaitlistExperiment — #4661 D5) ──────────────────────
|
|
199
202
|
// `upsert` — the ONLY write path into waitlist_experiments
|