create-ailk 0.1.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/LICENSE +201 -0
- package/README.md +39 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +80 -0
- package/dist/copy.d.ts +8 -0
- package/dist/copy.js +109 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/lib/pro-callout.d.ts +12 -0
- package/dist/lib/pro-callout.js +20 -0
- package/dist/parse-args.d.ts +13 -0
- package/dist/parse-args.js +81 -0
- package/dist/programmatic.d.ts +49 -0
- package/dist/programmatic.js +89 -0
- package/dist/substitute.d.ts +31 -0
- package/dist/substitute.js +45 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.js +70 -0
- package/package.json +47 -0
- package/templates/.claude/.gitkeep +0 -0
- package/templates/.claude/agents/web.md +80 -0
- package/templates/.claude/rules/architecture.md +62 -0
- package/templates/.claude/rules/preserve-existing-style.md +51 -0
- package/templates/.claude/skills/README.md +43 -0
- package/templates/.claude/skills/add-schema/SKILL.md +270 -0
- package/templates/.claude/skills/add-schema/references/conflict-handling.md +200 -0
- package/templates/.claude/skills/add-schema/references/file-finding.md +123 -0
- package/templates/.claude/skills/add-schema/references/schema-types.md +131 -0
- package/templates/.claude/skills/audit-site/SKILL.md +213 -0
- package/templates/.claude/skills/audit-site/references/common-errors.md +113 -0
- package/templates/.claude/skills/audit-site/references/findings-glossary.md +138 -0
- package/templates/.claude/skills/audit-site/references/severity-levels.md +114 -0
- package/templates/.claude/skills/provision-config/SKILL.md +243 -0
- package/templates/.claude/skills/provision-config/references/env-vars-template.md +184 -0
- package/templates/.claude/skills/provision-config/references/runbook-template.md +248 -0
- package/templates/.claude/skills/provision-config/references/vercel-json-template.md +98 -0
- package/templates/.claude/skills/scaffold-auth/SKILL.md +261 -0
- package/templates/.claude/skills/scaffold-auth/references/env-vars.md +91 -0
- package/templates/.claude/skills/scaffold-auth/references/oauth-app-setup.md +171 -0
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +310 -0
- package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +71 -0
- package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +83 -0
- package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +72 -0
- package/templates/.claude/skills/scaffold-page/SKILL.md +243 -0
- package/templates/.claude/skills/scaffold-page/references/page-types.md +169 -0
- package/templates/.claude/skills/scaffold-page/templates/content.mdx.tmpl +28 -0
- package/templates/.claude/skills/scaffold-page/templates/route.tsx.tmpl +28 -0
- package/templates/.claude/skills/suggest-site-pages/SKILL.md +242 -0
- package/templates/.claude/skills/suggest-site-pages/references/categories.md +287 -0
- package/templates/.env.example +205 -0
- package/templates/.gitkeep +2 -0
- package/templates/CLAUDE.md +142 -0
- package/templates/CONVENTIONS.md +222 -0
- package/templates/README.md +110 -0
- package/templates/apps/api/.env.example +40 -0
- package/templates/apps/api/CLAUDE.md +63 -0
- package/templates/apps/api/api/index.ts +144 -0
- package/templates/apps/api/jest.config.cjs +74 -0
- package/templates/apps/api/package.json +54 -0
- package/templates/apps/api/src/__tests__/factories/content-fixture.ts +138 -0
- package/templates/apps/api/src/__tests__/factories/index.ts +70 -0
- package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +82 -0
- package/templates/apps/api/src/__tests__/index.test.ts +118 -0
- package/templates/apps/api/src/__tests__/server.test.ts +207 -0
- package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +126 -0
- package/templates/apps/api/src/__tests__/setup.ts +24 -0
- package/templates/apps/api/src/cli.ts +30 -0
- package/templates/apps/api/src/config/__tests__/index.test.ts +97 -0
- package/templates/apps/api/src/config/index.ts +56 -0
- package/templates/apps/api/src/index.ts +32 -0
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +15 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +45 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +178 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +100 -0
- package/templates/apps/api/src/lib/__tests__/errors.test.ts +94 -0
- package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +144 -0
- package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +8 -0
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +227 -0
- package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/prisma.test.ts +165 -0
- package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +129 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +74 -0
- package/templates/apps/api/src/lib/content-adapter.ts +86 -0
- package/templates/apps/api/src/lib/error-serializer.ts +77 -0
- package/templates/apps/api/src/lib/errors.ts +95 -0
- package/templates/apps/api/src/lib/file-fallback.ts +87 -0
- package/templates/apps/api/src/lib/lead-capture-store.ts +110 -0
- package/templates/apps/api/src/lib/locales.ts +201 -0
- package/templates/apps/api/src/lib/outbound-http.ts +122 -0
- package/templates/apps/api/src/lib/prisma.ts +21 -0
- package/templates/apps/api/src/lib/require-paid-tier.ts +53 -0
- package/templates/apps/api/src/lib/stripe.ts +30 -0
- package/templates/apps/api/src/lib/webhook-idempotency.ts +92 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +111 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +172 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +119 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +138 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +149 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +139 -0
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +175 -0
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +76 -0
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +153 -0
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +72 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +292 -0
- package/templates/apps/api/src/middleware/auth.ts +246 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +132 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +129 -0
- package/templates/apps/api/src/openapi/index.ts +12 -0
- package/templates/apps/api/src/openapi/spec.ts +408 -0
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +193 -0
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +201 -0
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +294 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +486 -0
- package/templates/apps/api/src/routes/auth/tokens.ts +199 -0
- package/templates/apps/api/src/routes/auth/user.ts +244 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +588 -0
- package/templates/apps/api/src/routes/checkout/index.ts +13 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +185 -0
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +239 -0
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +201 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +141 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +122 -0
- package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +44 -0
- package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +118 -0
- package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +51 -0
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +252 -0
- package/templates/apps/api/src/routes/content/create.ts +114 -0
- package/templates/apps/api/src/routes/content/delete.ts +81 -0
- package/templates/apps/api/src/routes/content/get-page.ts +98 -0
- package/templates/apps/api/src/routes/content/index.ts +32 -0
- package/templates/apps/api/src/routes/content/list-pages.ts +83 -0
- package/templates/apps/api/src/routes/content/list-types.ts +19 -0
- package/templates/apps/api/src/routes/content/rate-limit.ts +13 -0
- package/templates/apps/api/src/routes/content/slug.ts +9 -0
- package/templates/apps/api/src/routes/content/update.ts +111 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +415 -0
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +165 -0
- package/templates/apps/api/src/routes/leads/index.ts +130 -0
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +296 -0
- package/templates/apps/api/src/routes/test/auth/session.ts +141 -0
- package/templates/apps/api/src/routes/webhooks/README.md +57 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +247 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +198 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +633 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +41 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +253 -0
- package/templates/apps/api/src/server.ts +157 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +395 -0
- package/templates/apps/api/src/services/enrichment-shim.ts +53 -0
- package/templates/apps/api/src/services/lead-routing.ts +338 -0
- package/templates/apps/api/tsconfig.json +34 -0
- package/templates/apps/api/vercel.json +17 -0
- package/templates/apps/mcp/.env.example +52 -0
- package/templates/apps/mcp/CLAUDE.md +84 -0
- package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +52 -0
- package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +46 -0
- package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +36 -0
- package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +54 -0
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +110 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +213 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +217 -0
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +52 -0
- package/templates/apps/mcp/__tests__/create_page.test.ts +201 -0
- package/templates/apps/mcp/__tests__/delete_page.test.ts +176 -0
- package/templates/apps/mcp/__tests__/get_business_hours.test.ts +82 -0
- package/templates/apps/mcp/__tests__/get_faq.test.ts +80 -0
- package/templates/apps/mcp/__tests__/get_locations.test.ts +65 -0
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +90 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +80 -0
- package/templates/apps/mcp/__tests__/get_site_info.test.ts +81 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +323 -0
- package/templates/apps/mcp/__tests__/lib/errors.test.ts +75 -0
- package/templates/apps/mcp/__tests__/lib/require-paid-tier.test.ts +75 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +112 -0
- package/templates/apps/mcp/__tests__/locale.test.ts +135 -0
- package/templates/apps/mcp/__tests__/parity.test.ts +205 -0
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +34 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +153 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +163 -0
- package/templates/apps/mcp/__tests__/search_content.test.ts +153 -0
- package/templates/apps/mcp/__tests__/server.test.ts +115 -0
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +165 -0
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +161 -0
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +227 -0
- package/templates/apps/mcp/__tests__/tools/scaffold.test.ts +237 -0
- package/templates/apps/mcp/__tests__/update_page.test.ts +195 -0
- package/templates/apps/mcp/jest.config.cjs +82 -0
- package/templates/apps/mcp/package.json +37 -0
- package/templates/apps/mcp/src/abuse/daily-cap.ts +47 -0
- package/templates/apps/mcp/src/abuse/email-validator.ts +25 -0
- package/templates/apps/mcp/src/abuse/honeypot.ts +21 -0
- package/templates/apps/mcp/src/abuse/index.ts +111 -0
- package/templates/apps/mcp/src/abuse/user-agent-deny.ts +58 -0
- package/templates/apps/mcp/src/http-transport.ts +303 -0
- package/templates/apps/mcp/src/index.ts +67 -0
- package/templates/apps/mcp/src/lib/audit-single-page.ts +194 -0
- package/templates/apps/mcp/src/lib/errors.ts +61 -0
- package/templates/apps/mcp/src/lib/locales.ts +171 -0
- package/templates/apps/mcp/src/lib/require-paid-tier.ts +44 -0
- package/templates/apps/mcp/src/lib/resolve-locale.ts +30 -0
- package/templates/apps/mcp/src/server.ts +139 -0
- package/templates/apps/mcp/src/tools/audit.ts +103 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +52 -0
- package/templates/apps/mcp/src/tools/create_page.ts +95 -0
- package/templates/apps/mcp/src/tools/delete_page.ts +73 -0
- package/templates/apps/mcp/src/tools/get_business_hours.ts +66 -0
- package/templates/apps/mcp/src/tools/get_faq.ts +69 -0
- package/templates/apps/mcp/src/tools/get_locations.ts +85 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +66 -0
- package/templates/apps/mcp/src/tools/get_pricing.ts +74 -0
- package/templates/apps/mcp/src/tools/get_site_info.ts +84 -0
- package/templates/apps/mcp/src/tools/index.ts +125 -0
- package/templates/apps/mcp/src/tools/list_pages.ts +72 -0
- package/templates/apps/mcp/src/tools/purchase.ts +89 -0
- package/templates/apps/mcp/src/tools/read_content.ts +61 -0
- package/templates/apps/mcp/src/tools/request_callback.ts +51 -0
- package/templates/apps/mcp/src/tools/scaffold.ts +119 -0
- package/templates/apps/mcp/src/tools/search_content.ts +59 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +47 -0
- package/templates/apps/mcp/src/tools/update_page.ts +91 -0
- package/templates/apps/mcp/tsconfig.json +19 -0
- package/templates/apps/web/.env.example +47 -0
- package/templates/apps/web/CLAUDE.md +81 -0
- package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +94 -0
- package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +75 -0
- package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +82 -0
- package/templates/apps/web/__tests__/auth/env-helpers.test.ts +168 -0
- package/templates/apps/web/__tests__/authority-list-routes.test.tsx +208 -0
- package/templates/apps/web/__tests__/better-stack.test.ts +66 -0
- package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +87 -0
- package/templates/apps/web/__tests__/blog-source-populated.test.ts +130 -0
- package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +60 -0
- package/templates/apps/web/__tests__/google-analytics.test.tsx +104 -0
- package/templates/apps/web/__tests__/i18n/middleware.test.ts +119 -0
- package/templates/apps/web/__tests__/i18n/routing.test.ts +114 -0
- package/templates/apps/web/__tests__/i18n/translations.test.ts +191 -0
- package/templates/apps/web/__tests__/json-ld.test.ts +98 -0
- package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +580 -0
- package/templates/apps/web/__tests__/layout-stealth.test.ts +39 -0
- package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +103 -0
- package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +114 -0
- package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +94 -0
- package/templates/apps/web/__tests__/page.test.tsx +230 -0
- package/templates/apps/web/__tests__/products-services-phase1.test.tsx +321 -0
- package/templates/apps/web/__tests__/projects-routes.test.tsx +392 -0
- package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +475 -0
- package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +53 -0
- package/templates/apps/web/__tests__/routes/blog-route.test.tsx +191 -0
- package/templates/apps/web/__tests__/routes/llms-txt.test.ts +123 -0
- package/templates/apps/web/__tests__/routes/robots-txt.test.ts +52 -0
- package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +78 -0
- package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +203 -0
- package/templates/apps/web/__tests__/section-routes.test.tsx +693 -0
- package/templates/apps/web/__tests__/security-headers.test.ts +201 -0
- package/templates/apps/web/__tests__/sentry-config.test.ts +116 -0
- package/templates/apps/web/__tests__/setup.ts +82 -0
- package/templates/apps/web/__tests__/sitemap-locales.test.ts +415 -0
- package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +844 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +81 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +110 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +58 -0
- package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +202 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +31 -0
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +173 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +45 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +117 -0
- package/templates/apps/web/app/[locale]/careers/page.tsx +25 -0
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +113 -0
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +25 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/clients/page.tsx +27 -0
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +130 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +152 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +92 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +79 -0
- package/templates/apps/web/app/[locale]/contact/error.tsx +24 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +38 -0
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +34 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +119 -0
- package/templates/apps/web/app/[locale]/courses/page.tsx +25 -0
- package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +160 -0
- package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +25 -0
- package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +117 -0
- package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +58 -0
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +118 -0
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +72 -0
- package/templates/apps/web/app/[locale]/downloads/page.tsx +25 -0
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +47 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +130 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +25 -0
- package/templates/apps/web/app/[locale]/faq/page.tsx +36 -0
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +34 -0
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +78 -0
- package/templates/apps/web/app/[locale]/how-to/page.tsx +40 -0
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +43 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +129 -0
- package/templates/apps/web/app/[locale]/integrations/page.tsx +25 -0
- package/templates/apps/web/app/[locale]/launch-service/page.tsx +31 -0
- package/templates/apps/web/app/[locale]/layout.tsx +241 -0
- package/templates/apps/web/app/[locale]/page.tsx +41 -0
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +113 -0
- package/templates/apps/web/app/[locale]/partners/page.tsx +25 -0
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +43 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +129 -0
- package/templates/apps/web/app/[locale]/products/page.tsx +25 -0
- package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +39 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +138 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +30 -0
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +117 -0
- package/templates/apps/web/app/[locale]/resources/page.tsx +27 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +43 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +129 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +25 -0
- package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +34 -0
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +81 -0
- package/templates/apps/web/app/[locale]/software/page.tsx +39 -0
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +34 -0
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/tech-articles/page.tsx +39 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/testimonials/page.tsx +27 -0
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/use-cases/page.tsx +27 -0
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +34 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/whitepapers/page.tsx +39 -0
- package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +152 -0
- package/templates/apps/web/app/api/auth/[...all]/route.ts +16 -0
- package/templates/apps/web/app/error-fallback.tsx +75 -0
- package/templates/apps/web/app/error.tsx +23 -0
- package/templates/apps/web/app/layout.tsx +18 -0
- package/templates/apps/web/app/llms.txt/route.ts +101 -0
- package/templates/apps/web/app/not-found.tsx +48 -0
- package/templates/apps/web/app/og/__tests__/route.test.ts +164 -0
- package/templates/apps/web/app/og/route.tsx +135 -0
- package/templates/apps/web/app/robots.txt/route.ts +36 -0
- package/templates/apps/web/app/sitemap.xml/route.ts +146 -0
- package/templates/apps/web/auth.ts +141 -0
- package/templates/apps/web/components/ThemeToggle.tsx +60 -0
- package/templates/apps/web/components/google-analytics.tsx +34 -0
- package/templates/apps/web/content/en/blog/index.mdx +15 -0
- package/templates/apps/web/content/en/blog/launching-ailk.mdx +20 -0
- package/templates/apps/web/content/en/docs/getting-started.mdx +30 -0
- package/templates/apps/web/i18n/request.ts +36 -0
- package/templates/apps/web/jest.config.cjs +100 -0
- package/templates/apps/web/lib/__mocks__/next-mdx-remote-rsc.js +12 -0
- package/templates/apps/web/lib/__mocks__/source-server.js +31 -0
- package/templates/apps/web/lib/__tests__/og.test.ts +65 -0
- package/templates/apps/web/lib/auth-env.ts +66 -0
- package/templates/apps/web/lib/blog-list-data.ts +95 -0
- package/templates/apps/web/lib/blog-post-data.ts +79 -0
- package/templates/apps/web/lib/nav-config.ts +13 -0
- package/templates/apps/web/lib/observability/better-stack.ts +27 -0
- package/templates/apps/web/lib/og.ts +23 -0
- package/templates/apps/web/lib/prisma-for-auth.ts +34 -0
- package/templates/apps/web/lib/source.ts +34 -0
- package/templates/apps/web/messages/en.json +131 -0
- package/templates/apps/web/middleware.ts +80 -0
- package/templates/apps/web/next.config.mjs +167 -0
- package/templates/apps/web/package.json +62 -0
- package/templates/apps/web/public/.well-known/security.txt +17 -0
- package/templates/apps/web/public/android-chrome-192x192.png +0 -0
- package/templates/apps/web/public/android-chrome-512x512.png +0 -0
- package/templates/apps/web/public/apple-touch-icon.png +0 -0
- package/templates/apps/web/public/favicon-16x16.png +0 -0
- package/templates/apps/web/public/favicon-32x32.png +0 -0
- package/templates/apps/web/public/favicon.ico +0 -0
- package/templates/apps/web/public/favicon.svg +4 -0
- package/templates/apps/web/public/logo-dark.svg +21 -0
- package/templates/apps/web/public/logo-light.svg +21 -0
- package/templates/apps/web/public/logomark-dark.svg +4 -0
- package/templates/apps/web/public/logomark-light.svg +4 -0
- package/templates/apps/web/public/og-default.png +0 -0
- package/templates/apps/web/public/site.webmanifest +19 -0
- package/templates/apps/web/scripts/assert-content-bundled.ts +55 -0
- package/templates/apps/web/security-headers.config.ts +132 -0
- package/templates/apps/web/sentry.client.config.ts +27 -0
- package/templates/apps/web/sentry.edge.config.ts +15 -0
- package/templates/apps/web/sentry.server.config.ts +20 -0
- package/templates/apps/web/source.config.ts +78 -0
- package/templates/apps/web/src/components/SiteNav.tsx +21 -0
- package/templates/apps/web/src/lib/routes.ts +37 -0
- package/templates/apps/web/tsconfig.json +35 -0
- package/templates/apps/web/types/messages.d.ts +19 -0
- package/templates/content/.gitkeep +0 -0
- package/templates/content/CLAUDE.md +85 -0
- package/templates/content/_site.mdx +38 -0
- package/templates/content/ar/HomePage/home.mdx +123 -0
- package/templates/content/ar/_site.mdx +35 -0
- package/templates/content/de/_site.mdx +29 -0
- package/templates/content/en/.gitkeep +0 -0
- package/templates/content/en/AboutPage/about.mdx +19 -0
- package/templates/content/en/AboutPage/about.schema.json +82 -0
- package/templates/content/en/BookCall/book-call.mdx +19 -0
- package/templates/content/en/BookCall/book-call.schema.json +121 -0
- package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +20 -0
- package/templates/content/en/CareersList/careers.mdx +24 -0
- package/templates/content/en/CaseStudiesList/case-studies.mdx +22 -0
- package/templates/content/en/CaseStudiesList/showcase.mdx +8 -0
- package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +32 -0
- package/templates/content/en/CaseStudy/schema-builder-migration.mdx +41 -0
- package/templates/content/en/ClientsList/clients.mdx +38 -0
- package/templates/content/en/ContactPage/contact.mdx +19 -0
- package/templates/content/en/ContactPage/contact.schema.json +100 -0
- package/templates/content/en/ContactPoint/primary.mdx +14 -0
- package/templates/content/en/Course/aeo-fundamentals.mdx +37 -0
- package/templates/content/en/Course/schema-builder-workshop.mdx +62 -0
- package/templates/content/en/CoursesList/courses.mdx +22 -0
- package/templates/content/en/DownloadsList/downloads.mdx +22 -0
- package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +39 -0
- package/templates/content/en/Event/ailk-launch-day-2026.mdx +35 -0
- package/templates/content/en/EventsList/events.mdx +26 -0
- package/templates/content/en/FAQPage/faq.mdx +420 -0
- package/templates/content/en/FAQPage/faq.schema.json +231 -0
- package/templates/content/en/GlossaryList/glossary.mdx +19 -0
- package/templates/content/en/GlossaryList/glossary.schema.json +300 -0
- package/templates/content/en/HomePage/home.mdx +312 -0
- package/templates/content/en/HomePage/home.schema.json +88 -0
- package/templates/content/en/HomePage/launch-service.mdx +475 -0
- package/templates/content/en/HomePage/launch-service.schema.json +138 -0
- package/templates/content/en/HowTo/add-mdx-content.mdx +38 -0
- package/templates/content/en/HowTo/configure-aeo-scoring.mdx +35 -0
- package/templates/content/en/HowTo/set-up-json-ld.mdx +36 -0
- package/templates/content/en/Integration/github-actions.mdx +31 -0
- package/templates/content/en/Integration/openai-api.mdx +26 -0
- package/templates/content/en/Integration/vercel.mdx +32 -0
- package/templates/content/en/IntegrationsList/integrations.mdx +22 -0
- package/templates/content/en/JobPosting/content-strategist.mdx +46 -0
- package/templates/content/en/JobPosting/developer-advocate.mdx +47 -0
- package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +57 -0
- package/templates/content/en/LandingPage/aeo-audit.mdx +19 -0
- package/templates/content/en/LandingPage/aeo-audit.schema.json +92 -0
- package/templates/content/en/LandingPage/agency-waitlist.mdx +340 -0
- package/templates/content/en/LandingPage/agency-waitlist.schema.json +152 -0
- package/templates/content/en/LandingPage/developers.mdx +19 -0
- package/templates/content/en/LandingPage/developers.schema.json +109 -0
- package/templates/content/en/LandingPage/for-agencies.mdx +390 -0
- package/templates/content/en/LandingPage/for-agencies.schema.json +107 -0
- package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +417 -0
- package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +105 -0
- package/templates/content/en/LandingPage/for-businesses.mdx +19 -0
- package/templates/content/en/LandingPage/for-businesses.schema.json +96 -0
- package/templates/content/en/LandingPage/platform.mdx +369 -0
- package/templates/content/en/LandingPage/platform.schema.json +166 -0
- package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +19 -0
- package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +102 -0
- package/templates/content/en/LandingPage/vs-wordpress.mdx +19 -0
- package/templates/content/en/LandingPage/vs-wordpress.schema.json +53 -0
- package/templates/content/en/NewsletterSignup/newsletter.mdx +236 -0
- package/templates/content/en/NewsletterSignup/newsletter.schema.json +91 -0
- package/templates/content/en/Organization/site-owner.mdx +16 -0
- package/templates/content/en/Partner/prisma.mdx +31 -0
- package/templates/content/en/Partner/supabase.mdx +30 -0
- package/templates/content/en/Partner/vercel.mdx +28 -0
- package/templates/content/en/PartnersList/partners.mdx +22 -0
- package/templates/content/en/Person/founder.mdx +18 -0
- package/templates/content/en/Pricing/pricing.mdx +538 -0
- package/templates/content/en/Pricing/pricing.schema.json +209 -0
- package/templates/content/en/PrivacyPage/privacy.mdx +165 -0
- package/templates/content/en/PrivacyPage/privacy.schema.json +64 -0
- package/templates/content/en/Product/ailk.mdx +19 -0
- package/templates/content/en/Product/ailk.schema.json +94 -0
- package/templates/content/en/Product/api-launch-kit.mdx +26 -0
- package/templates/content/en/Product/launch-kit.mdx +28 -0
- package/templates/content/en/ProductsList/products.mdx +26 -0
- package/templates/content/en/Project/ailk-phase-2.mdx +54 -0
- package/templates/content/en/ProjectsList/projects.mdx +25 -0
- package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +27 -0
- package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +28 -0
- package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +39 -0
- package/templates/content/en/ResourcesHub/resources.mdx +22 -0
- package/templates/content/en/Service/consulting.mdx +22 -0
- package/templates/content/en/Service/implementation.mdx +30 -0
- package/templates/content/en/Service/launch-service.mdx +232 -0
- package/templates/content/en/Service/launch.mdx +228 -0
- package/templates/content/en/ServicesList/services.mdx +84 -0
- package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +44 -0
- package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +47 -0
- package/templates/content/en/SoftwareProduct/ailk-schema.mdx +49 -0
- package/templates/content/en/TeamList/team.mdx +63 -0
- package/templates/content/en/TeamMember/founder.mdx +72 -0
- package/templates/content/en/TermsPage/terms.mdx +19 -0
- package/templates/content/en/TermsPage/terms.schema.json +99 -0
- package/templates/content/en/TestimonialsList/testimonials.mdx +44 -0
- package/templates/content/en/UseCasesList/use-cases.mdx +30 -0
- package/templates/content/en/Whitepaper/ax-first-product-development.mdx +27 -0
- package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +27 -0
- package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +31 -0
- package/templates/content/en-XA/AboutPage/about.mdx +8 -0
- package/templates/content/en-XA/FAQPage/faq.mdx +8 -0
- package/templates/content/en-XA/HomePage/home.mdx +8 -0
- package/templates/content/es/_site.mdx +30 -0
- package/templates/content/fr/_site.mdx +30 -0
- package/templates/content/ja/_site.mdx +30 -0
- package/templates/content/pt-BR/AboutPage/about.mdx +8 -0
- package/templates/content/pt-BR/FAQPage/faq.mdx +8 -0
- package/templates/content/pt-BR/HomePage/home.mdx +23 -0
- package/templates/database/CHANGELOG.md +5 -0
- package/templates/database/README.md +234 -0
- package/templates/database/__tests__/user-cascade.test.ts +125 -0
- package/templates/database/auth/.gitkeep +0 -0
- package/templates/database/config/.gitkeep +0 -0
- package/templates/database/content/.gitkeep +0 -0
- package/templates/database/inbox/.gitkeep +0 -0
- package/templates/database/inbox/README.md +3 -0
- package/templates/database/inbox/__tests__/schema.test.ts +40 -0
- package/templates/database/inbox/schema.prisma +52 -0
- package/templates/database/jest.config.cjs +29 -0
- package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +328 -0
- package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +100 -0
- package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +28 -0
- package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +25 -0
- package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +42 -0
- package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +67 -0
- package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +21 -0
- package/templates/database/migrations/migration_lock.toml +3 -0
- package/templates/database/ops/.gitkeep +0 -0
- package/templates/database/package.json +45 -0
- package/templates/database/prisma.config.ts +20 -0
- package/templates/database/schema.prisma +487 -0
- package/templates/database/scripts/db-generate-locked.sh +80 -0
- package/templates/database/seed-test.ts +85 -0
- package/templates/database/tsconfig.build.json +16 -0
- package/templates/database/tsconfig.json +9 -0
- package/templates/package.json +125 -0
- package/templates/pnpm-workspace.yaml +6 -0
- package/templates/project.yaml +59 -0
- package/templates/tsconfig.base.json +18 -0
- package/templates/tsconfig.json +45 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leads route group.
|
|
3
|
+
*
|
|
4
|
+
* Routes:
|
|
5
|
+
* POST /v1/leads/submit — lead submission (contact form / inquiry)
|
|
6
|
+
* POST /v1/leads/subscribe — newsletter subscription
|
|
7
|
+
* POST /v1/leads/callback — callback request (phone-back)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { track } from "@working-theory/analytics/server";
|
|
11
|
+
import type { FastifyPluginAsync } from "fastify";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type LeadKind,
|
|
15
|
+
type RouteLeadInput,
|
|
16
|
+
routeLead,
|
|
17
|
+
} from "../../services/lead-routing.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a typed RouteLeadInput from the raw request body for a given kind.
|
|
21
|
+
* Explicit destructure per kind prevents unknown fields (e.g. attacker-supplied
|
|
22
|
+
* `id`) from reaching the service — the service's .strict() Zod schemas provide
|
|
23
|
+
* a second layer, but we also keep the handler clean at the boundary.
|
|
24
|
+
*/
|
|
25
|
+
function buildInput(
|
|
26
|
+
kind: LeadKind,
|
|
27
|
+
body: Record<string, unknown>,
|
|
28
|
+
): RouteLeadInput {
|
|
29
|
+
if (kind === "lead") {
|
|
30
|
+
return {
|
|
31
|
+
kind,
|
|
32
|
+
siteId: body["siteId"] as string,
|
|
33
|
+
source: body["source"] as string,
|
|
34
|
+
email: body["email"] as string,
|
|
35
|
+
payload: (body["payload"] ?? {}) as Record<string, unknown>,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (kind === "subscribe") {
|
|
39
|
+
return {
|
|
40
|
+
kind,
|
|
41
|
+
siteId: body["siteId"] as string,
|
|
42
|
+
email: body["email"] as string,
|
|
43
|
+
source: body["source"] as string,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
kind,
|
|
48
|
+
siteId: body["siteId"] as string,
|
|
49
|
+
name: body["name"] as string,
|
|
50
|
+
phone: body["phone"] as string,
|
|
51
|
+
...(body["preferredTime"] !== undefined && {
|
|
52
|
+
preferredTime: body["preferredTime"] as string,
|
|
53
|
+
}),
|
|
54
|
+
payload: (body["payload"] ?? {}) as Record<string, unknown>,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
59
|
+
return async (fastify) => {
|
|
60
|
+
fastify.post(`/v1/leads/${kind}`, async (request, reply) => {
|
|
61
|
+
const body = request.body as Record<string, unknown>;
|
|
62
|
+
const sourceMeta = {
|
|
63
|
+
ip: request.ip,
|
|
64
|
+
userAgent: request.headers["user-agent"],
|
|
65
|
+
receivedAt: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const result = await routeLead(buildInput(kind, body), sourceMeta);
|
|
69
|
+
|
|
70
|
+
if (!result.ok) {
|
|
71
|
+
if (result.error.kind === "invalid_input") {
|
|
72
|
+
return reply
|
|
73
|
+
.status(400)
|
|
74
|
+
.send({ error: "invalid_input", message: result.error.message });
|
|
75
|
+
}
|
|
76
|
+
if (result.error.kind === "persist_failed") {
|
|
77
|
+
// Do not surface internal error detail — log it server-side only
|
|
78
|
+
request.log.error(
|
|
79
|
+
{ kind, siteId: body["siteId"], error: result.error.message },
|
|
80
|
+
"lead persist_failed",
|
|
81
|
+
);
|
|
82
|
+
return reply.status(503).send({ error: "persist_failed" });
|
|
83
|
+
}
|
|
84
|
+
// config_missing — lead capture not configured (file disabled + no DB)
|
|
85
|
+
return reply.status(503).send({
|
|
86
|
+
error: "lead_capture_not_configured",
|
|
87
|
+
message: "Lead capture is not configured.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// PII-safe structured log: only routing metadata, no email/phone/name/payload
|
|
92
|
+
const emailKind =
|
|
93
|
+
"skipped" in result.email
|
|
94
|
+
? "skipped"
|
|
95
|
+
: result.email.ok
|
|
96
|
+
? "ok"
|
|
97
|
+
: "error";
|
|
98
|
+
request.log.info(
|
|
99
|
+
{
|
|
100
|
+
siteId: body["siteId"],
|
|
101
|
+
kind,
|
|
102
|
+
ip: sourceMeta.ip,
|
|
103
|
+
persisted: result.persisted,
|
|
104
|
+
emailKind,
|
|
105
|
+
},
|
|
106
|
+
"lead routed",
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Fire-and-forget: track() never throws; no need to hold the response.
|
|
110
|
+
void track("lead-captured", {
|
|
111
|
+
source: body["source"] as string | undefined,
|
|
112
|
+
locale: (body["payload"] as Record<string, unknown> | undefined)?.[
|
|
113
|
+
"locale"
|
|
114
|
+
] as string | undefined,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return reply
|
|
118
|
+
.status(201)
|
|
119
|
+
.send({
|
|
120
|
+
id: result.id,
|
|
121
|
+
persisted: result.persisted,
|
|
122
|
+
email: result.email,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const leadSubmitRoute = makeLeadsRoute("lead");
|
|
129
|
+
export const leadSubscribeRoute = makeLeadsRoute("subscribe");
|
|
130
|
+
export const leadCallbackRoute = makeLeadsRoute("callback");
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file session.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for the triple-gated test-auth route (#517).
|
|
5
|
+
*
|
|
6
|
+
* The route is mounted only when all three gates pass:
|
|
7
|
+
* 1. NODE_ENV !== 'production'
|
|
8
|
+
* 2. AILK_TEST_AUTH_ENABLED === 'true'
|
|
9
|
+
* 3. AILK_TEST_AUTH_USER_ALLOWLIST is non-empty
|
|
10
|
+
*
|
|
11
|
+
* If any gate fails, the route returns 404 — NEVER 401 or 403 — so the surface
|
|
12
|
+
* stays hidden in production. The single exception is allowlist-mismatch when
|
|
13
|
+
* the route IS mounted: that returns 403 (the surface is intentionally exposed
|
|
14
|
+
* in non-prod test-auth mode; rejecting a non-allowlisted userId with 403 is
|
|
15
|
+
* the documented behaviour).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import Fastify from "fastify";
|
|
19
|
+
import type { FastifyInstance } from "fastify";
|
|
20
|
+
|
|
21
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; mirrors auth.cookie-path.test.ts pattern
|
|
22
|
+
jest.mock("../../../../lib/prisma.js", () => ({
|
|
23
|
+
prisma: {
|
|
24
|
+
user: {
|
|
25
|
+
findUnique: jest.fn(),
|
|
26
|
+
},
|
|
27
|
+
session: {
|
|
28
|
+
create: jest.fn(),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import { prisma } from "../../../../lib/prisma.js";
|
|
34
|
+
import { registerTestAuthRoute, shouldMountTestAuth } from "../session.js";
|
|
35
|
+
|
|
36
|
+
const ALLOWLISTED_USER_ID = "test-user-id-001";
|
|
37
|
+
const NON_ALLOWLISTED_USER_ID = "user-not-in-allowlist";
|
|
38
|
+
|
|
39
|
+
/** Build a Fastify instance with the test-auth route registered unconditionally. */
|
|
40
|
+
function buildAppWithRoute(): FastifyInstance {
|
|
41
|
+
const app = Fastify({ logger: false });
|
|
42
|
+
app.register(registerTestAuthRoute);
|
|
43
|
+
return app;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build a Fastify instance with NO test-auth route registered (gate-failed path). */
|
|
47
|
+
function buildAppWithoutRoute(): FastifyInstance {
|
|
48
|
+
const app = Fastify({ logger: false });
|
|
49
|
+
return app;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("test-auth route — triple gate (shouldMountTestAuth)", () => {
|
|
53
|
+
const originalEnv = { ...process.env };
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
process.env = { ...originalEnv };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns false when AILK_TEST_AUTH_ENABLED is unset (even with NODE_ENV=test)", () => {
|
|
60
|
+
process.env.NODE_ENV = "test";
|
|
61
|
+
delete process.env.AILK_TEST_AUTH_ENABLED;
|
|
62
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
63
|
+
expect(shouldMountTestAuth()).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns false when NODE_ENV=production (even with flag + allowlist set)", () => {
|
|
67
|
+
process.env.NODE_ENV = "production";
|
|
68
|
+
process.env.AILK_TEST_AUTH_ENABLED = "true";
|
|
69
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
70
|
+
expect(shouldMountTestAuth()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns false when AILK_TEST_AUTH_USER_ALLOWLIST is empty", () => {
|
|
74
|
+
process.env.NODE_ENV = "test";
|
|
75
|
+
process.env.AILK_TEST_AUTH_ENABLED = "true";
|
|
76
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = "";
|
|
77
|
+
expect(shouldMountTestAuth()).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns false when AILK_TEST_AUTH_ENABLED is 'false' (not the literal string 'true')", () => {
|
|
81
|
+
process.env.NODE_ENV = "test";
|
|
82
|
+
process.env.AILK_TEST_AUTH_ENABLED = "false";
|
|
83
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
84
|
+
expect(shouldMountTestAuth()).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns true when all three gates pass", () => {
|
|
88
|
+
process.env.NODE_ENV = "test";
|
|
89
|
+
process.env.AILK_TEST_AUTH_ENABLED = "true";
|
|
90
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
91
|
+
expect(shouldMountTestAuth()).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("test-auth route — POST /api/test/auth/session", () => {
|
|
96
|
+
let app: FastifyInstance;
|
|
97
|
+
const originalEnv = { ...process.env };
|
|
98
|
+
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
jest.clearAllMocks();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(async () => {
|
|
104
|
+
if (app) {
|
|
105
|
+
await app.close();
|
|
106
|
+
}
|
|
107
|
+
process.env = { ...originalEnv };
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns 404 when route is NOT mounted (gate failed — AILK_TEST_AUTH_ENABLED unset)", async () => {
|
|
111
|
+
// Simulate the gate-failed registration: route plugin is simply not called.
|
|
112
|
+
process.env.NODE_ENV = "test";
|
|
113
|
+
delete process.env.AILK_TEST_AUTH_ENABLED;
|
|
114
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
115
|
+
|
|
116
|
+
app = buildAppWithoutRoute();
|
|
117
|
+
await app.ready();
|
|
118
|
+
|
|
119
|
+
const res = await app.inject({
|
|
120
|
+
method: "POST",
|
|
121
|
+
url: "/v1/test/auth/session",
|
|
122
|
+
payload: { userId: ALLOWLISTED_USER_ID },
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Fastify's default for unregistered routes is 404 — the contract this AC enforces.
|
|
127
|
+
expect(res.statusCode).toBe(404);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns 404 when NODE_ENV=production (route is NOT mounted)", async () => {
|
|
131
|
+
process.env.NODE_ENV = "production";
|
|
132
|
+
process.env.AILK_TEST_AUTH_ENABLED = "true";
|
|
133
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
134
|
+
|
|
135
|
+
// server.ts gates registration on shouldMountTestAuth(); production should
|
|
136
|
+
// skip the registration and the route should not exist.
|
|
137
|
+
app = buildAppWithoutRoute();
|
|
138
|
+
await app.ready();
|
|
139
|
+
|
|
140
|
+
const res = await app.inject({
|
|
141
|
+
method: "POST",
|
|
142
|
+
url: "/v1/test/auth/session",
|
|
143
|
+
payload: { userId: ALLOWLISTED_USER_ID },
|
|
144
|
+
headers: { "content-type": "application/json" },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(res.statusCode).toBe(404);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns 200 with { token, expiresAt, user } for an allowlisted user", async () => {
|
|
151
|
+
process.env.NODE_ENV = "test";
|
|
152
|
+
process.env.AILK_TEST_AUTH_ENABLED = "true";
|
|
153
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
154
|
+
|
|
155
|
+
const userRow = {
|
|
156
|
+
id: ALLOWLISTED_USER_ID,
|
|
157
|
+
email: "test-user@example.com",
|
|
158
|
+
name: "Test User",
|
|
159
|
+
role: "user",
|
|
160
|
+
};
|
|
161
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(userRow);
|
|
162
|
+
(prisma.session.create as jest.Mock).mockImplementation(
|
|
163
|
+
({ data }: { data: Record<string, unknown> }) =>
|
|
164
|
+
Promise.resolve({
|
|
165
|
+
id: data.id,
|
|
166
|
+
token: data.token,
|
|
167
|
+
userId: data.userId,
|
|
168
|
+
expiresAt: data.expiresAt,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
app = buildAppWithRoute();
|
|
173
|
+
await app.ready();
|
|
174
|
+
|
|
175
|
+
const res = await app.inject({
|
|
176
|
+
method: "POST",
|
|
177
|
+
url: "/v1/test/auth/session",
|
|
178
|
+
payload: { userId: ALLOWLISTED_USER_ID },
|
|
179
|
+
headers: { "content-type": "application/json" },
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(res.statusCode).toBe(200);
|
|
183
|
+
const body = JSON.parse(res.body);
|
|
184
|
+
expect(typeof body.token).toBe("string");
|
|
185
|
+
expect(body.token.length).toBeGreaterThanOrEqual(32); // sufficient entropy
|
|
186
|
+
expect(body.expiresAt).toBeDefined();
|
|
187
|
+
expect(new Date(body.expiresAt).getTime()).toBeGreaterThan(Date.now());
|
|
188
|
+
expect(body.user).toEqual(userRow);
|
|
189
|
+
|
|
190
|
+
// Session.create was called with the correct shape
|
|
191
|
+
expect(prisma.session.create).toHaveBeenCalledWith({
|
|
192
|
+
data: expect.objectContaining({
|
|
193
|
+
userId: ALLOWLISTED_USER_ID,
|
|
194
|
+
token: expect.any(String),
|
|
195
|
+
expiresAt: expect.any(Date),
|
|
196
|
+
id: expect.any(String),
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns 403 when user is not in allowlist", async () => {
|
|
202
|
+
process.env.NODE_ENV = "test";
|
|
203
|
+
process.env.AILK_TEST_AUTH_ENABLED = "true";
|
|
204
|
+
// Allowlist contains only ALLOWLISTED_USER_ID
|
|
205
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
206
|
+
|
|
207
|
+
app = buildAppWithRoute();
|
|
208
|
+
await app.ready();
|
|
209
|
+
|
|
210
|
+
const res = await app.inject({
|
|
211
|
+
method: "POST",
|
|
212
|
+
url: "/v1/test/auth/session",
|
|
213
|
+
payload: { userId: NON_ALLOWLISTED_USER_ID },
|
|
214
|
+
headers: { "content-type": "application/json" },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(res.statusCode).toBe(403);
|
|
218
|
+
// Session.create MUST NOT have been called for a non-allowlisted user
|
|
219
|
+
expect(prisma.session.create).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns 404 when user is missing in DB (even if allowlisted)", async () => {
|
|
223
|
+
process.env.NODE_ENV = "test";
|
|
224
|
+
process.env.AILK_TEST_AUTH_ENABLED = "true";
|
|
225
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
226
|
+
|
|
227
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
|
228
|
+
|
|
229
|
+
app = buildAppWithRoute();
|
|
230
|
+
await app.ready();
|
|
231
|
+
|
|
232
|
+
const res = await app.inject({
|
|
233
|
+
method: "POST",
|
|
234
|
+
url: "/v1/test/auth/session",
|
|
235
|
+
payload: { userId: ALLOWLISTED_USER_ID },
|
|
236
|
+
headers: { "content-type": "application/json" },
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(res.statusCode).toBe(404);
|
|
240
|
+
expect(prisma.session.create).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("supports CSV-formatted allowlist (multiple userIds)", async () => {
|
|
244
|
+
process.env.NODE_ENV = "test";
|
|
245
|
+
process.env.AILK_TEST_AUTH_ENABLED = "true";
|
|
246
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = `${ALLOWLISTED_USER_ID},another-user,third-user`;
|
|
247
|
+
|
|
248
|
+
const userRow = {
|
|
249
|
+
id: "another-user",
|
|
250
|
+
email: "another@example.com",
|
|
251
|
+
name: "Another User",
|
|
252
|
+
role: "admin",
|
|
253
|
+
};
|
|
254
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(userRow);
|
|
255
|
+
(prisma.session.create as jest.Mock).mockImplementation(
|
|
256
|
+
({ data }: { data: Record<string, unknown> }) =>
|
|
257
|
+
Promise.resolve({
|
|
258
|
+
id: data.id,
|
|
259
|
+
token: data.token,
|
|
260
|
+
userId: data.userId,
|
|
261
|
+
expiresAt: data.expiresAt,
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
app = buildAppWithRoute();
|
|
266
|
+
await app.ready();
|
|
267
|
+
|
|
268
|
+
const res = await app.inject({
|
|
269
|
+
method: "POST",
|
|
270
|
+
url: "/v1/test/auth/session",
|
|
271
|
+
payload: { userId: "another-user" },
|
|
272
|
+
headers: { "content-type": "application/json" },
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(res.statusCode).toBe(200);
|
|
276
|
+
expect(JSON.parse(res.body).user).toEqual(userRow);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("returns 400 for invalid request body (missing userId)", async () => {
|
|
280
|
+
process.env.NODE_ENV = "test";
|
|
281
|
+
process.env.AILK_TEST_AUTH_ENABLED = "true";
|
|
282
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST = ALLOWLISTED_USER_ID;
|
|
283
|
+
|
|
284
|
+
app = buildAppWithRoute();
|
|
285
|
+
await app.ready();
|
|
286
|
+
|
|
287
|
+
const res = await app.inject({
|
|
288
|
+
method: "POST",
|
|
289
|
+
url: "/v1/test/auth/session",
|
|
290
|
+
payload: {},
|
|
291
|
+
headers: { "content-type": "application/json" },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
expect(res.statusCode).toBe(400);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-auth session minting route (#517 — pre-OSS e2e seam §3.1, §5.1).
|
|
3
|
+
*
|
|
4
|
+
* POST /v1/test/auth/session — mints a real Better Auth Session row for an
|
|
5
|
+
* allowlisted user, returning { token, expiresAt, user }. The caller is
|
|
6
|
+
* expected to set the cookie under one of Better Auth's session-cookie names
|
|
7
|
+
* (`better-auth.session_token` on HTTP/dev, `__Secure-better-auth.session_token`
|
|
8
|
+
* on HTTPS/prod) so the dual-mode preHandler (apps/api/src/middleware/auth.ts)
|
|
9
|
+
* reads the row on subsequent requests.
|
|
10
|
+
*
|
|
11
|
+
* SECURITY — TRIPLE GATE:
|
|
12
|
+
* The route is mounted ONLY when shouldMountTestAuth() returns true. All
|
|
13
|
+
* three of these must hold:
|
|
14
|
+
* 1. NODE_ENV !== 'production'
|
|
15
|
+
* 2. AILK_TEST_AUTH_ENABLED === 'true' (literal string match)
|
|
16
|
+
* 3. AILK_TEST_AUTH_USER_ALLOWLIST is a non-empty CSV string
|
|
17
|
+
* If any gate fails, the route is never registered. Fastify returns its
|
|
18
|
+
* default 404 for unknown routes, which is the documented behaviour — the
|
|
19
|
+
* surface NEVER advertises itself in production. The route handler itself
|
|
20
|
+
* does NOT re-check the gates; gating is registration-time, not request-time.
|
|
21
|
+
*
|
|
22
|
+
* ALLOWLIST FORMAT — comma-separated values (CSV), exact-match strings. No
|
|
23
|
+
* regex or wildcard. Empty entries (between commas) are trimmed and ignored.
|
|
24
|
+
*/
|
|
25
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
26
|
+
|
|
27
|
+
import { logger } from "@working-theory/observability";
|
|
28
|
+
import type { FastifyPluginAsync } from "fastify";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
|
|
31
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default session lifetime — matches Better Auth's session.expiresIn config
|
|
35
|
+
* in apps/web/auth.ts (60 * 60 * 24 * 7 seconds = 7 days). Kept in sync by
|
|
36
|
+
* convention; the dual-mode preHandler only reads expiresAt, so a small drift
|
|
37
|
+
* is harmless.
|
|
38
|
+
*/
|
|
39
|
+
const SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
const sessionRequestSchema = z.object({
|
|
42
|
+
userId: z.string().min(1),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pure triple-gate predicate. Exported so server.ts and tests can both check
|
|
47
|
+
* the gate state without duplicating the env-var contract.
|
|
48
|
+
*/
|
|
49
|
+
export function shouldMountTestAuth(): boolean {
|
|
50
|
+
if (process.env.NODE_ENV === "production") return false;
|
|
51
|
+
if (process.env.AILK_TEST_AUTH_ENABLED !== "true") return false;
|
|
52
|
+
const allowlist = process.env.AILK_TEST_AUTH_USER_ALLOWLIST ?? "";
|
|
53
|
+
if (parseAllowlist(allowlist).length === 0) return false;
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse the CSV allowlist into a normalized array of trimmed, non-empty
|
|
59
|
+
* strings. Centralised so route-handler matching and gate-check agree on the
|
|
60
|
+
* same parse semantics.
|
|
61
|
+
*/
|
|
62
|
+
function parseAllowlist(raw: string): string[] {
|
|
63
|
+
return raw
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((s) => s.trim())
|
|
66
|
+
.filter((s) => s.length > 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate an opaque session token with at least 256 bits of entropy. Matches
|
|
71
|
+
* the format Better Auth's own session-token generator uses (hex-encoded
|
|
72
|
+
* random bytes); the dual-mode preHandler treats this as opaque so the exact
|
|
73
|
+
* encoding is not load-bearing — but the entropy ceiling matters.
|
|
74
|
+
*
|
|
75
|
+
* Uses crypto.randomBytes directly (not @working-theory/common's getRandomness) because
|
|
76
|
+
* session tokens are security-critical and must not be seedable in e2e mode.
|
|
77
|
+
*/
|
|
78
|
+
function generateSessionToken(): string {
|
|
79
|
+
return randomBytes(32).toString("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fastify plugin that registers POST /v1/test/auth/session. Caller MUST gate
|
|
84
|
+
* registration on shouldMountTestAuth() — the plugin itself trusts that gate
|
|
85
|
+
* decision and does not re-check at request time.
|
|
86
|
+
*/
|
|
87
|
+
export const registerTestAuthRoute: FastifyPluginAsync = async (fastify) => {
|
|
88
|
+
fastify.post("/v1/test/auth/session", async (request, reply) => {
|
|
89
|
+
// ── Parse + validate body ──────────────────────────────────────────────
|
|
90
|
+
const parsed = sessionRequestSchema.safeParse(request.body);
|
|
91
|
+
if (!parsed.success) {
|
|
92
|
+
return reply.code(400).send({
|
|
93
|
+
error: "invalid_request",
|
|
94
|
+
details: parsed.error.issues,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { userId } = parsed.data;
|
|
99
|
+
|
|
100
|
+
// ── Allowlist check — 403 (not 404) when route is mounted ──────────────
|
|
101
|
+
// The gate to hide the surface is registration-time. Once the route is
|
|
102
|
+
// mounted (non-prod, flag on, allowlist non-empty), a non-allowlisted
|
|
103
|
+
// userId is a normal authorization failure → 403.
|
|
104
|
+
const allowlist = parseAllowlist(
|
|
105
|
+
process.env.AILK_TEST_AUTH_USER_ALLOWLIST ?? "",
|
|
106
|
+
);
|
|
107
|
+
if (!allowlist.includes(userId)) {
|
|
108
|
+
return reply.code(403).send({ error: "forbidden" });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── User existence check — 404 if the seeded user is missing ───────────
|
|
112
|
+
const user = await prisma.user.findUnique({
|
|
113
|
+
where: { id: userId },
|
|
114
|
+
select: { id: true, email: true, name: true, role: true },
|
|
115
|
+
});
|
|
116
|
+
if (!user) {
|
|
117
|
+
return reply.code(404).send({ error: "user_not_found" });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Mint the Session row ───────────────────────────────────────────────
|
|
121
|
+
const token = generateSessionToken();
|
|
122
|
+
const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS);
|
|
123
|
+
const sessionId = randomUUID();
|
|
124
|
+
|
|
125
|
+
await prisma.session.create({
|
|
126
|
+
data: {
|
|
127
|
+
id: sessionId,
|
|
128
|
+
token,
|
|
129
|
+
userId: user.id,
|
|
130
|
+
expiresAt,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
logger.info(
|
|
135
|
+
{ userId: user.id, sessionId },
|
|
136
|
+
"[test-auth] minted session for e2e harness",
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return reply.code(200).send({ token, expiresAt, user });
|
|
140
|
+
});
|
|
141
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Webhooks — `apps/api/src/routes/webhooks/`
|
|
2
|
+
|
|
3
|
+
Stripe webhook receiver at `POST /v1/webhooks/stripe`. Verifies
|
|
4
|
+
signatures, dispatches events via a `switch`, and fires server-side
|
|
5
|
+
telemetry. v1 handles `checkout.session.completed` only.
|
|
6
|
+
|
|
7
|
+
## How to add a new event handler
|
|
8
|
+
|
|
9
|
+
Add a `case` arm to the `switch` in `stripe.ts`:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
case "invoice.payment_succeeded": {
|
|
13
|
+
const invoice = event.data.object as { ... };
|
|
14
|
+
await fireTelemetryWithTimeout(request, "invoice-paid", { ... });
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Customer forks edit `stripe.ts` directly. v1 ships no pluggable handler
|
|
20
|
+
registry — if multiple forks request one, file an issue.
|
|
21
|
+
|
|
22
|
+
## Idempotency caveat (load-bearing)
|
|
23
|
+
|
|
24
|
+
Stripe uses **at-least-once delivery semantics** and occasionally
|
|
25
|
+
double-delivers events. v1 has no idempotency store. Telemetry-only side
|
|
26
|
+
effects make this an acceptable counting drift.
|
|
27
|
+
|
|
28
|
+
**The day a side effect with write semantics is added** (DB persistence,
|
|
29
|
+
confirmation email, refund processing), an idempotency layer must be added
|
|
30
|
+
first. Suggested pattern: a `webhook_events` table keyed on `event.id`
|
|
31
|
+
with an early-return for already-seen IDs.
|
|
32
|
+
|
|
33
|
+
## Env contract
|
|
34
|
+
|
|
35
|
+
Both vars are **server-only** — never use a `NEXT_PUBLIC_` prefix.
|
|
36
|
+
|
|
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 be `sk_test_*`; live keys are hook-blocked at commit and runtime-blocked by `assertSandboxKey` |
|
|
41
|
+
|
|
42
|
+
Without `STRIPE_WEBHOOK_SECRET`, the route returns 503 on every request.
|
|
43
|
+
|
|
44
|
+
## Deployment note
|
|
45
|
+
|
|
46
|
+
Customer forks must:
|
|
47
|
+
|
|
48
|
+
1. Configure a Stripe Dashboard webhook endpoint pointing at
|
|
49
|
+
`https://<your-host>/v1/webhooks/stripe`.
|
|
50
|
+
2. Copy the endpoint's **Signing secret** (`whsec_*`) into
|
|
51
|
+
`STRIPE_WEBHOOK_SECRET` in their environment.
|
|
52
|
+
|
|
53
|
+
Without both, telemetry will not fire and 503s will be logged.
|
|
54
|
+
|
|
55
|
+
## Brief
|
|
56
|
+
|
|
57
|
+
`docs/internal/decisions/ailk-stripe-webhook-receiver.md`
|