create-ailk 0.1.0 → 0.2.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/README.md +124 -3
- package/component-catalog.md +456 -0
- package/dist/cli.js +92 -16
- package/dist/component-catalog.d.ts +11 -0
- package/dist/component-catalog.js +39 -0
- package/dist/copy.d.ts +7 -0
- package/dist/copy.js +24 -0
- package/dist/derive-architecture.d.ts +13 -0
- package/dist/derive-architecture.js +238 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +19 -2
- package/dist/lib/extract-module-bundle.d.ts +16 -0
- package/dist/lib/extract-module-bundle.js +184 -0
- package/dist/lib/fetch-module.d.ts +55 -0
- package/dist/lib/fetch-module.js +125 -0
- package/dist/lib/module-license-gate.d.ts +48 -0
- package/dist/lib/module-license-gate.js +67 -0
- package/dist/lib/module-tier.d.ts +35 -0
- package/dist/lib/module-tier.js +37 -0
- package/dist/module-architecture.d.ts +175 -0
- package/dist/module-architecture.js +663 -0
- package/dist/parse-args.d.ts +47 -1
- package/dist/parse-args.js +143 -2
- package/dist/programmatic.d.ts +160 -3
- package/dist/programmatic.js +196 -7
- package/dist/surfaces.d.ts +211 -0
- package/dist/surfaces.js +532 -0
- package/dist/sync-routes.d.ts +92 -0
- package/dist/sync-routes.js +350 -0
- package/package.json +12 -5
- package/templates/.claude/rules/architecture.md +6 -2
- package/templates/.claude/skills/README.md +9 -9
- package/templates/.claude/skills/add-schema/SKILL.md +14 -7
- package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
- package/templates/.claude/skills/provision-config/SKILL.md +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
- package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
- package/templates/.env.example +126 -2
- package/templates/CLAUDE.md +11 -5
- package/templates/README.md +1 -1
- package/templates/apps/api/.env.example +38 -0
- package/templates/apps/api/CLAUDE.md +78 -1
- package/templates/apps/api/LICENSE +201 -0
- package/templates/apps/api/api/index.ts +6 -141
- package/templates/apps/api/jest.config.cjs +16 -0
- package/templates/apps/api/package.json +22 -6
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
- package/templates/apps/api/src/__tests__/server.test.ts +46 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
- package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
- package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
- package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
- package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
- package/templates/apps/api/src/bin/seed-presets.ts +58 -0
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
- package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
- package/templates/apps/api/src/cli.ts +5 -11
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/config/modules.ts +43 -0
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -0
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
- package/templates/apps/api/src/lib/site.ts +16 -0
- package/templates/apps/api/src/lib/stripe.ts +22 -0
- package/templates/apps/api/src/lib/tenant-db.ts +225 -0
- package/templates/apps/api/src/lib/ws-token.ts +91 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
- package/templates/apps/api/src/middleware/auth.ts +43 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
- package/templates/apps/api/src/middleware/tenant.ts +99 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
- package/templates/apps/api/src/openapi/spec.ts +312 -3
- package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
- package/templates/apps/api/src/routes/aeo/index.ts +107 -0
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
- package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
- package/templates/apps/api/src/routes/billing/index.ts +36 -0
- package/templates/apps/api/src/routes/billing/portal.ts +182 -0
- package/templates/apps/api/src/routes/billing/read.ts +75 -0
- package/templates/apps/api/src/routes/billing/usage.ts +153 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
- package/templates/apps/api/src/routes/content/create.ts +1 -0
- package/templates/apps/api/src/routes/content/delete.ts +1 -0
- package/templates/apps/api/src/routes/content/index.ts +42 -6
- package/templates/apps/api/src/routes/content/update.ts +1 -0
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
- package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
- package/templates/apps/api/src/routes/flows/README.md +147 -0
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
- package/templates/apps/api/src/routes/flows/index.ts +202 -0
- package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
- package/templates/apps/api/src/routes/leads/index.ts +43 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +558 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +869 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +375 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
- package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +120 -0
- package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
- package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
- package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +106 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
- package/templates/apps/api/src/routes/schedule/index.ts +249 -0
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
- package/templates/apps/api/src/routes/slack/actions.ts +177 -0
- package/templates/apps/api/src/routes/slack/index.ts +38 -0
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
- package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
- package/templates/apps/api/src/routes/webhooks/README.md +35 -12
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
- package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
- package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
- package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
- package/templates/apps/api/src/server.ts +307 -7
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +523 -0
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
- package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
- package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
- package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
- package/templates/apps/api/src/services/abandon-email.ts +307 -0
- package/templates/apps/api/src/services/aeo-score.ts +288 -0
- package/templates/apps/api/src/services/answer-distributions.ts +138 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
- package/templates/apps/api/src/services/entitlements.ts +102 -0
- package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
- package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
- package/templates/apps/api/src/services/flow-engine.ts +1296 -0
- package/templates/apps/api/src/services/lead-promotion.ts +176 -0
- package/templates/apps/api/src/services/lead-routing.ts +3 -3
- package/templates/apps/api/src/services/listing-config.ts +102 -0
- package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
- package/templates/apps/api/src/services/listing-promotion.ts +342 -0
- package/templates/apps/api/src/services/playbook-compile.ts +398 -0
- package/templates/apps/api/src/services/playbook-render.ts +263 -0
- package/templates/apps/api/src/services/project-listing-decision.ts +510 -0
- package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
- package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
- package/templates/apps/api/src/services/project-listings.ts +1472 -0
- package/templates/apps/api/src/services/recommender-capture.ts +835 -0
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
- package/templates/apps/api/src/services/scheduling/index.ts +63 -0
- package/templates/apps/api/src/services/scheduling/types.ts +88 -0
- package/templates/apps/api/src/services/slack-notify.ts +240 -0
- package/templates/apps/api/src/services/tenant-context.ts +451 -0
- package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
- package/templates/apps/api/src/services/usage-credits.ts +340 -0
- package/templates/apps/api/src/services/usage-metering.ts +699 -0
- package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
- package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
- package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
- package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
- package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
- package/templates/apps/api/src/vercel-handler.ts +165 -0
- package/templates/apps/api/tsconfig.json +0 -12
- package/templates/apps/mcp/CLAUDE.md +19 -9
- package/templates/apps/mcp/LICENSE +201 -0
- package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
- package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
- package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
- package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
- package/templates/apps/mcp/package.json +6 -4
- package/templates/apps/mcp/src/config/modules.ts +37 -0
- package/templates/apps/mcp/src/server.ts +109 -18
- package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
- package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
- package/templates/apps/mcp/src/tools/create_page.ts +1 -0
- package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
- package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
- package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
- package/templates/apps/mcp/src/tools/index.ts +25 -0
- package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
- package/templates/apps/mcp/src/tools/purchase.ts +3 -0
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
- package/templates/apps/mcp/src/tools/update_page.ts +1 -0
- package/templates/apps/mcp/tsconfig.json +1 -9
- package/templates/apps/web/.env.example +44 -0
- package/templates/apps/web/CLAUDE.md +28 -0
- package/templates/apps/web/LICENSE +201 -0
- package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
- package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
- package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
- package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
- package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
- package/templates/apps/web/__tests__/setup.ts +22 -32
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
- package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
- package/templates/apps/web/app/[locale]/layout.tsx +127 -16
- package/templates/apps/web/app/[locale]/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
- package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
- package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
- package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
- package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/globals.css +35 -0
- package/templates/apps/web/app/llms.txt/route.ts +2 -1
- package/templates/apps/web/app/og/route.tsx +10 -2
- package/templates/apps/web/auth.ts +8 -0
- package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
- package/templates/apps/web/content/en/blog/README.txt +25 -0
- package/templates/apps/web/content/en/docs/index.mdx +28 -0
- package/templates/apps/web/jest.config.cjs +90 -27
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
- package/templates/apps/web/lib/account-config.ts +13 -0
- package/templates/apps/web/lib/server-api.ts +85 -0
- package/templates/apps/web/lib/site-brand.tsx +121 -0
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/messages/en.json +275 -7
- package/templates/apps/web/middleware.ts +11 -3
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/apps/web/package.json +17 -7
- package/templates/apps/web/postcss.config.mjs +23 -0
- package/templates/apps/web/public/.well-known/security.txt +3 -3
- package/templates/apps/web/src/lib/routes.ts +15 -2
- package/templates/apps/web/tsconfig.json +1 -16
- package/templates/content/_site.mdx +36 -25
- package/templates/content/en/AboutPage/about.mdx +24 -14
- package/templates/content/en/ContactPage/contact.mdx +23 -14
- package/templates/content/en/FAQPage/faq.mdx +10 -392
- package/templates/content/en/HomePage/home.mdx +14 -285
- package/templates/database/CHANGELOG.md +443 -0
- package/templates/database/LICENSE +201 -0
- package/templates/database/README.md +3 -1
- package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
- package/templates/database/__tests__/flows-presets.test.ts +173 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
- package/templates/database/__tests__/seed.test.ts +88 -0
- package/templates/database/__tests__/user-cascade.test.ts +19 -4
- package/templates/database/auth/schema.prisma +521 -0
- package/templates/database/config/schema.prisma +234 -0
- package/templates/database/content/schema.prisma +263 -0
- package/templates/database/flows/listing-diagnostic.ts +182 -0
- package/templates/database/flows/presets.ts +315 -0
- package/templates/database/inbox/__tests__/schema.test.ts +4 -0
- package/templates/database/inbox/schema.prisma +574 -3
- package/templates/database/jest.config.cjs +8 -0
- package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
- package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
- package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
- package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
- package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
- package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
- package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
- package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
- package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
- package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
- package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
- package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
- package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
- package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
- package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
- package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
- package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
- package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
- package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
- package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
- package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
- package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
- package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
- package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
- package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
- package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
- package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
- package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
- package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
- package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
- package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
- package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/ops/schema.prisma +27 -0
- package/templates/database/package.json +42 -4
- package/templates/database/prisma.config.ts +1 -1
- package/templates/database/schema.prisma +19 -435
- package/templates/database/seed-test.ts +14 -4
- package/templates/database/seed.ts +148 -0
- package/templates/database/tenancy.ts +45 -0
- package/templates/database/tsconfig.flows.json +15 -0
- package/templates/database/tsconfig.json +7 -1
- package/templates/database/tsconfig.tenancy.json +14 -0
- package/templates/package.json +30 -55
- package/templates/pnpm-workspace.yaml +0 -3
- package/templates/project.yaml +1 -1
- package/templates/tsconfig.json +1 -37
- package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
- package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
- package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
- package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
- package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
- package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
- package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
- package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
- package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
- package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
- package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
- package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
- package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
- package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
- package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
- package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
- package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
- package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
- package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
- package/templates/apps/web/__tests__/page.test.tsx +0 -230
- package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
- package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
- package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
- package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
- package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
- package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
- package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
- package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
- package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
- package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
- package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
- package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
- package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
- package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
- package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
- package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
- package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
- package/templates/apps/web/content/en/blog/index.mdx +0 -15
- package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
- package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
- package/templates/content/.gitkeep +0 -0
- package/templates/content/CLAUDE.md +0 -85
- package/templates/content/ar/HomePage/home.mdx +0 -123
- package/templates/content/ar/_site.mdx +0 -35
- package/templates/content/de/_site.mdx +0 -29
- package/templates/content/en/.gitkeep +0 -0
- package/templates/content/en/AboutPage/about.schema.json +0 -82
- package/templates/content/en/BookCall/book-call.mdx +0 -19
- package/templates/content/en/BookCall/book-call.schema.json +0 -121
- package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
- package/templates/content/en/CareersList/careers.mdx +0 -24
- package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
- package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
- package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
- package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
- package/templates/content/en/ClientsList/clients.mdx +0 -38
- package/templates/content/en/ContactPage/contact.schema.json +0 -100
- package/templates/content/en/ContactPoint/primary.mdx +0 -14
- package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
- package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
- package/templates/content/en/CoursesList/courses.mdx +0 -22
- package/templates/content/en/DownloadsList/downloads.mdx +0 -22
- package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
- package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
- package/templates/content/en/EventsList/events.mdx +0 -26
- package/templates/content/en/FAQPage/faq.schema.json +0 -231
- package/templates/content/en/GlossaryList/glossary.mdx +0 -19
- package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
- package/templates/content/en/HomePage/home.schema.json +0 -88
- package/templates/content/en/HomePage/launch-service.mdx +0 -475
- package/templates/content/en/HomePage/launch-service.schema.json +0 -138
- package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
- package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
- package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
- package/templates/content/en/Integration/github-actions.mdx +0 -31
- package/templates/content/en/Integration/openai-api.mdx +0 -26
- package/templates/content/en/Integration/vercel.mdx +0 -32
- package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
- package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
- package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
- package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
- package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
- package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
- package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
- package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
- package/templates/content/en/LandingPage/developers.mdx +0 -19
- package/templates/content/en/LandingPage/developers.schema.json +0 -109
- package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
- package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
- package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
- package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
- package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
- package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
- package/templates/content/en/LandingPage/platform.mdx +0 -369
- package/templates/content/en/LandingPage/platform.schema.json +0 -166
- package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
- package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
- package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
- package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
- package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
- package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
- package/templates/content/en/Organization/site-owner.mdx +0 -16
- package/templates/content/en/Partner/prisma.mdx +0 -31
- package/templates/content/en/Partner/supabase.mdx +0 -30
- package/templates/content/en/Partner/vercel.mdx +0 -28
- package/templates/content/en/PartnersList/partners.mdx +0 -22
- package/templates/content/en/Person/founder.mdx +0 -18
- package/templates/content/en/Pricing/pricing.mdx +0 -538
- package/templates/content/en/Pricing/pricing.schema.json +0 -209
- package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
- package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
- package/templates/content/en/Product/ailk.mdx +0 -19
- package/templates/content/en/Product/ailk.schema.json +0 -94
- package/templates/content/en/Product/api-launch-kit.mdx +0 -26
- package/templates/content/en/Product/launch-kit.mdx +0 -28
- package/templates/content/en/ProductsList/products.mdx +0 -26
- package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
- package/templates/content/en/ProjectsList/projects.mdx +0 -25
- package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
- package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
- package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
- package/templates/content/en/ResourcesHub/resources.mdx +0 -22
- package/templates/content/en/Service/consulting.mdx +0 -22
- package/templates/content/en/Service/implementation.mdx +0 -30
- package/templates/content/en/Service/launch-service.mdx +0 -232
- package/templates/content/en/Service/launch.mdx +0 -228
- package/templates/content/en/ServicesList/services.mdx +0 -84
- package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
- package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
- package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
- package/templates/content/en/TeamList/team.mdx +0 -63
- package/templates/content/en/TeamMember/founder.mdx +0 -72
- package/templates/content/en/TermsPage/terms.mdx +0 -19
- package/templates/content/en/TermsPage/terms.schema.json +0 -99
- package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
- package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
- package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
- package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
- package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
- package/templates/content/en-XA/AboutPage/about.mdx +0 -8
- package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
- package/templates/content/en-XA/HomePage/home.mdx +0 -8
- package/templates/content/es/_site.mdx +0 -30
- package/templates/content/fr/_site.mdx +0 -30
- package/templates/content/ja/_site.mdx +0 -30
- package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
- package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
- package/templates/content/pt-BR/HomePage/home.mdx +0 -23
|
@@ -72,7 +72,13 @@ describe("auth preHandler — cookie path", () => {
|
|
|
72
72
|
// The signature suffix must be stripped before the Session lookup.
|
|
73
73
|
expect(prisma.session.findUnique).toHaveBeenCalledWith({
|
|
74
74
|
where: { token: "sess_tok_abc" },
|
|
75
|
-
select: {
|
|
75
|
+
select: {
|
|
76
|
+
id: true,
|
|
77
|
+
userId: true,
|
|
78
|
+
expiresAt: true,
|
|
79
|
+
activeOrganizationId: true,
|
|
80
|
+
activeWorkspaceId: true,
|
|
81
|
+
},
|
|
76
82
|
});
|
|
77
83
|
});
|
|
78
84
|
|
|
@@ -14,9 +14,16 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import Fastify from "fastify";
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
import {
|
|
18
|
+
rateLimitMiddleware,
|
|
19
|
+
__resetBuckets,
|
|
20
|
+
__bucketCount,
|
|
21
|
+
DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
22
|
+
} from "../rate-limit.js";
|
|
23
|
+
|
|
24
|
+
function buildApp(
|
|
25
|
+
opts: { perIpPerMinute?: number; windowMs?: number; maxBuckets?: number } = {},
|
|
26
|
+
) {
|
|
20
27
|
const app = Fastify({ trustProxy: true });
|
|
21
28
|
app.register(rateLimitMiddleware, opts);
|
|
22
29
|
app.get("/test", async () => ({ ok: true }));
|
|
@@ -159,6 +166,48 @@ describe("per-IP rate limit middleware", () => {
|
|
|
159
166
|
expect(ipB.statusCode).toBe(200);
|
|
160
167
|
});
|
|
161
168
|
|
|
169
|
+
it("derives independent buckets from the ORIGINATING address when two visitors share a proxy hop (#4730)", async () => {
|
|
170
|
+
// Simulates a consumer site's Next.js server action forwarding the
|
|
171
|
+
// visitor's real IP as x-forwarded-for, with this API's own edge then
|
|
172
|
+
// appending the calling web function's shared egress IP as a second
|
|
173
|
+
// hop — the exact multi-hop shape a consumer double-hop produces
|
|
174
|
+
// (apps/web/app/[locale]/flows/[slug]/flow.actions.ts). With
|
|
175
|
+
// trustProxy:true, req.ip resolves to the LEFTMOST (originating)
|
|
176
|
+
// address, so two visitors sharing the same egress hop still get
|
|
177
|
+
// independent buckets — this is the failure #4730 reports: before the
|
|
178
|
+
// fix, ALL visitors behind one web function's egress IP shared ONE
|
|
179
|
+
// bucket.
|
|
180
|
+
app = buildApp({ perIpPerMinute: 1 });
|
|
181
|
+
await app.ready();
|
|
182
|
+
const SHARED_EGRESS = "9.9.9.9";
|
|
183
|
+
|
|
184
|
+
const visitorA = await app.inject({
|
|
185
|
+
method: "GET",
|
|
186
|
+
url: "/test",
|
|
187
|
+
headers: { "x-forwarded-for": `20.0.0.1, ${SHARED_EGRESS}` },
|
|
188
|
+
});
|
|
189
|
+
expect(visitorA.statusCode).toBe(200);
|
|
190
|
+
|
|
191
|
+
// Visitor A's own second request, still behind the shared egress hop,
|
|
192
|
+
// correctly trips ITS OWN (now-exhausted) bucket.
|
|
193
|
+
const visitorAOverLimit = await app.inject({
|
|
194
|
+
method: "GET",
|
|
195
|
+
url: "/test",
|
|
196
|
+
headers: { "x-forwarded-for": `20.0.0.1, ${SHARED_EGRESS}` },
|
|
197
|
+
});
|
|
198
|
+
expect(visitorAOverLimit.statusCode).toBe(429);
|
|
199
|
+
|
|
200
|
+
// Visitor B, behind the SAME shared egress hop, still has a fresh
|
|
201
|
+
// budget — the two are bucketed on the leftmost (visitor) address, not
|
|
202
|
+
// the shared egress IP.
|
|
203
|
+
const visitorB = await app.inject({
|
|
204
|
+
method: "GET",
|
|
205
|
+
url: "/test",
|
|
206
|
+
headers: { "x-forwarded-for": `20.0.0.2, ${SHARED_EGRESS}` },
|
|
207
|
+
});
|
|
208
|
+
expect(visitorB.statusCode).toBe(200);
|
|
209
|
+
});
|
|
210
|
+
|
|
162
211
|
it("respects custom perIpPerMinute option over env/default", async () => {
|
|
163
212
|
app = buildApp({ perIpPerMinute: 3 });
|
|
164
213
|
await app.ready();
|
|
@@ -183,7 +232,7 @@ describe("per-IP rate limit middleware", () => {
|
|
|
183
232
|
});
|
|
184
233
|
|
|
185
234
|
it("reads perIpPerMinute from AILK_RATE_LIMIT_PER_IP_PER_MINUTE env var when option is absent", async () => {
|
|
186
|
-
// Cover `Number(process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE) ||
|
|
235
|
+
// Cover `Number(process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE) || 20` left-side truthy arm
|
|
187
236
|
// Build the app without a perIpPerMinute option so the env var path is taken
|
|
188
237
|
const prev = process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE;
|
|
189
238
|
process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE = "2";
|
|
@@ -289,4 +338,42 @@ describe("per-IP rate limit middleware", () => {
|
|
|
289
338
|
// Cleanup fired — the route still responds normally.
|
|
290
339
|
expect(res.statusCode).toBe(200);
|
|
291
340
|
});
|
|
341
|
+
|
|
342
|
+
it("caps total distinct buckets at maxBuckets, evicting the oldest-inserted entry on overflow (#5111/#4780 AC-3)", async () => {
|
|
343
|
+
// A caller minting a fresh identity every request (e.g. a spoofed
|
|
344
|
+
// X-Forwarded-For under trustProxy:true) must not grow the in-house
|
|
345
|
+
// buckets Map without bound — independent of the EXISTING time-based
|
|
346
|
+
// eviction (which only fires on stale/expired windows). This test
|
|
347
|
+
// never lets any window expire, so ONLY the new entry-count cap can
|
|
348
|
+
// be responsible for keeping distinct-bucket growth bounded. A small
|
|
349
|
+
// maxBuckets is passed explicitly so the test doesn't need thousands
|
|
350
|
+
// of injected requests to reach the real production default.
|
|
351
|
+
const maxBuckets = 3;
|
|
352
|
+
app = buildApp({ perIpPerMinute: 200, maxBuckets });
|
|
353
|
+
await app.ready();
|
|
354
|
+
|
|
355
|
+
for (let i = 0; i < maxBuckets; i++) {
|
|
356
|
+
const res = await app.inject({
|
|
357
|
+
method: "GET",
|
|
358
|
+
url: "/test",
|
|
359
|
+
headers: { "x-forwarded-for": `172.16.0.${i}` },
|
|
360
|
+
});
|
|
361
|
+
expect(res.statusCode).toBe(200);
|
|
362
|
+
expect(__bucketCount()).toBe(i + 1);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// A 4th distinct identity must not push the store past maxBuckets —
|
|
366
|
+
// the oldest-inserted bucket is evicted to make room.
|
|
367
|
+
const overflowRes = await app.inject({
|
|
368
|
+
method: "GET",
|
|
369
|
+
url: "/test",
|
|
370
|
+
headers: { "x-forwarded-for": "203.0.113.99" },
|
|
371
|
+
});
|
|
372
|
+
expect(overflowRes.statusCode).toBe(200);
|
|
373
|
+
expect(__bucketCount()).toBe(maxBuckets);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("exposes DEFAULT_PER_IP_PER_MINUTE_STRICT as the pre-#4730 per-scope default (10/min)", () => {
|
|
377
|
+
expect(DEFAULT_PER_IP_PER_MINUTE_STRICT).toBe(10);
|
|
378
|
+
});
|
|
292
379
|
});
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { createHash } from "node:crypto";
|
|
30
30
|
|
|
31
31
|
import { getClock } from "@working-theory/common";
|
|
32
|
+
import type { Id } from "@working-theory/ids";
|
|
32
33
|
import type {
|
|
33
34
|
FastifyPluginCallback,
|
|
34
35
|
FastifyReply,
|
|
@@ -50,10 +51,25 @@ const UNAUTHORIZED = { error: "unauthorized" } as const;
|
|
|
50
51
|
|
|
51
52
|
declare module "fastify" {
|
|
52
53
|
interface FastifyRequest {
|
|
53
|
-
userId?:
|
|
54
|
+
userId?: Id<"user">;
|
|
55
|
+
sessionTenant?: SessionTenant;
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Active tenant context carried by a cookie session (multi-tenant module).
|
|
61
|
+
* Set ONLY on the cookie path — bearer tokens carry no workspace binding yet
|
|
62
|
+
* (#3591 tracks tenant-scoped agent auth), so bearer-authenticated requests
|
|
63
|
+
* are denied by the tenant guard (default-deny), never silently granted a
|
|
64
|
+
* tenant. `sessionRowId` is the Session PK, used by the workspace-switch
|
|
65
|
+
* service to update the active tenant server-side.
|
|
66
|
+
*/
|
|
67
|
+
export type SessionTenant = {
|
|
68
|
+
sessionRowId: Id<"session">;
|
|
69
|
+
activeOrganizationId: Id<"organization"> | null;
|
|
70
|
+
activeWorkspaceId: Id<"workspace"> | null;
|
|
71
|
+
};
|
|
72
|
+
|
|
57
73
|
/**
|
|
58
74
|
* Hash an opaque bearer-token string the same way `tokens.ts` does at create
|
|
59
75
|
* time. Centralised here so the lookup contract stays identical.
|
|
@@ -114,10 +130,11 @@ function isOriginAllowed(
|
|
|
114
130
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
115
131
|
return true;
|
|
116
132
|
if (!frontendOrigin) {
|
|
117
|
-
// No FRONTEND_URL configured
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
133
|
+
// No FRONTEND_URL configured: fail OPEN in dev/test (zero-config OSS
|
|
134
|
+
// boot), fail CLOSED in production (security review M1 — with the
|
|
135
|
+
// multi-tenant module's state-changing routes, an unset origin must not
|
|
136
|
+
// silently disable the CSRF check in a real deployment).
|
|
137
|
+
return process.env.NODE_ENV !== "production";
|
|
121
138
|
}
|
|
122
139
|
const origin = request.headers.origin;
|
|
123
140
|
if (!origin) return false;
|
|
@@ -125,8 +142,11 @@ function isOriginAllowed(
|
|
|
125
142
|
}
|
|
126
143
|
|
|
127
144
|
type SessionRow = {
|
|
145
|
+
id: string;
|
|
128
146
|
userId: string;
|
|
129
147
|
expiresAt: Date;
|
|
148
|
+
activeOrganizationId: string | null;
|
|
149
|
+
activeWorkspaceId: string | null;
|
|
130
150
|
};
|
|
131
151
|
|
|
132
152
|
async function resolveCookieSession(
|
|
@@ -134,7 +154,13 @@ async function resolveCookieSession(
|
|
|
134
154
|
): Promise<SessionRow | undefined> {
|
|
135
155
|
const row = await prisma.session.findUnique({
|
|
136
156
|
where: { token: cookieToken },
|
|
137
|
-
select: {
|
|
157
|
+
select: {
|
|
158
|
+
id: true,
|
|
159
|
+
userId: true,
|
|
160
|
+
expiresAt: true,
|
|
161
|
+
activeOrganizationId: true,
|
|
162
|
+
activeWorkspaceId: true,
|
|
163
|
+
},
|
|
138
164
|
});
|
|
139
165
|
if (!row) return undefined;
|
|
140
166
|
return row;
|
|
@@ -187,7 +213,15 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
|
|
|
187
213
|
reply.code(401).send(UNAUTHORIZED);
|
|
188
214
|
return;
|
|
189
215
|
}
|
|
190
|
-
|
|
216
|
+
// Brand assertion at the Prisma read edge (ADR-0031 rule 5): the DB
|
|
217
|
+
// CHECK constraints guarantee these columns hold well-formed
|
|
218
|
+
// prefixed ids, so the compile-time brand is asserted once here.
|
|
219
|
+
request.userId = session.userId as Id<"user">;
|
|
220
|
+
request.sessionTenant = {
|
|
221
|
+
sessionRowId: session.id as Id<"session">,
|
|
222
|
+
activeOrganizationId: session.activeOrganizationId as Id<"organization"> | null,
|
|
223
|
+
activeWorkspaceId: session.activeWorkspaceId as Id<"workspace"> | null,
|
|
224
|
+
};
|
|
191
225
|
return;
|
|
192
226
|
}
|
|
193
227
|
// Cookie present but invalid / expired → 401 (no silent bearer fallback).
|
|
@@ -210,7 +244,7 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
|
|
|
210
244
|
reply.code(401).send(UNAUTHORIZED);
|
|
211
245
|
return;
|
|
212
246
|
}
|
|
213
|
-
request.userId = row.userId
|
|
247
|
+
request.userId = row.userId as Id<"user">;
|
|
214
248
|
// Best-effort: bump lastUsedAt. Failure here MUST NOT 401 — the caller
|
|
215
249
|
// is already authenticated. Swallow the error and continue.
|
|
216
250
|
prisma.apiToken
|
|
@@ -237,6 +271,7 @@ export const authPreHandlerPlugin: FastifyPluginCallback<
|
|
|
237
271
|
> = (fastify, opts, done) => {
|
|
238
272
|
const handler = makeAuthPreHandler(opts);
|
|
239
273
|
fastify.decorateRequest("userId", undefined);
|
|
274
|
+
fastify.decorateRequest("sessionTenant", undefined);
|
|
240
275
|
fastify.addHook("preHandler", handler);
|
|
241
276
|
done();
|
|
242
277
|
};
|
|
@@ -14,10 +14,37 @@
|
|
|
14
14
|
* IP cardinality). No setInterval is started until the middleware is registered,
|
|
15
15
|
* keeping the module import-side-effect-free.
|
|
16
16
|
*
|
|
17
|
+
* Entry-count cap (#5111/#4780 AC-3): the eviction above is time-based only —
|
|
18
|
+
* it removes buckets whose WINDOW has expired, never bounding the TOTAL
|
|
19
|
+
* distinct-identity count within a still-open window. Once `trustProxy: true`
|
|
20
|
+
* made `req.ip` a caller-influenced value (see below), a caller minting a
|
|
21
|
+
* fresh identity on every request (e.g. varying `X-Forwarded-For`) can grow
|
|
22
|
+
* `buckets` without bound for the lifetime of one isolate. `MAX_BUCKETS`
|
|
23
|
+
* caps total entries independent of the time-based eviction: once at
|
|
24
|
+
* capacity, a genuinely new identity evicts the single oldest-inserted entry
|
|
25
|
+
* (`Map` iteration order is insertion order) before inserting itself — O(1)
|
|
26
|
+
* amortized, no behavior change for any caller under the cap.
|
|
27
|
+
*
|
|
17
28
|
* Trust-proxy boundary: this middleware reads req.ip, which Fastify populates
|
|
18
29
|
* from the socket address and any trusted proxy headers (X-Forwarded-For) per
|
|
19
30
|
* the server's own trustProxy config. This middleware does NOT parse
|
|
20
|
-
* X-Forwarded-For itself.
|
|
31
|
+
* X-Forwarded-For itself. `server.ts` enables `trustProxy: true`, so req.ip
|
|
32
|
+
* resolves to the leftmost (originating) address of a multi-hop
|
|
33
|
+
* X-Forwarded-For chain.
|
|
34
|
+
*
|
|
35
|
+
* Consumer double-hop caveat (#4730): when a consumer site's own Next.js
|
|
36
|
+
* server action calls one of the rate-limited scopes below (flows/leads/aeo/
|
|
37
|
+
* schedule) on a visitor's behalf, `req.ip` would otherwise reflect the
|
|
38
|
+
* calling web function's shared egress IP, not the visitor's — every visitor
|
|
39
|
+
* of that consumer site would collapse into one bucket. The fix lives on the
|
|
40
|
+
* caller's side: `@working-theory/api-client`'s `post()`/`get()` accept an
|
|
41
|
+
* `init.headers` override, and the flows server action
|
|
42
|
+
* (`apps/web/app/[locale]/flows/[slug]/flow.actions.ts`) forwards the
|
|
43
|
+
* visitor's own `x-forwarded-for` (read from ITS OWN incoming request via
|
|
44
|
+
* Next's `headers()`) as an explicit `x-forwarded-for` header on the outbound
|
|
45
|
+
* call — appending to, not replacing, whatever hop this API's own edge adds,
|
|
46
|
+
* so trustProxy's leftmost-address resolution still lands on the real
|
|
47
|
+
* visitor.
|
|
21
48
|
*/
|
|
22
49
|
|
|
23
50
|
import { getClock } from "@working-theory/common";
|
|
@@ -28,12 +55,25 @@ import type {
|
|
|
28
55
|
} from "fastify";
|
|
29
56
|
|
|
30
57
|
export type RateLimitOptions = {
|
|
31
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Requests allowed per IP per window.
|
|
60
|
+
* Default: AILK_RATE_LIMIT_PER_IP_PER_MINUTE env var ?? 20 (#4730 — sized
|
|
61
|
+
* to cover a full flow: the longest shipped flow, ailk-founder-diagnostic,
|
|
62
|
+
* is 13 steps + 1 GET, plus headroom for an invisible aeo_score
|
|
63
|
+
* submission and one legitimate resubmit; see apps/api/src/routes/flows/
|
|
64
|
+
* README.md).
|
|
65
|
+
*/
|
|
32
66
|
perIpPerMinute?: number;
|
|
33
67
|
/** Window duration in milliseconds. Default: 60_000 */
|
|
34
68
|
windowMs?: number;
|
|
35
69
|
/** Extract the IP key from a request. Default: req.ip */
|
|
36
70
|
ipFromRequest?: (req: FastifyRequest) => string;
|
|
71
|
+
/**
|
|
72
|
+
* Maximum distinct entries `buckets` may hold at once, independent of the
|
|
73
|
+
* time-based eviction. Default: `MAX_BUCKETS`. Exposed for tests only —
|
|
74
|
+
* no production caller overrides this.
|
|
75
|
+
*/
|
|
76
|
+
maxBuckets?: number;
|
|
37
77
|
};
|
|
38
78
|
|
|
39
79
|
type Bucket = {
|
|
@@ -43,6 +83,21 @@ type Bucket = {
|
|
|
43
83
|
|
|
44
84
|
const RATE_LIMITED = "rate_limited" as const;
|
|
45
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The pre-#4730 per-scope default (10/min) — leads/aeo/schedule's own
|
|
88
|
+
* `perIpPerMinute` (#5111, DEF-004). `flowsScope` keeps the wider
|
|
89
|
+
* env-var-driven default below; it is NOT built from this constant.
|
|
90
|
+
*/
|
|
91
|
+
export const DEFAULT_PER_IP_PER_MINUTE_STRICT = 10;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Default cap on `buckets`' total distinct entries (#5111/#4780 AC-3). Sized
|
|
95
|
+
* generously above any plausible legitimate per-minute visitor cardinality
|
|
96
|
+
* for this product's anonymous scopes, while still bounding worst-case
|
|
97
|
+
* memory growth under an unbounded-identity-churn attack.
|
|
98
|
+
*/
|
|
99
|
+
export const MAX_BUCKETS = 10_000;
|
|
100
|
+
|
|
46
101
|
// Module-scoped bucket store. One entry per unique IP.
|
|
47
102
|
const buckets = new Map<string, Bucket>();
|
|
48
103
|
|
|
@@ -68,6 +123,14 @@ export function __resetBuckets(): void {
|
|
|
68
123
|
requestsSinceLastClean = 0;
|
|
69
124
|
}
|
|
70
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Test-only accessor for the current distinct-bucket count. Not intended for
|
|
128
|
+
* production use.
|
|
129
|
+
*/
|
|
130
|
+
export function __bucketCount(): number {
|
|
131
|
+
return buckets.size;
|
|
132
|
+
}
|
|
133
|
+
|
|
71
134
|
// Break Fastify's plugin encapsulation so the onRequest hook propagates to
|
|
72
135
|
// parent and sibling scopes. This is the manual equivalent of fastify-plugin's
|
|
73
136
|
// `skip-override` mechanism — documented in Fastify's plugin guide.
|
|
@@ -79,9 +142,10 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
79
142
|
) => {
|
|
80
143
|
const {
|
|
81
144
|
perIpPerMinute = Number(process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE) ||
|
|
82
|
-
|
|
145
|
+
20,
|
|
83
146
|
windowMs = 60_000,
|
|
84
147
|
ipFromRequest = (req: FastifyRequest) => req.ip,
|
|
148
|
+
maxBuckets = MAX_BUCKETS,
|
|
85
149
|
} = opts;
|
|
86
150
|
|
|
87
151
|
fastify.addHook(
|
|
@@ -98,6 +162,17 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
98
162
|
let bucket = buckets.get(ip);
|
|
99
163
|
|
|
100
164
|
if (!bucket || now - bucket.windowStartMs >= windowMs) {
|
|
165
|
+
// A genuinely new distinct identity (not a window roll on an
|
|
166
|
+
// existing bucket) at capacity evicts the single oldest-inserted
|
|
167
|
+
// entry first — independent of the time-based eviction above,
|
|
168
|
+
// which only removes EXPIRED windows and may leave `buckets` at or
|
|
169
|
+
// above `maxBuckets` when every entry is still within its window.
|
|
170
|
+
if (!buckets.has(ip) && buckets.size >= maxBuckets) {
|
|
171
|
+
const oldestKey = buckets.keys().next().value;
|
|
172
|
+
if (oldestKey !== undefined) {
|
|
173
|
+
buckets.delete(oldestKey);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
101
176
|
bucket = { count: 0, windowStartMs: now };
|
|
102
177
|
buckets.set(ip, bucket);
|
|
103
178
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* Tenant guard preHandler (multi-tenant module — FEAT-042).
|
|
4
|
+
*
|
|
5
|
+
* Runs AFTER the dual-mode auth preHandler inside the workspace route scope.
|
|
6
|
+
* Default-deny: resolves the caller's active tenant via
|
|
7
|
+
* `services/tenant-context.ts#resolveActiveTenant` and rejects everything
|
|
8
|
+
* that is not an explicit grant:
|
|
9
|
+
*
|
|
10
|
+
* - bearer-authenticated requests (no workspace binding yet — #3591) → 403
|
|
11
|
+
* - cookie sessions with no / stale active workspace → 403
|
|
12
|
+
* - revoked membership → 403
|
|
13
|
+
* - URL workspaceId ≠ the session's active workspace → 403
|
|
14
|
+
* (compared BEFORE any lookup of the target — no tenant enumeration)
|
|
15
|
+
*
|
|
16
|
+
* On grant it decorates `request.tenant` (a `TenantContext`); route handlers
|
|
17
|
+
* pass that to `tenantDb(tenant)` so every domain query is workspace-scoped.
|
|
18
|
+
* Routes whose target is a session/artifact id (no workspaceId param) rely on
|
|
19
|
+
* the scope layer's `findFirst({ id, workspaceId })` — a foreign target is a
|
|
20
|
+
* 404, indistinguishable from a missing one.
|
|
21
|
+
*/
|
|
22
|
+
import type {
|
|
23
|
+
FastifyPluginCallback,
|
|
24
|
+
FastifyReply,
|
|
25
|
+
FastifyRequest,
|
|
26
|
+
} from "fastify";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
resolveActiveTenant,
|
|
30
|
+
type TenantContext,
|
|
31
|
+
} from "../services/tenant-context.js";
|
|
32
|
+
|
|
33
|
+
declare module "fastify" {
|
|
34
|
+
interface FastifyRequest {
|
|
35
|
+
tenant?: TenantContext;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FORBIDDEN = {
|
|
40
|
+
ok: false,
|
|
41
|
+
error: { code: "forbidden", message: "Forbidden" },
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve + attach the active tenant, or reply 403. Registered as a hook in
|
|
46
|
+
* the workspace route scope (after auth), so no tenant-scoped handler runs
|
|
47
|
+
* without a granted TenantContext.
|
|
48
|
+
*/
|
|
49
|
+
export async function tenantPreHandler(
|
|
50
|
+
request: FastifyRequest,
|
|
51
|
+
reply: FastifyReply,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const userId = request.userId;
|
|
54
|
+
if (!userId) {
|
|
55
|
+
reply.code(401).send({
|
|
56
|
+
ok: false,
|
|
57
|
+
error: { code: "unauthorized", message: "Authentication required" },
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const resolved = await resolveActiveTenant({
|
|
63
|
+
userId,
|
|
64
|
+
sessionTenant: request.sessionTenant,
|
|
65
|
+
});
|
|
66
|
+
if (!resolved.ok) {
|
|
67
|
+
reply.code(403).send(FORBIDDEN);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// URL-scoped routes: the target workspace MUST be the session's active
|
|
72
|
+
// workspace. Compared before any target lookup — a foreign (or nonexistent)
|
|
73
|
+
// workspaceId gets the same 403.
|
|
74
|
+
const params = request.params as { workspaceId?: string } | undefined;
|
|
75
|
+
if (params?.workspaceId && params.workspaceId !== resolved.value.workspaceId) {
|
|
76
|
+
reply.code(403).send(FORBIDDEN);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
request.tenant = resolved.value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Plugin form, mirroring `authPreHandlerPlugin`: skip-override so the hook
|
|
85
|
+
* propagates to sibling route plugins registered in the same scope.
|
|
86
|
+
*/
|
|
87
|
+
export const tenantPreHandlerPlugin: FastifyPluginCallback = (
|
|
88
|
+
fastify,
|
|
89
|
+
_opts,
|
|
90
|
+
done,
|
|
91
|
+
) => {
|
|
92
|
+
fastify.decorateRequest("tenant", undefined);
|
|
93
|
+
fastify.addHook("preHandler", tenantPreHandler);
|
|
94
|
+
done();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
(tenantPreHandlerPlugin as FastifyPluginCallback & { [key: symbol]: boolean })[
|
|
98
|
+
Symbol.for("skip-override")
|
|
99
|
+
] = true;
|
|
@@ -126,4 +126,96 @@ describe("GET /api/openapi.json", () => {
|
|
|
126
126
|
spec.paths["/v1/content/{type}"].get.responses["400"],
|
|
127
127
|
).toMatchObject({ $ref: "#/components/responses/BadRequest" });
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("lead-write routes (#4991)", () => {
|
|
131
|
+
const leadPaths: Array<{
|
|
132
|
+
path: string;
|
|
133
|
+
operationId: string;
|
|
134
|
+
requiredFields: string[];
|
|
135
|
+
}> = [
|
|
136
|
+
{
|
|
137
|
+
path: "/v1/leads/submit",
|
|
138
|
+
operationId: "submitLead",
|
|
139
|
+
requiredFields: ["siteId", "source", "email", "payload"],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
path: "/v1/leads/subscribe",
|
|
143
|
+
operationId: "subscribeLead",
|
|
144
|
+
requiredFields: ["siteId", "email", "source"],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
path: "/v1/leads/callback",
|
|
148
|
+
operationId: "requestCallback",
|
|
149
|
+
requiredFields: ["siteId", "name", "phone", "payload"],
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
it.each(leadPaths)(
|
|
154
|
+
"documents $path as a POST with a matching operationId",
|
|
155
|
+
async ({ path, operationId }) => {
|
|
156
|
+
const res = await app.inject({
|
|
157
|
+
method: "GET",
|
|
158
|
+
url: "/v1/openapi.json",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const spec = JSON.parse(res.body);
|
|
162
|
+
expect(spec.paths[path].post.operationId).toBe(operationId);
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
it.each(leadPaths)(
|
|
167
|
+
"documents $path's requestBody with the Zod schema's required keys",
|
|
168
|
+
async ({ path, requiredFields }) => {
|
|
169
|
+
const res = await app.inject({
|
|
170
|
+
method: "GET",
|
|
171
|
+
url: "/v1/openapi.json",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const spec = JSON.parse(res.body);
|
|
175
|
+
const requestBodySchema =
|
|
176
|
+
spec.paths[path].post.requestBody.content["application/json"]
|
|
177
|
+
.schema;
|
|
178
|
+
const resolved =
|
|
179
|
+
"$ref" in requestBodySchema
|
|
180
|
+
? resolveRef(spec, requestBodySchema.$ref)
|
|
181
|
+
: requestBodySchema;
|
|
182
|
+
|
|
183
|
+
expect(resolved.required.slice().sort()).toEqual(
|
|
184
|
+
requiredFields.slice().sort(),
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
it.each(leadPaths)(
|
|
190
|
+
"documents 201, 400, and 503 responses for $path",
|
|
191
|
+
async ({ path }) => {
|
|
192
|
+
const res = await app.inject({
|
|
193
|
+
method: "GET",
|
|
194
|
+
url: "/v1/openapi.json",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const spec = JSON.parse(res.body);
|
|
198
|
+
const responses = spec.paths[path].post.responses;
|
|
199
|
+
expect(responses["201"]).toBeDefined();
|
|
200
|
+
expect(responses["400"]).toBeDefined();
|
|
201
|
+
expect(responses["503"]).toBeDefined();
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
it("no longer describes the API as read-only", async () => {
|
|
206
|
+
const res = await app.inject({
|
|
207
|
+
method: "GET",
|
|
208
|
+
url: "/v1/openapi.json",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const spec = JSON.parse(res.body);
|
|
212
|
+
expect(spec.info.description).not.toMatch(/Read-only/i);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
129
215
|
});
|
|
216
|
+
|
|
217
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test helper over a raw JSON document
|
|
218
|
+
function resolveRef(spec: any, ref: string): any {
|
|
219
|
+
const path = ref.replace(/^#\//, "").split("/");
|
|
220
|
+
return path.reduce((node, key) => node[key], spec);
|
|
221
|
+
}
|