create-ailk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.md +50 -53
- package/dist/lib/apply-module-patches.d.ts +47 -0
- package/dist/lib/apply-module-patches.js +370 -0
- package/dist/lib/extract-module-bundle.d.ts +12 -3
- package/dist/lib/extract-module-bundle.js +236 -24
- package/dist/lib/fetch-module.d.ts +3 -38
- package/dist/lib/fetch-module.js +26 -8
- package/dist/lib/module-license-gate.d.ts +2 -0
- package/dist/lib/module-license-gate.js +1 -0
- package/dist/lib/paid-paths.d.ts +16 -0
- package/dist/lib/paid-paths.js +90 -0
- package/dist/module-architecture.d.ts +139 -23
- package/dist/module-architecture.js +668 -25
- package/dist/parse-args.d.ts +2 -2
- package/dist/parse-args.js +3 -1
- package/dist/programmatic.d.ts +2 -1
- package/dist/programmatic.js +15 -4
- package/dist/surfaces.d.ts +12 -36
- package/dist/surfaces.js +35 -5
- package/dist/sync-routes.js +31 -1
- package/package.json +2 -2
- package/templates/.claude/agents/web.md +2 -3
- package/templates/.claude/rules/architecture.md +5 -5
- package/templates/.claude/skills/README.md +2 -1
- package/templates/.env.example +21 -9
- package/templates/CONVENTIONS.md +6 -7
- package/templates/apps/api/.env.example +9 -0
- package/templates/apps/api/CLAUDE.md +33 -5
- package/templates/apps/api/package.json +0 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
- package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
- package/templates/apps/api/src/config/index.ts +19 -0
- package/templates/apps/api/src/config/modules.ts +12 -14
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
- package/templates/apps/api/src/lib/slice-load.ts +90 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
- package/templates/apps/api/src/openapi/index.ts +9 -3
- package/templates/apps/api/src/openapi/spec.ts +505 -65
- package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
- package/templates/apps/api/src/routes/content/index.ts +38 -11
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
- package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
- package/templates/apps/api/src/server.ts +459 -135
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
- package/templates/apps/api/src/services/listing-config.ts +58 -0
- package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
- package/templates/apps/api/src/services/project-listings.ts +229 -4
- package/templates/apps/api/src/vercel-handler.ts +60 -26
- package/templates/apps/mcp/.env.example +8 -0
- package/templates/apps/mcp/CLAUDE.md +2 -2
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
- package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
- package/templates/apps/mcp/src/config/modules.ts +14 -13
- package/templates/apps/mcp/src/server.ts +8 -4
- package/templates/apps/mcp/src/tools/index.ts +7 -22
- package/templates/apps/web/.env.example +15 -0
- package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +12 -9
- package/templates/apps/web/jest.config.cjs +14 -13
- package/templates/apps/web/next.config.mjs +1 -3
- package/templates/apps/web/package.json +0 -6
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +6 -3
- package/templates/apps/web/public/lockup-horizontal.svg +4 -0
- package/templates/apps/web/public/logomark.svg +4 -0
- package/templates/apps/web/public/site.webmanifest +2 -2
- package/templates/apps/web/public/wordmark.svg +4 -0
- package/templates/database/CHANGELOG.md +33 -0
- package/templates/database/inbox/schema.prisma +67 -0
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
- package/templates/apps/api/src/__tests__/server.test.ts +0 -253
- package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
- package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
- package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
- package/templates/apps/api/src/bin/seed-presets.ts +0 -58
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
- package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
- package/templates/apps/api/src/lib/stripe.ts +0 -52
- package/templates/apps/api/src/lib/tenant-db.ts +0 -225
- package/templates/apps/api/src/lib/ws-token.ts +0 -91
- package/templates/apps/api/src/middleware/tenant.ts +0 -99
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
- package/templates/apps/api/src/routes/billing/index.ts +0 -36
- package/templates/apps/api/src/routes/billing/portal.ts +0 -182
- package/templates/apps/api/src/routes/billing/read.ts +0 -75
- package/templates/apps/api/src/routes/billing/usage.ts +0 -153
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
- package/templates/apps/api/src/routes/checkout/index.ts +0 -13
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
- package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
- package/templates/apps/api/src/routes/flows/README.md +0 -147
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
- package/templates/apps/api/src/routes/flows/index.ts +0 -202
- package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
- package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
- package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
- package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
- package/templates/apps/api/src/routes/schedule/index.ts +0 -249
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
- package/templates/apps/api/src/routes/slack/actions.ts +0 -177
- package/templates/apps/api/src/routes/slack/index.ts +0 -38
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
- package/templates/apps/api/src/routes/webhooks/README.md +0 -80
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
- package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
- package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
- package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
- package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
- package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
- package/templates/apps/api/src/services/flow-engine.ts +0 -1296
- package/templates/apps/api/src/services/lead-promotion.ts +0 -176
- package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
- package/templates/apps/api/src/services/listing-promotion.ts +0 -342
- package/templates/apps/api/src/services/playbook-compile.ts +0 -398
- package/templates/apps/api/src/services/playbook-render.ts +0 -263
- package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
- package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
- package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
- package/templates/apps/api/src/services/recommender-capture.ts +0 -835
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
- package/templates/apps/api/src/services/scheduling/index.ts +0 -63
- package/templates/apps/api/src/services/scheduling/types.ts +0 -88
- package/templates/apps/api/src/services/tenant-context.ts +0 -451
- package/templates/apps/api/src/services/usage-metering.ts +0 -699
- package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
- package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
- package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
- package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
- package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
- package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
- package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
- package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
- package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
- package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
- package/templates/apps/web/content/en/blog/README.txt +0 -25
- package/templates/apps/web/content/en/docs/index.mdx +0 -28
- package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
- package/templates/apps/web/lib/blog-list-data.ts +0 -95
- package/templates/apps/web/lib/blog-post-data.ts +0 -79
- package/templates/apps/web/lib/source.ts +0 -34
- package/templates/apps/web/public/logomark-dark.svg +0 -4
- package/templates/apps/web/public/logomark-light.svg +0 -4
- package/templates/apps/web/source.config.ts +0 -78
|
@@ -1,446 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subscription routes — instantiated by `scaffold-commerce`'s
|
|
3
|
-
* surface-selection branch (SKILL.md § Step 10h, FEAT-023 U5) into the
|
|
4
|
-
* consumer's own `apps/api`. Lands at, e.g.,
|
|
5
|
-
* `apps/api/src/routes/commerce/subscriptions.ts`. TWO handlers with
|
|
6
|
-
* DELIBERATELY OPPOSITE auth postures — do not register them in the same
|
|
7
|
-
* scope:
|
|
8
|
-
*
|
|
9
|
-
* 1. `subscribeHandler` — a PUBLIC, unauthenticated "subscribe" action.
|
|
10
|
-
* Mirrors `checkout-route.template.ts` exactly (this surface reuses
|
|
11
|
-
* `createCheckoutSession()`, pinned `mode: "subscription"` — spec §3
|
|
12
|
-
* design decision: no parallel subscription client module).
|
|
13
|
-
* 2. `subscriptionRoutes` — an ADMIN + TENANT-GUARDED Fastify plugin
|
|
14
|
-
* exposing the subscription-status READ. Mirrors
|
|
15
|
-
* `invoice-route.template.ts`'s `requireSiteAdmin` +
|
|
16
|
-
* `requireSingleTenantScope` guards verbatim (spec §5.3 escalation
|
|
17
|
-
* boundary: "retrieve-on-view is the v1 default, mirroring U4" — no
|
|
18
|
-
* subscription webhook is scaffolded at v1).
|
|
19
|
-
*
|
|
20
|
-
* Registration (BOTH required, in DIFFERENT scopes):
|
|
21
|
-
*
|
|
22
|
-
* // 1. subscribeHandler — public, rate-limited (mirrors checkout):
|
|
23
|
-
* await fastify.register(async (subscribeScope) => {
|
|
24
|
-
* subscribeScope.register(rateLimitMiddleware); // apps/api/src/middleware/rate-limit.ts
|
|
25
|
-
* subscribeScope.post("/api/subscriptions/subscribe", subscribeHandler);
|
|
26
|
-
* });
|
|
27
|
-
*
|
|
28
|
-
* // 2. subscriptionRoutes — tenant-guarded admin read (mirrors invoices):
|
|
29
|
-
* await tenantScope.register(subscriptionRoutes);
|
|
30
|
-
*
|
|
31
|
-
* ── Auth posture — split by handler, on purpose ─────────────────────────────
|
|
32
|
-
* `subscribeHandler` is a storefront "subscribe" action — the recurring-price
|
|
33
|
-
* analog of `checkout-route.template.ts`'s one-time "buy" action. It is
|
|
34
|
-
* intentionally UNAUTHENTICATED and does exactly ONE thing: create a NEW
|
|
35
|
-
* Stripe Checkout Session (`mode: "subscription"`, pinned — never
|
|
36
|
-
* caller-overridable) for ONE OF THE RECURRING PRICES this surface was
|
|
37
|
-
* scaffolded to sell (`ALLOWED_LOOKUP_KEYS`, enforced server-side before any
|
|
38
|
-
* Stripe call). It has NO list/read of existing subscriptions, no
|
|
39
|
-
* update/cancel — see `subscriptionRoutes` below for that, and see the
|
|
40
|
-
* "point management at the U6 portal" design decision (spec §3): this surface
|
|
41
|
-
* never builds self-service cancel/change, it only shows state + a portal
|
|
42
|
-
* link (`packages/ui`'s `SubscriptionStatus`).
|
|
43
|
-
*
|
|
44
|
-
* `subscriptionRoutes` READS an existing subscription's status — a
|
|
45
|
-
* MANAGEMENT-shaped action, so it is authenticated + site-admin + tenant
|
|
46
|
-
* guarded, the OPPOSITE of `subscribeHandler`'s public posture. Do not copy
|
|
47
|
-
* `subscribeHandler`'s public registration for it.
|
|
48
|
-
*
|
|
49
|
-
* ── Entitlement is NOT authorization (repeats the AILK#3744 lesson) ─────────
|
|
50
|
-
* `assertCommerceEntitled()`/`hasFeature("commerce")` answers "does this
|
|
51
|
-
* DEPLOYMENT have commerce?", never "is this caller allowed to do this?" — it
|
|
52
|
-
* is a SCAFFOLD-TIME check (this file is not emitted without
|
|
53
|
-
* `feature:commerce`); there is deliberately no runtime entitlement call
|
|
54
|
-
* here, and none is claimed for either handler.
|
|
55
|
-
*
|
|
56
|
-
* ── v1 tenancy limitation (mirrors invoice-route.template.ts verbatim) ──────
|
|
57
|
-
* `loadStripeSurfaceConfig()` resolves ONE deployment-wide
|
|
58
|
-
* `STRIPE_SECRET_KEY` and v1 has NO per-organization Stripe customer
|
|
59
|
-
* namespace (see `docs/research/commerce-template-audit/subscriptions.md`).
|
|
60
|
-
* A subscription therefore carries no org attribution, so on a multi-tenant
|
|
61
|
-
* deployment there is no honest way to decide whether a caller-supplied
|
|
62
|
-
* `subscriptionId` belongs to the caller's org. `requireSingleTenantScope`
|
|
63
|
-
* admits ONLY the deployment's own operator org (the fixed single-tenant
|
|
64
|
-
* sentinel `DEFAULT_ORG_ID`) and REFUSES every tenant org's admin outright —
|
|
65
|
-
* subscription-status reading is an OPERATOR surface at v1, not a per-tenant
|
|
66
|
-
* self-service one. Lifting the refusal needs a per-org Stripe Connect /
|
|
67
|
-
* customer-namespace model (tracked follow-up, not silently claimed fixed).
|
|
68
|
-
*
|
|
69
|
-
* TEST-MODE ONLY (v1), and the live-key guard has no remaining gap.
|
|
70
|
-
* `assertSandboxKey` (`packages/stripe-client/src/client.ts`) is an ALLOW-LIST
|
|
71
|
-
* as of AILK#3734: it accepts only `sk_test_`, `pk_test_` and `rk_test_` and
|
|
72
|
-
* refuses everything else, so a restricted live key and any future live prefix
|
|
73
|
-
* are refused by construction. It is a shared file outside this template's
|
|
74
|
-
* scope; both handlers below inherit that coverage via
|
|
75
|
-
* `loadStripeSurfaceConfig()` + `getStripeClient()`, never a second/divergent
|
|
76
|
-
* prefix check.
|
|
77
|
-
*/
|
|
78
|
-
import { DEFAULT_ORG_ID } from "@working-theory/database/tenancy";
|
|
79
|
-
import type {
|
|
80
|
-
FastifyPluginAsync,
|
|
81
|
-
FastifyReply,
|
|
82
|
-
FastifyRequest,
|
|
83
|
-
} from "fastify";
|
|
84
|
-
import { z } from "zod";
|
|
85
|
-
|
|
86
|
-
import {
|
|
87
|
-
createCheckoutSession,
|
|
88
|
-
retrieveSubscriptionStatus,
|
|
89
|
-
StripeConfigError,
|
|
90
|
-
UnknownProductCodeError,
|
|
91
|
-
} from "@working-theory/stripe-client";
|
|
92
|
-
|
|
93
|
-
import { prisma } from "../../lib/prisma.js";
|
|
94
|
-
import { loadStripeSurfaceConfig } from "./stripe-config.js";
|
|
95
|
-
|
|
96
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
97
|
-
// 1. subscribeHandler — PUBLIC "subscribe" action (mirrors checkout-route)
|
|
98
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
99
|
-
|
|
100
|
-
// ── Purchasable-plan allowlist — SCAFFOLD-TIME EDIT ───────────────────────────
|
|
101
|
-
//
|
|
102
|
-
// The ONLY recurring lookup_keys this surface is allowed to sell. Without
|
|
103
|
-
// this allowlist the route resolves a caller-supplied `lookupKey` against
|
|
104
|
-
// the merchant's ENTIRE Stripe account — the exact one-time-checkout hole
|
|
105
|
-
// closed in PR #3733, applied here for the subscription surface. Step 10h
|
|
106
|
-
// replaces the placeholder below with the recurring lookup_key(s) the
|
|
107
|
-
// consumer selected. Until it does, EVERY subscribe request 404s.
|
|
108
|
-
const ALLOWED_LOOKUP_KEYS: ReadonlySet<string> = new Set<string>([
|
|
109
|
-
"REPLACE_WITH_YOUR_RECURRING_STRIPE_LOOKUP_KEY",
|
|
110
|
-
]);
|
|
111
|
-
|
|
112
|
-
const LOOKUP_KEY_REGEX = /^[A-Za-z0-9._-]{1,200}$/;
|
|
113
|
-
const REDIRECT_MAX_LENGTH = 2048;
|
|
114
|
-
|
|
115
|
-
const SubscribeRequestSchema = z.object({
|
|
116
|
-
lookupKey: z
|
|
117
|
-
.string()
|
|
118
|
-
.regex(LOOKUP_KEY_REGEX, "lookupKey must match /^[A-Za-z0-9._-]{1,200}$/"),
|
|
119
|
-
successUrl: z
|
|
120
|
-
.string()
|
|
121
|
-
.min(1, "successUrl is required")
|
|
122
|
-
.max(REDIRECT_MAX_LENGTH, "successUrl is too long"),
|
|
123
|
-
cancelUrl: z
|
|
124
|
-
.string()
|
|
125
|
-
.min(1, "cancelUrl is required")
|
|
126
|
-
.max(REDIRECT_MAX_LENGTH, "cancelUrl is too long"),
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
/** Site's own origin — the ONLY origin a checkout redirect may resolve to (mirrors checkout-route.template.ts). */
|
|
130
|
-
function loadSiteOrigin(): URL {
|
|
131
|
-
const raw = process.env.FRONTEND_URL;
|
|
132
|
-
if (!raw) {
|
|
133
|
-
throw new Error(
|
|
134
|
-
"FRONTEND_URL is missing. It is the site origin checkout redirects are locked to.",
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
try {
|
|
138
|
-
return new URL(raw);
|
|
139
|
-
} catch {
|
|
140
|
-
throw new Error("FRONTEND_URL is not a valid absolute URL.");
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Resolves a caller-supplied redirect against the site's own origin, or null (mirrors checkout-route.template.ts). */
|
|
145
|
-
function toSameOriginRedirect(raw: string, siteOrigin: URL): string | null {
|
|
146
|
-
let resolved: URL;
|
|
147
|
-
try {
|
|
148
|
-
resolved = new URL(raw, siteOrigin);
|
|
149
|
-
} catch {
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
if (resolved.protocol !== "https:" && resolved.protocol !== "http:") {
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
|
-
if (resolved.origin !== siteOrigin.origin) {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
return resolved.toString();
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* POST handler — creates a test-mode SUBSCRIPTION (`mode: "subscription"`,
|
|
163
|
-
* pinned) Checkout Session for `lookupKey` and returns `{ sessionId, url }`.
|
|
164
|
-
* The caller (the scaffolded `PricingTable` section's `onSubscribe`)
|
|
165
|
-
* redirects the browser to `url` on success.
|
|
166
|
-
*
|
|
167
|
-
* @public-by-design — a storefront "subscribe" action, intentionally
|
|
168
|
-
* UNAUTHENTICATED (the recurring-price analog of checkout's public "buy"; see
|
|
169
|
-
* the file-header "Auth posture" note). It creates ONE allowlisted
|
|
170
|
-
* subscription Checkout Session and reads/mutates nothing else. Its ADMIN
|
|
171
|
-
* sibling `subscriptionRoutes` below is identity + tenant guarded — the marker
|
|
172
|
-
* exempts ONLY this handler (route-template-auth-gate, #3751), never the file.
|
|
173
|
-
*/
|
|
174
|
-
export async function subscribeHandler(
|
|
175
|
-
request: FastifyRequest,
|
|
176
|
-
reply: FastifyReply,
|
|
177
|
-
): Promise<void> {
|
|
178
|
-
let siteOrigin: URL;
|
|
179
|
-
try {
|
|
180
|
-
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_PUBLISHABLE_KEY) {
|
|
181
|
-
throw new Error(
|
|
182
|
-
"STRIPE_SECRET_KEY / STRIPE_PUBLISHABLE_KEY are missing. Run scaffold-commerce (direct payments path) first.",
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
siteOrigin = loadSiteOrigin();
|
|
186
|
-
loadStripeSurfaceConfig();
|
|
187
|
-
} catch (err) {
|
|
188
|
-
if (err instanceof StripeConfigError) {
|
|
189
|
-
request.log.error(
|
|
190
|
-
{ method: request.method, path: request.url, message: err.message },
|
|
191
|
-
"[subscriptions] subscribe: LIVE Stripe key REFUSED — live-mode-forbidden",
|
|
192
|
-
);
|
|
193
|
-
return reply.status(500).send({ error: "live-mode-forbidden" });
|
|
194
|
-
}
|
|
195
|
-
request.log.error(
|
|
196
|
-
{
|
|
197
|
-
method: request.method,
|
|
198
|
-
path: request.url,
|
|
199
|
-
message: err instanceof Error ? err.message : "unknown",
|
|
200
|
-
},
|
|
201
|
-
"[subscriptions] subscribe: config error",
|
|
202
|
-
);
|
|
203
|
-
return reply.status(500).send({ error: "config-error" });
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const parsed = SubscribeRequestSchema.safeParse(request.body);
|
|
207
|
-
if (!parsed.success) {
|
|
208
|
-
return reply.status(400).send({
|
|
209
|
-
error: "validation-error",
|
|
210
|
-
details: parsed.error.issues,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const { lookupKey, successUrl, cancelUrl } = parsed.data;
|
|
215
|
-
|
|
216
|
-
if (!ALLOWED_LOOKUP_KEYS.has(lookupKey)) {
|
|
217
|
-
request.log.warn(
|
|
218
|
-
{ method: request.method, path: request.url, status: 404 },
|
|
219
|
-
"[subscriptions] subscribe: lookupKey is not in this surface's allowlist; refused",
|
|
220
|
-
);
|
|
221
|
-
return reply.status(404).send({ error: "unknown-product-code" });
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const safeSuccessUrl = toSameOriginRedirect(successUrl, siteOrigin);
|
|
225
|
-
const safeCancelUrl = toSameOriginRedirect(cancelUrl, siteOrigin);
|
|
226
|
-
if (safeSuccessUrl === null || safeCancelUrl === null) {
|
|
227
|
-
request.log.warn(
|
|
228
|
-
{ method: request.method, path: request.url, status: 400 },
|
|
229
|
-
"[subscriptions] subscribe: redirect URL is not same-origin; refused",
|
|
230
|
-
);
|
|
231
|
-
return reply.status(400).send({ error: "invalid-redirect-url" });
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
// Pinned mode: "subscription" — this endpoint can never open a one-time
|
|
236
|
-
// checkout session, regardless of caller input (spec §3 design decision).
|
|
237
|
-
const { sessionId, url } = await createCheckoutSession({
|
|
238
|
-
productCode: lookupKey,
|
|
239
|
-
successUrl: safeSuccessUrl,
|
|
240
|
-
cancelUrl: safeCancelUrl,
|
|
241
|
-
mode: "subscription",
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
request.log.info(
|
|
245
|
-
{
|
|
246
|
-
method: request.method,
|
|
247
|
-
path: request.url,
|
|
248
|
-
sessionIdPrefix: sessionId.substring(0, 8),
|
|
249
|
-
status: 201,
|
|
250
|
-
},
|
|
251
|
-
"[subscriptions] subscribe: session created",
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
return reply.status(201).send({ sessionId, url });
|
|
255
|
-
} catch (err) {
|
|
256
|
-
if (err instanceof UnknownProductCodeError) {
|
|
257
|
-
return reply.status(404).send({ error: "unknown-product-code" });
|
|
258
|
-
}
|
|
259
|
-
if (err instanceof StripeConfigError) {
|
|
260
|
-
request.log.error(
|
|
261
|
-
{
|
|
262
|
-
method: request.method,
|
|
263
|
-
path: request.url,
|
|
264
|
-
errName: err.name,
|
|
265
|
-
message: err.message,
|
|
266
|
-
},
|
|
267
|
-
"[subscriptions] subscribe: config error",
|
|
268
|
-
);
|
|
269
|
-
return reply.status(500).send({ error: "config-error" });
|
|
270
|
-
}
|
|
271
|
-
request.log.error(
|
|
272
|
-
{
|
|
273
|
-
method: request.method,
|
|
274
|
-
path: request.url,
|
|
275
|
-
errName: err instanceof Error ? err.name : "unknown",
|
|
276
|
-
},
|
|
277
|
-
"[subscriptions] subscribe: stripe error; checkout-unavailable",
|
|
278
|
-
);
|
|
279
|
-
return reply.status(502).send({ error: "checkout-unavailable" });
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
284
|
-
// 2. subscriptionRoutes — ADMIN + TENANT-GUARDED status read (mirrors
|
|
285
|
-
// invoice-route.template.ts's guards verbatim)
|
|
286
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
287
|
-
|
|
288
|
-
function unauthorized(reply: FastifyReply): void {
|
|
289
|
-
reply.status(401).send({
|
|
290
|
-
ok: false,
|
|
291
|
-
error: { code: "unauthorized", message: "Authentication required." },
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function forbidden(reply: FastifyReply): void {
|
|
296
|
-
reply.status(403).send({
|
|
297
|
-
ok: false,
|
|
298
|
-
error: { code: "forbidden", message: "Admin access required." },
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Identical for EVERY request — leaks no information about which subscriptions exist (no enumeration oracle). */
|
|
303
|
-
function tenancyUnsupported(reply: FastifyReply): void {
|
|
304
|
-
reply.status(403).send({
|
|
305
|
-
ok: false,
|
|
306
|
-
error: {
|
|
307
|
-
code: "tenancy_unsupported",
|
|
308
|
-
message:
|
|
309
|
-
"Subscription-status reading is unavailable on a multi-tenant deployment: v1 has no per-organization Stripe customer namespace, so a subscription cannot be attributed to an organization.",
|
|
310
|
-
},
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function invalidBody(reply: FastifyReply): void {
|
|
315
|
-
reply.status(400).send({
|
|
316
|
-
ok: false,
|
|
317
|
-
error: { code: "invalid_body", message: "Invalid request." },
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function serverError(reply: FastifyReply): void {
|
|
322
|
-
// Never echo the raw Stripe/internal error — it may embed request params.
|
|
323
|
-
reply.status(500).send({
|
|
324
|
-
ok: false,
|
|
325
|
-
error: { code: "internal_error", message: "Something went wrong." },
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* The site-admin role set. `User.role` is a per-deployment String
|
|
331
|
-
* ("user" | "admin" | "owner") — DISTINCT from the org-membership `Role` enum
|
|
332
|
-
* the multi-tenant module uses. Named rather than compared inline so the next
|
|
333
|
-
* role change is one greppable edit per template; mirrored from
|
|
334
|
-
* `payment-link-route.template.ts:135` because these are copied-not-imported
|
|
335
|
-
* scaffold sources with nowhere to share a constant (AILK#3765).
|
|
336
|
-
*/
|
|
337
|
-
const SITE_ADMIN_ROLES: ReadonlySet<string> = new Set(["admin", "owner"]);
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Gate 1 — requires an authenticated, site-admin caller. `request.userId` is
|
|
341
|
-
* populated by `authPreHandlerPlugin` (see the Registration note above); this
|
|
342
|
-
* re-checks it defensively. Sends the 401/403 itself and returns `null` on
|
|
343
|
-
* denial; the caller's handler must `return` immediately when this returns
|
|
344
|
-
* `null`.
|
|
345
|
-
*/
|
|
346
|
-
async function requireSiteAdmin(
|
|
347
|
-
request: FastifyRequest,
|
|
348
|
-
reply: FastifyReply,
|
|
349
|
-
): Promise<string | null> {
|
|
350
|
-
const userId = request.userId;
|
|
351
|
-
if (!userId) {
|
|
352
|
-
unauthorized(reply);
|
|
353
|
-
return null;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
let role: string | undefined;
|
|
357
|
-
try {
|
|
358
|
-
const user = await prisma.user.findUnique({
|
|
359
|
-
where: { id: userId },
|
|
360
|
-
select: { role: true },
|
|
361
|
-
});
|
|
362
|
-
role = user?.role;
|
|
363
|
-
} catch (err) {
|
|
364
|
-
// FAIL CLOSED. Never `catch {}` into the happy path.
|
|
365
|
-
//
|
|
366
|
-
// Unwrapped, a rejection here escapes to Fastify's default error handler,
|
|
367
|
-
// which serializes `error.message` back to the caller — contradicting this
|
|
368
|
-
// file's own no-raw-echo contract. It always denied; it denied while
|
|
369
|
-
// leaking.
|
|
370
|
-
request.log.error(
|
|
371
|
-
{
|
|
372
|
-
method: request.method,
|
|
373
|
-
path: request.url,
|
|
374
|
-
message: err instanceof Error ? err.message : "unknown",
|
|
375
|
-
},
|
|
376
|
-
"[subscriptions] role resolution failed; DENYING",
|
|
377
|
-
);
|
|
378
|
-
serverError(reply);
|
|
379
|
-
return null;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// A missing user row (deleted account, stale session) has no role -> denied.
|
|
383
|
-
if (!role || !SITE_ADMIN_ROLES.has(role)) {
|
|
384
|
-
forbidden(reply);
|
|
385
|
-
return null;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
return userId;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Gate 2 — the tenancy guard. v1 resolves ONE deployment-wide
|
|
393
|
-
* `STRIPE_SECRET_KEY` and has no per-org Stripe customer namespace, so a
|
|
394
|
-
* subscription carries no organization attribution — this guard admits ONLY
|
|
395
|
-
* the deployment's own operator org (the fixed single-tenant sentinel
|
|
396
|
-
* `DEFAULT_ORG_ID`) and refuses everyone else, before any Stripe call.
|
|
397
|
-
* Mirrors `invoice-route.template.ts`'s `requireSingleTenantScope` verbatim.
|
|
398
|
-
*/
|
|
399
|
-
async function requireSingleTenantScope(
|
|
400
|
-
request: FastifyRequest,
|
|
401
|
-
reply: FastifyReply,
|
|
402
|
-
): Promise<boolean> {
|
|
403
|
-
const tenant = request.tenant;
|
|
404
|
-
if (!tenant) {
|
|
405
|
-
forbidden(reply);
|
|
406
|
-
return false;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (tenant.organizationId !== DEFAULT_ORG_ID) {
|
|
410
|
-
tenancyUnsupported(reply);
|
|
411
|
-
return false;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return true;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const statusQuerySchema = z.object({
|
|
418
|
-
subscriptionId: z.string().min(1).max(255),
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* `subscriptionRoutes` — a single GET endpoint: retrieve-on-view by the
|
|
423
|
-
* subscription's own Stripe id (spec §5.3 escalation boundary — no
|
|
424
|
-
* subscription webhook is scaffolded at v1; mirrors
|
|
425
|
-
* `invoice-route.template.ts`'s `?invoiceId=` shape).
|
|
426
|
-
*/
|
|
427
|
-
export const subscriptionRoutes: FastifyPluginAsync = async (fastify) => {
|
|
428
|
-
// GET /v1/commerce/subscriptions?subscriptionId=sub_...
|
|
429
|
-
fastify.get("/v1/commerce/subscriptions", async (request, reply) => {
|
|
430
|
-
if (!(await requireSiteAdmin(request, reply))) return;
|
|
431
|
-
if (!(await requireSingleTenantScope(request, reply))) return;
|
|
432
|
-
|
|
433
|
-
const query = statusQuerySchema.safeParse(request.query);
|
|
434
|
-
if (!query.success) return invalidBody(reply);
|
|
435
|
-
|
|
436
|
-
try {
|
|
437
|
-
loadStripeSurfaceConfig();
|
|
438
|
-
const status = await retrieveSubscriptionStatus({
|
|
439
|
-
subscriptionId: query.data.subscriptionId,
|
|
440
|
-
});
|
|
441
|
-
return reply.send({ ok: true, data: status });
|
|
442
|
-
} catch {
|
|
443
|
-
return serverError(reply);
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
};
|
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file server.test.ts
|
|
3
|
-
* @description Unit tests for server.ts - Server setup
|
|
4
|
-
*
|
|
5
|
-
* Uses the real build() function with real route plugins. All internal modules
|
|
6
|
-
* run real; external boundaries (CORS, rate-limit) are mocked at the plugin
|
|
7
|
-
* boundary since they would require live network/Redis in production.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { build } from '../server';
|
|
11
|
-
|
|
12
|
-
// External service boundaries — mocked because they require live infrastructure
|
|
13
|
-
// or have ESM-only transitive deps that Jest's CJS preset cannot resolve:
|
|
14
|
-
// @fastify/cors, @fastify/rate-limit — Fastify plugins that would require
|
|
15
|
-
// live network infrastructure (CORS policy sync, Redis store) in unit tests.
|
|
16
|
-
// @working-theory/email — transitive dep on @react-email/components which is ESM-only
|
|
17
|
-
// and cannot be resolved by jest-resolve in CommonJS mode.
|
|
18
|
-
jest.mock('@fastify/cors', () => {
|
|
19
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
jest.mock('@fastify/rate-limit', () => {
|
|
23
|
-
return jest.fn().mockResolvedValue(undefined);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
jest.mock('@working-theory/email', () => ({
|
|
27
|
-
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
describe('Server', () => {
|
|
31
|
-
// 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
|
|
32
|
-
let app: any;
|
|
33
|
-
|
|
34
|
-
beforeEach(async () => {
|
|
35
|
-
jest.clearAllMocks();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
afterEach(async () => {
|
|
39
|
-
if (app) {
|
|
40
|
-
await app.close();
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('build function', () => {
|
|
45
|
-
it('should build Fastify server instance', async () => {
|
|
46
|
-
// ACT
|
|
47
|
-
app = await build();
|
|
48
|
-
|
|
49
|
-
// ASSERT
|
|
50
|
-
expect(app).toBeDefined();
|
|
51
|
-
expect(typeof app.inject).toBe('function');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('should register health check route returning static OK without external calls', async () => {
|
|
55
|
-
// ARRANGE
|
|
56
|
-
app = await build();
|
|
57
|
-
|
|
58
|
-
// ACT
|
|
59
|
-
const response = await app.inject({
|
|
60
|
-
method: 'GET',
|
|
61
|
-
url: '/health',
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
// ASSERT
|
|
65
|
-
expect(response.statusCode).toBe(200);
|
|
66
|
-
const body = JSON.parse(response.body);
|
|
67
|
-
expect(body.status).toBe('healthy');
|
|
68
|
-
expect(body.timestamp).toBeDefined();
|
|
69
|
-
// No external dependencies in the health check anymore
|
|
70
|
-
expect(body.platform).toBeUndefined();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('should register root endpoint', async () => {
|
|
74
|
-
// ARRANGE
|
|
75
|
-
app = await build();
|
|
76
|
-
|
|
77
|
-
// ACT
|
|
78
|
-
const response = await app.inject({
|
|
79
|
-
method: 'GET',
|
|
80
|
-
url: '/',
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// ASSERT
|
|
84
|
-
expect(response.statusCode).toBe(200);
|
|
85
|
-
const body = JSON.parse(response.body);
|
|
86
|
-
expect(body.name).toBe('AI Launch Kit API');
|
|
87
|
-
expect(body.endpoints).toBeDefined();
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('should register CORS', async () => {
|
|
91
|
-
// ACT
|
|
92
|
-
app = await build();
|
|
93
|
-
|
|
94
|
-
// ASSERT - CORS should be registered (no error thrown)
|
|
95
|
-
expect(app).toBeDefined();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('should register all route plugins', async () => {
|
|
99
|
-
// ARRANGE
|
|
100
|
-
app = await build();
|
|
101
|
-
|
|
102
|
-
// ACT & ASSERT - Test that routes are registered by checking they don't 404
|
|
103
|
-
const healthResponse = await app.inject({
|
|
104
|
-
method: 'GET',
|
|
105
|
-
url: '/health',
|
|
106
|
-
});
|
|
107
|
-
expect(healthResponse.statusCode).toBe(200);
|
|
108
|
-
|
|
109
|
-
const rootResponse = await app.inject({
|
|
110
|
-
method: 'GET',
|
|
111
|
-
url: '/',
|
|
112
|
-
});
|
|
113
|
-
expect(rootResponse.statusCode).toBe(200);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('should include all endpoints in root response', async () => {
|
|
117
|
-
// ARRANGE
|
|
118
|
-
app = await build();
|
|
119
|
-
|
|
120
|
-
// ACT
|
|
121
|
-
const response = await app.inject({
|
|
122
|
-
method: 'GET',
|
|
123
|
-
url: '/',
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// ASSERT
|
|
127
|
-
const body = JSON.parse(response.body);
|
|
128
|
-
expect(body.endpoints).toHaveProperty('health');
|
|
129
|
-
expect(body.endpoints).toHaveProperty('tokens');
|
|
130
|
-
expect(body.endpoints).toHaveProperty('profile');
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('startup throws when LEAD_CAPTURE_FILE points at a missing directory', async () => {
|
|
134
|
-
// ARRANGE — set LEAD_CAPTURE_FILE to a path whose parent dir doesn't exist
|
|
135
|
-
// so the real validateLeadCaptureDir() throws (AC8). Restore after test.
|
|
136
|
-
const prior = process.env.LEAD_CAPTURE_FILE;
|
|
137
|
-
process.env.LEAD_CAPTURE_FILE = '/nonexistent-ailk-test-dir/leads.jsonl';
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
// ACT & ASSERT
|
|
141
|
-
await expect(build()).rejects.toThrow(/LEAD_CAPTURE_FILE/);
|
|
142
|
-
} finally {
|
|
143
|
-
if (prior === undefined) {
|
|
144
|
-
delete process.env.LEAD_CAPTURE_FILE;
|
|
145
|
-
} else {
|
|
146
|
-
process.env.LEAD_CAPTURE_FILE = prior;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
// ── Bootstrap test: raw-body parser scope encapsulation (AC 13) ──────────────
|
|
154
|
-
//
|
|
155
|
-
// Proves that registering the webhooks plugin (with its raw-body parser scoped
|
|
156
|
-
// to application/json) does NOT leak that parser to other routes. The global
|
|
157
|
-
// Fastify JSON parser must remain active for all non-webhook routes.
|
|
158
|
-
|
|
159
|
-
import Fastify from 'fastify';
|
|
160
|
-
import { webhooksRoutes } from '../routes/webhooks/index.js';
|
|
161
|
-
|
|
162
|
-
describe('Parser scope — webhooks raw-body parser does not leak', () => {
|
|
163
|
-
it('other routes still receive parsed JSON objects after webhooks plugin registers', async () => {
|
|
164
|
-
// Build a minimal server that mirrors the encapsulation pattern in server.ts:
|
|
165
|
-
// 1. Register a JSON route that echoes the parsed body.
|
|
166
|
-
// 2. Register the webhooks plugin inside an encapsulated scope with a
|
|
167
|
-
// raw-body (buffer) parser.
|
|
168
|
-
// If the raw-body parser leaks out of the webhooks scope, the echo route
|
|
169
|
-
// would receive a Buffer instead of a parsed object.
|
|
170
|
-
|
|
171
|
-
const server = Fastify({ logger: false });
|
|
172
|
-
|
|
173
|
-
// A simple JSON echo route — simulates any existing JSON-parsed route
|
|
174
|
-
server.post('/api/echo', async (req) => ({ received: req.body }));
|
|
175
|
-
|
|
176
|
-
// Mirror of server.ts registration pattern
|
|
177
|
-
await server.register(async (webhooksScope) => {
|
|
178
|
-
webhooksScope.addContentTypeParser(
|
|
179
|
-
'application/json',
|
|
180
|
-
{ parseAs: 'buffer' },
|
|
181
|
-
(_req, body, done) => {
|
|
182
|
-
done(null, body);
|
|
183
|
-
},
|
|
184
|
-
);
|
|
185
|
-
await webhooksScope.register(webhooksRoutes);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
await server.ready();
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const response = await server.inject({
|
|
192
|
-
method: 'POST',
|
|
193
|
-
url: '/api/echo',
|
|
194
|
-
payload: { hello: 'world' },
|
|
195
|
-
headers: { 'content-type': 'application/json' },
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
expect(response.statusCode).toBe(200);
|
|
199
|
-
const body = JSON.parse(response.body);
|
|
200
|
-
// Must be a plain object, not a stringified Buffer
|
|
201
|
-
expect(body.received).toEqual({ hello: 'world' });
|
|
202
|
-
expect(Buffer.isBuffer(body.received)).toBe(false);
|
|
203
|
-
} finally {
|
|
204
|
-
await server.close();
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
describe('per-scope rate-limit defaults (#5111, DEF-004)', () => {
|
|
209
|
-
// Real end-to-end behavior against the real in-house rate-limit
|
|
210
|
-
// middleware (NOT mocked — this repo's convention is to mock only real
|
|
211
|
-
// external boundaries, never an internal module, see the file header).
|
|
212
|
-
// aeoScope's `/v1/aeo/score/example` needs no DB/network (a bundled
|
|
213
|
-
// example, `loadExampleScore()`); flowsScope's `/v1/flows/:slug` with a
|
|
214
|
-
// schema-invalid slug returns 400 before any DB access — both let the
|
|
215
|
-
// onRequest rate-limit hook be the only thing under test, regardless of
|
|
216
|
-
// the eventual handler status.
|
|
217
|
-
it('caps aeoScope at the strict pre-#4730 default (10/min) — the 11th request 429s', async () => {
|
|
218
|
-
app = await build();
|
|
219
|
-
const ip = '198.51.100.11';
|
|
220
|
-
|
|
221
|
-
for (let i = 0; i < 10; i++) {
|
|
222
|
-
const res = await app.inject({
|
|
223
|
-
method: 'GET',
|
|
224
|
-
url: '/v1/aeo/score/example',
|
|
225
|
-
headers: { 'x-forwarded-for': ip },
|
|
226
|
-
});
|
|
227
|
-
expect(res.statusCode).not.toBe(429);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const eleventh = await app.inject({
|
|
231
|
-
method: 'GET',
|
|
232
|
-
url: '/v1/aeo/score/example',
|
|
233
|
-
headers: { 'x-forwarded-for': ip },
|
|
234
|
-
});
|
|
235
|
-
expect(eleventh.statusCode).toBe(429);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it('leaves flowsScope above the strict default — 11 requests never 429', async () => {
|
|
239
|
-
app = await build();
|
|
240
|
-
const ip = '198.51.100.12';
|
|
241
|
-
|
|
242
|
-
for (let i = 0; i < 11; i++) {
|
|
243
|
-
const res = await app.inject({
|
|
244
|
-
method: 'GET',
|
|
245
|
-
url: '/v1/flows/not-a-real-slug!!!',
|
|
246
|
-
headers: { 'x-forwarded-for': ip },
|
|
247
|
-
});
|
|
248
|
-
// Schema-invalid slug → 400, well before any rate limit or DB call.
|
|
249
|
-
expect(res.statusCode).toBe(400);
|
|
250
|
-
}
|
|
251
|
-
});
|
|
252
|
-
});
|
|
253
|
-
});
|