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,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* readLocales — read and validate the `product.locales` field from project.yaml.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `apps/web/lib/locales.ts`. Duplicated because architecture.yaml does
|
|
5
|
+
* not permit cross-app imports (apps/api may not import apps/web), and the
|
|
6
|
+
* shared package boundary would require either a new package or extending
|
|
7
|
+
* packages/common's architectural surface (which neither apps/api nor apps/web
|
|
8
|
+
* declares as a may-import-from for filesystem helpers today).
|
|
9
|
+
*
|
|
10
|
+
* Validation rules:
|
|
11
|
+
* - `locales` field absent → defaults to `["en"]` (backward-compat with OSS
|
|
12
|
+
* forks that pre-date the field)
|
|
13
|
+
* - `locales` present → must be a non-empty array of BCP-47 locale tags
|
|
14
|
+
* matching `/^[a-z]{2}(-[A-Z]{2})?$/` (MVP subset)
|
|
15
|
+
* - `product.default_locale` must be included in the `locales` list; parse
|
|
16
|
+
* error if not
|
|
17
|
+
* - `locales: []` → parse error (empty array is invalid)
|
|
18
|
+
*
|
|
19
|
+
* Used by the content read routes to validate the `?locale=` query parameter
|
|
20
|
+
* against the configured allowlist before passing to the adapter (defense-in-
|
|
21
|
+
* depth per AC-i18n-07).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { parse } from "yaml";
|
|
28
|
+
|
|
29
|
+
/** BCP-47 MVP subset: `xx` or `xx-XX` (language + optional region). */
|
|
30
|
+
const BCP47_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reads, validates, and returns the sorted locale list from project.yaml.
|
|
34
|
+
*
|
|
35
|
+
* Walks up from cwd to locate the file:
|
|
36
|
+
* - When apps/api runs from its own dir, project.yaml lives at `../..`
|
|
37
|
+
* - When invoked from repo root, project.yaml lives at `./`
|
|
38
|
+
*
|
|
39
|
+
* @param cwd - Directory to search from. Defaults to `process.cwd()`.
|
|
40
|
+
* @returns Sorted `string[]` of enabled locale tags.
|
|
41
|
+
* @throws Error with a descriptive message (file + cause) when validation fails.
|
|
42
|
+
*/
|
|
43
|
+
export function readLocales(cwd = process.cwd()): string[] {
|
|
44
|
+
const filePath = resolveProjectYamlPath(cwd);
|
|
45
|
+
|
|
46
|
+
let raw: string;
|
|
47
|
+
try {
|
|
48
|
+
raw = readFileSync(filePath, "utf8");
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[locales] Cannot read ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
+
{ cause: err },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let doc: unknown;
|
|
57
|
+
try {
|
|
58
|
+
doc = parse(raw);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`[locales] YAML parse error in ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
62
|
+
{ cause: err },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!doc || typeof doc !== "object") {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[locales] ${filePath}: expected a YAML mapping at the top level`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const top = doc as Record<string, unknown>;
|
|
73
|
+
const product = top["product"];
|
|
74
|
+
|
|
75
|
+
const p =
|
|
76
|
+
product && typeof product === "object"
|
|
77
|
+
? (product as Record<string, unknown>)
|
|
78
|
+
: {};
|
|
79
|
+
|
|
80
|
+
const defaultLocale =
|
|
81
|
+
typeof p["default_locale"] === "string" ? p["default_locale"] : "en";
|
|
82
|
+
|
|
83
|
+
const rawLocales = p["locales"];
|
|
84
|
+
|
|
85
|
+
if (rawLocales === undefined || rawLocales === null) {
|
|
86
|
+
return ["en"];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!Array.isArray(rawLocales)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`[locales] ${filePath}: product.locales must be an array, got ${typeof rawLocales}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (rawLocales.length === 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[locales] ${filePath}: product.locales must be a non-empty array (got empty array)`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const entry of rawLocales) {
|
|
102
|
+
if (typeof entry !== "string") {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`[locales] ${filePath}: product.locales entries must be strings, got ${typeof entry}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (!BCP47_RE.test(entry)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`[locales] ${filePath}: product.locales entry "${entry}" is not a valid BCP-47 locale tag (expected format: xx or xx-XX)`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const locales = rawLocales as string[];
|
|
115
|
+
|
|
116
|
+
if (!locales.includes(defaultLocale)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`[locales] ${filePath}: product.default_locale "${defaultLocale}" is not in product.locales [${locales.join(", ")}]`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return [...locales].sort();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read the project's default locale from project.yaml. Mirrors `readLocales`
|
|
127
|
+
* resolution and fallback semantics. Returns `"en"` when project.yaml is
|
|
128
|
+
* absent or `product.default_locale` is unset — same default as the adapter.
|
|
129
|
+
*/
|
|
130
|
+
export function readDefaultLocale(cwd = process.cwd()): string {
|
|
131
|
+
const filePath = resolveProjectYamlPath(cwd);
|
|
132
|
+
|
|
133
|
+
let raw: string;
|
|
134
|
+
try {
|
|
135
|
+
raw = readFileSync(filePath, "utf8");
|
|
136
|
+
} catch {
|
|
137
|
+
return "en";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let doc: unknown;
|
|
141
|
+
try {
|
|
142
|
+
doc = parse(raw);
|
|
143
|
+
} catch {
|
|
144
|
+
return "en";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!doc || typeof doc !== "object") return "en";
|
|
148
|
+
const product = (doc as Record<string, unknown>)["product"];
|
|
149
|
+
if (!product || typeof product !== "object") return "en";
|
|
150
|
+
const defaultLocale = (product as Record<string, unknown>)["default_locale"];
|
|
151
|
+
return typeof defaultLocale === "string" ? defaultLocale : "en";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve the validated locale to pass to the adapter.
|
|
156
|
+
*
|
|
157
|
+
* Allowlist enforcement at the API boundary (AC-i18n-07): if `requested` is
|
|
158
|
+
* not in `readLocales()`, silently fall back to the project's `default_locale`.
|
|
159
|
+
* The adapter still applies its own regex check as defense-in-depth.
|
|
160
|
+
*
|
|
161
|
+
* `readLocales` failures (project.yaml absent / malformed) degrade gracefully
|
|
162
|
+
* to a default-locale-only allowlist.
|
|
163
|
+
*/
|
|
164
|
+
export function resolveLocale(requested: string | undefined): string {
|
|
165
|
+
const defaultLocale = readDefaultLocale();
|
|
166
|
+
if (requested === undefined) return defaultLocale;
|
|
167
|
+
|
|
168
|
+
let allow: string[];
|
|
169
|
+
try {
|
|
170
|
+
allow = readLocales();
|
|
171
|
+
} catch {
|
|
172
|
+
allow = [defaultLocale];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return allow.includes(requested) ? requested : defaultLocale;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Probe likely locations for project.yaml. Apps run from their own dirs in
|
|
180
|
+
* dev/build (`apps/api/`, `apps/mcp/`) but from repo root in some test setups.
|
|
181
|
+
*/
|
|
182
|
+
function resolveProjectYamlPath(cwd: string): string {
|
|
183
|
+
// Most common case — cwd is repo root, or cwd is an app dir (./../../project.yaml).
|
|
184
|
+
// Probe in order: cwd, ../, ../../.
|
|
185
|
+
const candidates = [
|
|
186
|
+
join(cwd, "project.yaml"),
|
|
187
|
+
join(cwd, "..", "project.yaml"),
|
|
188
|
+
join(cwd, "..", "..", "project.yaml"),
|
|
189
|
+
];
|
|
190
|
+
for (const candidate of candidates) {
|
|
191
|
+
try {
|
|
192
|
+
readFileSync(candidate, "utf8");
|
|
193
|
+
return candidate;
|
|
194
|
+
} catch {
|
|
195
|
+
// continue
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Fall back to the cwd-relative path so the caller's error references the
|
|
199
|
+
// expected location.
|
|
200
|
+
return candidates[0] ?? join(cwd, "project.yaml");
|
|
201
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound HTTP wrapper — fetch with default timeout + SSRF allow-list.
|
|
3
|
+
*
|
|
4
|
+
* Every outbound HTTP call originating from `apps/api/src/lib/**` SHOULD route
|
|
5
|
+
* through this wrapper. Two guarantees:
|
|
6
|
+
*
|
|
7
|
+
* 1. Default 10s timeout via `AbortSignal.timeout(timeoutMs)`. Overridable
|
|
8
|
+
* per call. Without this, a hung peer can pin a Fastify worker
|
|
9
|
+
* indefinitely.
|
|
10
|
+
*
|
|
11
|
+
* 2. SSRF allow-list — RFC 1918 / loopback / link-local / metadata-service
|
|
12
|
+
* addresses are rejected before the fetch is issued. Without this, a
|
|
13
|
+
* user-influenced URL (webhook config, license-refresh endpoint, etc.)
|
|
14
|
+
* can probe cloud-provider metadata endpoints (169.254.169.254 →
|
|
15
|
+
* cloud IAM credentials) or pivot into the cluster network.
|
|
16
|
+
*
|
|
17
|
+
* The wrapper is forward-looking — `apps/api/src/lib` has no outbound fetch
|
|
18
|
+
* callsites in this PR. Subsequent issues that introduce them MUST use this
|
|
19
|
+
* wrapper. Direct `globalThis.fetch` in `apps/api/src/lib/**` is a recon
|
|
20
|
+
* finding waiting to happen.
|
|
21
|
+
*
|
|
22
|
+
* Test-mode escape hatch — `allowPrivate: true` suppresses the SSRF gate.
|
|
23
|
+
* Use only for explicit, internal-by-design traffic (typically tests).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
export type OutboundFetchOptions = RequestInit & {
|
|
29
|
+
/** Override the default 10s timeout. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Suppress the SSRF allow-list — test-mode escape hatch only.
|
|
33
|
+
* NEVER pass true for user-controlled URLs.
|
|
34
|
+
*/
|
|
35
|
+
allowPrivate?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns true if the URL should be blocked by the SSRF allow-list.
|
|
40
|
+
* Pure — no side effects, no fetch issued.
|
|
41
|
+
*
|
|
42
|
+
* Blocked:
|
|
43
|
+
* - Non-HTTP(S) schemes (file:, javascript:, gopher:, ftp:, …)
|
|
44
|
+
* - Unparseable URLs
|
|
45
|
+
* - Hostnames that resolve syntactically to RFC 1918 / loopback / link-local
|
|
46
|
+
* IPv4 ranges, IPv6 loopback (::1), or the wildcard 0.0.0.0
|
|
47
|
+
* - The literal hostname "localhost"
|
|
48
|
+
*
|
|
49
|
+
* NOT covered (out of scope for syntactic check):
|
|
50
|
+
* - DNS rebinding (host resolves to a public IP at check time, then to a
|
|
51
|
+
* private IP at fetch time). A defense for this would resolve the
|
|
52
|
+
* hostname here and bind the fetch to the resolved IP — significant
|
|
53
|
+
* extra complexity, deferred.
|
|
54
|
+
*/
|
|
55
|
+
export function ssrfBlocked(rawUrl: string): boolean {
|
|
56
|
+
let parsed: URL;
|
|
57
|
+
try {
|
|
58
|
+
parsed = new URL(rawUrl);
|
|
59
|
+
} catch {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Node's URL.hostname for IPv6 keeps the brackets (e.g. "[::1]"). Strip
|
|
68
|
+
// them so the literal-loopback comparison works.
|
|
69
|
+
const rawHost = parsed.hostname.toLowerCase();
|
|
70
|
+
const host =
|
|
71
|
+
rawHost.startsWith("[") && rawHost.endsWith("]")
|
|
72
|
+
? rawHost.slice(1, -1)
|
|
73
|
+
: rawHost;
|
|
74
|
+
|
|
75
|
+
if (host === "localhost") return true;
|
|
76
|
+
if (host === "0.0.0.0") return true;
|
|
77
|
+
|
|
78
|
+
// IPv6 loopback (bracketed in URL form)
|
|
79
|
+
if (host === "::1") return true;
|
|
80
|
+
|
|
81
|
+
// IPv4 dotted-quad check
|
|
82
|
+
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
83
|
+
if (ipv4) {
|
|
84
|
+
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
|
|
85
|
+
if (a === 127) return true; // 127.0.0.0/8 — loopback
|
|
86
|
+
if (a === 10) return true; // 10.0.0.0/8 — RFC 1918
|
|
87
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 — link-local (incl. AWS metadata)
|
|
88
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 — RFC 1918
|
|
89
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16 — RFC 1918
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Performs an outbound fetch with timeout + SSRF allow-list enforcement.
|
|
97
|
+
*
|
|
98
|
+
* Throws synchronously (well, rejects) before any network call when:
|
|
99
|
+
* - The URL fails the SSRF check (and `allowPrivate` is not true)
|
|
100
|
+
*
|
|
101
|
+
* Rejects with an AbortError when the per-call timeout fires.
|
|
102
|
+
*/
|
|
103
|
+
export async function outboundFetch(
|
|
104
|
+
url: string,
|
|
105
|
+
init: OutboundFetchOptions = {},
|
|
106
|
+
): Promise<Response> {
|
|
107
|
+
const {
|
|
108
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
109
|
+
allowPrivate = false,
|
|
110
|
+
...rest
|
|
111
|
+
} = init;
|
|
112
|
+
|
|
113
|
+
if (!allowPrivate && ssrfBlocked(url)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`outbound-http: SSRF allow-list blocked URL (disallowed: private/loopback/link-local/non-HTTP)`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const signal = rest.signal ?? AbortSignal.timeout(timeoutMs);
|
|
120
|
+
|
|
121
|
+
return globalThis.fetch(url, { ...rest, signal });
|
|
122
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrismaClient singleton for the local auth database.
|
|
3
|
+
* Business data (leads, orders, analytics) lives on the Platform, not here.
|
|
4
|
+
*/
|
|
5
|
+
import { PrismaClient } from '@working-theory/database';
|
|
6
|
+
import { PrismaPg } from '@prisma/adapter-pg';
|
|
7
|
+
|
|
8
|
+
declare global {
|
|
9
|
+
var prisma: PrismaClient | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
|
13
|
+
|
|
14
|
+
export const prisma = globalThis.prisma || new PrismaClient({
|
|
15
|
+
adapter,
|
|
16
|
+
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
20
|
+
globalThis.prisma = prisma;
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paid-tier license guard for apps/api content-write routes (D14a Stage 0).
|
|
3
|
+
*
|
|
4
|
+
* Calls `checkLicense()` from @working-theory/license; on free tier emits a 402 with the
|
|
5
|
+
* canonical `payment_required` shape from `./errors.ts`. Paid tier proceeds.
|
|
6
|
+
* Network/refresh failures inside checkLicense() degrade gracefully to free
|
|
7
|
+
* (per ADR 0014 — local-verify, no remote-validate path).
|
|
8
|
+
*
|
|
9
|
+
* Usage as Fastify preHandler:
|
|
10
|
+
*
|
|
11
|
+
* fastify.post("/path", { preHandler: requirePaidTier }, handler);
|
|
12
|
+
*
|
|
13
|
+
* Or composed with other preHandlers:
|
|
14
|
+
*
|
|
15
|
+
* fastify.post("/path", {
|
|
16
|
+
* preHandler: [requirePaidTier, ...others],
|
|
17
|
+
* }, handler);
|
|
18
|
+
*
|
|
19
|
+
* The handler returns nothing (void) on pro and short-circuits with reply.send
|
|
20
|
+
* on free, matching Fastify's preHandler contract.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { checkLicense } from "@working-theory/license";
|
|
24
|
+
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
25
|
+
|
|
26
|
+
import { paymentRequiredError } from "./errors.js";
|
|
27
|
+
|
|
28
|
+
const FREE_TIER_MESSAGE =
|
|
29
|
+
"This endpoint requires a paid license. Visit https://ailaunchkit.ai to upgrade.";
|
|
30
|
+
|
|
31
|
+
export async function requirePaidTier(
|
|
32
|
+
_request: FastifyRequest,
|
|
33
|
+
reply: FastifyReply,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
let result;
|
|
36
|
+
try {
|
|
37
|
+
result = await checkLicense();
|
|
38
|
+
} catch {
|
|
39
|
+
// Per ADR 0014 — license verification is local; an exception here means a
|
|
40
|
+
// bug, not a network failure. Treat as free for safe-by-default gating.
|
|
41
|
+
const err = paymentRequiredError(FREE_TIER_MESSAGE);
|
|
42
|
+
return reply.status(err.status).send(err.body);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Revoked paid licenses keep verifying until JWT exp (up to 24h after the
|
|
46
|
+
// server reports `revoked`). Closing the gate when `revoked === true`
|
|
47
|
+
// shrinks that window to "next request" without waiting for exp. See
|
|
48
|
+
// packages/license/src/types.ts § LicenseResult and ADR 0014.
|
|
49
|
+
if (result.revoked === true || result.tier === "free") {
|
|
50
|
+
const err = paymentRequiredError(FREE_TIER_MESSAGE);
|
|
51
|
+
return reply.status(err.status).send(err.body);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apps/api Stripe shim.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports @working-theory/stripe-client's public surface for use by routes and
|
|
5
|
+
* services under apps/api. This is the SOLE Stripe-touching file in apps/api.
|
|
6
|
+
*
|
|
7
|
+
* Future routes import from here (`from '../lib/stripe'`), not directly from
|
|
8
|
+
* '@working-theory/stripe-client' — single chokepoint for AILK-internal ergonomics
|
|
9
|
+
* (logger context, request-id propagation) without touching every consumer.
|
|
10
|
+
*/
|
|
11
|
+
export {
|
|
12
|
+
assertSandboxKey,
|
|
13
|
+
createCheckoutSession,
|
|
14
|
+
getStripeClient,
|
|
15
|
+
STRIPE_API_VERSION,
|
|
16
|
+
StripeConfigError,
|
|
17
|
+
StripeWebhookSignatureError,
|
|
18
|
+
UnknownProductCodeError,
|
|
19
|
+
verifyWebhookSignature,
|
|
20
|
+
WEBHOOK_TOLERANCE_SECONDS,
|
|
21
|
+
} from "@working-theory/stripe-client";
|
|
22
|
+
|
|
23
|
+
export type {
|
|
24
|
+
CreateCheckoutSessionInput,
|
|
25
|
+
CreateCheckoutSessionOutput,
|
|
26
|
+
CreateCheckoutSessionParams,
|
|
27
|
+
Stripe,
|
|
28
|
+
StripeCheckoutSession,
|
|
29
|
+
StripeWebhookEvent,
|
|
30
|
+
} from "@working-theory/stripe-client";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook idempotency store — in-memory dedup of Stripe `event.id` values.
|
|
3
|
+
*
|
|
4
|
+
* Stripe guarantees at-least-once delivery; the same `event.id` can arrive
|
|
5
|
+
* multiple times (network retries, manual dashboard replay, webhook URL
|
|
6
|
+
* reconfiguration). We persist seen event IDs for a bounded window so the
|
|
7
|
+
* second arrival short-circuits before any side effect fires.
|
|
8
|
+
*
|
|
9
|
+
* IMPLEMENTATION CHOICE — module-scoped Map, NOT a database table:
|
|
10
|
+
*
|
|
11
|
+
* The S14 spec mentioned a `StripeEventLog` table for durability across
|
|
12
|
+
* restarts. We chose an in-process Map for the OSS v1 release because:
|
|
13
|
+
*
|
|
14
|
+
* 1. AILK ships as a single-replica template (see middleware/rate-limit.ts
|
|
15
|
+
* § "SINGLE-REPLICA CAVEAT" for the same reasoning applied to per-IP
|
|
16
|
+
* rate limits — both modules share the deployment-shape assumption).
|
|
17
|
+
* 2. Stripe retries within a bounded window (~3 days). A TTL-bounded Map
|
|
18
|
+
* covers practical replay traffic; the worst-case loss after a process
|
|
19
|
+
* restart is "an event we already successfully processed re-fires
|
|
20
|
+
* telemetry once," which is itself idempotent (analytics is dedup-safe).
|
|
21
|
+
* 3. Avoiding a schema migration here keeps S14 isolated from the recent
|
|
22
|
+
* #587 Better Auth schema work and the prisma-client test mocks.
|
|
23
|
+
*
|
|
24
|
+
* A durable swap (Redis or DB-backed) is the right answer for multi-replica
|
|
25
|
+
* deployments. Tracked as a follow-up; the consumer-facing contract
|
|
26
|
+
* (`hasSeen` / `markSeen`) is unchanged, so the swap is a drop-in.
|
|
27
|
+
*
|
|
28
|
+
* Eviction strategy: lazy — every insert past the soft cap (10k entries)
|
|
29
|
+
* triggers a sweep that drops entries older than `IDEMPOTENCY_TTL_MS`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1_000; // 24h — well above Stripe's retry horizon
|
|
33
|
+
const SOFT_CAP = 10_000;
|
|
34
|
+
|
|
35
|
+
type Entry = {
|
|
36
|
+
insertedAtMs: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const store = new Map<string, Entry>();
|
|
40
|
+
let insertsSinceLastSweep = 0;
|
|
41
|
+
const SWEEP_INTERVAL_INSERTS = 1_000;
|
|
42
|
+
|
|
43
|
+
function sweepExpired(nowMs: number): void {
|
|
44
|
+
for (const [eventId, entry] of store) {
|
|
45
|
+
if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
|
|
46
|
+
store.delete(eventId);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
insertsSinceLastSweep = 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns true if this `eventId` was already marked seen within the TTL window.
|
|
54
|
+
* Lazily evicts the entry if its TTL has expired (so callers see fresh state).
|
|
55
|
+
*/
|
|
56
|
+
export function hasSeen(eventId: string, nowMs: number = Date.now()): boolean {
|
|
57
|
+
const entry = store.get(eventId);
|
|
58
|
+
if (!entry) return false;
|
|
59
|
+
if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
|
|
60
|
+
store.delete(eventId);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Records `eventId` as seen at the given timestamp. Idempotent — re-marking
|
|
68
|
+
* a known event leaves the original insertedAtMs (so TTL is anchored to first
|
|
69
|
+
* sighting, not most-recent retry).
|
|
70
|
+
*/
|
|
71
|
+
export function markSeen(eventId: string, nowMs: number = Date.now()): void {
|
|
72
|
+
if (!store.has(eventId)) {
|
|
73
|
+
store.set(eventId, { insertedAtMs: nowMs });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
insertsSinceLastSweep += 1;
|
|
77
|
+
if (
|
|
78
|
+
store.size > SOFT_CAP ||
|
|
79
|
+
insertsSinceLastSweep >= SWEEP_INTERVAL_INSERTS
|
|
80
|
+
) {
|
|
81
|
+
sweepExpired(nowMs);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Test-only reset. Clears all dedup state between test cases so they do not
|
|
87
|
+
* interfere with each other.
|
|
88
|
+
*/
|
|
89
|
+
export function __resetIdempotencyStore(): void {
|
|
90
|
+
store.clear();
|
|
91
|
+
insertsSinceLastSweep = 0;
|
|
92
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file bearer-token-format.test.ts (S13 adversarial)
|
|
3
|
+
*
|
|
4
|
+
* Adversarial Authorization-header parsing coverage on the bearer path.
|
|
5
|
+
* The baseline `auth.bearer-path.test.ts` asserts that `NotBearer x` rejects.
|
|
6
|
+
* This file extends with the malformed-input matrix from S13 §3:
|
|
7
|
+
*
|
|
8
|
+
* - Wrong scheme (`Token foo`, `Basic xyz`).
|
|
9
|
+
* - Missing token after `Bearer ` → 401.
|
|
10
|
+
* - Leading or trailing whitespace inside the header.
|
|
11
|
+
* - Bearer with embedded control characters → 401.
|
|
12
|
+
* - Lowercase `bearer` is treated as valid (RFC 7235 — schemes are
|
|
13
|
+
* case-insensitive), but still requires a non-empty token.
|
|
14
|
+
* - Two `Bearer ` prefixes (`Bearer Bearer xyz`) — the second word is
|
|
15
|
+
* taken as the token (well-formed per the regex), but adversarial code
|
|
16
|
+
* trying to smuggle "Bearer" inside the token should still produce a
|
|
17
|
+
* DB miss → 401.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import Fastify from "fastify";
|
|
21
|
+
import type { FastifyInstance } from "fastify";
|
|
22
|
+
|
|
23
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
24
|
+
jest.mock("../../../lib/prisma.js", () => ({
|
|
25
|
+
prisma: {
|
|
26
|
+
session: { findUnique: jest.fn() },
|
|
27
|
+
apiToken: {
|
|
28
|
+
findUnique: jest.fn(),
|
|
29
|
+
update: jest.fn().mockResolvedValue(undefined),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
35
|
+
import { authPreHandlerPlugin } from "../../auth.js";
|
|
36
|
+
|
|
37
|
+
function buildApp(): FastifyInstance {
|
|
38
|
+
const app = Fastify();
|
|
39
|
+
app.register(authPreHandlerPlugin, {
|
|
40
|
+
frontendOrigin: "https://app.example.com",
|
|
41
|
+
});
|
|
42
|
+
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
43
|
+
return app;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("S13 adversarial — bearer token format", () => {
|
|
47
|
+
let app: FastifyInstance;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
jest.clearAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
await app.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it.each([
|
|
58
|
+
["wrong scheme — Token", "Token abc123"],
|
|
59
|
+
["wrong scheme — Basic", "Basic dXNlcjpwYXNz"],
|
|
60
|
+
["empty token after Bearer", "Bearer "],
|
|
61
|
+
["only the scheme word", "Bearer"],
|
|
62
|
+
])("rejects %s and never queries the token table", async (_label, header) => {
|
|
63
|
+
app = buildApp();
|
|
64
|
+
await app.ready();
|
|
65
|
+
|
|
66
|
+
const res = await app.inject({
|
|
67
|
+
method: "GET",
|
|
68
|
+
url: "/echo",
|
|
69
|
+
headers: { authorization: header },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(res.statusCode).toBe(401);
|
|
73
|
+
expect(JSON.parse(res.body)).toEqual({ error: "unauthorized" });
|
|
74
|
+
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("accepts the case-insensitive scheme but still rejects an unknown token", async () => {
|
|
78
|
+
(prisma.apiToken.findUnique as jest.Mock).mockResolvedValue(null);
|
|
79
|
+
|
|
80
|
+
app = buildApp();
|
|
81
|
+
await app.ready();
|
|
82
|
+
|
|
83
|
+
const res = await app.inject({
|
|
84
|
+
method: "GET",
|
|
85
|
+
url: "/echo",
|
|
86
|
+
headers: { authorization: "bearer abc" },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(res.statusCode).toBe(401);
|
|
90
|
+
// Lowercase scheme matched; lookup happened; token unknown → 401.
|
|
91
|
+
expect(prisma.apiToken.findUnique).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("treats `Bearer Bearer x` as a normal token value and rejects on miss", async () => {
|
|
95
|
+
(prisma.apiToken.findUnique as jest.Mock).mockResolvedValue(null);
|
|
96
|
+
|
|
97
|
+
app = buildApp();
|
|
98
|
+
await app.ready();
|
|
99
|
+
|
|
100
|
+
const res = await app.inject({
|
|
101
|
+
method: "GET",
|
|
102
|
+
url: "/echo",
|
|
103
|
+
headers: { authorization: "Bearer Bearer x" },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// The regex anchors `^Bearer\s+(\S+)$`; "Bearer x" is two tokens, so the
|
|
107
|
+
// header does NOT match the single-token shape → 401 before any DB hit.
|
|
108
|
+
expect(res.statusCode).toBe(401);
|
|
109
|
+
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|