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
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Stripe webhook receiver at `POST /v1/webhooks/stripe`. Verifies
|
|
4
4
|
signatures, dispatches events via a `switch`, and fires server-side
|
|
5
|
-
telemetry.
|
|
5
|
+
telemetry. Handles `customer.created`, `checkout.session.completed`, and
|
|
6
|
+
`checkout.session.expired` (the last two also stamp the recommender's
|
|
7
|
+
buyer row — below).
|
|
6
8
|
|
|
7
9
|
## How to add a new event handler
|
|
8
10
|
|
|
@@ -19,25 +21,46 @@ case "invoice.payment_succeeded": {
|
|
|
19
21
|
Customer forks edit `stripe.ts` directly. v1 ships no pluggable handler
|
|
20
22
|
registry — if multiple forks request one, file an issue.
|
|
21
23
|
|
|
24
|
+
## Recommender purchase stamp (#4838)
|
|
25
|
+
|
|
26
|
+
A Checkout Session the recommender opened (`POST /v1/flows/:slug/checkout`,
|
|
27
|
+
#4841) carries `metadata.flowCheckoutId` — the `FlowCheckout` buyer row's
|
|
28
|
+
id. On `checkout.session.completed` the handler stamps that row
|
|
29
|
+
`status: "completed"` (+ `completedAt`, `stripeSessionId`,
|
|
30
|
+
`stripeCustomerId`); on `checkout.session.expired` it stamps
|
|
31
|
+
`status: "expired"`. Both are ONE guarded update —
|
|
32
|
+
`updateMany({ where: { id, status: "created" } })` — so the stamp is
|
|
33
|
+
at-most-once regardless of the in-memory store below: a replay, a restart
|
|
34
|
+
between deliveries, or a late `expired` after a `completed` matches zero
|
|
35
|
+
rows and logs `outcome: "stale_or_unknown"`. A charged `amount_total` that
|
|
36
|
+
differs from the quoted `amountMinor` is logged (`expected` / `received`)
|
|
37
|
+
and the row is still stamped — the quote is never overwritten. The stamp
|
|
38
|
+
runs beside the telemetry + billing tasks, never instead of them, and an
|
|
39
|
+
event without the key never touches `flowCheckout`. Always 200.
|
|
40
|
+
|
|
22
41
|
## Idempotency caveat (load-bearing)
|
|
23
42
|
|
|
24
43
|
Stripe uses **at-least-once delivery semantics** and occasionally
|
|
25
|
-
double-delivers events.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
double-delivers events. The in-memory store (`lib/webhook-idempotency.ts`)
|
|
45
|
+
short-circuits a replayed `event.id` within one process; it is not durable
|
|
46
|
+
across a restart or a second API instance. Telemetry-only side effects make
|
|
47
|
+
that an acceptable counting drift.
|
|
48
|
+
|
|
49
|
+
**A side effect with write semantics carries its own durable layer.** The
|
|
50
|
+
recommender stamp above uses a guarded conditional update (the row's
|
|
51
|
+
`status: "created"` is the key). A future write that cannot be expressed as
|
|
52
|
+
a guarded update (confirmation email, refund processing) needs a
|
|
53
|
+
`webhook_events` table keyed on `event.id` with an early-return for
|
|
54
|
+
already-seen IDs — add it first.
|
|
32
55
|
|
|
33
56
|
## Env contract
|
|
34
57
|
|
|
35
58
|
Both vars are **server-only** — never use a `NEXT_PUBLIC_` prefix.
|
|
36
59
|
|
|
37
|
-
| Var | Required | Notes
|
|
38
|
-
| ----------------------- | ---------------------------- |
|
|
39
|
-
| `STRIPE_WEBHOOK_SECRET` | For the route to do anything | `whsec_*` from Stripe Dashboard
|
|
40
|
-
| `STRIPE_SECRET_KEY` | For checkout + sandbox check | Must
|
|
60
|
+
| Var | Required | Notes |
|
|
61
|
+
| ----------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| `STRIPE_WEBHOOK_SECRET` | For the route to do anything | `whsec_*` from Stripe Dashboard |
|
|
63
|
+
| `STRIPE_SECRET_KEY` | For checkout + sandbox check | Must match an allow-listed test-mode prefix (`sk_test_*` / `pk_test_*` / `rk_test_*`); anything else — including live and restricted-live keys — is runtime-blocked by `assertSandboxKey`. The commit-time hook is a partial backstop only: it greps for `sk_live_` / `pk_live_` and does **not** catch restricted-live (`rk_live_`) keys, so the runtime allow-list is the load-bearing guard |
|
|
41
64
|
|
|
42
65
|
Without `STRIPE_WEBHOOK_SECRET`, the route returns 503 on every request.
|
|
43
66
|
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/webhooks/stripe — the recommender FlowCheckout stamp (#4838).
|
|
3
|
+
*
|
|
4
|
+
* The stripe.test.ts mock shape: `@working-theory/analytics/server` `track`,
|
|
5
|
+
* the prisma auto-mock, `@working-theory/stripe-client`'s
|
|
6
|
+
* `verifyWebhookSignature` at the package boundary. AC-1 … AC-5. The four
|
|
7
|
+
* pre-existing webhook suites are the regression floor and are not edited.
|
|
8
|
+
* AC-6 (#4862) adds the `no_payment_required` (zero-due) case.
|
|
9
|
+
*/
|
|
10
|
+
import type { FastifyInstance } from "fastify";
|
|
11
|
+
import Fastify from "fastify";
|
|
12
|
+
|
|
13
|
+
const mockTrack = jest.fn().mockResolvedValue(undefined);
|
|
14
|
+
jest.mock("@working-theory/analytics/server", () => ({
|
|
15
|
+
track: (...args: unknown[]) => mockTrack(...args),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
19
|
+
jest.mock("../../../lib/prisma.js");
|
|
20
|
+
|
|
21
|
+
const mockVerifyWebhookSignature = jest.fn();
|
|
22
|
+
class MockStripeWebhookSignatureError extends Error {
|
|
23
|
+
constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "StripeWebhookSignatureError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
jest.mock("@working-theory/stripe-client", () => ({
|
|
29
|
+
verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args),
|
|
30
|
+
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
31
|
+
assertSandboxKey: jest.fn(),
|
|
32
|
+
createCheckoutSession: jest.fn(),
|
|
33
|
+
getStripeClient: jest.fn(),
|
|
34
|
+
STRIPE_API_VERSION: "2024-06-20",
|
|
35
|
+
StripeConfigError: class StripeConfigError extends Error {},
|
|
36
|
+
UnknownProductCodeError: class UnknownProductCodeError extends Error {},
|
|
37
|
+
WEBHOOK_TOLERANCE_SECONDS: 300,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import * as configModule from "../../../config/index.js";
|
|
41
|
+
import { stripeWebhookHandler } from "../stripe.js";
|
|
42
|
+
import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
|
|
43
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
44
|
+
|
|
45
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const FLOW_CHECKOUT_ID = "flchk_test_1";
|
|
48
|
+
const SESSION_ID = "cs_test_session_12345678901234567890";
|
|
49
|
+
const CUSTOMER_ID = "cus_test_12345678901234567890";
|
|
50
|
+
const EMAIL = "alice@example.com";
|
|
51
|
+
const PERMALINK = "eyJ2ZXJzaW9uIjoxLCJmbG93U2x1ZyI6ImFpbGstcmVjbyJ9";
|
|
52
|
+
|
|
53
|
+
function sessionEvent(type: "checkout.session.completed" | "checkout.session.expired", object: Record<string, unknown> = {}) {
|
|
54
|
+
return {
|
|
55
|
+
id: "evt_test_12345678abcdef",
|
|
56
|
+
type,
|
|
57
|
+
data: {
|
|
58
|
+
object: {
|
|
59
|
+
id: SESSION_ID,
|
|
60
|
+
payment_status: "paid",
|
|
61
|
+
amount_total: 24900,
|
|
62
|
+
currency: "usd",
|
|
63
|
+
customer: CUSTOMER_ID,
|
|
64
|
+
customer_details: { email: EMAIL },
|
|
65
|
+
metadata: { flowCheckoutId: FLOW_CHECKOUT_ID, flowSlug: "ailk-reco", offerKey: "pro", permalink: PERMALINK },
|
|
66
|
+
...object,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type LogCall = [unknown, string?];
|
|
73
|
+
const logCalls: LogCall[] = [];
|
|
74
|
+
|
|
75
|
+
async function buildTestServer(): Promise<FastifyInstance> {
|
|
76
|
+
const server = Fastify({ logger: false });
|
|
77
|
+
await server.register(async (webhooksScope) => {
|
|
78
|
+
webhooksScope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
|
|
79
|
+
done(null, body);
|
|
80
|
+
});
|
|
81
|
+
webhooksScope.addHook("onRequest", async (request) => {
|
|
82
|
+
for (const level of ["info", "warn", "error"] as const) {
|
|
83
|
+
const original = request.log[level].bind(request.log);
|
|
84
|
+
request.log[level] = ((...args: unknown[]) => {
|
|
85
|
+
logCalls.push(args as LogCall);
|
|
86
|
+
return (original as (...a: unknown[]) => void)(...args);
|
|
87
|
+
}) as typeof request.log.info;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
91
|
+
});
|
|
92
|
+
await server.ready();
|
|
93
|
+
return server;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
|
|
97
|
+
const HEADERS = { "content-type": "application/json", "stripe-signature": "t=1234,v1=abc" };
|
|
98
|
+
|
|
99
|
+
let server: FastifyInstance;
|
|
100
|
+
|
|
101
|
+
beforeEach(async () => {
|
|
102
|
+
jest.clearAllMocks();
|
|
103
|
+
logCalls.length = 0;
|
|
104
|
+
__resetIdempotencyStore();
|
|
105
|
+
jest.replaceProperty(configModule, "config", {
|
|
106
|
+
...configModule.config,
|
|
107
|
+
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
108
|
+
});
|
|
109
|
+
(prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue({ amountMinor: 24900 });
|
|
110
|
+
(prisma.flowCheckout.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
111
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
|
|
112
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
|
113
|
+
server = await buildTestServer();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(async () => {
|
|
117
|
+
await server.close();
|
|
118
|
+
jest.restoreAllMocks();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
async function post() {
|
|
122
|
+
return server.inject({ method: "POST", url: "/v1/webhooks/stripe", payload: VALID_BODY, headers: HEADERS });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stampCall() {
|
|
126
|
+
expect(prisma.flowCheckout.updateMany).toHaveBeenCalledTimes(1);
|
|
127
|
+
return (prisma.flowCheckout.updateMany as jest.Mock).mock.calls[0][0];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── AC-1 — completed stamps the row once, guarded, beside the legacy tasks ──
|
|
131
|
+
|
|
132
|
+
describe("AC-1 — completed stamps the row once, guarded, beside the legacy tasks", () => {
|
|
133
|
+
it("stamps completed with the guard, the session id and the customer; telemetry + billing still run", async () => {
|
|
134
|
+
mockVerifyWebhookSignature.mockReturnValue(sessionEvent("checkout.session.completed"));
|
|
135
|
+
const res = await post();
|
|
136
|
+
expect(res.statusCode).toBe(200);
|
|
137
|
+
expect(JSON.parse(res.body)).toEqual({ received: true });
|
|
138
|
+
|
|
139
|
+
const call = stampCall();
|
|
140
|
+
expect(call.where).toEqual({ id: FLOW_CHECKOUT_ID, status: "created" });
|
|
141
|
+
expect(call.data.status).toBe("completed");
|
|
142
|
+
expect(call.data.completedAt).toBeInstanceOf(Date);
|
|
143
|
+
expect(call.data.stripeSessionId).toBe(SESSION_ID);
|
|
144
|
+
expect(call.data.stripeCustomerId).toBe(CUSTOMER_ID);
|
|
145
|
+
|
|
146
|
+
// The legacy tasks, exactly as stripe.test.ts asserts them.
|
|
147
|
+
expect(mockTrack).toHaveBeenCalledTimes(1);
|
|
148
|
+
expect(mockTrack).toHaveBeenCalledWith("purchase-completed", {
|
|
149
|
+
amount: 24900,
|
|
150
|
+
currency: "usd",
|
|
151
|
+
productId: undefined,
|
|
152
|
+
eventType: "checkout.session.completed",
|
|
153
|
+
eventIdPrefix: "evt_test",
|
|
154
|
+
});
|
|
155
|
+
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
|
156
|
+
where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
|
|
157
|
+
});
|
|
158
|
+
expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { email: EMAIL } });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── AC-2 — expired stamps expired ───────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
describe("AC-2 — expired stamps expired", () => {
|
|
165
|
+
it("stamps status expired under the same guard, with no completedAt", async () => {
|
|
166
|
+
mockVerifyWebhookSignature.mockReturnValue(sessionEvent("checkout.session.expired"));
|
|
167
|
+
const res = await post();
|
|
168
|
+
expect(res.statusCode).toBe(200);
|
|
169
|
+
const call = stampCall();
|
|
170
|
+
expect(call.where.status).toBe("created");
|
|
171
|
+
expect(call.where.id).toBe(FLOW_CHECKOUT_ID);
|
|
172
|
+
expect(call.data).toEqual({ status: "expired" });
|
|
173
|
+
expect(call.data).not.toHaveProperty("completedAt");
|
|
174
|
+
expect(prisma.flowCheckout.findUnique).not.toHaveBeenCalled();
|
|
175
|
+
expect(mockTrack).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── AC-3 — the legacy paths are byte-unchanged ──────────────────────────────
|
|
180
|
+
|
|
181
|
+
describe("AC-3 — the legacy paths are byte-unchanged", () => {
|
|
182
|
+
it("an event without flowCheckoutId never touches prisma.flowCheckout", async () => {
|
|
183
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
184
|
+
sessionEvent("checkout.session.completed", { metadata: { productId: "starter" } }),
|
|
185
|
+
);
|
|
186
|
+
const res = await post();
|
|
187
|
+
expect(res.statusCode).toBe(200);
|
|
188
|
+
expect(prisma.flowCheckout.findUnique).not.toHaveBeenCalled();
|
|
189
|
+
expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
|
|
190
|
+
expect(mockTrack).toHaveBeenCalledTimes(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("an expired event without the key is a no-op 200 that logs the unhandled-event line", async () => {
|
|
194
|
+
mockVerifyWebhookSignature.mockReturnValue(sessionEvent("checkout.session.expired", { metadata: null }));
|
|
195
|
+
expect((await post()).statusCode).toBe(200);
|
|
196
|
+
expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
|
|
197
|
+
const unhandled = logCalls.find((c) => c[1] === "unhandled webhook event");
|
|
198
|
+
expect(unhandled?.[0]).toEqual({ eventType: "checkout.session.expired", eventIdPrefix: "evt_test" });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("a completed session that is not paid logs `unpaid` and does NOT stamp — the row stays created", async () => {
|
|
202
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
203
|
+
sessionEvent("checkout.session.completed", { payment_status: "unpaid" }),
|
|
204
|
+
);
|
|
205
|
+
expect((await post()).statusCode).toBe(200);
|
|
206
|
+
expect(prisma.flowCheckout.findUnique).not.toHaveBeenCalled();
|
|
207
|
+
expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
|
|
208
|
+
const unpaid = logCalls.find((c) => (c[0] as { outcome?: string })?.outcome === "unpaid");
|
|
209
|
+
expect(unpaid?.[0]).toEqual({
|
|
210
|
+
eventIdPrefix: "evt_test",
|
|
211
|
+
flowCheckoutIdPrefix: FLOW_CHECKOUT_ID.substring(0, 8),
|
|
212
|
+
outcome: "unpaid",
|
|
213
|
+
});
|
|
214
|
+
// The legacy tasks still ran for the event.
|
|
215
|
+
expect(mockTrack).toHaveBeenCalledTimes(1);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ── AC-6 — a zero-due (no_payment_required) session stamps completed ───────
|
|
220
|
+
|
|
221
|
+
describe("AC-6 — no_payment_required stamps completed exactly as paid (#4862)", () => {
|
|
222
|
+
it("stamps completed with the guard, the session id and the customer; telemetry + billing still run", async () => {
|
|
223
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
224
|
+
sessionEvent("checkout.session.completed", { payment_status: "no_payment_required" }),
|
|
225
|
+
);
|
|
226
|
+
const res = await post();
|
|
227
|
+
expect(res.statusCode).toBe(200);
|
|
228
|
+
expect(JSON.parse(res.body)).toEqual({ received: true });
|
|
229
|
+
|
|
230
|
+
const call = stampCall();
|
|
231
|
+
expect(call.where).toEqual({ id: FLOW_CHECKOUT_ID, status: "created" });
|
|
232
|
+
expect(call.data.status).toBe("completed");
|
|
233
|
+
expect(call.data.completedAt).toBeInstanceOf(Date);
|
|
234
|
+
expect(call.data.stripeSessionId).toBe(SESSION_ID);
|
|
235
|
+
expect(call.data.stripeCustomerId).toBe(CUSTOMER_ID);
|
|
236
|
+
|
|
237
|
+
expect(mockTrack).toHaveBeenCalledTimes(1);
|
|
238
|
+
const unpaid = logCalls.find((c) => (c[0] as { outcome?: string })?.outcome === "unpaid");
|
|
239
|
+
expect(unpaid).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── AC-4 — never a retry storm ──────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
describe("AC-4 — never a retry storm", () => {
|
|
246
|
+
it("count 0 logs stale_or_unknown and still 200s", async () => {
|
|
247
|
+
(prisma.flowCheckout.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
|
248
|
+
mockVerifyWebhookSignature.mockReturnValue(sessionEvent("checkout.session.completed"));
|
|
249
|
+
expect((await post()).statusCode).toBe(200);
|
|
250
|
+
const stale = logCalls.find((c) => (c[0] as { outcome?: string })?.outcome === "stale_or_unknown");
|
|
251
|
+
expect(stale).toBeDefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("an updateMany rejection logs an error and still 200s", async () => {
|
|
255
|
+
(prisma.flowCheckout.updateMany as jest.Mock).mockRejectedValue(new Error("db down"));
|
|
256
|
+
mockVerifyWebhookSignature.mockReturnValue(sessionEvent("checkout.session.completed"));
|
|
257
|
+
expect((await post()).statusCode).toBe(200);
|
|
258
|
+
const failed = logCalls.find((c) => (c[0] as { outcome?: string })?.outcome === "stamp_failed");
|
|
259
|
+
expect(failed).toBeDefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("a charged amount that differs from the quote logs both integers and STILL stamps completed", async () => {
|
|
263
|
+
mockVerifyWebhookSignature.mockReturnValue(sessionEvent("checkout.session.completed", { amount_total: 25000 }));
|
|
264
|
+
expect((await post()).statusCode).toBe(200);
|
|
265
|
+
const mismatch = logCalls.find((c) => (c[0] as { outcome?: string })?.outcome === "amount_mismatch");
|
|
266
|
+
expect(mismatch?.[0]).toEqual({
|
|
267
|
+
eventIdPrefix: "evt_test",
|
|
268
|
+
flowCheckoutIdPrefix: FLOW_CHECKOUT_ID.substring(0, 8),
|
|
269
|
+
outcome: "amount_mismatch",
|
|
270
|
+
expected: 24900,
|
|
271
|
+
received: 25000,
|
|
272
|
+
});
|
|
273
|
+
expect(stampCall().data.status).toBe("completed");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ── AC-5 — PII boundary ─────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
describe("AC-5 — PII boundary", () => {
|
|
280
|
+
it.each([
|
|
281
|
+
["the AC-1 event", sessionEvent("checkout.session.completed"), null],
|
|
282
|
+
["the AC-4 mismatch event", sessionEvent("checkout.session.completed", { amount_total: 25000 }), null],
|
|
283
|
+
[
|
|
284
|
+
"the stamp_failed branch",
|
|
285
|
+
sessionEvent("checkout.session.completed"),
|
|
286
|
+
new Error(`connection refused for ${EMAIL} at ${CUSTOMER_ID}`),
|
|
287
|
+
],
|
|
288
|
+
])("every log line for %s carries no email, customer_details, permalink, or a full cus_/cs_ id", async (_name, event, rejection) => {
|
|
289
|
+
if (rejection) (prisma.flowCheckout.updateMany as jest.Mock).mockRejectedValue(rejection);
|
|
290
|
+
mockVerifyWebhookSignature.mockReturnValue(event);
|
|
291
|
+
expect((await post()).statusCode).toBe(200);
|
|
292
|
+
const ALLOWED = new Set(["eventIdPrefix", "flowCheckoutIdPrefix", "outcome", "expected", "received", "errName"]);
|
|
293
|
+
const stampLines = logCalls.filter((c) => typeof c[0] === "object" && c[0] !== null && "flowCheckoutIdPrefix" in c[0]);
|
|
294
|
+
expect(stampLines.length).toBeGreaterThan(0);
|
|
295
|
+
for (const line of stampLines) {
|
|
296
|
+
for (const key of Object.keys(line[0] as object)) expect(ALLOWED.has(key)).toBe(true);
|
|
297
|
+
}
|
|
298
|
+
if (rejection) {
|
|
299
|
+
const failed = stampLines.find((c) => (c[0] as { outcome?: string }).outcome === "stamp_failed");
|
|
300
|
+
expect(failed?.[0]).toEqual({
|
|
301
|
+
eventIdPrefix: "evt_test",
|
|
302
|
+
flowCheckoutIdPrefix: FLOW_CHECKOUT_ID.substring(0, 8),
|
|
303
|
+
outcome: "stamp_failed",
|
|
304
|
+
errName: "Error",
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
for (const line of logCalls) {
|
|
308
|
+
const text = JSON.stringify(line);
|
|
309
|
+
expect(text).not.toContain(EMAIL);
|
|
310
|
+
expect(text).not.toContain("customer_details");
|
|
311
|
+
expect(text).not.toContain(PERMALINK);
|
|
312
|
+
for (const token of text.match(/\b(?:cus|cs)_[A-Za-z0-9_]+/g) ?? []) expect(token.length).toBeLessThanOrEqual(8);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/webhooks/stripe — org-scoped billing path (#3601, spec AC2/AC4)
|
|
3
|
+
*
|
|
4
|
+
* Covers the Organization billing-link branch keyed on
|
|
5
|
+
* metadata.organizationId (spec D2): a checkout with organizationId links the
|
|
6
|
+
* Organization (real org AND individual-as-default-org), idempotently; a
|
|
7
|
+
* checkout WITHOUT organizationId takes the User path and never touches the
|
|
8
|
+
* Organization (single-tenant, degrade-clean regression floor).
|
|
9
|
+
*
|
|
10
|
+
* Companion to stripe.test.ts (the User-path tests, which stay unmodified —
|
|
11
|
+
* AC4 regression floor).
|
|
12
|
+
*/
|
|
13
|
+
import type { FastifyInstance } from "fastify";
|
|
14
|
+
import Fastify from "fastify";
|
|
15
|
+
|
|
16
|
+
// ── Mocks (mirror stripe.test.ts) ────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const mockTrack = jest.fn().mockResolvedValue(undefined);
|
|
19
|
+
jest.mock("@working-theory/analytics/server", () => ({
|
|
20
|
+
track: (...args: unknown[]) => mockTrack(...args),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
24
|
+
jest.mock("../../../lib/prisma.js");
|
|
25
|
+
|
|
26
|
+
const mockVerifyWebhookSignature = jest.fn();
|
|
27
|
+
class MockStripeWebhookSignatureError extends Error {
|
|
28
|
+
constructor(message: string) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "StripeWebhookSignatureError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
jest.mock("@working-theory/stripe-client", () => ({
|
|
34
|
+
verifyWebhookSignature: (...args: unknown[]) =>
|
|
35
|
+
mockVerifyWebhookSignature(...args),
|
|
36
|
+
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
37
|
+
assertSandboxKey: jest.fn(),
|
|
38
|
+
createCheckoutSession: jest.fn(),
|
|
39
|
+
getStripeClient: jest.fn(),
|
|
40
|
+
STRIPE_API_VERSION: "2024-06-20",
|
|
41
|
+
StripeConfigError: class StripeConfigError extends Error {},
|
|
42
|
+
UnknownProductCodeError: class UnknownProductCodeError extends Error {},
|
|
43
|
+
WEBHOOK_TOLERANCE_SECONDS: 300,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import * as configModule from "../../../config/index.js";
|
|
47
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
48
|
+
import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
|
|
49
|
+
import { stripeWebhookHandler } from "../stripe.js";
|
|
50
|
+
|
|
51
|
+
// The sentinel default Organization id (orgs-of-one billing anchor).
|
|
52
|
+
const DEFAULT_ORG_ID = "org_00000000-0000-7000-8000-000000000001";
|
|
53
|
+
const REAL_ORG_ID = "org_00000000-0000-7000-8000-0000000000ab";
|
|
54
|
+
|
|
55
|
+
const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
|
|
56
|
+
const VALID_SIGNATURE = "t=1234,v1=abc";
|
|
57
|
+
|
|
58
|
+
async function buildTestServer(): Promise<FastifyInstance> {
|
|
59
|
+
const server = Fastify({ logger: false });
|
|
60
|
+
await server.register(async (scope) => {
|
|
61
|
+
scope.addContentTypeParser(
|
|
62
|
+
"application/json",
|
|
63
|
+
{ parseAs: "buffer" },
|
|
64
|
+
(_req, body, done) => done(null, body),
|
|
65
|
+
);
|
|
66
|
+
scope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
67
|
+
});
|
|
68
|
+
await server.ready();
|
|
69
|
+
return server;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkoutEvent(
|
|
73
|
+
id: string,
|
|
74
|
+
metadata: Record<string, string> | null,
|
|
75
|
+
customer = "cus_test_org_123",
|
|
76
|
+
) {
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
type: "checkout.session.completed",
|
|
80
|
+
data: {
|
|
81
|
+
object: {
|
|
82
|
+
id: "cs_test_org",
|
|
83
|
+
amount_total: 9900,
|
|
84
|
+
currency: "usd",
|
|
85
|
+
customer,
|
|
86
|
+
customer_details: { email: "org-admin@example.com" },
|
|
87
|
+
metadata,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fire(server: FastifyInstance) {
|
|
94
|
+
return server.inject({
|
|
95
|
+
method: "POST",
|
|
96
|
+
url: "/v1/webhooks/stripe",
|
|
97
|
+
payload: VALID_BODY,
|
|
98
|
+
headers: {
|
|
99
|
+
"content-type": "application/json",
|
|
100
|
+
"stripe-signature": VALID_SIGNATURE,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("POST /api/webhooks/stripe — org-scoped billing (#3601)", () => {
|
|
106
|
+
let server: FastifyInstance;
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
jest.clearAllMocks();
|
|
110
|
+
__resetIdempotencyStore();
|
|
111
|
+
jest.replaceProperty(configModule, "config", {
|
|
112
|
+
...configModule.config,
|
|
113
|
+
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
114
|
+
});
|
|
115
|
+
server = await buildTestServer();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
await server.close();
|
|
120
|
+
jest.restoreAllMocks();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// AC2 — a real org checkout links the Organization by id; replay is a no-op.
|
|
124
|
+
it("links the Organization on checkout with metadata.organizationId, idempotent on replay", async () => {
|
|
125
|
+
const customerId = "cus_test_org_123";
|
|
126
|
+
|
|
127
|
+
// First fire: no prior composite link → update the target org.
|
|
128
|
+
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
129
|
+
(prisma.organization.update as jest.Mock).mockResolvedValueOnce({
|
|
130
|
+
id: REAL_ORG_ID,
|
|
131
|
+
billingProvider: "stripe",
|
|
132
|
+
billingCustomerId: customerId,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
136
|
+
checkoutEvent("evt_org_001", { organizationId: REAL_ORG_ID }, customerId),
|
|
137
|
+
);
|
|
138
|
+
const r1 = await fire(server);
|
|
139
|
+
expect(r1.statusCode).toBe(200);
|
|
140
|
+
expect(prisma.organization.update).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(prisma.organization.update).toHaveBeenCalledWith(
|
|
142
|
+
expect.objectContaining({
|
|
143
|
+
where: { id: REAL_ORG_ID },
|
|
144
|
+
data: expect.objectContaining({
|
|
145
|
+
billingProvider: "stripe",
|
|
146
|
+
billingCustomerId: customerId,
|
|
147
|
+
}),
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
// The User path is never taken for an org checkout.
|
|
151
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
152
|
+
|
|
153
|
+
// Replay the SAME event id → idempotency store short-circuits, no 2nd write.
|
|
154
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
155
|
+
checkoutEvent("evt_org_001", { organizationId: REAL_ORG_ID }, customerId),
|
|
156
|
+
);
|
|
157
|
+
const r2 = await fire(server);
|
|
158
|
+
expect(r2.statusCode).toBe(200);
|
|
159
|
+
expect(prisma.organization.update).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// AC2 — typed-composite match already pointing at this org → no-op (a fresh
|
|
163
|
+
// event id, but the customer is already linked to the target org).
|
|
164
|
+
it("is a no-op when the org is already linked to this customer (typed-composite match)", async () => {
|
|
165
|
+
const customerId = "cus_already_linked";
|
|
166
|
+
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce({
|
|
167
|
+
id: REAL_ORG_ID,
|
|
168
|
+
billingProvider: "stripe",
|
|
169
|
+
billingCustomerId: customerId,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
173
|
+
checkoutEvent("evt_org_002", { organizationId: REAL_ORG_ID }, customerId),
|
|
174
|
+
);
|
|
175
|
+
const r = await fire(server);
|
|
176
|
+
expect(r.statusCode).toBe(200);
|
|
177
|
+
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// AC2 — an individual is billed on their sentinel default Organization
|
|
181
|
+
// (orgs-of-one): the SAME org path, targeting DEFAULT_ORG_ID.
|
|
182
|
+
it("bills an individual on their sentinel default Organization (orgs-of-one)", async () => {
|
|
183
|
+
const customerId = "cus_individual_xyz";
|
|
184
|
+
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
185
|
+
(prisma.organization.update as jest.Mock).mockResolvedValueOnce({
|
|
186
|
+
id: DEFAULT_ORG_ID,
|
|
187
|
+
billingProvider: "stripe",
|
|
188
|
+
billingCustomerId: customerId,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
192
|
+
checkoutEvent(
|
|
193
|
+
"evt_org_003",
|
|
194
|
+
{ organizationId: DEFAULT_ORG_ID },
|
|
195
|
+
customerId,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
const r = await fire(server);
|
|
199
|
+
expect(r.statusCode).toBe(200);
|
|
200
|
+
expect(prisma.organization.update).toHaveBeenCalledWith(
|
|
201
|
+
expect.objectContaining({ where: { id: DEFAULT_ORG_ID } }),
|
|
202
|
+
);
|
|
203
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// AC4 — a checkout WITHOUT organizationId takes the User path unchanged; the
|
|
207
|
+
// Organization is never touched (single-tenant degrade-clean regression).
|
|
208
|
+
it("takes the User path (never touches Organization) when no organizationId is present", async () => {
|
|
209
|
+
const customerId = "cus_single_tenant";
|
|
210
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
211
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({
|
|
212
|
+
id: "usr_st",
|
|
213
|
+
email: "org-admin@example.com",
|
|
214
|
+
billingProvider: null,
|
|
215
|
+
billingCustomerId: null,
|
|
216
|
+
});
|
|
217
|
+
(prisma.user.update as jest.Mock).mockResolvedValueOnce({ id: "usr_st" });
|
|
218
|
+
|
|
219
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
220
|
+
checkoutEvent("evt_st_004", { productId: "pro" }, customerId),
|
|
221
|
+
);
|
|
222
|
+
const r = await fire(server);
|
|
223
|
+
expect(r.statusCode).toBe(200);
|
|
224
|
+
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(prisma.organization.findFirst).not.toHaveBeenCalled();
|
|
226
|
+
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// customer.created also routes on metadata.organizationId.
|
|
230
|
+
it("links the Organization on customer.created with metadata.organizationId", async () => {
|
|
231
|
+
const customerId = "cus_created_org";
|
|
232
|
+
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
233
|
+
(prisma.organization.update as jest.Mock).mockResolvedValueOnce({
|
|
234
|
+
id: REAL_ORG_ID,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
mockVerifyWebhookSignature.mockReturnValue({
|
|
238
|
+
id: "evt_cust_005",
|
|
239
|
+
type: "customer.created",
|
|
240
|
+
data: {
|
|
241
|
+
object: {
|
|
242
|
+
id: customerId,
|
|
243
|
+
email: "org-admin@example.com",
|
|
244
|
+
metadata: { organizationId: REAL_ORG_ID },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
const r = await fire(server);
|
|
249
|
+
expect(r.statusCode).toBe(200);
|
|
250
|
+
expect(prisma.organization.update).toHaveBeenCalledWith(
|
|
251
|
+
expect.objectContaining({ where: { id: REAL_ORG_ID } }),
|
|
252
|
+
);
|
|
253
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// A DB failure on the org link never throws (200 still returned).
|
|
257
|
+
it("returns 200 even when the org billing update throws", async () => {
|
|
258
|
+
(prisma.organization.findFirst as jest.Mock).mockResolvedValueOnce(null);
|
|
259
|
+
(prisma.organization.update as jest.Mock).mockRejectedValueOnce(
|
|
260
|
+
new Error("db unreachable"),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
264
|
+
checkoutEvent("evt_org_006", { organizationId: REAL_ORG_ID }),
|
|
265
|
+
);
|
|
266
|
+
const r = await fire(server);
|
|
267
|
+
expect(r.statusCode).toBe(200);
|
|
268
|
+
expect(JSON.parse(r.body)).toEqual({ received: true });
|
|
269
|
+
});
|
|
270
|
+
});
|