create-ailk 0.2.0 → 0.4.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/cli.js +19 -9
- 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 +51 -3
- package/dist/lib/extract-module-bundle.js +327 -28
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/fetch-template.d.ts +20 -0
- package/dist/lib/fetch-template.js +129 -0
- package/dist/lib/module-license-gate.d.ts +10 -0
- package/dist/lib/module-license-gate.js +13 -4
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +197 -23
- package/dist/module-architecture.js +820 -25
- package/dist/parse-args.d.ts +9 -2
- package/dist/parse-args.js +46 -1
- package/dist/programmatic.d.ts +28 -2
- package/dist/programmatic.js +115 -8
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +77 -5
- package/dist/sync-routes.js +31 -1
- package/dist/template-manifest.d.ts +92 -0
- package/dist/template-manifest.js +168 -0
- package/dist/template-names.d.ts +41 -0
- package/dist/template-names.js +44 -0
- package/package.json +3 -2
- package/site-templates/marketing-site-free/ailk-template.json +8 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +103 -0
- package/site-templates/marketing-site-free/content/en/PrivacyPage/privacy.mdx +79 -0
- package/site-templates/marketing-site-free/content/en/TermsPage/terms.mdx +80 -0
- package/site-templates/marketing-site-free/provenance.json +4 -0
- 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/__tests__/workspace-module-registration.test.ts +166 -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 +504 -137
- 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/[locale]/pricing/page.tsx +40 -0
- package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
- package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +19 -13
- package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
- package/templates/apps/web/lib/server-api.ts +28 -12
- package/templates/apps/web/messages/en.json +58 -0
- 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 +47 -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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice-module load boundary (#5334 D4).
|
|
3
|
+
*
|
|
4
|
+
* Every paid-module SLICE in `apps/api` is reached through an indirect dynamic
|
|
5
|
+
* `import()` — a specifier the call site never writes as a literal, because
|
|
6
|
+
* `tsc` would then resolve it and the OSS strip would break (#5182 D1). #5304
|
|
7
|
+
* moved those specifiers onto module-level constants, the one shape that is
|
|
8
|
+
* simultaneously unresolvable to `tsc` and traceable to `@vercel/nft`, so the
|
|
9
|
+
* files reach a Vercel deployment.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS FILE IS FOR. That shape is correct for the deploy path the scaffold
|
|
12
|
+
* ships, and it cannot be made to work under a BUNDLER. esbuild bundles a
|
|
13
|
+
* dynamic `import()` iff its argument is a string literal at the call site —
|
|
14
|
+
* exactly the predicate `tsc` resolves on, and exactly the one #5182 D1
|
|
15
|
+
* requires be false. So in a bundled build the specifier survives verbatim and
|
|
16
|
+
* Node resolves it against the BUNDLE OUTPUT directory rather than the source
|
|
17
|
+
* module's: `"./list.js"`, written in `routes/project-listings/index.ts`,
|
|
18
|
+
* becomes `dist/list.js` and does not exist. `build()` throws during
|
|
19
|
+
* CONSTRUCTION, so every route 500s rather than one.
|
|
20
|
+
*
|
|
21
|
+
* The failure is otherwise silent on every channel: esbuild emits no warning
|
|
22
|
+
* for the imports it declines to bundle, and a consuming frontend degrades to
|
|
23
|
+
* an empty list and still answers 200. `salessmyth/samhenry` spent nine hours
|
|
24
|
+
* on it. This boundary is what turns that into a one-line diagnosis — it does
|
|
25
|
+
* not make bundling work, because nothing in this file could.
|
|
26
|
+
*
|
|
27
|
+
* WHY IT TAKES A THUNK AND NOT A SPECIFIER. `loadSlice("./list.js")` doing the
|
|
28
|
+
* `import()` in here would be the obvious signature and would silently undo
|
|
29
|
+
* #5304. `enumerateSliceSpecifiers()` treats a file as an "indirect file" only
|
|
30
|
+
* if it carries a dynamic `import()` with a non-literal argument; move the call
|
|
31
|
+
* out of the callers and they stop qualifying, their specifier constants stop
|
|
32
|
+
* being candidates, and `check-slice-reachability.ts` passes vacuously over an
|
|
33
|
+
* empty set. Worse, `@vercel/nft` constant-propagates `WORKSPACES_INDEX` into
|
|
34
|
+
* `import(WORKSPACES_INDEX)` only because the constant and the call sit in one
|
|
35
|
+
* module — hoist the call and there is nothing to propagate, so every slice
|
|
36
|
+
* file goes untraced and #5304's outage returns with its own gate green.
|
|
37
|
+
*
|
|
38
|
+
* So the `import()` stays at the call site, in the caller's module, and this
|
|
39
|
+
* function only awaits the thunk and interprets the failure.
|
|
40
|
+
*
|
|
41
|
+
* Gated by `scripts/check-bundled-build-diagnoses.ts`, which bundles `apps/api`
|
|
42
|
+
* and asserts that the boot fails WITH this diagnostic rather than without one.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Node reports an unresolvable ESM specifier as `ERR_MODULE_NOT_FOUND` and an
|
|
47
|
+
* unresolvable CJS one as `MODULE_NOT_FOUND`. A bundle can produce either,
|
|
48
|
+
* since a bundled graph mixes both.
|
|
49
|
+
*/
|
|
50
|
+
const MODULE_NOT_FOUND_CODES = new Set(["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]);
|
|
51
|
+
|
|
52
|
+
function isModuleNotFound(error: unknown): boolean {
|
|
53
|
+
const code = (error as { code?: unknown } | null | undefined)?.code;
|
|
54
|
+
return typeof code === "string" && MODULE_NOT_FOUND_CODES.has(code);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Await a slice module's loader thunk, turning an unresolvable module into a
|
|
59
|
+
* diagnosis instead of a bare `ERR_MODULE_NOT_FOUND` on a path that never
|
|
60
|
+
* existed.
|
|
61
|
+
*
|
|
62
|
+
* Anything that is not a module-resolution failure is rethrown UNTOUCHED — the
|
|
63
|
+
* same object, not a wrapper. A slice that throws while evaluating is a real
|
|
64
|
+
* bug in the slice and must keep its own stack and type.
|
|
65
|
+
*
|
|
66
|
+
* @param specifier the module specifier the thunk imports, for the message.
|
|
67
|
+
* Every call site already has it in scope, so nothing is enumerated here.
|
|
68
|
+
* @param load the caller's own `() => import(CONST)` thunk. Must close over a
|
|
69
|
+
* constant in the CALLER's module — see the file header.
|
|
70
|
+
*/
|
|
71
|
+
export async function loadSlice(
|
|
72
|
+
specifier: string,
|
|
73
|
+
load: () => Promise<unknown>,
|
|
74
|
+
): Promise<unknown> {
|
|
75
|
+
try {
|
|
76
|
+
return await load();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!isModuleNotFound(error)) throw error;
|
|
79
|
+
throw new Error(
|
|
80
|
+
`AILK slice module '${specifier}' could not be resolved at runtime. ` +
|
|
81
|
+
`This throws during server construction, so every route fails, not one. Two causes produce it:\n` +
|
|
82
|
+
` 1. The module was compiled but not SHIPPED — the deploy-time file tracer did not emit it (#5304). ` +
|
|
83
|
+
`Check with \`pnpm slice-reachability:check\`.\n` +
|
|
84
|
+
` 2. This build was BUNDLED. apps/api does not support bundling (#5334): a bundler resolves this ` +
|
|
85
|
+
`specifier against the bundle's own output directory rather than its source module's, and esbuild ` +
|
|
86
|
+
`leaves it unbundled without a warning. See apps/api/CLAUDE.md § Deploy path.`,
|
|
87
|
+
{ cause: error },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -28,10 +28,10 @@ describe("GET /api/openapi.json", () => {
|
|
|
28
28
|
|
|
29
29
|
const spec = JSON.parse(res.body);
|
|
30
30
|
expect(spec.openapi).toBe("3.1.0");
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
// The title is resolved from the serving site's own `_site.mdx` (#5307),
|
|
32
|
+
// so assert its SHAPE rather than a literal site name — the same build
|
|
33
|
+
// serves a different title on every consuming site.
|
|
34
|
+
expect(spec.info.title).toMatch(/Public Content API$/);
|
|
35
35
|
expect(spec.info.version).toBe("1.0.0");
|
|
36
36
|
expect(typeof spec.paths).toBe("object");
|
|
37
37
|
});
|
|
@@ -54,39 +54,59 @@ describe("GET /api/openapi.json", () => {
|
|
|
54
54
|
);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
// The checkout path's OpenAPI documentation is module-conditional (commerce,
|
|
58
|
+
// D-47 / #5364) — mirroring the route's own registration gate in server.ts —
|
|
59
|
+
// so these two assertions need the module enabled, exactly as a real
|
|
60
|
+
// commerce-installed deployment would have it.
|
|
61
|
+
describe("checkout endpoint (commerce module enabled)", () => {
|
|
62
|
+
const PRIOR_ENABLED_MODULES = process.env.AILK_ENABLED_MODULES;
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
process.env.AILK_ENABLED_MODULES = "commerce";
|
|
61
66
|
});
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
if (PRIOR_ENABLED_MODULES === undefined) {
|
|
70
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
71
|
+
} else {
|
|
72
|
+
process.env.AILK_ENABLED_MODULES = PRIOR_ENABLED_MODULES;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
it("documents the checkout sessions endpoint with operationId createCheckoutSession", async () => {
|
|
77
|
+
const res = await app.inject({
|
|
78
|
+
method: "GET",
|
|
79
|
+
url: "/v1/openapi.json",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const spec = JSON.parse(res.body);
|
|
83
|
+
expect(spec.paths["/v1/checkout/sessions"].post.operationId).toBe(
|
|
84
|
+
"createCheckoutSession",
|
|
85
|
+
);
|
|
73
86
|
});
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
it("documents 201, 400, 404, 500, 502 responses for the checkout endpoint", async () => {
|
|
89
|
+
const res = await app.inject({
|
|
90
|
+
method: "GET",
|
|
91
|
+
url: "/v1/openapi.json",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const spec = JSON.parse(res.body);
|
|
95
|
+
const responses = spec.paths["/v1/checkout/sessions"].post.responses;
|
|
96
|
+
expect(responses["201"].description).toBe("Checkout session created");
|
|
97
|
+
expect(responses["400"].description).toBe(
|
|
98
|
+
"Validation error — invalid request body",
|
|
99
|
+
);
|
|
100
|
+
expect(responses["404"].description).toBe(
|
|
101
|
+
"Unknown product code — no active Stripe price for the given productCode",
|
|
102
|
+
);
|
|
103
|
+
expect(responses["500"].description).toBe(
|
|
104
|
+
"Live-mode forbidden — STRIPE_SECRET_KEY is a live key",
|
|
105
|
+
);
|
|
106
|
+
expect(responses["502"].description).toBe(
|
|
107
|
+
"Checkout unavailable — Stripe returned an unexpected error",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
90
110
|
});
|
|
91
111
|
|
|
92
112
|
it("declares query and path parameters for content endpoints", async () => {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file spec-identity.test.ts
|
|
3
|
+
*
|
|
4
|
+
* The served API's self-description (#5307) and the `servers` block (#5306).
|
|
5
|
+
*
|
|
6
|
+
* These assert the BUILDER's output given a config rather than a deployed
|
|
7
|
+
* response: the defect is that a deployed site serves the kit's identity and
|
|
8
|
+
* the kit's host, and the builder is the only place that can be proved
|
|
9
|
+
* without deploying.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
API_VERSION,
|
|
17
|
+
apiDescription,
|
|
18
|
+
apiDisplayName,
|
|
19
|
+
buildOpenApiSpec,
|
|
20
|
+
resetApiIdentity,
|
|
21
|
+
resolveApiIdentity,
|
|
22
|
+
} from "../spec";
|
|
23
|
+
|
|
24
|
+
/** Write a throwaway content root carrying the given `_site.mdx` frontmatter. */
|
|
25
|
+
function withContentRoot(frontmatter: string): string {
|
|
26
|
+
const root = mkdtempSync(join(tmpdir(), "ailk-site-"));
|
|
27
|
+
mkdirSync(root, { recursive: true });
|
|
28
|
+
writeFileSync(join(root, "_site.mdx"), `---\n${frontmatter}\n---\n\nbody\n`);
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("buildOpenApiSpec — site identity (#5307)", () => {
|
|
33
|
+
it("titles the spec after the site, not after the kit", () => {
|
|
34
|
+
const spec = buildOpenApiSpec({ siteName: "Sam Henry" });
|
|
35
|
+
|
|
36
|
+
expect(spec.info.title).toBe("Sam Henry — Public Content API");
|
|
37
|
+
expect(JSON.stringify(spec.info)).not.toMatch(/AI Launch Kit|AILK/i);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("degrades to a named neutral title when no site name is resolvable", () => {
|
|
41
|
+
const spec = buildOpenApiSpec({});
|
|
42
|
+
|
|
43
|
+
expect(spec.info.title).toBe("Public Content API");
|
|
44
|
+
expect(spec.info.title).not.toMatch(/AI Launch Kit|AILK/i);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("asserts no licence over a deployment that has not chosen one", () => {
|
|
48
|
+
expect(buildOpenApiSpec({ siteName: "Sam Henry" }).info).not.toHaveProperty(
|
|
49
|
+
"license",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps API_VERSION as the /v1 contract version", () => {
|
|
54
|
+
expect(API_VERSION).toBe("1.0.0");
|
|
55
|
+
expect(buildOpenApiSpec({}).info.version).toBe(API_VERSION);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("buildOpenApiSpec — servers block (#5306)", () => {
|
|
60
|
+
it("names the deployment's own API origin", () => {
|
|
61
|
+
const spec = buildOpenApiSpec({ publicApiUrl: "https://api.samhenry.org" });
|
|
62
|
+
|
|
63
|
+
expect(spec.servers).toEqual([
|
|
64
|
+
{ url: "https://api.samhenry.org", description: expect.any(String) },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("never serves the kit's own host", () => {
|
|
69
|
+
const spec = buildOpenApiSpec({
|
|
70
|
+
siteName: "Sam Henry",
|
|
71
|
+
publicApiUrl: "https://api.samhenry.org",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(JSON.stringify(spec)).not.toContain("ailaunchkit.ai");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("normalises a trailing slash", () => {
|
|
78
|
+
expect(
|
|
79
|
+
buildOpenApiSpec({ publicApiUrl: "https://api.samhenry.org/" }).servers[0]
|
|
80
|
+
?.url,
|
|
81
|
+
).toBe("https://api.samhenry.org");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("degrades to a document-relative origin when the value is unset", () => {
|
|
85
|
+
const spec = buildOpenApiSpec({});
|
|
86
|
+
|
|
87
|
+
expect(spec.servers).toHaveLength(1);
|
|
88
|
+
expect(spec.servers[0]?.url).toBe("/");
|
|
89
|
+
// The degradation is named in the served document itself, so an agent
|
|
90
|
+
// reading the spec learns why rather than following a wrong absolute URL.
|
|
91
|
+
expect(spec.servers[0]?.description).toMatch(/AILK_PUBLIC_API_URL/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("degrades rather than serving a malformed origin", () => {
|
|
95
|
+
expect(buildOpenApiSpec({ publicApiUrl: "not-a-url" }).servers[0]?.url).toBe(
|
|
96
|
+
"/",
|
|
97
|
+
);
|
|
98
|
+
expect(
|
|
99
|
+
buildOpenApiSpec({ publicApiUrl: "ftp://api.example.com" }).servers[0]
|
|
100
|
+
?.url,
|
|
101
|
+
).toBe("/");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("resolveApiIdentity — reads _site.mdx", () => {
|
|
106
|
+
const originalContentRoot = process.env.CONTENT_ROOT;
|
|
107
|
+
let root: string | undefined;
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
if (originalContentRoot === undefined) delete process.env.CONTENT_ROOT;
|
|
111
|
+
else process.env.CONTENT_ROOT = originalContentRoot;
|
|
112
|
+
if (root) rmSync(root, { recursive: true, force: true });
|
|
113
|
+
root = undefined;
|
|
114
|
+
resetApiIdentity();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("sources the site name and description from _site.mdx", () => {
|
|
118
|
+
root = withContentRoot(
|
|
119
|
+
[
|
|
120
|
+
'version: "1"',
|
|
121
|
+
"siteName: Sam Henry",
|
|
122
|
+
"baseUrl: https://samhenry.org",
|
|
123
|
+
"siteIdentity:",
|
|
124
|
+
" kind: Person",
|
|
125
|
+
" name: Sam Henry",
|
|
126
|
+
" description: Independent software consultant.",
|
|
127
|
+
].join("\n"),
|
|
128
|
+
);
|
|
129
|
+
process.env.CONTENT_ROOT = root;
|
|
130
|
+
resetApiIdentity();
|
|
131
|
+
|
|
132
|
+
const identity = resolveApiIdentity();
|
|
133
|
+
|
|
134
|
+
expect(identity.siteName).toBe("Sam Henry");
|
|
135
|
+
expect(identity.siteDescription).toBe("Independent software consultant.");
|
|
136
|
+
expect(apiDisplayName(identity)).toBe("Sam Henry API");
|
|
137
|
+
expect(apiDescription(identity)).toBe("Independent software consultant.");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// #5306 AC-3 — the trap this card exists to refuse.
|
|
141
|
+
it("does NOT use _site.mdx baseUrl as the API origin", () => {
|
|
142
|
+
root = withContentRoot(
|
|
143
|
+
[
|
|
144
|
+
'version: "1"',
|
|
145
|
+
"siteName: Sam Henry",
|
|
146
|
+
// The SITE origin. The API is deployed on a different host
|
|
147
|
+
// (api.samhenry.org), so this value must never reach `servers`.
|
|
148
|
+
"baseUrl: https://samhenry.org",
|
|
149
|
+
].join("\n"),
|
|
150
|
+
);
|
|
151
|
+
process.env.CONTENT_ROOT = root;
|
|
152
|
+
resetApiIdentity();
|
|
153
|
+
|
|
154
|
+
const identity = resolveApiIdentity();
|
|
155
|
+
expect(identity.publicApiUrl).not.toBe("https://samhenry.org");
|
|
156
|
+
|
|
157
|
+
const spec = buildOpenApiSpec({ ...identity, publicApiUrl: undefined });
|
|
158
|
+
expect(JSON.stringify(spec.servers)).not.toContain("samhenry.org");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("degrades to a named neutral default when _site.mdx is unreadable", () => {
|
|
162
|
+
root = mkdtempSync(join(tmpdir(), "ailk-empty-"));
|
|
163
|
+
process.env.CONTENT_ROOT = root;
|
|
164
|
+
resetApiIdentity();
|
|
165
|
+
|
|
166
|
+
const identity = resolveApiIdentity();
|
|
167
|
+
|
|
168
|
+
expect(identity.siteName).toBeUndefined();
|
|
169
|
+
expect(apiDisplayName(identity)).toBe("Content API");
|
|
170
|
+
expect(apiDisplayName(identity)).not.toMatch(/AI Launch Kit|AILK/i);
|
|
171
|
+
expect(buildOpenApiSpec(identity).info.title).toBe("Public Content API");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("degrades when _site.mdx frontmatter is malformed", () => {
|
|
175
|
+
root = withContentRoot("siteName: [unclosed");
|
|
176
|
+
process.env.CONTENT_ROOT = root;
|
|
177
|
+
resetApiIdentity();
|
|
178
|
+
|
|
179
|
+
expect(resolveApiIdentity().siteName).toBeUndefined();
|
|
180
|
+
expect(apiDisplayName(resolveApiIdentity())).toBe("Content API");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file surface-drift.test.ts
|
|
3
|
+
* @description The self-description drift gate (#5314).
|
|
4
|
+
*
|
|
5
|
+
* Builds the REAL server, takes the route table Fastify actually registered,
|
|
6
|
+
* and diffs it against both surfaces that claim to describe it — the root
|
|
7
|
+
* index `endpoints` object and the served OpenAPI `paths` object.
|
|
8
|
+
*
|
|
9
|
+
* Both surfaces are read over `inject`, i.e. exactly what an agent would
|
|
10
|
+
* fetch, never the object the builder returned.
|
|
11
|
+
*
|
|
12
|
+
* This gate was RED before the derivation landed: 61 registered routes were
|
|
13
|
+
* missing from `paths` and 53 from `endpoints`. Two of its five concerns
|
|
14
|
+
* (AC-1 coverage on each surface) are unfailable now that both surfaces
|
|
15
|
+
* derive from the same registry they are compared against — that is the
|
|
16
|
+
* inherent consequence of AC-2's "derived" branch, and the spec's D5 says so.
|
|
17
|
+
* What stays live is the overlay join (a hand-authored entry whose route no
|
|
18
|
+
* longer registers — the concern that caught `DELETE /v1/auth/tokens`, whose
|
|
19
|
+
* real path is `/v1/auth/tokens/:id`), the anti-vacuity floor (an empty or
|
|
20
|
+
* stale registry would pass a coverage check trivially, so it is asserted
|
|
21
|
+
* against directly), and the module-agreement case's non-empty guard, which
|
|
22
|
+
* fails if module gating ever collapses so both configs register identically.
|
|
23
|
+
*
|
|
24
|
+
* Mirrors `src/__tests__/module-exclusion.test.ts`'s external-boundary mocks
|
|
25
|
+
* (CORS, rate-limit, email) — see that file's header for why each is mocked.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
jest.mock("@fastify/cors", () => {
|
|
29
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
jest.mock("@fastify/rate-limit", () => {
|
|
33
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
jest.mock("@working-theory/email", () => ({
|
|
37
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import { build } from "../../server";
|
|
41
|
+
import { readRegisteredRoutes } from "../spec";
|
|
42
|
+
import {
|
|
43
|
+
compareSurfaces,
|
|
44
|
+
formatSurfaceDrift,
|
|
45
|
+
hasDrift,
|
|
46
|
+
openApiSurfaceKeys,
|
|
47
|
+
rootIndexSurfaceKeys,
|
|
48
|
+
routeKey,
|
|
49
|
+
toOpenApiPath,
|
|
50
|
+
type RegisteredRoute,
|
|
51
|
+
} from "../surface-drift";
|
|
52
|
+
|
|
53
|
+
/** A route every build registers, outside every module conditional. */
|
|
54
|
+
const FLOOR_ROUTE = routeKey("GET", "/v1/content/types");
|
|
55
|
+
|
|
56
|
+
interface Surfaces {
|
|
57
|
+
registered: RegisteredRoute[];
|
|
58
|
+
paths: Record<string, Record<string, unknown>>;
|
|
59
|
+
endpoints: Record<string, string>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the server under one module configuration and read both surfaces back
|
|
64
|
+
* over the wire. `undefined` means "leave AILK_ENABLED_MODULES unset", which
|
|
65
|
+
* is now the default deployment shape (no paid module, D-44 / #5368).
|
|
66
|
+
*/
|
|
67
|
+
async function surfacesFor(enabled: string | undefined): Promise<Surfaces> {
|
|
68
|
+
const prior = process.env.AILK_ENABLED_MODULES;
|
|
69
|
+
if (enabled === undefined) {
|
|
70
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
71
|
+
} else {
|
|
72
|
+
process.env.AILK_ENABLED_MODULES = enabled;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const app = await build();
|
|
77
|
+
await app.ready();
|
|
78
|
+
|
|
79
|
+
const registered = [...readRegisteredRoutes(app)];
|
|
80
|
+
|
|
81
|
+
const rootRes = await app.inject({ method: "GET", url: "/" });
|
|
82
|
+
const specRes = await app.inject({
|
|
83
|
+
method: "GET",
|
|
84
|
+
url: "/v1/openapi.json",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await app.close();
|
|
88
|
+
|
|
89
|
+
expect(rootRes.statusCode).toBe(200);
|
|
90
|
+
expect(specRes.statusCode).toBe(200);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
registered,
|
|
94
|
+
paths: JSON.parse(specRes.body).paths,
|
|
95
|
+
endpoints: JSON.parse(rootRes.body).endpoints,
|
|
96
|
+
};
|
|
97
|
+
} finally {
|
|
98
|
+
if (prior === undefined) {
|
|
99
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
100
|
+
} else {
|
|
101
|
+
process.env.AILK_ENABLED_MODULES = prior;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The module configurations every claim below is asserted under. Unset is now
|
|
107
|
+
// the default deployment (no paid module, D-44 / #5368), the same as an
|
|
108
|
+
// explicit "" (the OSS-strip build target); a full allowlist is the
|
|
109
|
+
// every-module-on case. A slice route absent under "" must be absent from
|
|
110
|
+
// BOTH surfaces there, which is what makes its absence agreement rather than
|
|
111
|
+
// drift.
|
|
112
|
+
const ALL_PAID_MODULES =
|
|
113
|
+
"docs,multi-tenant,scheduling,content-write,signal-collectors";
|
|
114
|
+
const CONFIGS: Array<[string, string | undefined]> = [
|
|
115
|
+
["AILK_ENABLED_MODULES unset (no paid module, the default)", undefined],
|
|
116
|
+
['AILK_ENABLED_MODULES="" (no paid module)', ""],
|
|
117
|
+
[
|
|
118
|
+
`AILK_ENABLED_MODULES="${ALL_PAID_MODULES}" (every paid module on)`,
|
|
119
|
+
ALL_PAID_MODULES,
|
|
120
|
+
],
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
describe("API self-description drift (#5314)", () => {
|
|
124
|
+
describe.each(CONFIGS)("%s", (_label, enabled) => {
|
|
125
|
+
let surfaces: Surfaces;
|
|
126
|
+
|
|
127
|
+
beforeAll(async () => {
|
|
128
|
+
surfaces = await surfacesFor(enabled);
|
|
129
|
+
}, 60000);
|
|
130
|
+
|
|
131
|
+
// Anti-vacuity floor. A coverage assertion over an empty registry passes
|
|
132
|
+
// trivially, so an empty or stale registry is failed directly — the most
|
|
133
|
+
// likely way a derivation gate silently stops testing anything.
|
|
134
|
+
it("registers a non-empty route table including the floor route", () => {
|
|
135
|
+
expect(surfaces.registered.length).toBeGreaterThan(0);
|
|
136
|
+
const keys = surfaces.registered.map((r) => routeKey(r.method, r.url));
|
|
137
|
+
expect(keys).toContain(FLOOR_ROUTE);
|
|
138
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("publishes no surface entry for a route the server does not register", () => {
|
|
142
|
+
const diffs = compareSurfaces(
|
|
143
|
+
surfaces.registered,
|
|
144
|
+
surfaces.paths,
|
|
145
|
+
surfaces.endpoints,
|
|
146
|
+
);
|
|
147
|
+
const orphaned = diffs.filter((d) => d.orphanedInSurface.length > 0);
|
|
148
|
+
expect(formatSurfaceDrift(orphaned)).toBe("");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("describes every registered route on both surfaces", () => {
|
|
152
|
+
const diffs = compareSurfaces(
|
|
153
|
+
surfaces.registered,
|
|
154
|
+
surfaces.paths,
|
|
155
|
+
surfaces.endpoints,
|
|
156
|
+
);
|
|
157
|
+
expect(formatSurfaceDrift(diffs)).toBe("");
|
|
158
|
+
expect(hasDrift(diffs)).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// AC-1 names this endpoint specifically: it returns 200 and is what the
|
|
162
|
+
// consuming site depends on, and before #5314 both surfaces omitted it.
|
|
163
|
+
// #5366 (D-48) moved it to signal-collectors: the no-drift half
|
|
164
|
+
// (`missingFromSurface`) holds regardless of whether the route
|
|
165
|
+
// registers at all, but the route itself now only registers when
|
|
166
|
+
// signal-collectors is one of the enabled modules for this config.
|
|
167
|
+
it("describes GET /v1/project-listings/public on both surfaces when registered", () => {
|
|
168
|
+
const diffs = compareSurfaces(
|
|
169
|
+
surfaces.registered,
|
|
170
|
+
surfaces.paths,
|
|
171
|
+
surfaces.endpoints,
|
|
172
|
+
);
|
|
173
|
+
for (const d of diffs) {
|
|
174
|
+
expect(d.missingFromSurface).not.toContain(
|
|
175
|
+
routeKey("GET", "/v1/project-listings/public"),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const signalCollectorsEnabled = (enabled ?? "")
|
|
179
|
+
.split(",")
|
|
180
|
+
.map((s) => s.trim())
|
|
181
|
+
.includes("signal-collectors");
|
|
182
|
+
expect(
|
|
183
|
+
surfaces.registered.some(
|
|
184
|
+
(r) => r.url === "/v1/project-listings/public" && r.method === "GET",
|
|
185
|
+
),
|
|
186
|
+
).toBe(signalCollectorsEnabled);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Regression: the route table is per-instance, not module state.
|
|
191
|
+
//
|
|
192
|
+
// `vercel-handler.ts`'s `getServer()` is a check-then-set — it tests
|
|
193
|
+
// `!serverInstance`, then awaits twice before assigning — so two concurrent
|
|
194
|
+
// invocations can both call `build()`. With a module-level table, one
|
|
195
|
+
// build's reset would land mid-fill of the other and leave a truncated or
|
|
196
|
+
// doubled set, and both surfaces would silently misreport. Building two
|
|
197
|
+
// servers under DIFFERENT module configs and asserting each keeps its own
|
|
198
|
+
// table is what makes that unreachable rather than merely unlikely.
|
|
199
|
+
it("keeps each server's route table on its own instance", async () => {
|
|
200
|
+
const prior = process.env.AILK_ENABLED_MODULES;
|
|
201
|
+
try {
|
|
202
|
+
process.env.AILK_ENABLED_MODULES = ALL_PAID_MODULES;
|
|
203
|
+
const allOn = await build();
|
|
204
|
+
await allOn.ready();
|
|
205
|
+
|
|
206
|
+
process.env.AILK_ENABLED_MODULES = "";
|
|
207
|
+
const noneOn = await build();
|
|
208
|
+
await noneOn.ready();
|
|
209
|
+
|
|
210
|
+
const allKeys = readRegisteredRoutes(allOn).map((r) =>
|
|
211
|
+
routeKey(r.method, r.url),
|
|
212
|
+
);
|
|
213
|
+
const noneKeys = readRegisteredRoutes(noneOn).map((r) =>
|
|
214
|
+
routeKey(r.method, r.url),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// The second build must not have emptied, truncated or doubled the
|
|
218
|
+
// first — the exact corruption a shared table would produce.
|
|
219
|
+
expect(allKeys.length).toBeGreaterThan(noneKeys.length);
|
|
220
|
+
expect(new Set(allKeys).size).toBe(allKeys.length);
|
|
221
|
+
expect(new Set(noneKeys).size).toBe(noneKeys.length);
|
|
222
|
+
expect(allKeys).toContain(FLOOR_ROUTE);
|
|
223
|
+
expect(noneKeys).toContain(FLOOR_ROUTE);
|
|
224
|
+
|
|
225
|
+
// And each server's own OpenAPI document still matches its own table.
|
|
226
|
+
for (const app of [allOn, noneOn]) {
|
|
227
|
+
const paths = JSON.parse(
|
|
228
|
+
(await app.inject({ method: "GET", url: "/v1/openapi.json" })).body,
|
|
229
|
+
).paths;
|
|
230
|
+
const endpoints = JSON.parse(
|
|
231
|
+
(await app.inject({ method: "GET", url: "/" })).body,
|
|
232
|
+
).endpoints;
|
|
233
|
+
expect(
|
|
234
|
+
formatSurfaceDrift(
|
|
235
|
+
compareSurfaces(readRegisteredRoutes(app), paths, endpoints),
|
|
236
|
+
),
|
|
237
|
+
).toBe("");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await allOn.close();
|
|
241
|
+
await noneOn.close();
|
|
242
|
+
} finally {
|
|
243
|
+
if (prior === undefined) {
|
|
244
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
245
|
+
} else {
|
|
246
|
+
process.env.AILK_ENABLED_MODULES = prior;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}, 60000);
|
|
250
|
+
|
|
251
|
+
// AC-4. Asserted as agreement rather than as an exemption list: a disabled
|
|
252
|
+
// module's routes vanish from the registered set and from both surfaces at
|
|
253
|
+
// once, so there is nothing for the gate to forgive.
|
|
254
|
+
it("drops a disabled module's routes from the route table AND both surfaces together", async () => {
|
|
255
|
+
const allOn = await surfacesFor(ALL_PAID_MODULES);
|
|
256
|
+
const noneOn = await surfacesFor("");
|
|
257
|
+
|
|
258
|
+
const noneKeys = new Set(
|
|
259
|
+
noneOn.registered.map((r) => routeKey(r.method, r.url)),
|
|
260
|
+
);
|
|
261
|
+
const moduleOnly = allOn.registered.filter(
|
|
262
|
+
(r) => !noneKeys.has(routeKey(r.method, r.url)),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// The configurations must actually differ, or this asserts nothing.
|
|
266
|
+
expect(moduleOnly.length).toBeGreaterThan(0);
|
|
267
|
+
|
|
268
|
+
// Every module-only route is described when its module is on...
|
|
269
|
+
expect(
|
|
270
|
+
formatSurfaceDrift(
|
|
271
|
+
compareSurfaces(allOn.registered, allOn.paths, allOn.endpoints),
|
|
272
|
+
),
|
|
273
|
+
).toBe("");
|
|
274
|
+
|
|
275
|
+
// ...and named by neither surface when it is off, which is agreement.
|
|
276
|
+
//
|
|
277
|
+
// Each surface is parsed by the instrument's own reader, and each route is
|
|
278
|
+
// compared in that surface's vocabulary — OpenAPI-templated for `paths`,
|
|
279
|
+
// Fastify-shaped for `endpoints`. Comparing raw values instead would make
|
|
280
|
+
// this assertion quietly weaker than it reads: `paths` keys `{id}` where a
|
|
281
|
+
// registered route says `:id`, so every parametric route would "pass" on a
|
|
282
|
+
// vocabulary mismatch, and `endpoints` groups methods into one value
|
|
283
|
+
// (`"GET/PUT/DELETE /v1/user/data/:key"`), which never equals a
|
|
284
|
+
// single-method key. Both halves would then be vacuous for exactly the
|
|
285
|
+
// routes most likely to regress.
|
|
286
|
+
const offPathKeys = openApiSurfaceKeys(noneOn.paths);
|
|
287
|
+
const offEndpointKeys = rootIndexSurfaceKeys(noneOn.endpoints);
|
|
288
|
+
for (const { method, url } of moduleOnly) {
|
|
289
|
+
expect(offPathKeys).not.toContain(routeKey(method, toOpenApiPath(url)));
|
|
290
|
+
expect(offEndpointKeys).not.toContain(routeKey(method, url));
|
|
291
|
+
}
|
|
292
|
+
}, 60000);
|
|
293
|
+
});
|