create-ailk 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -8
- package/component-catalog.json +336 -27
- package/component-catalog.md +25 -12
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/module-architecture.d.ts +1 -0
- package/dist/module-architecture.js +253 -127
- package/dist/surfaces.js +105 -7
- package/dist/template-manifest.d.ts +33 -0
- package/dist/template-manifest.js +99 -16
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +1 -0
- package/package.json +3 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
- package/site-templates/marketing-site/ailk-template.json +1 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
- package/site-templates/minimalism-site/ailk-template.json +2 -1
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.env.example +72 -7
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +26 -2
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +439 -49
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +70 -31
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +13 -14
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +23 -2
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +57 -24
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +2 -0
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +59 -35
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +6 -4
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +71 -5
- package/templates/apps/web/CLAUDE.md +44 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/layout.tsx +133 -87
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/jest.config.cjs +8 -4
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +187 -0
- package/templates/apps/web/package.json +2 -1
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +142 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +1 -1
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file transactional-email.test.ts — the signed auth-email relay (#5962 § D3).
|
|
3
|
+
*
|
|
4
|
+
* Written before the implementation. This route is the one thing `apps/web`
|
|
5
|
+
* cannot do for itself — `architecture.yaml` denies `apps/web` →
|
|
6
|
+
* `packages/email` — so it takes a caller-chosen recipient and a caller-chosen
|
|
7
|
+
* URL. That shape is mail-relay-shaped, and the whole of what makes it
|
|
8
|
+
* tolerable is asserted here rather than assumed:
|
|
9
|
+
*
|
|
10
|
+
* - the rejection ORDER is 503 → 401 → 400, and the signature is verified
|
|
11
|
+
* over the RAW bytes BEFORE the body is parsed (a 400 before a 401 hands
|
|
12
|
+
* an unauthenticated caller a JSON-parser oracle);
|
|
13
|
+
* - the timestamp is inside the signed material and a skew past 300 s is a
|
|
14
|
+
* 401, so a captured body cannot be replayed;
|
|
15
|
+
* - `kind` selects from FIXED templates — a caller chooses neither subject
|
|
16
|
+
* nor copy;
|
|
17
|
+
* - a budget-suppressed send still answers 202. This is the load-bearing
|
|
18
|
+
* one: an observable suppression is exactly the enumeration oracle the
|
|
19
|
+
* rest of the design closes;
|
|
20
|
+
* - no log line carries the recipient, the token, or the assembled link
|
|
21
|
+
* (§ D12's PII boundary).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHmac } from "node:crypto";
|
|
25
|
+
|
|
26
|
+
import Fastify from "fastify";
|
|
27
|
+
import type { FastifyInstance } from "fastify";
|
|
28
|
+
|
|
29
|
+
const mockSendPasswordReset = jest.fn();
|
|
30
|
+
const mockSendPasswordChanged = jest.fn();
|
|
31
|
+
const mockSendEmailVerification = jest.fn();
|
|
32
|
+
|
|
33
|
+
jest.mock("@working-theory/email", () => ({
|
|
34
|
+
sendPasswordResetEmail: (...args: unknown[]) =>
|
|
35
|
+
mockSendPasswordReset(...args),
|
|
36
|
+
sendPasswordChangedEmail: (...args: unknown[]) =>
|
|
37
|
+
mockSendPasswordChanged(...args),
|
|
38
|
+
sendEmailVerificationEmail: (...args: unknown[]) =>
|
|
39
|
+
mockSendEmailVerification(...args),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import { __resetAuthEmailBudget } from "../../../lib/auth-email-budget.js";
|
|
43
|
+
import { transactionalEmailRoute } from "../transactional-email.js";
|
|
44
|
+
|
|
45
|
+
const SECRET = "test-webhook-secret";
|
|
46
|
+
const FROM = "noreply@site.example";
|
|
47
|
+
|
|
48
|
+
function sign(rawBody: string, timestamp: string, secret = SECRET): string {
|
|
49
|
+
return `sha256=${createHmac("sha256", secret)
|
|
50
|
+
.update(`${timestamp}.${rawBody}`, "utf8")
|
|
51
|
+
.digest("hex")}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function bodyFor(overrides: Record<string, unknown> = {}): string {
|
|
55
|
+
return JSON.stringify({
|
|
56
|
+
kind: "password-reset",
|
|
57
|
+
userId: "user_01",
|
|
58
|
+
to: "person@example.com",
|
|
59
|
+
url: "https://site.example/api/auth/reset-password/tok?callbackURL=%2Freset-password",
|
|
60
|
+
expiresInMinutes: 60,
|
|
61
|
+
...overrides,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function buildApp(): Promise<FastifyInstance> {
|
|
66
|
+
const app = Fastify();
|
|
67
|
+
// The raw-body parser the route's scope carries in server.ts — the
|
|
68
|
+
// signature must be verified over the EXACT bytes, never a re-serialized
|
|
69
|
+
// parse.
|
|
70
|
+
app.addContentTypeParser(
|
|
71
|
+
"application/json",
|
|
72
|
+
{ parseAs: "buffer" },
|
|
73
|
+
(_req, body, done) => done(null, body),
|
|
74
|
+
);
|
|
75
|
+
await app.register(transactionalEmailRoute);
|
|
76
|
+
await app.ready();
|
|
77
|
+
return app;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function post(
|
|
81
|
+
app: FastifyInstance,
|
|
82
|
+
{
|
|
83
|
+
rawBody = bodyFor(),
|
|
84
|
+
timestamp = String(Math.floor(Date.now() / 1000)),
|
|
85
|
+
signature,
|
|
86
|
+
}: { rawBody?: string; timestamp?: string; signature?: string } = {},
|
|
87
|
+
) {
|
|
88
|
+
return app.inject({
|
|
89
|
+
method: "POST",
|
|
90
|
+
url: "/v1/auth/transactional-email",
|
|
91
|
+
headers: {
|
|
92
|
+
"content-type": "application/json",
|
|
93
|
+
"x-ailk-auth-email-signature": signature ?? sign(rawBody, timestamp),
|
|
94
|
+
"x-ailk-auth-email-timestamp": timestamp,
|
|
95
|
+
},
|
|
96
|
+
payload: rawBody,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let app: FastifyInstance;
|
|
101
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
102
|
+
|
|
103
|
+
beforeEach(async () => {
|
|
104
|
+
jest.clearAllMocks();
|
|
105
|
+
__resetAuthEmailBudget();
|
|
106
|
+
process.env.AUTH_EMAIL_WEBHOOK_SECRET = SECRET;
|
|
107
|
+
process.env.AUTH_EMAIL_FROM = FROM;
|
|
108
|
+
mockSendPasswordReset.mockResolvedValue({ ok: true, messageId: "m1" });
|
|
109
|
+
mockSendPasswordChanged.mockResolvedValue({ ok: true, messageId: "m2" });
|
|
110
|
+
mockSendEmailVerification.mockResolvedValue({ ok: true, messageId: "m3" });
|
|
111
|
+
app = await buildApp();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterEach(async () => {
|
|
115
|
+
await app.close();
|
|
116
|
+
process.env = { ...ORIGINAL_ENV };
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("POST /v1/auth/transactional-email — the gate", () => {
|
|
120
|
+
it("answers 503 when AUTH_EMAIL_WEBHOOK_SECRET is unset, before any signature check", async () => {
|
|
121
|
+
delete process.env.AUTH_EMAIL_WEBHOOK_SECRET;
|
|
122
|
+
const res = await post(app, { signature: "sha256=" + "0".repeat(64) });
|
|
123
|
+
expect(res.statusCode).toBe(503);
|
|
124
|
+
expect(res.json()).toEqual({ error: "not_configured" });
|
|
125
|
+
expect(mockSendPasswordReset).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("answers 401 on a bad signature", async () => {
|
|
129
|
+
const res = await post(app, { signature: "sha256=" + "a".repeat(64) });
|
|
130
|
+
expect(res.statusCode).toBe(401);
|
|
131
|
+
expect(res.json()).toEqual({ error: "unauthorized" });
|
|
132
|
+
expect(mockSendPasswordReset).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("answers 401 on a signature made with a different secret", async () => {
|
|
136
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
137
|
+
const raw = bodyFor();
|
|
138
|
+
const res = await post(app, {
|
|
139
|
+
rawBody: raw,
|
|
140
|
+
timestamp: ts,
|
|
141
|
+
signature: sign(raw, ts, "wrong-secret"),
|
|
142
|
+
});
|
|
143
|
+
expect(res.statusCode).toBe(401);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("answers 401 on a malformed signature header shape", async () => {
|
|
147
|
+
const res = await post(app, { signature: "nonsense" });
|
|
148
|
+
expect(res.statusCode).toBe(401);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("answers 401 when the timestamp is outside the 300 s window (replay)", async () => {
|
|
152
|
+
const stale = String(Math.floor(Date.now() / 1000) - 301);
|
|
153
|
+
const raw = bodyFor();
|
|
154
|
+
const res = await post(app, {
|
|
155
|
+
rawBody: raw,
|
|
156
|
+
timestamp: stale,
|
|
157
|
+
signature: sign(raw, stale),
|
|
158
|
+
});
|
|
159
|
+
expect(res.statusCode).toBe(401);
|
|
160
|
+
expect(mockSendPasswordReset).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("accepts a timestamp inside the window", async () => {
|
|
164
|
+
const recent = String(Math.floor(Date.now() / 1000) - 120);
|
|
165
|
+
const raw = bodyFor();
|
|
166
|
+
const res = await post(app, {
|
|
167
|
+
rawBody: raw,
|
|
168
|
+
timestamp: recent,
|
|
169
|
+
signature: sign(raw, recent),
|
|
170
|
+
});
|
|
171
|
+
expect(res.statusCode).toBe(202);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("answers 401 — not 400 — for an unsigned body that is also unparseable", async () => {
|
|
175
|
+
const res = await post(app, {
|
|
176
|
+
rawBody: "{not json",
|
|
177
|
+
signature: "sha256=" + "b".repeat(64),
|
|
178
|
+
});
|
|
179
|
+
expect(res.statusCode).toBe(401);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("answers 400 for a correctly signed body of the wrong shape", async () => {
|
|
183
|
+
const res = await post(app, { rawBody: JSON.stringify({ kind: "nope" }) });
|
|
184
|
+
expect(res.statusCode).toBe(400);
|
|
185
|
+
expect(res.json()).toEqual({ error: "validation_failed" });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("answers 400 for a kind outside the fixed three", async () => {
|
|
189
|
+
const res = await post(app, {
|
|
190
|
+
rawBody: bodyFor({ kind: "arbitrary-marketing-blast" }),
|
|
191
|
+
});
|
|
192
|
+
expect(res.statusCode).toBe(400);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("answers 400 for a password-reset with no url", async () => {
|
|
196
|
+
const res = await post(app, { rawBody: bodyFor({ url: undefined }) });
|
|
197
|
+
expect(res.statusCode).toBe(400);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("POST /v1/auth/transactional-email — the sends", () => {
|
|
202
|
+
it("sends a password-reset through the fixed template and answers 202", async () => {
|
|
203
|
+
const res = await post(app);
|
|
204
|
+
expect(res.statusCode).toBe(202);
|
|
205
|
+
expect(res.json()).toEqual({ ok: true });
|
|
206
|
+
expect(mockSendPasswordReset).toHaveBeenCalledTimes(1);
|
|
207
|
+
const [input] = mockSendPasswordReset.mock.calls[0];
|
|
208
|
+
expect(input.to).toBe("person@example.com");
|
|
209
|
+
expect(input.fromAddress).toBe(FROM);
|
|
210
|
+
// The caller chooses neither subject nor copy.
|
|
211
|
+
expect(input.subject).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("sends the password-changed notice with NO url", async () => {
|
|
215
|
+
const res = await post(app, {
|
|
216
|
+
rawBody: JSON.stringify({
|
|
217
|
+
kind: "password-changed",
|
|
218
|
+
userId: "user_01",
|
|
219
|
+
to: "person@example.com",
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
expect(res.statusCode).toBe(202);
|
|
223
|
+
expect(mockSendPasswordChanged).toHaveBeenCalledTimes(1);
|
|
224
|
+
const [input] = mockSendPasswordChanged.mock.calls[0];
|
|
225
|
+
expect(input).not.toHaveProperty("url");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("sends an email-verification through its OWN template, never the reset one", async () => {
|
|
229
|
+
const res = await post(app, {
|
|
230
|
+
rawBody: bodyFor({
|
|
231
|
+
kind: "email-verification",
|
|
232
|
+
url: "https://site.example/api/auth/verify-email?token=jwt",
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
expect(res.statusCode).toBe(202);
|
|
236
|
+
expect(mockSendEmailVerification).toHaveBeenCalledTimes(1);
|
|
237
|
+
// A verification message must not claim someone asked to reset a password.
|
|
238
|
+
expect(mockSendPasswordReset).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("answers 202 even when the sender reports a failure — never an oracle", async () => {
|
|
242
|
+
mockSendPasswordReset.mockResolvedValue({
|
|
243
|
+
ok: false,
|
|
244
|
+
error: { kind: "send_failed", message: "resend down" },
|
|
245
|
+
});
|
|
246
|
+
const res = await post(app);
|
|
247
|
+
expect(res.statusCode).toBe(202);
|
|
248
|
+
expect(res.json()).toEqual({ ok: true });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("answers 202 with NO send once the recipient is over budget", async () => {
|
|
252
|
+
for (let i = 0; i < 3; i++) {
|
|
253
|
+
const r = await post(app);
|
|
254
|
+
expect(r.statusCode).toBe(202);
|
|
255
|
+
}
|
|
256
|
+
expect(mockSendPasswordReset).toHaveBeenCalledTimes(3);
|
|
257
|
+
|
|
258
|
+
const res = await post(app);
|
|
259
|
+
expect(res.statusCode).toBe(202);
|
|
260
|
+
expect(res.json()).toEqual({ ok: true });
|
|
261
|
+
// Suppressed — and indistinguishable from a send.
|
|
262
|
+
expect(mockSendPasswordReset).toHaveBeenCalledTimes(3);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("the PII boundary (§ D12)", () => {
|
|
267
|
+
it("logs no recipient, no token and no assembled link", async () => {
|
|
268
|
+
const lines: string[] = [];
|
|
269
|
+
const capture = new Proxy(
|
|
270
|
+
{},
|
|
271
|
+
{
|
|
272
|
+
get: () => (obj: unknown, msg?: unknown) =>
|
|
273
|
+
lines.push(JSON.stringify({ obj, msg })),
|
|
274
|
+
},
|
|
275
|
+
) as never;
|
|
276
|
+
|
|
277
|
+
const loggingApp = Fastify({ logger: false });
|
|
278
|
+
loggingApp.addContentTypeParser(
|
|
279
|
+
"application/json",
|
|
280
|
+
{ parseAs: "buffer" },
|
|
281
|
+
(_req, body, done) => done(null, body),
|
|
282
|
+
);
|
|
283
|
+
loggingApp.addHook("onRequest", async (req) => {
|
|
284
|
+
// eslint-disable-next-line no-restricted-syntax -- the point of this case is to capture what the handler LOGS, and Fastify types `req.log` as a concrete Logger; there is no boundary type to fix.
|
|
285
|
+
(req as unknown as { log: unknown }).log = capture;
|
|
286
|
+
});
|
|
287
|
+
await loggingApp.register(transactionalEmailRoute);
|
|
288
|
+
await loggingApp.ready();
|
|
289
|
+
|
|
290
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
291
|
+
const raw = bodyFor();
|
|
292
|
+
await loggingApp.inject({
|
|
293
|
+
method: "POST",
|
|
294
|
+
url: "/v1/auth/transactional-email",
|
|
295
|
+
headers: {
|
|
296
|
+
"content-type": "application/json",
|
|
297
|
+
"x-ailk-auth-email-signature": sign(raw, ts),
|
|
298
|
+
"x-ailk-auth-email-timestamp": ts,
|
|
299
|
+
},
|
|
300
|
+
payload: raw,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const joined = lines.join("|");
|
|
304
|
+
expect(joined).not.toContain("person@example.com");
|
|
305
|
+
expect(joined).not.toContain("tok");
|
|
306
|
+
expect(joined).not.toContain("site.example/api/auth");
|
|
307
|
+
await loggingApp.close();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -306,6 +306,103 @@ describe('User Routes', () => {
|
|
|
306
306
|
});
|
|
307
307
|
});
|
|
308
308
|
|
|
309
|
+
describe('reserved user-data keys — #6034 § D12', () => {
|
|
310
|
+
async function buildAuthenticatedApp(): Promise<FastifyInstance> {
|
|
311
|
+
const fastify2 = require('fastify')();
|
|
312
|
+
fastify2.decorateRequest('userId', null);
|
|
313
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- #506/#507: pre-existing test-hygiene violation
|
|
314
|
+
fastify2.addHook('preHandler', async (request: any) => {
|
|
315
|
+
request.userId = 'user_123';
|
|
316
|
+
});
|
|
317
|
+
await fastify2.register(userRoutes);
|
|
318
|
+
return fastify2;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
it.each(['commerce.subscription_dunning', 'Commerce.Subscription_Dunning', 'system.anything'])(
|
|
322
|
+
'GET %s refuses 403 with zero Prisma calls',
|
|
323
|
+
async (key) => {
|
|
324
|
+
const fastify2 = await buildAuthenticatedApp();
|
|
325
|
+
|
|
326
|
+
const response = await fastify2.inject({
|
|
327
|
+
method: 'GET',
|
|
328
|
+
url: `/v1/user/data/${key}`,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(response.statusCode).toBe(403);
|
|
332
|
+
expect(JSON.parse(response.body)).toEqual({
|
|
333
|
+
success: false,
|
|
334
|
+
error: 'reserved-key',
|
|
335
|
+
});
|
|
336
|
+
expect(prisma.userData.findUnique).not.toHaveBeenCalled();
|
|
337
|
+
|
|
338
|
+
await fastify2.close();
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
it.each(['commerce.subscription_dunning', 'Commerce.Subscription_Dunning', 'system.anything'])(
|
|
343
|
+
'PUT %s refuses 403 with zero Prisma calls',
|
|
344
|
+
async (key) => {
|
|
345
|
+
const fastify2 = await buildAuthenticatedApp();
|
|
346
|
+
|
|
347
|
+
const response = await fastify2.inject({
|
|
348
|
+
method: 'PUT',
|
|
349
|
+
url: `/v1/user/data/${key}`,
|
|
350
|
+
payload: { status: 'active' },
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(response.statusCode).toBe(403);
|
|
354
|
+
expect(JSON.parse(response.body)).toEqual({
|
|
355
|
+
success: false,
|
|
356
|
+
error: 'reserved-key',
|
|
357
|
+
});
|
|
358
|
+
expect(prisma.userData.upsert).not.toHaveBeenCalled();
|
|
359
|
+
|
|
360
|
+
await fastify2.close();
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
it.each(['commerce.subscription_dunning', 'Commerce.Subscription_Dunning', 'system.anything'])(
|
|
365
|
+
'DELETE %s refuses 403 with zero Prisma calls',
|
|
366
|
+
async (key) => {
|
|
367
|
+
const fastify2 = await buildAuthenticatedApp();
|
|
368
|
+
|
|
369
|
+
const response = await fastify2.inject({
|
|
370
|
+
method: 'DELETE',
|
|
371
|
+
url: `/v1/user/data/${key}`,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(response.statusCode).toBe(403);
|
|
375
|
+
expect(JSON.parse(response.body)).toEqual({
|
|
376
|
+
success: false,
|
|
377
|
+
error: 'reserved-key',
|
|
378
|
+
});
|
|
379
|
+
expect(prisma.userData.deleteMany).not.toHaveBeenCalled();
|
|
380
|
+
|
|
381
|
+
await fastify2.close();
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
it('an unreserved key is unaffected — the existing prefs path stays green', async () => {
|
|
386
|
+
const fastify2 = await buildAuthenticatedApp();
|
|
387
|
+
(prisma.userData.upsert as jest.Mock).mockResolvedValue({
|
|
388
|
+
userId: 'user_123',
|
|
389
|
+
key: 'preferences',
|
|
390
|
+
value: { theme: 'light' },
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const response = await fastify2.inject({
|
|
394
|
+
method: 'PUT',
|
|
395
|
+
url: '/v1/user/data/preferences',
|
|
396
|
+
payload: { theme: 'light' },
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
expect(response.statusCode).toBe(200);
|
|
400
|
+
expect(prisma.userData.upsert).toHaveBeenCalled();
|
|
401
|
+
|
|
402
|
+
await fastify2.close();
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
309
406
|
describe('PUT /v1/user/data/:key', () => {
|
|
310
407
|
it('should return 401 when user is not authenticated', async () => {
|
|
311
408
|
const response = await app.inject({
|
|
@@ -381,6 +478,32 @@ describe('User Routes', () => {
|
|
|
381
478
|
|
|
382
479
|
await fastify2.close();
|
|
383
480
|
});
|
|
481
|
+
|
|
482
|
+
it('rejects a value whose serialized size exceeds 64 KiB — #6034 § D12', async () => {
|
|
483
|
+
const fastify2 = require('fastify')();
|
|
484
|
+
fastify2.decorateRequest('userId', null);
|
|
485
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- #506/#507: pre-existing test-hygiene violation
|
|
486
|
+
fastify2.addHook('preHandler', async (request: any) => {
|
|
487
|
+
request.userId = 'user_123';
|
|
488
|
+
});
|
|
489
|
+
await fastify2.register(userRoutes);
|
|
490
|
+
|
|
491
|
+
const oversized = { blob: 'x'.repeat(70 * 1024) };
|
|
492
|
+
|
|
493
|
+
const response = await fastify2.inject({
|
|
494
|
+
method: 'PUT',
|
|
495
|
+
url: '/v1/user/data/preferences',
|
|
496
|
+
payload: oversized,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
expect(response.statusCode).toBe(400);
|
|
500
|
+
const body = JSON.parse(response.body);
|
|
501
|
+
expect(body.success).toBe(false);
|
|
502
|
+
expect(body.error).toBe('value-too-large');
|
|
503
|
+
expect(prisma.userData.upsert).not.toHaveBeenCalled();
|
|
504
|
+
|
|
505
|
+
await fastify2.close();
|
|
506
|
+
});
|
|
384
507
|
});
|
|
385
508
|
|
|
386
509
|
describe('DELETE /v1/user/data/:key', () => {
|