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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flows route group (#3956 D4).
|
|
3
|
+
*
|
|
4
|
+
* Routes:
|
|
5
|
+
* GET /v1/flows/:slug — serve a validated flow config
|
|
6
|
+
* POST /v1/flows/:slug/steps — submit one step's answers; persists
|
|
7
|
+
* server-side on landing
|
|
8
|
+
* GET /v1/flows/:slug/aggregate — anonymous, PII-free per-question
|
|
9
|
+
* response counts across all completed
|
|
10
|
+
* respondents (#4771), for the results
|
|
11
|
+
* reveal
|
|
12
|
+
*
|
|
13
|
+
* `siteId` is not carried in the URL or the request body — flows follow the
|
|
14
|
+
* same anonymous single-site default `apps/mcp/src/tools/capture_lead.ts`
|
|
15
|
+
* already established for agent-facing anonymous surfaces
|
|
16
|
+
* (`process.env.AILK_SITE_ID ?? "default"`), rather than the leads routes'
|
|
17
|
+
* explicit client-supplied `siteId` (spec D4 documents no siteId field for
|
|
18
|
+
* either flows route — this is the design decision that fills that gap).
|
|
19
|
+
*
|
|
20
|
+
* Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
|
|
21
|
+
* `{ ok: false, error: { code, message } }`, never 200-with-ok:false.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
flowSlugSchema,
|
|
25
|
+
submitFlowStepInputSchema,
|
|
26
|
+
} from "@working-theory/validation";
|
|
27
|
+
import type { FastifyPluginAsync, FastifyReply } from "fastify";
|
|
28
|
+
|
|
29
|
+
import { currentSiteId } from "../../lib/site.js";
|
|
30
|
+
import { getFlowAggregate } from "../../services/flow-aggregate.js";
|
|
31
|
+
import {
|
|
32
|
+
serveFlow,
|
|
33
|
+
submitStep,
|
|
34
|
+
type FlowEngineError,
|
|
35
|
+
type FlowEngineErrorKind,
|
|
36
|
+
} from "../../services/flow-engine.js";
|
|
37
|
+
|
|
38
|
+
const FLOW_ERROR_STATUS: Record<FlowEngineErrorKind, number> = {
|
|
39
|
+
invalid_input: 400,
|
|
40
|
+
flow_not_found: 404,
|
|
41
|
+
step_conflict: 409,
|
|
42
|
+
flows_not_configured: 503,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function sendFlowError(reply: FastifyReply, error: FlowEngineError) {
|
|
46
|
+
return reply
|
|
47
|
+
.status(FLOW_ERROR_STATUS[error.kind])
|
|
48
|
+
.send({ ok: false, error: { code: error.kind, message: error.message } });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const getFlowRoute: FastifyPluginAsync = async (fastify) => {
|
|
52
|
+
fastify.get<{ Params: { slug: string } }>(
|
|
53
|
+
"/v1/flows/:slug",
|
|
54
|
+
async (request, reply) => {
|
|
55
|
+
const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
|
|
56
|
+
if (!parsedSlug.success) {
|
|
57
|
+
return reply.status(400).send({
|
|
58
|
+
ok: false,
|
|
59
|
+
error: { code: "invalid_input", message: "Invalid flow slug." },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await serveFlow(currentSiteId(), parsedSlug.data);
|
|
64
|
+
if (!result.ok) return sendFlowError(reply, result.error);
|
|
65
|
+
|
|
66
|
+
return reply.send({ ok: true, data: result.config });
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const getFlowAggregateRoute: FastifyPluginAsync = async (fastify) => {
|
|
72
|
+
fastify.get<{ Params: { slug: string } }>(
|
|
73
|
+
"/v1/flows/:slug/aggregate",
|
|
74
|
+
async (request, reply) => {
|
|
75
|
+
const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
|
|
76
|
+
if (!parsedSlug.success) {
|
|
77
|
+
return reply.status(400).send({
|
|
78
|
+
ok: false,
|
|
79
|
+
error: { code: "invalid_input", message: "Invalid flow slug." },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = await getFlowAggregate(currentSiteId(), parsedSlug.data);
|
|
84
|
+
if (!result.ok) return sendFlowError(reply, result.error);
|
|
85
|
+
|
|
86
|
+
return reply.send({ ok: true, data: result.data });
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const submitFlowStepRoute: FastifyPluginAsync = async (fastify) => {
|
|
92
|
+
fastify.post<{ Params: { slug: string } }>(
|
|
93
|
+
"/v1/flows/:slug/steps",
|
|
94
|
+
async (request, reply) => {
|
|
95
|
+
const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
|
|
96
|
+
if (!parsedSlug.success) {
|
|
97
|
+
return reply.status(400).send({
|
|
98
|
+
ok: false,
|
|
99
|
+
error: { code: "invalid_input", message: "Invalid flow slug." },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Validate the RAW body directly against the .strict() schema — an
|
|
104
|
+
// unexpected top-level key (e.g. attacker-supplied `siteId`) is
|
|
105
|
+
// rejected here rather than silently dropped by a pre-filter, so the
|
|
106
|
+
// 400 invalid_input D4 documents for "schema validation" actually
|
|
107
|
+
// fires on it.
|
|
108
|
+
const body = request.body as Record<string, unknown>;
|
|
109
|
+
const parsedBody = submitFlowStepInputSchema.safeParse(body);
|
|
110
|
+
if (!parsedBody.success) {
|
|
111
|
+
return reply.status(400).send({
|
|
112
|
+
ok: false,
|
|
113
|
+
error: {
|
|
114
|
+
code: "invalid_input",
|
|
115
|
+
message:
|
|
116
|
+
parsedBody.error.issues[0]?.message ?? "Invalid step submission.",
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const sourceMeta = {
|
|
122
|
+
ip: request.ip,
|
|
123
|
+
userAgent: request.headers["user-agent"],
|
|
124
|
+
receivedAt: new Date().toISOString(),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const result = await submitStep(
|
|
128
|
+
{
|
|
129
|
+
siteId: currentSiteId(),
|
|
130
|
+
slug: parsedSlug.data,
|
|
131
|
+
...(parsedBody.data.sessionId !== undefined && {
|
|
132
|
+
sessionId: parsedBody.data.sessionId,
|
|
133
|
+
}),
|
|
134
|
+
stepKey: parsedBody.data.stepKey,
|
|
135
|
+
values: parsedBody.data.values,
|
|
136
|
+
...(parsedBody.data.locale !== undefined && {
|
|
137
|
+
locale: parsedBody.data.locale,
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
sourceMeta,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if (!result.ok) {
|
|
144
|
+
if (result.error.kind === "invalid_input") {
|
|
145
|
+
// Client-fixable — no server-side log needed.
|
|
146
|
+
} else if (result.error.kind !== "flows_not_configured") {
|
|
147
|
+
// step_conflict / flow_not_found — PII-safe operational visibility.
|
|
148
|
+
request.log.warn(
|
|
149
|
+
{
|
|
150
|
+
slug: parsedSlug.data,
|
|
151
|
+
stepKey: body["stepKey"],
|
|
152
|
+
kind: result.error.kind,
|
|
153
|
+
},
|
|
154
|
+
"flow step submission rejected",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return sendFlowError(reply, result.error);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// PII-safe structured log — mirrors leads route: no email/values/payload.
|
|
161
|
+
request.log.info(
|
|
162
|
+
{
|
|
163
|
+
slug: parsedSlug.data,
|
|
164
|
+
sessionId: result.sessionId,
|
|
165
|
+
stepKey: result.landed,
|
|
166
|
+
completed: result.completed,
|
|
167
|
+
},
|
|
168
|
+
"flow step landed",
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (result.leadRoutingFailed) {
|
|
172
|
+
// Completion ran but routeLead's own db+file fallback both failed —
|
|
173
|
+
// the session (not a Lead row) is the durable record for this
|
|
174
|
+
// completion. PII-safe: no email/payload, matches the shape above.
|
|
175
|
+
request.log.warn(
|
|
176
|
+
{
|
|
177
|
+
slug: parsedSlug.data,
|
|
178
|
+
sessionId: result.sessionId,
|
|
179
|
+
},
|
|
180
|
+
"flow completed but lead routing failed — session is the record",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const { sessionId, landed, nextStepKey, completed, leadId, segment, aeoScore } =
|
|
185
|
+
result;
|
|
186
|
+
return reply.send({
|
|
187
|
+
ok: true,
|
|
188
|
+
data: {
|
|
189
|
+
sessionId,
|
|
190
|
+
landed,
|
|
191
|
+
nextStepKey,
|
|
192
|
+
completed,
|
|
193
|
+
...(leadId !== undefined && { leadId }),
|
|
194
|
+
...(segment !== undefined && { segment }),
|
|
195
|
+
// D9 (#4663) — forward the aeo_score step's own result, or the
|
|
196
|
+
// persisted result on the completion response.
|
|
197
|
+
...(aeoScore !== undefined && { aeoScore }),
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
};
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recommender email-moment routes — #4841 (Epic #4760 C2).
|
|
3
|
+
*
|
|
4
|
+
* Routes (both anonymous, registered inside server.ts's `flowsScope` beside
|
|
5
|
+
* the three existing flow routes, so they inherit the per-IP rate limiter):
|
|
6
|
+
* POST /v1/flows/:slug/save — "email me this plan": FlowSession + Lead
|
|
7
|
+
* (+ the T+0 grant) for the permalink's
|
|
8
|
+
* answers. No Stripe call.
|
|
9
|
+
* POST /v1/flows/:slug/checkout — the purchase moment: the email moment
|
|
10
|
+
* above, then the buyer row at a
|
|
11
|
+
* SERVER-recomputed price, then a Stripe
|
|
12
|
+
* TEST-mode Checkout Session whose metadata
|
|
13
|
+
* carries the permalink + offer key.
|
|
14
|
+
*
|
|
15
|
+
* The body carries NO amount of any name (`flowCheckoutInputSchema` is
|
|
16
|
+
* `.strict()`): the server recomputes `price()` from the register it served
|
|
17
|
+
* (PRD L104) and `#4837`'s `recordCheckout` is the authority — it resolves
|
|
18
|
+
* the register from the DB itself and verifies the session + lead ids are
|
|
19
|
+
* this site's before writing (the PR #4851 security-review conditions).
|
|
20
|
+
*
|
|
21
|
+
* Gate order on checkout (spec FR5, + #4912 FR1): sandbox key → body →
|
|
22
|
+
* redirect origin (400) → register (404) → `stripeConfigRef` +
|
|
23
|
+
* `rateTable.currency` (409) → `offerKey` (400) → the capture → the row →
|
|
24
|
+
* Stripe. A Stripe failure lands AFTER the session, lead and row exist, so the
|
|
25
|
+
* buyer record survives a Stripe outage (FR6); a foreign redirect origin lands
|
|
26
|
+
* BEFORE all of them, so a refused redirect writes nothing at all.
|
|
27
|
+
*
|
|
28
|
+
* `siteId` is `currentSiteId()` — the anonymous single-site default every
|
|
29
|
+
* flows route takes (`index.ts`); `sourceMeta` is the same triple
|
|
30
|
+
* `submitFlowStepRoute` hands `routeLead`.
|
|
31
|
+
*
|
|
32
|
+
* Security invariants (the `checkout/sessions.ts` posture, inherited):
|
|
33
|
+
* - Test mode only: a key outside the `sk_test_` allow-list is refused at
|
|
34
|
+
* request time, before any DB or Stripe call.
|
|
35
|
+
* - No PII in logs: only `slug`, 8-char id prefixes, and an outcome.
|
|
36
|
+
* - No user input echoed in any error body.
|
|
37
|
+
* - The two redirect URLs must be on the site's own origin (#4912 D1) — the
|
|
38
|
+
* shared `flowRedirectUrlSchema` pins only `http(s)` + a host, so without
|
|
39
|
+
* this gate a direct API caller could aim a real Checkout Session's
|
|
40
|
+
* post-payment redirect at a host they control.
|
|
41
|
+
* - The `X-Internal-Auth` gate is deliberately NOT applied — these are the
|
|
42
|
+
* visitor's own anonymous moment (spec Clarifications).
|
|
43
|
+
*
|
|
44
|
+
* Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
|
|
45
|
+
* `{ ok: false, error: { code, message } }`, never 200-with-ok:false.
|
|
46
|
+
*
|
|
47
|
+
* API↔MCP parity: no MCP twin — recorded exception (apps/api/CLAUDE.md): an
|
|
48
|
+
* agent walking a flow has no email to give and no card to pay with.
|
|
49
|
+
*/
|
|
50
|
+
import {
|
|
51
|
+
encodePermalink,
|
|
52
|
+
flowCheckoutInputSchema,
|
|
53
|
+
flowSaveInputSchema,
|
|
54
|
+
flowSlugSchema,
|
|
55
|
+
PERMALINK_VERSION,
|
|
56
|
+
resolveLocalizedString,
|
|
57
|
+
} from "@working-theory/validation";
|
|
58
|
+
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
|
59
|
+
|
|
60
|
+
import { currentSiteId } from "../../lib/site.js";
|
|
61
|
+
import {
|
|
62
|
+
assertSandboxKey,
|
|
63
|
+
createQuotedCheckoutSession,
|
|
64
|
+
expireCheckoutSession,
|
|
65
|
+
StripeConfigError,
|
|
66
|
+
} from "../../lib/stripe.js";
|
|
67
|
+
import {
|
|
68
|
+
attachStripeSession,
|
|
69
|
+
captureRecommenderChoice,
|
|
70
|
+
expireFlowCheckout,
|
|
71
|
+
findRegisterForSlug,
|
|
72
|
+
recordCheckout,
|
|
73
|
+
type RecommenderCaptureError,
|
|
74
|
+
type RecordCheckoutErrorKind,
|
|
75
|
+
} from "../../services/recommender-capture.js";
|
|
76
|
+
|
|
77
|
+
// ─── Error mapping ───────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const CAPTURE_ERROR_STATUS: Record<RecommenderCaptureError["kind"], number> = {
|
|
80
|
+
invalid_input: 400,
|
|
81
|
+
flow_not_found: 404,
|
|
82
|
+
not_recommending: 409,
|
|
83
|
+
lead_failed: 502,
|
|
84
|
+
flows_not_configured: 503,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** #4837 §6's `RecordCheckoutResult.kind` vocabulary → this unit's 400/409 split (spec Clarifications — provenance). */
|
|
88
|
+
const RECORD_CHECKOUT_STATUS: Record<RecordCheckoutErrorKind, { status: number; code: string }> = {
|
|
89
|
+
no_register: { status: 404, code: "flow_not_found" },
|
|
90
|
+
session_mismatch: { status: 400, code: "invalid_input" },
|
|
91
|
+
unknown_offer: { status: 400, code: "invalid_input" },
|
|
92
|
+
no_seat_rate: { status: 400, code: "invalid_input" },
|
|
93
|
+
invalid_seats: { status: 400, code: "invalid_input" },
|
|
94
|
+
unknown_credit_stop: { status: 400, code: "invalid_input" },
|
|
95
|
+
custom_stop: { status: 400, code: "invalid_input" },
|
|
96
|
+
no_rate_table: { status: 409, code: "checkout_not_configured" },
|
|
97
|
+
no_currency: { status: 409, code: "checkout_not_configured" },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function sendError(reply: FastifyReply, status: number, code: string, message: string) {
|
|
101
|
+
return reply.status(status).send({ ok: false, error: { code, message } });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sendCaptureError(reply: FastifyReply, error: RecommenderCaptureError) {
|
|
105
|
+
const code = error.kind === "not_recommending" ? "checkout_not_configured" : error.kind;
|
|
106
|
+
// Never echo user input — the service's messages are fixed strings.
|
|
107
|
+
return sendError(reply, CAPTURE_ERROR_STATUS[error.kind], code, error.message);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Stripe caps a metadata value at 500 characters; a long permalink is chunked, never truncated (spec D5). */
|
|
111
|
+
export const METADATA_VALUE_MAX = 500;
|
|
112
|
+
|
|
113
|
+
export function permalinkMetadata(permalink: string): Record<string, string> {
|
|
114
|
+
if (permalink.length <= METADATA_VALUE_MAX) return { permalink };
|
|
115
|
+
const out: Record<string, string> = {};
|
|
116
|
+
for (let i = 0, n = 1; i < permalink.length; i += METADATA_VALUE_MAX, n++) {
|
|
117
|
+
out[n === 1 ? "permalink" : `permalink_${n}`] = permalink.slice(i, i + METADATA_VALUE_MAX);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The site's own origin — the ONLY origin a checkout redirect may resolve to
|
|
124
|
+
* (#4912 FR1/D1).
|
|
125
|
+
*
|
|
126
|
+
* `FRONTEND_URL` is the single site-origin contract this API already carries:
|
|
127
|
+
* the CORS origin (`server.ts`), the origin check in `middleware/auth.ts`, and
|
|
128
|
+
* the billing-portal return URL (`routes/billing/portal.ts`) all read it. The
|
|
129
|
+
* fallback is byte-identical to the one `config/index.ts` applies, and it is
|
|
130
|
+
* read per request for the same reason `STRIPE_SECRET_KEY` is: this route's
|
|
131
|
+
* environment-sourced security inputs are resolved at request time, not frozen
|
|
132
|
+
* at import.
|
|
133
|
+
*
|
|
134
|
+
* Returns `null` when the value is set but unparseable — a misconfiguration
|
|
135
|
+
* the route reports as a config error rather than silently widening.
|
|
136
|
+
*/
|
|
137
|
+
function siteRedirectOrigin(): string | null {
|
|
138
|
+
const raw = process.env["FRONTEND_URL"] || "http://localhost:3000";
|
|
139
|
+
let parsed: URL;
|
|
140
|
+
try {
|
|
141
|
+
parsed = new URL(raw);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// A non-http(s) value parses without throwing and yields the literal origin
|
|
146
|
+
// `"null"` (`mailto:…`, `javascript:…`, a bare `host:port`). That would deny
|
|
147
|
+
// every redirect — fail-closed, but undiagnosable — so it is reported as the
|
|
148
|
+
// misconfiguration it is rather than as a 400 the operator cannot explain.
|
|
149
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
|
|
150
|
+
return parsed.origin;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Exact origin equality — scheme + host + port. A prefix or suffix comparison
|
|
155
|
+
* would accept `https://<site-origin>.evil.example`; a scheme check keeps
|
|
156
|
+
* `javascript:` and `data:` out even though the shared schema already refuses
|
|
157
|
+
* them.
|
|
158
|
+
*/
|
|
159
|
+
function isSameOriginRedirect(raw: string, siteOrigin: string): boolean {
|
|
160
|
+
let resolved: URL;
|
|
161
|
+
try {
|
|
162
|
+
resolved = new URL(raw);
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
if (resolved.protocol !== "https:" && resolved.protocol !== "http:") return false;
|
|
167
|
+
return resolved.origin === siteOrigin;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sourceMetaOf(request: FastifyRequest) {
|
|
171
|
+
return {
|
|
172
|
+
ip: request.ip,
|
|
173
|
+
userAgent: request.headers["user-agent"],
|
|
174
|
+
receivedAt: new Date().toISOString(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function prefix(id: string): string {
|
|
179
|
+
return id.substring(0, 8);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── POST /v1/flows/:slug/save ───────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
export const saveRecommendationRoute: FastifyPluginAsync = async (fastify) => {
|
|
185
|
+
fastify.post<{ Params: { slug: string } }>("/v1/flows/:slug/save", async (request, reply) => {
|
|
186
|
+
const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
|
|
187
|
+
if (!parsedSlug.success) return sendError(reply, 400, "invalid_input", "Invalid flow slug.");
|
|
188
|
+
|
|
189
|
+
const parsedBody = flowSaveInputSchema.safeParse(request.body);
|
|
190
|
+
if (!parsedBody.success) {
|
|
191
|
+
return sendError(
|
|
192
|
+
reply,
|
|
193
|
+
400,
|
|
194
|
+
"invalid_input",
|
|
195
|
+
parsedBody.error.issues[0]?.message ?? "Invalid save request.",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const body = parsedBody.data;
|
|
199
|
+
|
|
200
|
+
const result = await captureRecommenderChoice({
|
|
201
|
+
siteId: currentSiteId(),
|
|
202
|
+
flowSlug: parsedSlug.data,
|
|
203
|
+
answers: body.answers,
|
|
204
|
+
email: body.email,
|
|
205
|
+
...(body.locale !== undefined && { locale: body.locale }),
|
|
206
|
+
...(body.sessionId !== undefined && { sessionId: body.sessionId }),
|
|
207
|
+
choice: "save",
|
|
208
|
+
sourceMeta: sourceMetaOf(request),
|
|
209
|
+
});
|
|
210
|
+
if (!result.ok) return sendCaptureError(reply, result.error);
|
|
211
|
+
|
|
212
|
+
request.log.info(
|
|
213
|
+
{ slug: parsedSlug.data, sessionIdPrefix: prefix(result.sessionId), outcome: "saved" },
|
|
214
|
+
"[recommender] plan saved",
|
|
215
|
+
);
|
|
216
|
+
return reply.send({ ok: true, data: { leadId: result.leadId, sessionId: result.sessionId } });
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// ─── POST /v1/flows/:slug/checkout ───────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
export const checkoutRecommendationRoute: FastifyPluginAsync = async (fastify) => {
|
|
223
|
+
fastify.post<{ Params: { slug: string } }>("/v1/flows/:slug/checkout", async (request, reply) => {
|
|
224
|
+
// 1. Test mode only — before any DB or Stripe call (the sessions.ts posture).
|
|
225
|
+
const secretKey = process.env["STRIPE_SECRET_KEY"];
|
|
226
|
+
if (!secretKey) return sendError(reply, 500, "config-error", "Stripe is not configured.");
|
|
227
|
+
try {
|
|
228
|
+
assertSandboxKey(secretKey);
|
|
229
|
+
} catch {
|
|
230
|
+
return sendError(reply, 500, "live-mode-forbidden", "Stripe live mode is not permitted.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 2. Body — strict; an amount of any name is a 400 here, never read.
|
|
234
|
+
const parsedSlug = flowSlugSchema.safeParse(request.params.slug);
|
|
235
|
+
if (!parsedSlug.success) return sendError(reply, 400, "invalid_input", "Invalid flow slug.");
|
|
236
|
+
const slug = parsedSlug.data;
|
|
237
|
+
const parsedBody = flowCheckoutInputSchema.safeParse(request.body);
|
|
238
|
+
if (!parsedBody.success) {
|
|
239
|
+
return sendError(
|
|
240
|
+
reply,
|
|
241
|
+
400,
|
|
242
|
+
"invalid_input",
|
|
243
|
+
parsedBody.error.issues[0]?.message ?? "Invalid checkout request.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const body = parsedBody.data;
|
|
247
|
+
const siteId = currentSiteId();
|
|
248
|
+
|
|
249
|
+
// 2b. #4912 FR1 — both redirect URLs must land on this site's own origin.
|
|
250
|
+
// Before every gate below, so a refused redirect creates no session,
|
|
251
|
+
// no lead and no row, and never reaches Stripe.
|
|
252
|
+
const siteOrigin = siteRedirectOrigin();
|
|
253
|
+
if (siteOrigin === null) {
|
|
254
|
+
request.log.error({ slug }, "[recommender] FRONTEND_URL is not a valid absolute URL");
|
|
255
|
+
return sendError(reply, 500, "config-error", "The site origin is not configured.");
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
!isSameOriginRedirect(body.successUrl, siteOrigin) ||
|
|
259
|
+
!isSameOriginRedirect(body.cancelUrl, siteOrigin)
|
|
260
|
+
) {
|
|
261
|
+
// Never echo the offending URL back.
|
|
262
|
+
request.log.warn({ slug, outcome: "redirect_origin_refused" }, "[recommender] redirect origin refused");
|
|
263
|
+
return sendError(reply, 400, "invalid_input", "Redirect URLs must be on this site's origin.");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 3. Register gates — 404 unknown, 409 not set up to sell, 400 unknown offer.
|
|
267
|
+
const register = await findRegisterForSlug(siteId, slug);
|
|
268
|
+
if (!register) return sendError(reply, 404, "flow_not_found", "Flow not found.");
|
|
269
|
+
if (register.stripeConfigRef === null || !register.rateTable?.currency) {
|
|
270
|
+
return sendError(
|
|
271
|
+
reply,
|
|
272
|
+
409,
|
|
273
|
+
"checkout_not_configured",
|
|
274
|
+
"This flow is not configured for checkout.",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const offer = register.offers?.find((o) => o.key === body.offerKey);
|
|
278
|
+
if (!offer) return sendError(reply, 400, "invalid_input", "Unknown offer.");
|
|
279
|
+
|
|
280
|
+
// 4. The email moment — session + lead (+ grant).
|
|
281
|
+
const capture = await captureRecommenderChoice({
|
|
282
|
+
siteId,
|
|
283
|
+
flowSlug: slug,
|
|
284
|
+
answers: body.answers,
|
|
285
|
+
email: body.email,
|
|
286
|
+
...(body.locale !== undefined && { locale: body.locale }),
|
|
287
|
+
...(body.sessionId !== undefined && { sessionId: body.sessionId }),
|
|
288
|
+
choice: "checkout",
|
|
289
|
+
sourceMeta: sourceMetaOf(request),
|
|
290
|
+
});
|
|
291
|
+
if (!capture.ok) return sendCaptureError(reply, capture.error);
|
|
292
|
+
|
|
293
|
+
// 5. The buyer row at the SERVER's price — the request carried no amount.
|
|
294
|
+
const permalink = encodePermalink({ version: PERMALINK_VERSION, flowSlug: slug, answers: body.answers });
|
|
295
|
+
const recorded = await recordCheckout({
|
|
296
|
+
siteId,
|
|
297
|
+
flowSlug: slug,
|
|
298
|
+
experimentKey: register.key,
|
|
299
|
+
sessionId: capture.sessionId,
|
|
300
|
+
leadId: capture.leadId,
|
|
301
|
+
email: body.email,
|
|
302
|
+
offerKey: body.offerKey,
|
|
303
|
+
seats: body.seats,
|
|
304
|
+
period: body.period,
|
|
305
|
+
creditStop: body.creditStop,
|
|
306
|
+
permalink,
|
|
307
|
+
});
|
|
308
|
+
if (!recorded.ok) {
|
|
309
|
+
const mapped = RECORD_CHECKOUT_STATUS[recorded.error.kind];
|
|
310
|
+
return sendError(reply, mapped.status, mapped.code, recorded.error.message);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 5b. #4912 FR5 — an identical resubmission inside the idempotency window
|
|
314
|
+
// replays the first attempt's session instead of opening a second one.
|
|
315
|
+
// `replayed` is the union's discriminant, so the two Stripe fields are
|
|
316
|
+
// non-null by construction here — there is no fall-through that could
|
|
317
|
+
// open a second session for a request the service already replayed.
|
|
318
|
+
if (recorded.replayed) {
|
|
319
|
+
request.log.info(
|
|
320
|
+
{
|
|
321
|
+
slug,
|
|
322
|
+
flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
|
|
323
|
+
stripeSessionIdPrefix: prefix(recorded.stripeSessionId),
|
|
324
|
+
outcome: "checkout_session_replayed",
|
|
325
|
+
},
|
|
326
|
+
"[recommender] checkout session replayed",
|
|
327
|
+
);
|
|
328
|
+
return reply
|
|
329
|
+
.status(201)
|
|
330
|
+
.send({ ok: true, data: { url: recorded.checkoutUrl, sessionId: recorded.stripeSessionId } });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 6. Stripe — after the record exists, so an outage never loses it (FR6).
|
|
334
|
+
const locale = body.locale ?? capture.config.defaultLocale;
|
|
335
|
+
const productName = resolveLocalizedString(offer.copy.name, locale, capture.config.defaultLocale);
|
|
336
|
+
let session: { sessionId: string; url: string };
|
|
337
|
+
try {
|
|
338
|
+
session = await createQuotedCheckoutSession({
|
|
339
|
+
currency: recorded.currency,
|
|
340
|
+
unitAmount: recorded.amountMinor,
|
|
341
|
+
productName,
|
|
342
|
+
period: body.period,
|
|
343
|
+
customerEmail: body.email,
|
|
344
|
+
successUrl: body.successUrl,
|
|
345
|
+
cancelUrl: body.cancelUrl,
|
|
346
|
+
metadata: {
|
|
347
|
+
flowCheckoutId: recorded.flowCheckoutId,
|
|
348
|
+
flowSlug: slug,
|
|
349
|
+
offerKey: body.offerKey,
|
|
350
|
+
sessionId: capture.sessionId,
|
|
351
|
+
leadId: capture.leadId,
|
|
352
|
+
...permalinkMetadata(permalink),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
} catch (err) {
|
|
356
|
+
const logCtx = {
|
|
357
|
+
slug,
|
|
358
|
+
flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
|
|
359
|
+
errName: err instanceof Error ? err.name : "unknown",
|
|
360
|
+
};
|
|
361
|
+
if (err instanceof StripeConfigError) {
|
|
362
|
+
request.log.error(logCtx, "[recommender] stripe config error");
|
|
363
|
+
return sendError(reply, 500, "config-error", "Stripe is not configured.");
|
|
364
|
+
}
|
|
365
|
+
request.log.error(logCtx, "[recommender] stripe error; checkout-unavailable");
|
|
366
|
+
return sendError(reply, 502, "checkout-unavailable", "Checkout is temporarily unavailable.");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const attached = await attachStripeSession(
|
|
370
|
+
siteId,
|
|
371
|
+
recorded.flowCheckoutId,
|
|
372
|
+
session.sessionId,
|
|
373
|
+
session.url,
|
|
374
|
+
);
|
|
375
|
+
if (attached.count !== 1) {
|
|
376
|
+
// The Stripe session exists but the row it must join on did not take
|
|
377
|
+
// the stamp — handing the visitor that URL would produce a payment the
|
|
378
|
+
// #4838 webhook cannot attribute. Refuse rather than 201.
|
|
379
|
+
//
|
|
380
|
+
// #4912 FR7 — and the session is live and payable until somebody closes
|
|
381
|
+
// it, so it is expired and the row marked dead BEFORE the 502 returns
|
|
382
|
+
// (#4863). Both compensations are best-effort: a failure is logged and
|
|
383
|
+
// the 502 still returns, because the visitor's answer does not change.
|
|
384
|
+
let sessionCompensation = "expired";
|
|
385
|
+
try {
|
|
386
|
+
await expireCheckoutSession(session.sessionId);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
sessionCompensation = "expire_failed";
|
|
389
|
+
request.log.error(
|
|
390
|
+
{
|
|
391
|
+
slug,
|
|
392
|
+
flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
|
|
393
|
+
errName: err instanceof Error ? err.name : "unknown",
|
|
394
|
+
outcome: "attach_miss_expire_failed",
|
|
395
|
+
},
|
|
396
|
+
"[recommender] could not expire the orphaned stripe session",
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
// The row mark uses the same `{ id, siteId }` match that just failed to
|
|
400
|
+
// attach, so it can legitimately match nothing — a zero count is not a
|
|
401
|
+
// throw, and reporting it as "expired" would make the audit trail lie.
|
|
402
|
+
let rowCompensation = "marked_expired";
|
|
403
|
+
try {
|
|
404
|
+
const marked = await expireFlowCheckout(siteId, recorded.flowCheckoutId);
|
|
405
|
+
if (marked.count !== 1) rowCompensation = "row_mark_missed";
|
|
406
|
+
} catch (err) {
|
|
407
|
+
rowCompensation = "row_mark_failed";
|
|
408
|
+
request.log.error(
|
|
409
|
+
{
|
|
410
|
+
slug,
|
|
411
|
+
flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
|
|
412
|
+
errName: err instanceof Error ? err.name : "unknown",
|
|
413
|
+
outcome: "attach_miss_row_mark_failed",
|
|
414
|
+
},
|
|
415
|
+
"[recommender] could not mark the orphaned checkout row expired",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
request.log.warn(
|
|
419
|
+
{
|
|
420
|
+
slug,
|
|
421
|
+
flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
|
|
422
|
+
outcome: "attach_miss",
|
|
423
|
+
sessionCompensation,
|
|
424
|
+
rowCompensation,
|
|
425
|
+
},
|
|
426
|
+
"[recommender] stripe session id did not attach to the checkout row",
|
|
427
|
+
);
|
|
428
|
+
return sendError(reply, 502, "checkout-unavailable", "Checkout is temporarily unavailable.");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
request.log.info(
|
|
432
|
+
{
|
|
433
|
+
slug,
|
|
434
|
+
sessionIdPrefix: prefix(capture.sessionId),
|
|
435
|
+
flowCheckoutIdPrefix: prefix(recorded.flowCheckoutId),
|
|
436
|
+
stripeSessionIdPrefix: prefix(session.sessionId),
|
|
437
|
+
outcome: "checkout_session_created",
|
|
438
|
+
},
|
|
439
|
+
"[recommender] checkout session created",
|
|
440
|
+
);
|
|
441
|
+
return reply.status(201).send({ ok: true, data: { url: session.url, sessionId: session.sessionId } });
|
|
442
|
+
});
|
|
443
|
+
};
|