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,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /api/revalidate receiver — the site half of #5851's publish webhook
|
|
3
|
+
* (#5877, spec docs/specs/5877-revalidate-receiver-spec.md).
|
|
4
|
+
*
|
|
5
|
+
* This is the SECOND api-shaped route under apps/web/app/api/, beside
|
|
6
|
+
* app/api/auth/[...all]/route.ts — whose header calls itself "the only
|
|
7
|
+
* api-shaped code permitted in apps/web". That claim is amended here: a
|
|
8
|
+
* push-revalidation receiver has to live on the site it purges, because
|
|
9
|
+
* next/cache's tag store is per-deployment. It holds no content and no
|
|
10
|
+
* database handle; it purges cache keys for a read path that goes through
|
|
11
|
+
* apps/api (ADR 0002, headless-first).
|
|
12
|
+
*
|
|
13
|
+
* The wire contract is #5851's, consumed verbatim (D1): POST; body the
|
|
14
|
+
* four-key JSON { workspaceId, type, slug, locale }; headers
|
|
15
|
+
* `x-ailk-revalidate-signature: sha256=<lowercase hex>` — HMAC-SHA256 over
|
|
16
|
+
* the exact body bytes — and `x-ailk-revalidate-event: publish | unpublish`.
|
|
17
|
+
*
|
|
18
|
+
* The order is 503 → 401 → 400, and the signature is verified over the RAW
|
|
19
|
+
* body before it is parsed (D2). Re-serialising a parsed object changes the
|
|
20
|
+
* bytes, and answering 400 before 401 would hand an unauthenticated caller a
|
|
21
|
+
* JSON-parser oracle.
|
|
22
|
+
*
|
|
23
|
+
* Both events take one code path (D6): purging the page tag is what makes an
|
|
24
|
+
* unpublished page disappear, and purging the list tag drops it from the
|
|
25
|
+
* index — the same two purges a publish needs. The event header is logged and
|
|
26
|
+
* never branched on.
|
|
27
|
+
*
|
|
28
|
+
* No replay window, by design and on the wire (D7): #5851 signs the body
|
|
29
|
+
* bytes alone, so there is no timestamp to check a window against, and the
|
|
30
|
+
* only action a replay causes — a second revalidateTag on an already-purged
|
|
31
|
+
* tag — is a no-op. #5902 is the open amendment that would give this receiver
|
|
32
|
+
* a timestamp; D7 is reopened with it.
|
|
33
|
+
*
|
|
34
|
+
* PII / secret boundary (FR9): no log line carries the secret, the signature
|
|
35
|
+
* header value, the request body, or page content.
|
|
36
|
+
*/
|
|
37
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
38
|
+
|
|
39
|
+
import { contentListTag, contentPageTag } from "@working-theory/page-renderer";
|
|
40
|
+
import { revalidateTag } from "next/cache";
|
|
41
|
+
|
|
42
|
+
const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
|
|
43
|
+
const EVENT_HEADER = "x-ailk-revalidate-event";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A FORMAT guard, not a secret-dependent one — it compares the header against
|
|
47
|
+
* a constant pattern, never against the digest (D3).
|
|
48
|
+
*/
|
|
49
|
+
const SIGNATURE_SHAPE = /^sha256=[0-9a-f]{64}$/;
|
|
50
|
+
|
|
51
|
+
type RevalidateBody = {
|
|
52
|
+
workspaceId: string;
|
|
53
|
+
type: string;
|
|
54
|
+
slug: string;
|
|
55
|
+
locale: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Line-for-line the repo's one shipped verifier
|
|
60
|
+
* (apps/api/src/routes/slack/actions.ts:50-62), with the base string changed
|
|
61
|
+
* from Slack's `v0:<ts>:<body>` to the raw body. The length guard is required,
|
|
62
|
+
* not decorative: timingSafeEqual throws on unequal lengths. The `sha256=`
|
|
63
|
+
* prefix stays inside the compared material so a same-length `sha512=` header
|
|
64
|
+
* is rejected outright rather than compared against a sha256 digest.
|
|
65
|
+
*/
|
|
66
|
+
function isValidSignature(
|
|
67
|
+
rawBody: string,
|
|
68
|
+
provided: string,
|
|
69
|
+
secret: string,
|
|
70
|
+
): boolean {
|
|
71
|
+
const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
|
|
72
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
73
|
+
const providedBuf = Buffer.from(provided, "utf8");
|
|
74
|
+
if (expectedBuf.length !== providedBuf.length) return false;
|
|
75
|
+
return timingSafeEqual(expectedBuf, providedBuf);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The parsed four-key body, or null if the payload is not one (FR5). */
|
|
79
|
+
function parseBody(rawBody: string): RevalidateBody | null {
|
|
80
|
+
let parsed: unknown;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(rawBody);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
// The array clause is belt-and-braces: the four string checks below already
|
|
87
|
+
// reject `[]`. It stays because FR5 says "a non-object" and an
|
|
88
|
+
// unauthenticated path is the wrong place to make a reader derive that.
|
|
89
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const { workspaceId, type, slug, locale } = parsed as Record<string, unknown>;
|
|
93
|
+
if (
|
|
94
|
+
typeof workspaceId !== "string" ||
|
|
95
|
+
typeof type !== "string" ||
|
|
96
|
+
typeof slug !== "string" ||
|
|
97
|
+
typeof locale !== "string"
|
|
98
|
+
) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return { workspaceId, type, slug, locale };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function POST(request: Request): Promise<Response> {
|
|
105
|
+
// Honoured if a proxy or a future signer supplies one, else minted so the
|
|
106
|
+
// single line for one delivery is joinable. #5851 ships no correlation
|
|
107
|
+
// header, so roadmap §7.4's end-to-end trace is NOT closed by this route
|
|
108
|
+
// (D8) — it is recorded on #5851, not absorbed here.
|
|
109
|
+
const correlationId = request.headers.get("x-request-id") ?? randomUUID();
|
|
110
|
+
|
|
111
|
+
const rawEvent = request.headers.get(EVENT_HEADER);
|
|
112
|
+
const event =
|
|
113
|
+
rawEvent === "publish" || rawEvent === "unpublish" ? rawEvent : "unknown";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The one place a line is emitted (FR8), so the `[revalidate]` prefix, the
|
|
117
|
+
* correlation id and the event cannot drift between rungs — and so FR9's
|
|
118
|
+
* "no secret, no signature, no body" boundary has ONE site to audit rather
|
|
119
|
+
* than four. Severity carries the outcome so an operator can alert on
|
|
120
|
+
* `error` alone (D9).
|
|
121
|
+
*/
|
|
122
|
+
const log = (
|
|
123
|
+
level: "error" | "warn" | "info",
|
|
124
|
+
fields: Record<string, unknown>,
|
|
125
|
+
): void => {
|
|
126
|
+
console[level](
|
|
127
|
+
`[revalidate] ${JSON.stringify({ correlationId, event, ...fields })}`,
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Read per request, never at module load (D10): a module-scope const latches
|
|
132
|
+
// the value at cold start, so a secret added to a running deployment would
|
|
133
|
+
// keep returning 503 until the lambda recycles.
|
|
134
|
+
const secret = process.env.CONTENT_REVALIDATE_SECRET;
|
|
135
|
+
if (secret === undefined || secret.length === 0) {
|
|
136
|
+
log("error", {
|
|
137
|
+
status: 503,
|
|
138
|
+
reason:
|
|
139
|
+
"CONTENT_REVALIDATE_SECRET unset — the site is not push-revalidated",
|
|
140
|
+
});
|
|
141
|
+
return Response.json({ error: "not_configured" }, { status: 503 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Read once, as text, and kept as the signed material — never re-serialised
|
|
145
|
+
// (FR3).
|
|
146
|
+
const rawBody = await request.text();
|
|
147
|
+
|
|
148
|
+
const provided = request.headers.get(SIGNATURE_HEADER);
|
|
149
|
+
if (
|
|
150
|
+
provided === null ||
|
|
151
|
+
!SIGNATURE_SHAPE.test(provided) ||
|
|
152
|
+
!isValidSignature(rawBody, provided, secret)
|
|
153
|
+
) {
|
|
154
|
+
log("warn", { status: 401 });
|
|
155
|
+
return Response.json({ error: "invalid_signature" }, { status: 401 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const body = parseBody(rawBody);
|
|
159
|
+
if (body === null) {
|
|
160
|
+
log("warn", { status: 400 });
|
|
161
|
+
return Response.json({ error: "invalid_body" }, { status: 400 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The receiver formats no tag (D5). Both strings come from #5875's
|
|
165
|
+
// cache-tags.ts, which the read path calls too — one exported pair, one
|
|
166
|
+
// process, so reader and purger cannot drift. Neither builder takes
|
|
167
|
+
// `workspaceId`: the {workspace} segment is siteWorkspaceSegment(), a
|
|
168
|
+
// property of the deployment that the webhook body cannot influence.
|
|
169
|
+
const pageTag = contentPageTag({
|
|
170
|
+
type: body.type,
|
|
171
|
+
slug: body.slug,
|
|
172
|
+
locale: body.locale,
|
|
173
|
+
});
|
|
174
|
+
const listTag = contentListTag({ type: body.type });
|
|
175
|
+
|
|
176
|
+
// The two-argument form, pinned to next@16.3.3: `profile` is not optional in
|
|
177
|
+
// the declared type, and `{ expire: 0 }` lands on the same
|
|
178
|
+
// immediate-expiration branch as the deprecated one-argument call. Not
|
|
179
|
+
// `"max"` — that carries stale: 300 s, which argues directly against the
|
|
180
|
+
// 60 s budget this route exists to meet (D4).
|
|
181
|
+
revalidateTag(pageTag, { expire: 0 });
|
|
182
|
+
revalidateTag(listTag, { expire: 0 });
|
|
183
|
+
|
|
184
|
+
log("info", {
|
|
185
|
+
status: 200,
|
|
186
|
+
workspaceId: body.workspaceId,
|
|
187
|
+
type: body.type,
|
|
188
|
+
slug: body.slug,
|
|
189
|
+
locale: body.locale,
|
|
190
|
+
pageTag,
|
|
191
|
+
listTag,
|
|
192
|
+
});
|
|
193
|
+
return Response.json({ revalidated: true }, { status: 200 });
|
|
194
|
+
}
|
|
@@ -30,7 +30,34 @@ import { betterAuth } from "better-auth";
|
|
|
30
30
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
31
31
|
import { nextCookies } from "better-auth/next-js";
|
|
32
32
|
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
authEmailTransport,
|
|
35
|
+
makeSendPasswordChangedNotice,
|
|
36
|
+
makeSendResetPassword,
|
|
37
|
+
makeSendVerificationEmail,
|
|
38
|
+
} from "./lib/auth-email";
|
|
39
|
+
import {
|
|
40
|
+
getActiveProviderIds,
|
|
41
|
+
isSignUpOpen,
|
|
42
|
+
resetSendConfigured,
|
|
43
|
+
} from "./lib/auth-env";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reset-token lifetime (#5962 § D11). One hour — the library's own default,
|
|
47
|
+
* set explicitly so the number is reviewable rather than implicit. It is a
|
|
48
|
+
* live credential: the window should be the shortest one a person can
|
|
49
|
+
* realistically act inside.
|
|
50
|
+
*/
|
|
51
|
+
const RESET_TOKEN_TTL_SECONDS = 3600;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Verification-token lifetime (§ D11). Twenty-four hours. Unlike the reset
|
|
55
|
+
* token this is NOT a credential — it is a signed JWT whose blast radius is
|
|
56
|
+
* `emailVerified: true` on one row — so its failure mode is a person who
|
|
57
|
+
* opened the mail next morning having to ask again. The library's 3600 s
|
|
58
|
+
* default is too short for that; a day is not.
|
|
59
|
+
*/
|
|
60
|
+
const VERIFY_TOKEN_TTL_SECONDS = 86_400;
|
|
34
61
|
|
|
35
62
|
// Lazy-load prisma-for-auth: import is deferred until createAuth() is called
|
|
36
63
|
// without an explicit `prisma` dep. This prevents the PrismaClient singleton
|
|
@@ -44,6 +71,16 @@ function getDefaultPrisma(): PrismaClient {
|
|
|
44
71
|
return prismaForAuth;
|
|
45
72
|
}
|
|
46
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The one delegate method `onPasswordReset` reaches for, named so the call
|
|
76
|
+
* site needs no cast. Method shorthand for the same reason `AuthEmailPrisma`
|
|
77
|
+
* uses it: the generated `PrismaClient` satisfies this structurally, and
|
|
78
|
+
* nothing wider than `verification.deleteMany` is reachable through it.
|
|
79
|
+
*/
|
|
80
|
+
interface VerificationStore {
|
|
81
|
+
verification: { deleteMany(args: unknown): Promise<unknown> };
|
|
82
|
+
}
|
|
83
|
+
|
|
47
84
|
type SocialProviders = {
|
|
48
85
|
google?: { clientId: string; clientSecret: string };
|
|
49
86
|
github?: { clientId: string; clientSecret: string };
|
|
@@ -76,15 +113,88 @@ function buildSocialProviders(): SocialProviders {
|
|
|
76
113
|
}
|
|
77
114
|
|
|
78
115
|
/**
|
|
79
|
-
* Resolve the secret used to sign session cookies
|
|
80
|
-
*
|
|
81
|
-
*
|
|
116
|
+
* Resolve the secret used to sign session cookies AND — since #5962 § D9 — the
|
|
117
|
+
* email-verification JWTs.
|
|
118
|
+
*
|
|
119
|
+
* THROWS in production when BETTER_AUTH_SECRET is unset (§ D16). Better Auth's
|
|
120
|
+
* own `validateSecret` throws only when the secret equals ITS default; this
|
|
121
|
+
* repo's literal is a different string, so it cleared that check and produced
|
|
122
|
+
* nothing but a low-entropy warning — while sitting in a repository whose
|
|
123
|
+
* `oss`-tier tree ships publicly. Anyone who can read this file could forge
|
|
124
|
+
* both a session cookie and a verification token on any deployment that
|
|
125
|
+
* omitted the variable.
|
|
126
|
+
*
|
|
127
|
+
* This changes boot behaviour: a production deployment with no
|
|
128
|
+
* BETTER_AUTH_SECRET boots today and does not after this. That is the correct
|
|
129
|
+
* failure — a site signing sessions with a published string is not usefully
|
|
130
|
+
* "up" — and `apps/web/.env.example` has always documented the variable as
|
|
131
|
+
* REQUIRED in production. The change makes the file honest rather than adding
|
|
132
|
+
* a requirement.
|
|
82
133
|
*/
|
|
83
134
|
function resolveSecret(): string {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
135
|
+
const configured = process.env.BETTER_AUTH_SECRET;
|
|
136
|
+
if (configured) return configured;
|
|
137
|
+
|
|
138
|
+
// SERVING, not BUILDING. `next build` collects page data with
|
|
139
|
+
// NODE_ENV=production, and that evaluates `app/api/auth/[...all]/route.ts`,
|
|
140
|
+
// which resolves the lazy `auth` proxy — so an unqualified production throw
|
|
141
|
+
// fails the BUILD of any tree that has not set the variable. That is much
|
|
142
|
+
// broader than the risk: a build produces an artifact, it does not serve a
|
|
143
|
+
// session, and it would break CI, preview builds, and `pnpm build` on a
|
|
144
|
+
// freshly scaffolded site that has not configured auth at all.
|
|
145
|
+
//
|
|
146
|
+
// Next sets NEXT_PHASE to PHASE_PRODUCTION_BUILD ("phase-production-build")
|
|
147
|
+
// for the duration of the build, so excluding that phase keeps the guard
|
|
148
|
+
// exactly where it was aimed: a production process that is about to answer
|
|
149
|
+
// requests. Such a process still throws on the first access to `auth`, which
|
|
150
|
+
// is the intended failure — a site signing sessions with a string published
|
|
151
|
+
// in this repository is not usefully "up".
|
|
152
|
+
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
153
|
+
if (process.env.NODE_ENV === "production" && !isBuildPhase) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"BETTER_AUTH_SECRET is required in production: it signs session cookies and email-verification tokens, and the development fallback is published in this repository.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `advanced.ipAddress.trustedProxies` from AILK_AUTH_TRUSTED_PROXIES (§ D8b).
|
|
164
|
+
*
|
|
165
|
+
* DEPLOYMENT REQUIREMENT when this IS set (security review round 2, finding 2):
|
|
166
|
+
* the origin must not be reachable except through the declared proxies. This
|
|
167
|
+
* guard keys on the VARIABLE being set, not on the IP being resolvable on each
|
|
168
|
+
* request, and `getIPFromHeader` still returns null for a request with no
|
|
169
|
+
* `x-forwarded-for` at all — which would put that request in the shared
|
|
170
|
+
* `no-trusted-ip` bucket WITH the 900 s rules applied. A correctly fronted
|
|
171
|
+
* origin cannot receive such a request (the edge always appends the caller's
|
|
172
|
+
* own peer address), so this is not attacker-reachable there; an origin that
|
|
173
|
+
* is ALSO directly reachable makes it reachable, and the blast radius is
|
|
174
|
+
* site-wide reset denial at five requests per 900 s. Resolving it in code
|
|
175
|
+
* would mean importing `@better-auth/core`'s internal `getIp` — an undeclared
|
|
176
|
+
* transitive dependency — so it is stated as a requirement instead.
|
|
177
|
+
*
|
|
178
|
+
* Better Auth's `getIPFromHeader` returns `null` for any multi-hop
|
|
179
|
+
* `x-forwarded-for` unless this is configured, and a null IP degrades the rate
|
|
180
|
+
* limiter to ONE SHARED BUCKET for the whole deployment — five reset requests
|
|
181
|
+
* from anyone in fifteen minutes would lock out every visitor. The opposite
|
|
182
|
+
* failure is just as real: with a single-hop XFF and no trustedProxies, the
|
|
183
|
+
* client's own header is trusted and an attacker mints a fresh bucket per
|
|
184
|
+
* request.
|
|
185
|
+
*
|
|
186
|
+
* Unset is the honest default: this repo cannot know a downstream site's edge
|
|
187
|
+
* topology, and guessing one is worse than saying so. Both failure modes are
|
|
188
|
+
* documented in `.env.example` beside the variable.
|
|
189
|
+
*/
|
|
190
|
+
function resolveTrustedProxies(): string[] | undefined {
|
|
191
|
+
const raw = process.env.AILK_AUTH_TRUSTED_PROXIES;
|
|
192
|
+
if (!raw) return undefined;
|
|
193
|
+
const entries = raw
|
|
194
|
+
.split(",")
|
|
195
|
+
.map((entry) => entry.trim())
|
|
196
|
+
.filter((entry) => entry.length > 0);
|
|
197
|
+
return entries.length > 0 ? entries : undefined;
|
|
88
198
|
}
|
|
89
199
|
|
|
90
200
|
export interface CreateAuthDeps {
|
|
@@ -106,6 +216,111 @@ export interface CreateAuthDeps {
|
|
|
106
216
|
*/
|
|
107
217
|
export function createAuth(deps: CreateAuthDeps = {}) {
|
|
108
218
|
const prisma = deps.prisma ?? getDefaultPrisma();
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* ONE predicate, read in ONE place (#5962 § D2).
|
|
222
|
+
*
|
|
223
|
+
* AC-2 requires that with the password method disabled the "Forgot
|
|
224
|
+
* password?" link is not rendered. That link lives inside `AuthCard`'s
|
|
225
|
+
* password body, which renders iff the card's `method === "password"`,
|
|
226
|
+
* which `sign-in/page.tsx` derives from `getActiveProviderIds()` and
|
|
227
|
+
* nothing else. Keying `emailAndPassword.enabled` on any OTHER predicate —
|
|
228
|
+
* a new AILK_AUTH_PASSWORD flag, say — would let the two disagree, and
|
|
229
|
+
* AC-2 would be false in the disagreeing configuration.
|
|
230
|
+
*/
|
|
231
|
+
const passwordMethodActive = getActiveProviderIds().includes("email");
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether the reset CALLBACKS are wired at all (§ D2, § D10).
|
|
235
|
+
*
|
|
236
|
+
* Load-bearing: Better Auth's `/request-password-reset` refuses only when
|
|
237
|
+
* `sendResetPassword` is ABSENT. So a defined callback on a site whose page
|
|
238
|
+
* renders the not-available state would be a live anonymous mail-sending
|
|
239
|
+
* surface behind a UI that denies it exists. One predicate decides both.
|
|
240
|
+
*/
|
|
241
|
+
const resetConfigured = resetSendConfigured();
|
|
242
|
+
|
|
243
|
+
const trustedProxies = resolveTrustedProxies();
|
|
244
|
+
|
|
245
|
+
/** Plain assignment, no cast — see `VerificationStore`. */
|
|
246
|
+
const verificationStore: VerificationStore = prisma;
|
|
247
|
+
|
|
248
|
+
// No cast: `AuthEmailPrisma` is declared with method shorthand, which the
|
|
249
|
+
// generated `PrismaClient` satisfies structurally (see its docblock).
|
|
250
|
+
const sendResetPassword = makeSendResetPassword({
|
|
251
|
+
prisma,
|
|
252
|
+
send: authEmailTransport,
|
|
253
|
+
});
|
|
254
|
+
const sendVerificationEmail = makeSendVerificationEmail({
|
|
255
|
+
send: authEmailTransport,
|
|
256
|
+
});
|
|
257
|
+
const sendPasswordChangedNotice = makeSendPasswordChangedNotice({
|
|
258
|
+
send: authEmailTransport,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Better Auth consumes only the token that was presented
|
|
263
|
+
* (`password.mjs`'s `consumeVerificationValue` deletes ONE row). Every OTHER
|
|
264
|
+
* live reset token for this user is still a working credential, so AC-1's
|
|
265
|
+
* "invalidates … every other outstanding token for that user" has to happen
|
|
266
|
+
* here — the library does not do it at any version in this line.
|
|
267
|
+
*
|
|
268
|
+
* `Verification.value` holds the user id and `identifier` is
|
|
269
|
+
* `reset-password:<token>`. There is no index on `value`, so this is a
|
|
270
|
+
* sequential scan; accepted rather than overlooked, because it runs once per
|
|
271
|
+
* COMPLETED reset — the rarest write in this flow — against a table whose
|
|
272
|
+
* rows are short-lived. An index bought for that is a schema change bought
|
|
273
|
+
* with nothing.
|
|
274
|
+
*
|
|
275
|
+
* Both halves are guarded: the reset itself has ALREADY SUCCEEDED by the
|
|
276
|
+
* time this runs, so neither a failed sibling delete nor a failed
|
|
277
|
+
* notification may turn a completed password change into a 500.
|
|
278
|
+
*/
|
|
279
|
+
const onPasswordReset = async ({
|
|
280
|
+
user,
|
|
281
|
+
}: {
|
|
282
|
+
user: { id: string; email: string };
|
|
283
|
+
}): Promise<void> => {
|
|
284
|
+
try {
|
|
285
|
+
await verificationStore.verification.deleteMany({
|
|
286
|
+
where: {
|
|
287
|
+
value: user.id,
|
|
288
|
+
identifier: { startsWith: "reset-password:" },
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
} catch {
|
|
292
|
+
console.warn(
|
|
293
|
+
`[auth-email] ${JSON.stringify({
|
|
294
|
+
userId: user.id,
|
|
295
|
+
kind: "password-reset",
|
|
296
|
+
reason: "sibling_invalidation_failed",
|
|
297
|
+
})}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Guarded like its sibling above, though `lib/auth-email.ts` now also
|
|
302
|
+
* guards its own signing step (#6068). BOTH, deliberately: this callback
|
|
303
|
+
* runs AFTER the password has changed and the presented token has been
|
|
304
|
+
* consumed, so a rejection here is not a failed reset — it is a 500 shown
|
|
305
|
+
* to a visitor whose password DID change, over a token they cannot retry
|
|
306
|
+
* with. That outcome must not depend on a promise made in another file
|
|
307
|
+
* staying true; the notice is best-effort by design, and the completed
|
|
308
|
+
* reset outranks it.
|
|
309
|
+
*/
|
|
310
|
+
try {
|
|
311
|
+
await sendPasswordChangedNotice(user);
|
|
312
|
+
} catch {
|
|
313
|
+
console.warn(
|
|
314
|
+
`[auth-email] ${JSON.stringify({
|
|
315
|
+
userId: user.id,
|
|
316
|
+
kind: "password-changed",
|
|
317
|
+
reason: "send_failed",
|
|
318
|
+
errorKind: "notice_threw",
|
|
319
|
+
})}`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
109
324
|
return betterAuth({
|
|
110
325
|
database: prismaAdapter(prisma, {
|
|
111
326
|
provider: "postgresql",
|
|
@@ -113,6 +328,139 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
113
328
|
secret: resolveSecret(),
|
|
114
329
|
baseURL: process.env.BETTER_AUTH_URL,
|
|
115
330
|
socialProviders: buildSocialProviders(),
|
|
331
|
+
/**
|
|
332
|
+
* The emitted reset link's host and the `redirectTo` allow-list are
|
|
333
|
+
* PINNED, never derived from the request (§ D13).
|
|
334
|
+
*
|
|
335
|
+
* With `baseURL` falsy — every deployment that has not set the variable —
|
|
336
|
+
* Better Auth recomputes both PER REQUEST from the request itself. Before
|
|
337
|
+
* this issue that was a latent host-header bug with no credential behind
|
|
338
|
+
* it. The moment the password method is on it becomes an account-takeover
|
|
339
|
+
* chain: `Host: evil.tld` with `redirectTo: https://evil.tld/x` passes an
|
|
340
|
+
* attacker-derived origin check, the emailed link is built on the
|
|
341
|
+
* attacker's host, and `/reset-password/:token` then redirects the live
|
|
342
|
+
* token to the attacker's origin.
|
|
343
|
+
*
|
|
344
|
+
* `BETTER_AUTH_URL` is also a conjunct of `resetSendConfigured()`, so a
|
|
345
|
+
* deployment that has not pinned its origin never offers reset at all.
|
|
346
|
+
*/
|
|
347
|
+
...(process.env.BETTER_AUTH_URL
|
|
348
|
+
? { trustedOrigins: [process.env.BETTER_AUTH_URL] }
|
|
349
|
+
: {}),
|
|
350
|
+
emailAndPassword: {
|
|
351
|
+
enabled: passwordMethodActive,
|
|
352
|
+
// Turning the password method on also opens POST /sign-up/email —
|
|
353
|
+
// anonymous account creation — and that endpoint answers a taken
|
|
354
|
+
// address with a distinguishable USER_ALREADY_EXISTS, a direct existence
|
|
355
|
+
// oracle no care on /request-password-reset offsets. Default closed.
|
|
356
|
+
disableSignUp: !isSignUpOpen(),
|
|
357
|
+
// Departs from apps-paid/dashboard-web's `true` deliberately:
|
|
358
|
+
// auto-signing-in on account creation means an unverified address holds
|
|
359
|
+
// a session.
|
|
360
|
+
autoSignIn: false,
|
|
361
|
+
// Keeps the library default. Flipping it would change sign-IN behaviour
|
|
362
|
+
// for every existing account on every upgrading site — beyond this issue.
|
|
363
|
+
requireEmailVerification: false,
|
|
364
|
+
// So an attacker holding a stolen session loses it when the real owner
|
|
365
|
+
// resets. Opt-in in the library; not on today.
|
|
366
|
+
revokeSessionsOnPasswordReset: true,
|
|
367
|
+
// Overrides the library's default of 8 — the ONLY check
|
|
368
|
+
// `/reset-password` applies to the new password, and the reset endpoint
|
|
369
|
+
// is precisely where a weak one gets set. No composition rules: they
|
|
370
|
+
// push people toward predictable substitutions and away from length.
|
|
371
|
+
minPasswordLength: 12,
|
|
372
|
+
resetPasswordTokenExpiresIn: RESET_TOKEN_TTL_SECONDS,
|
|
373
|
+
...(resetConfigured ? { sendResetPassword } : {}),
|
|
374
|
+
/**
|
|
375
|
+
* UNCONDITIONAL, unlike `sendResetPassword` (code review, AC-2).
|
|
376
|
+
*
|
|
377
|
+
* Better Auth registers its `/reset-password` POST on
|
|
378
|
+
* `emailAndPassword.enabled` — `passwordMethodActive` — not on
|
|
379
|
+
* `resetConfigured`. So for the token's whole hour after an operator
|
|
380
|
+
* removes any send conjunct, an already-emitted link is still
|
|
381
|
+
* consumable. Gating this callback too would make exactly those resets
|
|
382
|
+
* the ones that skip sibling invalidation and the § D17 notice — the
|
|
383
|
+
* degraded configuration getting the weaker guarantee.
|
|
384
|
+
*
|
|
385
|
+
* It is safe unconditionally because it cannot run without a valid
|
|
386
|
+
* token, which cannot exist without a mail this deployment once sent;
|
|
387
|
+
* and `sendPasswordChangedNotice` logs `not_configured` and returns
|
|
388
|
+
* rather than throwing when the transport is gone (lib/auth-email.ts).
|
|
389
|
+
*/
|
|
390
|
+
onPasswordReset,
|
|
391
|
+
},
|
|
392
|
+
emailVerification: {
|
|
393
|
+
sendOnSignUp: true,
|
|
394
|
+
autoSignInAfterVerification: false,
|
|
395
|
+
expiresIn: VERIFY_TOKEN_TTL_SECONDS,
|
|
396
|
+
...(resetConfigured ? { sendVerificationEmail } : {}),
|
|
397
|
+
},
|
|
398
|
+
/**
|
|
399
|
+
* Rate limiting (§ D8a). `enabled` is set explicitly rather than left to
|
|
400
|
+
* the library's `?? isProduction`: a preview deployment is as enumerable
|
|
401
|
+
* as production.
|
|
402
|
+
*
|
|
403
|
+
* THE WIDENED WINDOWS ARE CONDITIONAL ON A RESOLVABLE CLIENT IP, and that
|
|
404
|
+
* condition is the whole of this block's safety.
|
|
405
|
+
*
|
|
406
|
+
* Better Auth keys the limiter on `getIp()`. With a multi-hop
|
|
407
|
+
* `x-forwarded-for` and no `trustedProxies`, `getIPFromHeader` returns
|
|
408
|
+
* `null` (`forwardedIps.length !== 1`), and the limiter then keys on the
|
|
409
|
+
* literal constant `"no-trusted-ip"` — ONE BUCKET PER PATH FOR THE WHOLE
|
|
410
|
+
* DEPLOYMENT, shared by every visitor. A multi-hop XFF is what a typical
|
|
411
|
+
* platform edge presents, and `AILK_AUTH_TRUSTED_PROXIES` is unset by
|
|
412
|
+
* default because this repo cannot know a downstream site's topology.
|
|
413
|
+
*
|
|
414
|
+
* In that state a 900 s rule is not a rate limit, it is a lockout
|
|
415
|
+
* primitive: five anonymous POSTs to `/request-password-reset` deny
|
|
416
|
+
* password reset to EVERY user of the site, and since `decideConsume`
|
|
417
|
+
* does not refresh `lastRequest` on a denied request, ~5 requests per
|
|
418
|
+
* 900 s holds it there indefinitely. The library's own default for these
|
|
419
|
+
* paths is 60 s / 3 — the same shared bucket, but an outage that recovers
|
|
420
|
+
* on its own in a minute. Setting the wider rules unconditionally would
|
|
421
|
+
* therefore make the default deployment 15× WORSE than configuring
|
|
422
|
+
* nothing at all.
|
|
423
|
+
*
|
|
424
|
+
* So: when the IP is resolvable the § D8a values apply as specified; when
|
|
425
|
+
* it is not, we fall through to the library's shorter-recovery defaults.
|
|
426
|
+
* This is the same reasoning `apps/api/src/lib/auth-email-budget.ts`
|
|
427
|
+
* applies to the site-wide send cap it declines to implement — a
|
|
428
|
+
* site-wide cap an attacker can hold open is a denial of the control it
|
|
429
|
+
* implements — and the two now agree instead of contradicting each other.
|
|
430
|
+
*
|
|
431
|
+
* STORAGE CAVEAT, load-bearing and NOT solved here: `"memory"` is a
|
|
432
|
+
* process-local Map, so on a multi-instance deployment the effective
|
|
433
|
+
* ceiling is 5 × N per 900 s — the identical caveat
|
|
434
|
+
* `apps/api/src/middleware/rate-limit.ts` documents for itself. The
|
|
435
|
+
* durable alternative needs a rate-limit table the schema does not have,
|
|
436
|
+
* and this issue adds no migration. What does bound the consequence is
|
|
437
|
+
* the per-recipient send budget at the apps/api relay.
|
|
438
|
+
*/
|
|
439
|
+
rateLimit: {
|
|
440
|
+
enabled: true,
|
|
441
|
+
storage: "memory",
|
|
442
|
+
// The GLOBAL window/max are inside the guard too, not just customRules
|
|
443
|
+
// (security review round 2, finding 1). They apply to every auth path
|
|
444
|
+
// the library's special rules do NOT cover — `/verify-email`,
|
|
445
|
+
// `/reset-password`, the OAuth callbacks, `/get-session`. Under the
|
|
446
|
+
// shared `no-trusted-ip` bucket a global 60/60 s is TIGHTER than the
|
|
447
|
+
// library's own 10 s/100 default, so setting it unconditionally made
|
|
448
|
+
// those paths ~10x cheaper to exhaust and ~6x slower to recover than
|
|
449
|
+
// `main` — one caller at ~1 rps could deny email verification, reset
|
|
450
|
+
// completion and OAuth sign-in to the whole site, and ordinary traffic
|
|
451
|
+
// on `/get-session` would trip it without an attacker at all.
|
|
452
|
+
...(trustedProxies
|
|
453
|
+
? {
|
|
454
|
+
window: 60,
|
|
455
|
+
max: 60,
|
|
456
|
+
customRules: {
|
|
457
|
+
"/request-password-reset": { window: 900, max: 5 },
|
|
458
|
+
"/reset-password": { window: 900, max: 10 },
|
|
459
|
+
"/send-verification-email": { window: 900, max: 5 },
|
|
460
|
+
},
|
|
461
|
+
}
|
|
462
|
+
: {}),
|
|
463
|
+
},
|
|
116
464
|
// Ids are minted by the DATABASE (ADR-0031): generateId: false makes the
|
|
117
465
|
// Prisma adapter omit `id` on insert so the dbgenerated prefixed-UUIDv7
|
|
118
466
|
// default fires (user_/sess_/acct_/verify_). Do not re-enable.
|
|
@@ -120,6 +468,57 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
120
468
|
database: {
|
|
121
469
|
generateId: false,
|
|
122
470
|
},
|
|
471
|
+
...(trustedProxies ? { ipAddress: { trustedProxies } } : {}),
|
|
472
|
+
/**
|
|
473
|
+
* The timing half of AC-1 (§ D6).
|
|
474
|
+
*
|
|
475
|
+
* `runInBackgroundOrAwait` AWAITS `sendResetPassword` unless this
|
|
476
|
+
* handler is set. A Resend round-trip is tens-to-hundreds of
|
|
477
|
+
* milliseconds; the unknown-address branch's dummy lookup is one indexed
|
|
478
|
+
* read. Identical bodies, non-identical latencies — the exact oracle
|
|
479
|
+
* AC-1's "timing-safe behaviour" names.
|
|
480
|
+
*
|
|
481
|
+
* Next's `after()` runs the callback AFTER the response is flushed,
|
|
482
|
+
* inside the same request's lifetime, so a serverless function is not
|
|
483
|
+
* torn down mid-send. It THROWS outside a Next request scope (a direct
|
|
484
|
+
* `auth.api.*` call from a script), and the library's own try/catch
|
|
485
|
+
* would swallow that into one logger.error — dropping the mail after the
|
|
486
|
+
* endpoint already returned 200. The fallback keeps the promise alive.
|
|
487
|
+
*
|
|
488
|
+
* THIS HANDLER IS GLOBAL, not scoped to reset: `runInBackgroundOrAwait`
|
|
489
|
+
* reads one handler for every call site, and ten others exist in the
|
|
490
|
+
* installed tree (change-email, delete-account, sign-up). None is
|
|
491
|
+
* enabled on this build today, and any future feature that enables one
|
|
492
|
+
* inherits fire-and-forget delivery. Stated here so the next author
|
|
493
|
+
* finds it rather than discovers it.
|
|
494
|
+
*
|
|
495
|
+
* The cost: a backgrounded send returns 200 before the send is known to
|
|
496
|
+
* have succeeded, so a failed send is invisible to the visitor. That is
|
|
497
|
+
* already the correct visitor-facing behaviour — telling them the send
|
|
498
|
+
* failed for THIS address is itself an oracle — and the operator's
|
|
499
|
+
* signal is the log line in lib/auth-email.ts.
|
|
500
|
+
*/
|
|
501
|
+
backgroundTasks: {
|
|
502
|
+
handler: (promise: Promise<unknown>): void => {
|
|
503
|
+
try {
|
|
504
|
+
// Required lazily, not imported at module scope: `next/server`
|
|
505
|
+
// pulls in the Edge runtime's `Request` global, which this
|
|
506
|
+
// module's own test harness (and any plain-Node importer of
|
|
507
|
+
// `createAuth`) does not provide. The deferral keeps `auth.ts`
|
|
508
|
+
// importable outside a Next request.
|
|
509
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
510
|
+
const { after } = require("next/server") as {
|
|
511
|
+
after: (task: () => unknown) => void;
|
|
512
|
+
};
|
|
513
|
+
after(() => promise);
|
|
514
|
+
} catch {
|
|
515
|
+
// `after()` throws outside a Next request scope (a direct
|
|
516
|
+
// auth.api.* call from a script). Keep the promise alive rather
|
|
517
|
+
// than letting the library's try/catch swallow the send.
|
|
518
|
+
void promise.catch(() => {});
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
},
|
|
123
522
|
},
|
|
124
523
|
// Persist sessions in the database so admin revocation by row delete remains
|
|
125
524
|
// the model the dual-mode preHandler relies on.
|
|
@@ -84,11 +84,15 @@ module.exports = {
|
|
|
84
84
|
"<rootDir>/node_modules/@working-theory/site-config/dist/schema/index.js",
|
|
85
85
|
"^@working-theory/validation$": "<rootDir>/node_modules/@working-theory/validation/dist/index.js",
|
|
86
86
|
"^@working-theory/web-i18n$": "<rootDir>/node_modules/@working-theory/web-i18n/dist/index.js",
|
|
87
|
-
// The dependency-free tenancy-constants subpath (database/tenancy.ts
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// pre-`pnpm build`.
|
|
87
|
+
// The dependency-free tenancy-constants subpath (database/tenancy.ts).
|
|
88
|
+
// Mapped to source like every sibling above; `database/dist` is not
|
|
89
|
+
// built in this workspace pre-`pnpm build`.
|
|
91
90
|
"^@working-theory/database/tenancy$": "<rootDir>/node_modules/@working-theory/database/dist/tenancy.js",
|
|
91
|
+
// #5876: database/tenancy.ts imports isSentinel as a VALUE (not just the
|
|
92
|
+
// Id type) from @working-theory/ids, so ts-jest needs it resolvable at
|
|
93
|
+
// runtime here too — mirrors the same mapping added to
|
|
94
|
+
// database/jest.config.cjs and apps/mcp/jest.config.cjs.
|
|
95
|
+
"^@working-theory/ids$": "<rootDir>/node_modules/@working-theory/ids/dist/index.js",
|
|
92
96
|
// #5025: pnpm keys next-intl's virtual-store path on its resolved peers,
|
|
93
97
|
// so apps/web and packages/page-renderer can link two PHYSICALLY DISTINCT
|
|
94
98
|
// copies of the same version — and a `jest.mock("next-intl/server")` in
|