create-ailk 0.4.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 +12248 -0
- package/component-catalog.md +193 -68
- package/dist/cli.js +29 -1
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/extract-module-bundle.d.ts +50 -2
- package/dist/lib/extract-module-bundle.js +223 -15
- package/dist/lib/fetch-guard.d.ts +28 -0
- package/dist/lib/fetch-guard.js +101 -0
- package/dist/lib/fetch-module.js +4 -0
- package/dist/lib/fetch-template.js +4 -0
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/module-architecture.d.ts +60 -8
- package/dist/module-architecture.js +587 -242
- package/dist/parse-args.js +6 -2
- package/dist/programmatic.d.ts +10 -0
- package/dist/programmatic.js +134 -19
- package/dist/site-chrome.d.ts +80 -0
- package/dist/site-chrome.js +135 -0
- package/dist/surfaces.d.ts +21 -6
- package/dist/surfaces.js +209 -36
- package/dist/template-manifest.d.ts +88 -3
- package/dist/template-manifest.js +281 -29
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +2 -0
- package/package.json +10 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
- package/site-templates/marketing-site/ailk-template.json +11 -0
- package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
- package/site-templates/marketing-site/provenance.json +4 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
- package/site-templates/marketing-site-free/ailk-template.json +2 -1
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
- package/site-templates/minimalism-site/ailk-template.json +22 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
- package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
- package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
- package/site-templates/minimalism-site/provenance.json +4 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
- package/site-templates/saas-product/ailk-template.json +16 -0
- package/site-templates/saas-product/provenance.json +10 -0
- 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/.claude/skills/README.md +16 -25
- package/templates/.env.example +102 -12
- package/templates/.github/workflows/ci.yml +48 -0
- package/templates/README.md +3 -1
- 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 +29 -2
- package/templates/apps/api/eslint.config.mjs +69 -0
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +7 -2
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
- 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 +10 -1
- 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 +241 -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 +521 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
- package/templates/apps/api/src/middleware/default-tenant.ts +136 -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/__tests__/list.test.ts +171 -0
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/leads/list.ts +104 -0
- 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 +521 -55
- 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-promotion.test.ts +408 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -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 +300 -0
- 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 +160 -0
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +284 -0
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
- 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 +58 -25
- 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 +12 -9
- 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/{scaffold.test.ts → create_project.test.ts} +23 -23
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/eslint.config.mjs +69 -0
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +8 -2
- 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 +61 -37
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
- 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 +9 -7
- 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 +74 -8
- package/templates/apps/web/CLAUDE.md +46 -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 +67 -14
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +308 -65
- 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/components/google-analytics.tsx +18 -1
- package/templates/apps/web/eslint.config.mjs +69 -0
- package/templates/apps/web/jest.config.cjs +15 -19
- 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 +50 -6
- 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/nav-config.ts +27 -7
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/site-chrome.ts +29 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
- package/templates/apps/web/messages/en.json +216 -2
- package/templates/apps/web/next.config.mjs +20 -0
- package/templates/apps/web/package.json +8 -3
- package/templates/apps/web/sentry.client.config.ts +131 -0
- package/templates/apps/web/sentry.edge.config.ts +72 -0
- package/templates/apps/web/sentry.server.config.ts +72 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
- package/templates/apps/web/src/lib/routes.ts +13 -18
- package/templates/brand_assets/README.md +24 -0
- package/templates/content/en/AboutPage/about.mdx +160 -15
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ContactPage/contact.mdx +90 -13
- package/templates/content/en/FAQPage/faq.mdx +62 -22
- package/templates/content/en/HomePage/home.mdx +244 -20
- package/templates/content/en/LandingPage/blog.mdx +83 -0
- package/templates/content/en/LandingPage/features.mdx +103 -0
- package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +149 -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__/seed.test.ts +1 -1
- 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/eslint.config.mjs +69 -0
- 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 +7 -3
- package/templates/database/seed.ts +1 -1
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/templates/package.json +1 -14
- package/versions.json +52 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
- 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/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
- /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
- /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
- /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file password-reset.test.tsx — AC-2's scorer (#5962).
|
|
3
|
+
*
|
|
4
|
+
* The three pages' branch matrix, rendered against a mocked `auth-env` — the
|
|
5
|
+
* idiom the sibling `auth-pages.test.tsx` and `sign-in/__tests__/page.test.tsx`
|
|
6
|
+
* already use.
|
|
7
|
+
*
|
|
8
|
+
* "Never a 404" is STRUCTURAL rather than asserted here: the three segments
|
|
9
|
+
* exist as files, so Next resolves them whatever the predicate says. What this
|
|
10
|
+
* suite asserts is that each resolves to a real card in every branch.
|
|
11
|
+
*
|
|
12
|
+
* The seventh case is the one a page-render assertion would miss entirely:
|
|
13
|
+
* `forgot-password-card` must pass a NON-EMPTY, ABSOLUTE `redirectTo`. Better
|
|
14
|
+
* Auth rejects an empty `callbackURL` on the token route, so without it every
|
|
15
|
+
* emitted reset link dead-ends at an error page while the page itself looks
|
|
16
|
+
* perfectly correct.
|
|
17
|
+
*/
|
|
18
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
19
|
+
import userEvent from "@testing-library/user-event";
|
|
20
|
+
|
|
21
|
+
const mockIsResetAvailable = jest.fn();
|
|
22
|
+
const mockResetSendConfigured = jest.fn();
|
|
23
|
+
const mockGetActiveProviderIds = jest.fn();
|
|
24
|
+
const mockIsSignUpOpen = jest.fn();
|
|
25
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth-env.ts is a pure env-introspection module with no external boundary to mock instead.
|
|
26
|
+
jest.mock("../../../../lib/auth-env", () => ({
|
|
27
|
+
__esModule: true,
|
|
28
|
+
getActiveProviderIds: () => mockGetActiveProviderIds(),
|
|
29
|
+
isSignUpOpen: () => mockIsSignUpOpen(),
|
|
30
|
+
isResetAvailable: () => mockIsResetAvailable(),
|
|
31
|
+
resetSendConfigured: () => mockResetSendConfigured(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const mockRequestPasswordReset = jest.fn();
|
|
35
|
+
const mockResetPassword = jest.fn();
|
|
36
|
+
jest.mock("better-auth/react", () => ({
|
|
37
|
+
__esModule: true,
|
|
38
|
+
createAuthClient: () => ({
|
|
39
|
+
requestPasswordReset: (...args: unknown[]) =>
|
|
40
|
+
mockRequestPasswordReset(...args),
|
|
41
|
+
resetPassword: (...args: unknown[]) => mockResetPassword(...args),
|
|
42
|
+
signIn: { social: jest.fn(), email: jest.fn() },
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* #6068 AC-1 exercises `createAuth()`'s `onPasswordReset` callback, so the
|
|
48
|
+
* three Better Auth entry points it touches are stubbed the way the sibling
|
|
49
|
+
* `apps/web/__tests__/auth/password-reset-config.test.ts` stubs them — the
|
|
50
|
+
* real ESM-only module never loads under the Jest CJS preset, and `betterAuth`
|
|
51
|
+
* returning `{ options }` is what makes the wired callback reachable at all.
|
|
52
|
+
*/
|
|
53
|
+
jest.mock("better-auth", () => ({
|
|
54
|
+
betterAuth: (options: unknown) => ({ options }),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
jest.mock("better-auth/adapters/prisma", () => ({
|
|
58
|
+
prismaAdapter: () => ({ id: "mock-prisma-adapter" }),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
jest.mock("better-auth/next-js", () => ({
|
|
62
|
+
nextCookies: () => ({ id: "next-cookies" }),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The signer seam. `buildAuthEmailRequest` has exactly one external
|
|
67
|
+
* dependency — `node:crypto`'s `createHmac` — so a throwing `createHmac` is a
|
|
68
|
+
* throwing `buildAuthEmailRequest` reached through the real call path, with no
|
|
69
|
+
* stub standing in for the function under test. Every other member is the real
|
|
70
|
+
* one, and the default implementation below is the real `createHmac`, so only
|
|
71
|
+
* the case that opts in sees a failure.
|
|
72
|
+
*/
|
|
73
|
+
const mockCreateHmac = jest.fn();
|
|
74
|
+
jest.mock("node:crypto", () => {
|
|
75
|
+
const actual = jest.requireActual("node:crypto");
|
|
76
|
+
return {
|
|
77
|
+
...actual,
|
|
78
|
+
createHmac: (...args: unknown[]) => mockCreateHmac(...args),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
import en from "../../../../messages/en.json";
|
|
83
|
+
import { createAuth } from "../../../../auth";
|
|
84
|
+
import ForgotPasswordPage from "../forgot-password/page";
|
|
85
|
+
import ResetPasswordPage from "../reset-password/page";
|
|
86
|
+
import { ResetPasswordCard } from "../reset-password/reset-password-card";
|
|
87
|
+
import SignInPage from "../sign-in/page";
|
|
88
|
+
import VerifyEmailPage from "../verify-email/page";
|
|
89
|
+
|
|
90
|
+
const ORIGINAL_ENV = process.env;
|
|
91
|
+
|
|
92
|
+
/** The slice of Better Auth's options object #6068 AC-1 reads. */
|
|
93
|
+
type AuthOptionsUnderTest = {
|
|
94
|
+
emailAndPassword?: {
|
|
95
|
+
onPasswordReset?: (args: {
|
|
96
|
+
user: { id: string; email: string };
|
|
97
|
+
}) => Promise<void>;
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `NonNullable` on BOTH levels, exactly as the sibling config suite does it:
|
|
103
|
+
* the inner one strips the `| undefined` off the optional `prisma` member,
|
|
104
|
+
* without which `exactOptionalPropertyTypes: true` rejects the assignment.
|
|
105
|
+
*/
|
|
106
|
+
type AuthPrisma = NonNullable<
|
|
107
|
+
NonNullable<Parameters<typeof createAuth>[0]>["prisma"]
|
|
108
|
+
>;
|
|
109
|
+
|
|
110
|
+
async function renderForgot(): Promise<void> {
|
|
111
|
+
render(await ForgotPasswordPage({ params: Promise.resolve({ locale: "en" }) }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function renderReset(
|
|
115
|
+
query: Record<string, string> = {},
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
render(
|
|
118
|
+
await ResetPasswordPage({
|
|
119
|
+
params: Promise.resolve({ locale: "en" }),
|
|
120
|
+
searchParams: Promise.resolve(query),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function renderVerify(
|
|
126
|
+
query: Record<string, string> = {},
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
render(
|
|
129
|
+
await VerifyEmailPage({
|
|
130
|
+
params: Promise.resolve({ locale: "en" }),
|
|
131
|
+
searchParams: Promise.resolve(query),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
process.env = { ...ORIGINAL_ENV, BETTER_AUTH_URL: "https://site.example" };
|
|
138
|
+
jest.clearAllMocks();
|
|
139
|
+
mockIsResetAvailable.mockReturnValue(true);
|
|
140
|
+
mockResetSendConfigured.mockReturnValue(true);
|
|
141
|
+
mockGetActiveProviderIds.mockReturnValue(["email"]);
|
|
142
|
+
mockIsSignUpOpen.mockReturnValue(false);
|
|
143
|
+
mockRequestPasswordReset.mockResolvedValue({ data: {}, error: null });
|
|
144
|
+
mockResetPassword.mockResolvedValue({ data: {}, error: null });
|
|
145
|
+
// The real signer by default — only the #6068 case below makes it throw.
|
|
146
|
+
mockCreateHmac.mockImplementation((...args: unknown[]) =>
|
|
147
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- forwarding an untyped rest to the real overloaded builtin
|
|
148
|
+
(jest.requireActual("node:crypto") as any).createHmac(...args),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterAll(() => {
|
|
153
|
+
process.env = ORIGINAL_ENV;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ── The six states AC-2 names ───────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("/forgot-password", () => {
|
|
159
|
+
it("renders the forgot form when reset is available", async () => {
|
|
160
|
+
await renderForgot();
|
|
161
|
+
expect(screen.getByTestId("auth-email")).toBeInTheDocument();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("renders the not-available state — never a 404 — when it is not", async () => {
|
|
165
|
+
mockResetSendConfigured.mockReturnValue(false);
|
|
166
|
+
await renderForgot();
|
|
167
|
+
expect(screen.getByTestId("forgot-password-page")).toBeInTheDocument();
|
|
168
|
+
expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
|
|
169
|
+
en.auth.forgotUnavailableBody,
|
|
170
|
+
);
|
|
171
|
+
expect(screen.queryByTestId("auth-email")).not.toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("/reset-password", () => {
|
|
176
|
+
it("renders the password fields when it carries a token", async () => {
|
|
177
|
+
await renderReset({ token: "tok_live" });
|
|
178
|
+
expect(screen.getByTestId("auth-password")).toBeInTheDocument();
|
|
179
|
+
expect(screen.getByTestId("auth-confirm-password")).toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("renders the expired-token state — and NO password field — on ?error", async () => {
|
|
183
|
+
await renderReset({ error: "INVALID_TOKEN" });
|
|
184
|
+
expect(screen.getByTestId("reset-token-error")).toBeInTheDocument();
|
|
185
|
+
expect(
|
|
186
|
+
screen.getByRole("heading", { name: en.auth.resetExpiredHeading }),
|
|
187
|
+
).toBeInTheDocument();
|
|
188
|
+
expect(screen.queryByTestId("auth-password")).not.toBeInTheDocument();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("renders its own not-available copy when reset is off", async () => {
|
|
192
|
+
mockResetSendConfigured.mockReturnValue(false);
|
|
193
|
+
await renderReset({ token: "tok_live" });
|
|
194
|
+
expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
|
|
195
|
+
en.auth.resetUnavailableBody,
|
|
196
|
+
);
|
|
197
|
+
expect(screen.queryByTestId("auth-password")).not.toBeInTheDocument();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("/verify-email", () => {
|
|
202
|
+
it("renders the sent terminus with no ?error", async () => {
|
|
203
|
+
await renderVerify();
|
|
204
|
+
expect(screen.getByTestId("auth-sent-body")).toBeInTheDocument();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("renders the expired-token state on ?error", async () => {
|
|
208
|
+
await renderVerify({ error: "TOKEN_EXPIRED" });
|
|
209
|
+
expect(screen.getByTestId("verify-token-error")).toBeInTheDocument();
|
|
210
|
+
expect(screen.queryByTestId("auth-sent-body")).not.toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── The flag-alone configuration: intent set, send impossible ──────────────
|
|
215
|
+
|
|
216
|
+
describe("the flag alone does not open either page", () => {
|
|
217
|
+
/**
|
|
218
|
+
* `AILK_AUTH_PASSWORD_RESET=1` with a send conjunct missing is a real
|
|
219
|
+
* deployment state, and it is the one a flag-only predicate reads as
|
|
220
|
+
* "available". On the request page that renders a live form whose only
|
|
221
|
+
* possible outcome is the error card; on the consume page it renders a live
|
|
222
|
+
* password field over a POST Better Auth still serves. Both pages key on
|
|
223
|
+
* `resetSendConfigured()` so neither can be reached in that state.
|
|
224
|
+
*/
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
mockIsResetAvailable.mockReturnValue(true);
|
|
227
|
+
mockResetSendConfigured.mockReturnValue(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("/forgot-password renders not-available, not the form", async () => {
|
|
231
|
+
await renderForgot();
|
|
232
|
+
expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
|
|
233
|
+
en.auth.forgotUnavailableBody,
|
|
234
|
+
);
|
|
235
|
+
expect(screen.queryByTestId("auth-email")).not.toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("/reset-password renders not-available even carrying a live token", async () => {
|
|
239
|
+
await renderReset({ token: "tok_live" });
|
|
240
|
+
expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
|
|
241
|
+
en.auth.resetUnavailableBody,
|
|
242
|
+
);
|
|
243
|
+
expect(screen.queryByTestId("auth-password")).not.toBeInTheDocument();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── The seventh case: the redirectTo a render assertion cannot see ──────────
|
|
248
|
+
|
|
249
|
+
describe("the forgot island's requestPasswordReset call", () => {
|
|
250
|
+
it("passes a non-empty, ABSOLUTE, same-origin redirectTo", async () => {
|
|
251
|
+
const user = userEvent.setup();
|
|
252
|
+
await renderForgot();
|
|
253
|
+
|
|
254
|
+
await user.type(screen.getByTestId("auth-email"), "person@example.com");
|
|
255
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
256
|
+
|
|
257
|
+
await waitFor(() => expect(mockRequestPasswordReset).toHaveBeenCalled());
|
|
258
|
+
const [args] = mockRequestPasswordReset.mock.calls[0];
|
|
259
|
+
expect(args.email).toBe("person@example.com");
|
|
260
|
+
expect(args.redirectTo).toBe("https://site.example/reset-password");
|
|
261
|
+
// Absolute: originCheck compares an ORIGIN, and an empty callbackURL makes
|
|
262
|
+
// Better Auth reject the emitted link outright.
|
|
263
|
+
expect(args.redirectTo.startsWith("https://")).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("swaps to the sent intent only on success", async () => {
|
|
267
|
+
const user = userEvent.setup();
|
|
268
|
+
await renderForgot();
|
|
269
|
+
|
|
270
|
+
await user.type(screen.getByTestId("auth-email"), "person@example.com");
|
|
271
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
272
|
+
|
|
273
|
+
await waitFor(() =>
|
|
274
|
+
expect(screen.getByTestId("auth-sent-body")).toBeInTheDocument(),
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("shows the error state — never 'check your email' — when the server refuses", async () => {
|
|
279
|
+
mockRequestPasswordReset.mockResolvedValue({
|
|
280
|
+
error: { code: "RESET_PASSWORD_DISABLED" },
|
|
281
|
+
});
|
|
282
|
+
const user = userEvent.setup();
|
|
283
|
+
await renderForgot();
|
|
284
|
+
|
|
285
|
+
await user.type(screen.getByTestId("auth-email"), "person@example.com");
|
|
286
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
287
|
+
|
|
288
|
+
await waitFor(() =>
|
|
289
|
+
expect(screen.getByText(en.auth.resetRequestFailed)).toBeInTheDocument(),
|
|
290
|
+
);
|
|
291
|
+
// The whole point: a site that cannot send never claims it did.
|
|
292
|
+
expect(screen.queryByTestId("auth-sent-body")).not.toBeInTheDocument();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("the reset island's resetPassword call", () => {
|
|
297
|
+
it("sends the token in the body and routes to sign-in on success", async () => {
|
|
298
|
+
// The island directly, so the post-reset navigation is observable —
|
|
299
|
+
// jsdom's `window.location` is read-only, which is why the component takes
|
|
300
|
+
// an injectable `navigate` at all.
|
|
301
|
+
const navigate = jest.fn();
|
|
302
|
+
const user = userEvent.setup();
|
|
303
|
+
render(
|
|
304
|
+
<ResetPasswordCard
|
|
305
|
+
token="tok_live"
|
|
306
|
+
signInHref="/sign-in"
|
|
307
|
+
labels={{
|
|
308
|
+
resetHeading: en.auth.resetHeading,
|
|
309
|
+
resetSubmit: en.auth.resetSubmit,
|
|
310
|
+
newPassword: en.auth.newPassword,
|
|
311
|
+
confirmPassword: en.auth.confirmPassword,
|
|
312
|
+
backToSignIn: en.auth.backToSignIn,
|
|
313
|
+
}}
|
|
314
|
+
errorLabels={{
|
|
315
|
+
resetFailed: en.auth.resetFailed,
|
|
316
|
+
invalidToken: en.auth.resetExpiredSubhead,
|
|
317
|
+
mismatch: en.auth.passwordMismatch,
|
|
318
|
+
}}
|
|
319
|
+
navigate={navigate}
|
|
320
|
+
/>,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await user.type(screen.getByTestId("auth-password"), "a-long-enough-pw");
|
|
324
|
+
await user.type(
|
|
325
|
+
screen.getByTestId("auth-confirm-password"),
|
|
326
|
+
"a-long-enough-pw",
|
|
327
|
+
);
|
|
328
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
329
|
+
|
|
330
|
+
await waitFor(() => expect(mockResetPassword).toHaveBeenCalled());
|
|
331
|
+
expect(mockResetPassword.mock.calls[0][0]).toEqual({
|
|
332
|
+
newPassword: "a-long-enough-pw",
|
|
333
|
+
token: "tok_live",
|
|
334
|
+
});
|
|
335
|
+
await waitFor(() => expect(navigate).toHaveBeenCalledWith("/sign-in"));
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("refuses mismatched passwords without calling the server", async () => {
|
|
339
|
+
const user = userEvent.setup();
|
|
340
|
+
await renderReset({ token: "tok_live" });
|
|
341
|
+
|
|
342
|
+
await user.type(screen.getByTestId("auth-password"), "a-long-enough-pw");
|
|
343
|
+
await user.type(screen.getByTestId("auth-confirm-password"), "different-pw");
|
|
344
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
345
|
+
|
|
346
|
+
await waitFor(() =>
|
|
347
|
+
expect(screen.getByText(en.auth.passwordMismatch)).toBeInTheDocument(),
|
|
348
|
+
);
|
|
349
|
+
expect(mockResetPassword).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("treats an absent token as an expired one, and does not call the server", async () => {
|
|
353
|
+
const user = userEvent.setup();
|
|
354
|
+
await renderReset({});
|
|
355
|
+
|
|
356
|
+
await user.type(screen.getByTestId("auth-password"), "a-long-enough-pw");
|
|
357
|
+
await user.type(
|
|
358
|
+
screen.getByTestId("auth-confirm-password"),
|
|
359
|
+
"a-long-enough-pw",
|
|
360
|
+
);
|
|
361
|
+
await user.click(screen.getByTestId("auth-submit"));
|
|
362
|
+
|
|
363
|
+
await waitFor(() =>
|
|
364
|
+
expect(
|
|
365
|
+
screen.getByText(en.auth.resetExpiredSubhead),
|
|
366
|
+
).toBeInTheDocument(),
|
|
367
|
+
);
|
|
368
|
+
expect(mockResetPassword).not.toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ── #6068 AC-1: a throwing signer cannot reject onPasswordReset ─────────────
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The reset has ALREADY SUCCEEDED by the time `onPasswordReset` runs: the new
|
|
376
|
+
* password is written and the presented token is consumed. A rejection here is
|
|
377
|
+
* therefore not "the reset failed" — it is a 500 handed to a visitor whose
|
|
378
|
+
* password DID change, over a token they can no longer retry with.
|
|
379
|
+
*
|
|
380
|
+
* `sendPasswordChangedNotice` carries a "never throws by construction"
|
|
381
|
+
* invariant, and before #6068 that invariant had a hole: `dispatch()` built
|
|
382
|
+
* and SIGNED the request OUTSIDE its own try, so anything `buildAuthEmailRequest`
|
|
383
|
+
* threw propagated all the way out. #6061 made this callback unconditional, so
|
|
384
|
+
* it now runs in degraded configurations — exactly where a signer is most
|
|
385
|
+
* likely to be unhappy.
|
|
386
|
+
*
|
|
387
|
+
* The signer's one external dependency is `node:crypto`'s `createHmac`, so a
|
|
388
|
+
* throwing `createHmac` IS a throwing `buildAuthEmailRequest`, reached through
|
|
389
|
+
* the real call path rather than a stubbed-out seam.
|
|
390
|
+
*/
|
|
391
|
+
describe("onPasswordReset survives a throwing buildAuthEmailRequest (#6068)", () => {
|
|
392
|
+
const user = { id: "usr_reset", email: "person@example.com" };
|
|
393
|
+
|
|
394
|
+
function onPasswordResetUnderTest(deleteMany: jest.Mock): (args: {
|
|
395
|
+
user: { id: string; email: string };
|
|
396
|
+
}) => Promise<void> {
|
|
397
|
+
// `unknown` on the way in, exactly as the sibling config suite does it, so
|
|
398
|
+
// the stub needs ONE assertion rather than a double one.
|
|
399
|
+
const prismaStub: unknown = { verification: { deleteMany } };
|
|
400
|
+
const auth = createAuth({ prisma: prismaStub as AuthPrisma });
|
|
401
|
+
// The `betterAuth` mock above returns `{ options }` rather than a real
|
|
402
|
+
// instance, so the declared return type is not the runtime shape. There is
|
|
403
|
+
// no boundary type to fix: the double assertion IS the mock seam — the
|
|
404
|
+
// same seam, and the same justification, as the sibling config suite.
|
|
405
|
+
// eslint-disable-next-line no-restricted-syntax -- see above.
|
|
406
|
+
const { options } = auth as unknown as { options: AuthOptionsUnderTest };
|
|
407
|
+
const callback = options.emailAndPassword?.onPasswordReset;
|
|
408
|
+
// Guarded rather than asserted inline: without it the cases below would
|
|
409
|
+
// read `undefined(...)` and fail with a TypeError instead of a diagnosis.
|
|
410
|
+
if (!callback) throw new Error("onPasswordReset was not wired");
|
|
411
|
+
return callback;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
beforeEach(() => {
|
|
415
|
+
// Past `dispatch()`'s `not_configured` early return — without a secret the
|
|
416
|
+
// signer is never reached and the case would pass vacuously.
|
|
417
|
+
process.env.AUTH_EMAIL_WEBHOOK_SECRET = "whsec_test";
|
|
418
|
+
process.env.AUTH_EMAIL_FROM = "noreply@site.example";
|
|
419
|
+
mockCreateHmac.mockImplementation(() => {
|
|
420
|
+
throw new Error("signer unavailable");
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("resolves rather than rejecting — the completed reset is not turned into a 500", async () => {
|
|
425
|
+
const deleteMany = jest.fn().mockResolvedValue({ count: 1 });
|
|
426
|
+
await expect(
|
|
427
|
+
onPasswordResetUnderTest(deleteMany)({ user }),
|
|
428
|
+
).resolves.toBeUndefined();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("still consumes the user's other outstanding reset tokens", async () => {
|
|
432
|
+
const deleteMany = jest.fn().mockResolvedValue({ count: 2 });
|
|
433
|
+
await onPasswordResetUnderTest(deleteMany)({ user });
|
|
434
|
+
// The sibling invalidation runs BEFORE the notice, so a notice that threw
|
|
435
|
+
// would leave it done-but-unreported; what it may never do is leave the
|
|
436
|
+
// caller with a rejected promise over work that already landed.
|
|
437
|
+
expect(deleteMany).toHaveBeenCalledWith({
|
|
438
|
+
where: {
|
|
439
|
+
value: user.id,
|
|
440
|
+
identifier: { startsWith: "reset-password:" },
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// ── #6068 AC-2: the flag-alone config, read through the REAL predicate ──────
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The sibling "the flag alone does not open either page" block above drives
|
|
450
|
+
* the two reset pages through the MOCKED predicate — it asserts the pages
|
|
451
|
+
* branch correctly once `resetSendConfigured()` has already answered false.
|
|
452
|
+
* This block asserts the answer itself, and the third affordance: with
|
|
453
|
+
* `AILK_AUTH_PASSWORD_RESET=1` and `AUTH_EMAIL_WEBHOOK_SECRET` unset — intent
|
|
454
|
+
* set, transport missing, every other conjunct satisfied — the REAL
|
|
455
|
+
* `resetSendConfigured()` is what decides, and `/sign-in` must render no
|
|
456
|
+
* "Forgot password?" link while `/forgot-password` renders its not-available
|
|
457
|
+
* card. A flag-keyed link in this state is the dangling link #6018 shipped to
|
|
458
|
+
* prevent and #6061 left behind on this one surface.
|
|
459
|
+
*
|
|
460
|
+
* The module mock stays (the two pages share it with the whole file); its
|
|
461
|
+
* members are re-bound here to `jest.requireActual`'s real implementations, so
|
|
462
|
+
* the env vars below — not a `mockReturnValue` — are what the pages read.
|
|
463
|
+
*/
|
|
464
|
+
describe("the flag alone hides the /sign-in forgot link (#6068 AC-2)", () => {
|
|
465
|
+
const realAuthEnv = jest.requireActual<
|
|
466
|
+
typeof import("../../../../lib/auth-env")
|
|
467
|
+
>("../../../../lib/auth-env");
|
|
468
|
+
|
|
469
|
+
beforeEach(() => {
|
|
470
|
+
mockIsResetAvailable.mockImplementation(() => realAuthEnv.isResetAvailable());
|
|
471
|
+
mockResetSendConfigured.mockImplementation(() =>
|
|
472
|
+
realAuthEnv.resetSendConfigured(),
|
|
473
|
+
);
|
|
474
|
+
mockGetActiveProviderIds.mockImplementation(() =>
|
|
475
|
+
realAuthEnv.getActiveProviderIds(),
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
// Every conjunct of `resetSendConfigured()` satisfied EXCEPT the send
|
|
479
|
+
// secret: the operator meant to offer reset and the relay cannot sign.
|
|
480
|
+
process.env.AILK_AUTH_PASSWORD_RESET = "1";
|
|
481
|
+
process.env.AUTH_EMAIL_SERVER = "smtp://mail.example";
|
|
482
|
+
process.env.AUTH_EMAIL_FROM = "noreply@site.example";
|
|
483
|
+
process.env.BETTER_AUTH_URL = "https://site.example";
|
|
484
|
+
process.env.BETTER_AUTH_SECRET = "test-secret";
|
|
485
|
+
delete process.env.AUTH_EMAIL_WEBHOOK_SECRET;
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("is a state the two predicates genuinely disagree about", () => {
|
|
489
|
+
// Without this the two cases below could pass for the wrong reason — a
|
|
490
|
+
// config neither predicate calls available proves nothing about which one
|
|
491
|
+
// an affordance keys on.
|
|
492
|
+
expect(realAuthEnv.isResetAvailable()).toBe(true);
|
|
493
|
+
expect(realAuthEnv.resetSendConfigured()).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("/sign-in renders the password form and NO forgot link", async () => {
|
|
497
|
+
render(
|
|
498
|
+
await SignInPage({
|
|
499
|
+
params: Promise.resolve({ locale: "en" }),
|
|
500
|
+
searchParams: Promise.resolve({}),
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
// The card itself is live — this is a hidden affordance, not a dead page.
|
|
504
|
+
expect(screen.getByTestId("auth-password")).toBeInTheDocument();
|
|
505
|
+
expect(screen.queryByTestId("auth-forgot")).toBeNull();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("/forgot-password renders the not-available card", async () => {
|
|
509
|
+
await renderForgot();
|
|
510
|
+
expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
|
|
511
|
+
en.auth.forgotUnavailableBody,
|
|
512
|
+
);
|
|
513
|
+
expect(screen.queryByTestId("auth-email")).not.toBeInTheDocument();
|
|
514
|
+
});
|
|
515
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client"; // Better Auth's client call (requestPasswordReset) + the sent/error state
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ForgotPasswordCard — `/forgot-password`'s client island (#5962).
|
|
5
|
+
*
|
|
6
|
+
* #5963 shipped this page as a static `forgot` intent with no `onSubmit`, so
|
|
7
|
+
* the form was a deliberate no-op. This island is what makes it work.
|
|
8
|
+
*
|
|
9
|
+
* `redirectTo` IS MANDATORY, and that is not a detail. Better Auth builds the
|
|
10
|
+
* emailed link's `callbackURL` from it, and `/reset-password/:token` REJECTS an
|
|
11
|
+
* empty `callbackURL` outright — so without it every emitted reset link would
|
|
12
|
+
* dead-end at an error page and the whole flow would be unreachable while
|
|
13
|
+
* looking configured.
|
|
14
|
+
*
|
|
15
|
+
* It is absolute and same-origin, resolved SERVER-side from `BETTER_AUTH_URL`
|
|
16
|
+
* and handed down as a prop — never from `window.location`, which is the value
|
|
17
|
+
* an attacker influences and the one the pinned `trustedOrigins` exists to stop
|
|
18
|
+
* trusting.
|
|
19
|
+
*
|
|
20
|
+
* The card swaps to the `sent` intent only on a SUCCESSFUL response, which is
|
|
21
|
+
* what keeps "check your email" honest on a site whose transport is missing:
|
|
22
|
+
* the server refuses, and the visitor sees the error state instead.
|
|
23
|
+
*
|
|
24
|
+
* The success copy is identical for a known and an unknown address — the whole
|
|
25
|
+
* point of the endpoint's constant body — so this component must never branch
|
|
26
|
+
* its rendering on anything the response says about the address.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { AuthCard, type AuthCardLabels } from "@working-theory/ui/blocks";
|
|
30
|
+
import { createAuthClient } from "better-auth/react";
|
|
31
|
+
import type { ReactElement } from "react";
|
|
32
|
+
import { useState } from "react";
|
|
33
|
+
|
|
34
|
+
const authClient = createAuthClient();
|
|
35
|
+
|
|
36
|
+
export interface ForgotPasswordCardProps {
|
|
37
|
+
/** Absolute same-origin URL of `/reset-password`, resolved server-side. */
|
|
38
|
+
resetUrl: string;
|
|
39
|
+
/** Href for the card footer's "Back to sign in". */
|
|
40
|
+
signInHref: string;
|
|
41
|
+
/** Translated AuthCard labels (resolved server-side by the page). */
|
|
42
|
+
labels: AuthCardLabels;
|
|
43
|
+
/** Translated error copy. */
|
|
44
|
+
errorLabels: { requestFailed: string };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ForgotPasswordCard({
|
|
48
|
+
resetUrl,
|
|
49
|
+
signInHref,
|
|
50
|
+
labels,
|
|
51
|
+
errorLabels,
|
|
52
|
+
}: ForgotPasswordCardProps): ReactElement {
|
|
53
|
+
const [sent, setSent] = useState(false);
|
|
54
|
+
const [sentTo, setSentTo] = useState("");
|
|
55
|
+
const [errors, setErrors] = useState<{ email?: string }>({});
|
|
56
|
+
|
|
57
|
+
const submit = async (email: string): Promise<void> => {
|
|
58
|
+
setErrors({});
|
|
59
|
+
// A network-level failure rejects; a refusal (the method disabled, the
|
|
60
|
+
// rate limit tripped) resolves with `{ error }`. Both land on the same
|
|
61
|
+
// message — neither may hint at whether the address exists.
|
|
62
|
+
const failed = await authClient
|
|
63
|
+
.requestPasswordReset({ email, redirectTo: resetUrl })
|
|
64
|
+
.then(
|
|
65
|
+
(res) => Boolean(res?.error),
|
|
66
|
+
() => true,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (failed) {
|
|
70
|
+
setErrors({ email: errorLabels.requestFailed });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setSentTo(email);
|
|
74
|
+
setSent(true);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col gap-4" data-testid="forgot-password-page">
|
|
79
|
+
<AuthCard
|
|
80
|
+
intent={sent ? "sent" : "forgot"}
|
|
81
|
+
signInHref={signInHref}
|
|
82
|
+
labels={labels}
|
|
83
|
+
{...(sent ? { sentTo } : {})}
|
|
84
|
+
onSubmit={(data) => void submit(data.email)}
|
|
85
|
+
errors={errors}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|