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,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET/POST /v1/requests/:id/confirm (#5970) — the pay half of
|
|
3
|
+
* request-and-confirm.
|
|
4
|
+
*
|
|
5
|
+
* Driven through `server.inject` against a Fastify instance that registers the
|
|
6
|
+
* real route group, so the urlencoded parser, the path shapes and the headers
|
|
7
|
+
* are all exercised rather than stubbed. The Stripe package is mocked at the
|
|
8
|
+
* PACKAGE boundary (`@working-theory/stripe-client`, which `lib/stripe.ts`
|
|
9
|
+
* re-exports), and Prisma through the repo-standard `__mocks__/prisma.ts`.
|
|
10
|
+
*
|
|
11
|
+
* "Before any Stripe call" is asserted by MEASURING the doubles rather than by
|
|
12
|
+
* reading the handler: every refusal case ends with both
|
|
13
|
+
* `createAdHocPrice` and `createPaymentLink` at zero calls.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { FastifyInstance } from "fastify";
|
|
17
|
+
import Fastify from "fastify";
|
|
18
|
+
|
|
19
|
+
const mockCreateAdHocPrice = jest.fn();
|
|
20
|
+
const mockCreatePaymentLink = jest.fn();
|
|
21
|
+
const mockAssertSandboxKey = jest.fn();
|
|
22
|
+
const mockSendRequestPaidLinkEmail = jest.fn();
|
|
23
|
+
|
|
24
|
+
class MockUnknownProductCodeError extends Error {
|
|
25
|
+
constructor(code: string) {
|
|
26
|
+
super(`Unknown product code: ${code}`);
|
|
27
|
+
this.name = "UnknownProductCodeError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
class MockStripeConfigError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "StripeConfigError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
jest.mock("@working-theory/stripe-client", () => ({
|
|
38
|
+
assertSandboxKey: (...args: unknown[]) => mockAssertSandboxKey(...args),
|
|
39
|
+
createAdHocPrice: (...args: unknown[]) => mockCreateAdHocPrice(...args),
|
|
40
|
+
createPaymentLink: (...args: unknown[]) => mockCreatePaymentLink(...args),
|
|
41
|
+
getStripeClient: jest.fn(),
|
|
42
|
+
UnknownProductCodeError: MockUnknownProductCodeError,
|
|
43
|
+
StripeConfigError: MockStripeConfigError,
|
|
44
|
+
STRIPE_API_VERSION: "2024-06-20",
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// The licence PACKAGE is the boundary the commerce gate reads, so that is what
|
|
48
|
+
// is doubled — never `lib/require-commerce-claim.js` itself. The gate exercised
|
|
49
|
+
// in the last describe block is therefore the REAL one, resolving a real
|
|
50
|
+
// tri-state off a fake licence, which is the only way that test could notice
|
|
51
|
+
// the gate being removed.
|
|
52
|
+
const mockCheckLicense = jest.fn();
|
|
53
|
+
jest.mock("@working-theory/license", () => ({
|
|
54
|
+
checkLicense: (...args: unknown[]) => mockCheckLicense(...args),
|
|
55
|
+
readStoredJwtState: jest.fn().mockResolvedValue({ state: "absent" }),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
jest.mock("@working-theory/email", () => ({
|
|
59
|
+
sendRequestConfirmEmail: jest.fn(),
|
|
60
|
+
sendRequestPaidLinkEmail: (...args: unknown[]) =>
|
|
61
|
+
mockSendRequestPaidLinkEmail(...args),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
65
|
+
jest.mock("../../../lib/prisma.js");
|
|
66
|
+
|
|
67
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
68
|
+
import { hashConfirmToken } from "../../../services/request-confirm.js";
|
|
69
|
+
import { requireCommerceClaim } from "../../../lib/require-commerce-claim.js";
|
|
70
|
+
import { requestRoutes } from "../index.js";
|
|
71
|
+
|
|
72
|
+
// A valid prefixed-UUIDv7 shape (ADR-0031) — `confirm.ts` now validates
|
|
73
|
+
// `request.params.id` with `zCallbackId` (#6019 AC-3), and this fixture must
|
|
74
|
+
// pass that gate exactly as a database-generated id would.
|
|
75
|
+
const ID = "callbk_00000000-0000-7000-8000-000000000001";
|
|
76
|
+
const TOKEN = "confirm-token-value";
|
|
77
|
+
const PATH = `/v1/requests/${ID}/confirm`;
|
|
78
|
+
const ROW = {
|
|
79
|
+
id: ID,
|
|
80
|
+
name: "Ada Lovelace",
|
|
81
|
+
phone: "+44 7700 900000",
|
|
82
|
+
preferredTime: "Tuesday morning",
|
|
83
|
+
payload: { email: "buyer@example.com" },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const ENV_KEYS = [
|
|
87
|
+
"AILK_REQUEST_CONFIRM_ENABLED",
|
|
88
|
+
"AILK_REQUEST_CONFIRM_TO",
|
|
89
|
+
"AILK_REQUEST_CONFIRM_FROM",
|
|
90
|
+
"AILK_REQUEST_CONFIRM_MAX_MINOR",
|
|
91
|
+
"AILK_PUBLIC_API_URL",
|
|
92
|
+
"STRIPE_SECRET_KEY",
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
95
|
+
function enable(overrides: Record<string, string | undefined> = {}) {
|
|
96
|
+
process.env["AILK_REQUEST_CONFIRM_ENABLED"] = "true";
|
|
97
|
+
process.env["AILK_REQUEST_CONFIRM_TO"] = "owner@example.com";
|
|
98
|
+
process.env["AILK_REQUEST_CONFIRM_FROM"] = "hello@example.com";
|
|
99
|
+
process.env["AILK_PUBLIC_API_URL"] = "https://api.example.com";
|
|
100
|
+
process.env["STRIPE_SECRET_KEY"] = "sk_test_confirm_route";
|
|
101
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
102
|
+
if (value === undefined) delete process.env[key];
|
|
103
|
+
else process.env[key] = value;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let server: FastifyInstance;
|
|
108
|
+
|
|
109
|
+
beforeEach(async () => {
|
|
110
|
+
jest.clearAllMocks();
|
|
111
|
+
for (const key of ENV_KEYS) delete process.env[key];
|
|
112
|
+
enable();
|
|
113
|
+
|
|
114
|
+
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
115
|
+
mockCreateAdHocPrice.mockResolvedValue({
|
|
116
|
+
priceId: "price_1",
|
|
117
|
+
lookupKey: `ailk-request-${ID}`,
|
|
118
|
+
});
|
|
119
|
+
mockCreatePaymentLink.mockResolvedValue({
|
|
120
|
+
paymentLinkId: "plink_abc12345",
|
|
121
|
+
url: "https://buy.stripe.com/test_abc",
|
|
122
|
+
});
|
|
123
|
+
mockSendRequestPaidLinkEmail.mockResolvedValue({ ok: true, messageId: "m" });
|
|
124
|
+
(prisma.callback.findFirst as jest.Mock).mockResolvedValue(ROW);
|
|
125
|
+
(prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
126
|
+
(prisma.callback.update as jest.Mock).mockResolvedValue({});
|
|
127
|
+
|
|
128
|
+
server = Fastify({ logger: false });
|
|
129
|
+
await server.register(requestRoutes);
|
|
130
|
+
await server.ready();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(async () => {
|
|
134
|
+
await server.close();
|
|
135
|
+
for (const key of ENV_KEYS) delete process.env[key];
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function get(query = `?token=${TOKEN}`) {
|
|
139
|
+
return server.inject({ method: "GET", url: `${PATH}${query}` });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function post(fields: Record<string, string>) {
|
|
143
|
+
return server.inject({
|
|
144
|
+
method: "POST",
|
|
145
|
+
url: PATH,
|
|
146
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
147
|
+
payload: new URLSearchParams(fields).toString(),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const VALID_AMOUNT_FORM = {
|
|
152
|
+
token: TOKEN,
|
|
153
|
+
confirmedTime: "Tuesday 9am",
|
|
154
|
+
buyerEmail: "buyer@example.com",
|
|
155
|
+
amountMinor: "12900",
|
|
156
|
+
currency: "usd",
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
function noStripeCalls() {
|
|
160
|
+
expect(mockCreateAdHocPrice).not.toHaveBeenCalled();
|
|
161
|
+
expect(mockCreatePaymentLink).not.toHaveBeenCalled();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── GET — the form, its headers and its escaping (D7) ──────────────────────
|
|
165
|
+
|
|
166
|
+
describe("GET — the confirm form", () => {
|
|
167
|
+
it("renders the form as html with the five security headers", async () => {
|
|
168
|
+
const res = await get();
|
|
169
|
+
expect(res.statusCode).toBe(200);
|
|
170
|
+
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8");
|
|
171
|
+
expect(res.headers["cache-control"]).toBe("no-store");
|
|
172
|
+
// The token is in the query string — this is what keeps it out of a third
|
|
173
|
+
// party's referrer log.
|
|
174
|
+
expect(res.headers["referrer-policy"]).toBe("no-referrer");
|
|
175
|
+
expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
|
|
176
|
+
expect(res.headers["content-security-policy"]).toContain(
|
|
177
|
+
"default-src 'none'",
|
|
178
|
+
);
|
|
179
|
+
expect(res.headers["content-security-policy"]).toContain(
|
|
180
|
+
"frame-ancestors 'none'",
|
|
181
|
+
);
|
|
182
|
+
// Neither of these falls back to `default-src` under CSP3.
|
|
183
|
+
expect(res.headers["content-security-policy"]).toContain(
|
|
184
|
+
"form-action 'self'",
|
|
185
|
+
);
|
|
186
|
+
expect(res.headers["content-security-policy"]).toContain("base-uri 'none'");
|
|
187
|
+
expect(res.headers["x-content-type-options"]).toBe("nosniff");
|
|
188
|
+
expect(res.body).toContain("Ada Lovelace");
|
|
189
|
+
expect(res.body).toContain("Confirm this request");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("prefills the buyer email from the captured payload", async () => {
|
|
193
|
+
expect((await get()).body).toContain('value="buyer@example.com"');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("escapes an attacker-supplied name rather than rendering it as markup", async () => {
|
|
197
|
+
(prisma.callback.findFirst as jest.Mock).mockResolvedValue({
|
|
198
|
+
...ROW,
|
|
199
|
+
name: "<script>alert(1)</script>",
|
|
200
|
+
});
|
|
201
|
+
const res = await get();
|
|
202
|
+
expect(res.body).not.toContain("<script>alert(1)</script>");
|
|
203
|
+
expect(res.body).toContain("<script>alert(1)</script>");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("answers one 404 for a token that does not resolve, whatever the reason", async () => {
|
|
207
|
+
(prisma.callback.findFirst as jest.Mock).mockResolvedValue(null);
|
|
208
|
+
const res = await get("?token=tampered");
|
|
209
|
+
expect(res.statusCode).toBe(404);
|
|
210
|
+
noStripeCalls();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("answers 404 with no token at all", async () => {
|
|
214
|
+
expect((await get("")).statusCode).toBe(404);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("answers 404 when the feature is not configured", async () => {
|
|
218
|
+
delete process.env["AILK_REQUEST_CONFIRM_ENABLED"];
|
|
219
|
+
expect((await get()).statusCode).toBe(404);
|
|
220
|
+
expect(prisma.callback.findFirst).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// #6019 AC-3: `:id` is validated with `zCallbackId` (the sibling routes'
|
|
224
|
+
// idiom) before anything else runs — a malformed id can never resolve a
|
|
225
|
+
// row anyway, so this changes nothing about what a caller learns, only
|
|
226
|
+
// where the request stops.
|
|
227
|
+
it("answers 404 for a malformed id, without ever querying Prisma", async () => {
|
|
228
|
+
const res = await server.inject({
|
|
229
|
+
method: "GET",
|
|
230
|
+
url: `/v1/requests/not-a-callback-id/confirm?token=${TOKEN}`,
|
|
231
|
+
});
|
|
232
|
+
expect(res.statusCode).toBe(404);
|
|
233
|
+
expect(prisma.callback.findFirst).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ── POST — the happy paths, both of D4's price routes ──────────────────────
|
|
238
|
+
|
|
239
|
+
describe("POST — D4 path 2, an amount priced at confirm time", () => {
|
|
240
|
+
it("stamps, mints ONE Payment Link through an ad-hoc price, and emails the buyer", async () => {
|
|
241
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
242
|
+
expect(res.statusCode).toBe(200);
|
|
243
|
+
|
|
244
|
+
// The claim carries the stamp AND spends the credential, in one update.
|
|
245
|
+
const claim = (prisma.callback.updateMany as jest.Mock).mock.calls[0][0];
|
|
246
|
+
expect(claim.where.confirmTokenHash).toBe(hashConfirmToken(TOKEN));
|
|
247
|
+
expect(claim.data.confirmedAt).toBeInstanceOf(Date);
|
|
248
|
+
expect(claim.data.confirmedTime).toBe("Tuesday 9am");
|
|
249
|
+
expect(claim.data.confirmTokenHash).toBeNull();
|
|
250
|
+
|
|
251
|
+
expect(mockCreateAdHocPrice).toHaveBeenCalledTimes(1);
|
|
252
|
+
expect(mockCreateAdHocPrice.mock.calls[0][0]).toMatchObject({
|
|
253
|
+
currency: "usd",
|
|
254
|
+
unitAmountMinor: 12900,
|
|
255
|
+
lookupKey: `ailk-request-${ID}`,
|
|
256
|
+
});
|
|
257
|
+
expect(mockCreatePaymentLink).toHaveBeenCalledTimes(1);
|
|
258
|
+
// `completedSessionsLimit: 1` is what stops the emailed link staying
|
|
259
|
+
// permanently payable (#5970 security review round 2, M1).
|
|
260
|
+
expect(mockCreatePaymentLink).toHaveBeenCalledWith({
|
|
261
|
+
lookupKey: `ailk-request-${ID}`,
|
|
262
|
+
completedSessionsLimit: 1,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// The artifact id lands on the row — the key the webhook stamp matches on.
|
|
266
|
+
expect(prisma.callback.update).toHaveBeenCalledWith({
|
|
267
|
+
where: { id: ID },
|
|
268
|
+
data: { stripePaymentLinkId: "plink_abc12345" },
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(mockSendRequestPaidLinkEmail).toHaveBeenCalledTimes(1);
|
|
272
|
+
expect(mockSendRequestPaidLinkEmail.mock.calls[0][0]).toMatchObject({
|
|
273
|
+
to: "buyer@example.com",
|
|
274
|
+
paymentUrl: "https://buy.stripe.com/test_abc",
|
|
275
|
+
});
|
|
276
|
+
expect(res.body).toContain("https://buy.stripe.com/test_abc");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("POST — D4 path 1, a price already in the tenant's catalogue", () => {
|
|
281
|
+
it("passes the lookup key straight to createPaymentLink and mints no price", async () => {
|
|
282
|
+
const res = await post({
|
|
283
|
+
token: TOKEN,
|
|
284
|
+
confirmedTime: "Tuesday 9am",
|
|
285
|
+
buyerEmail: "buyer@example.com",
|
|
286
|
+
lookupKey: "gutter-clean-standard",
|
|
287
|
+
});
|
|
288
|
+
expect(res.statusCode).toBe(200);
|
|
289
|
+
expect(mockCreateAdHocPrice).not.toHaveBeenCalled();
|
|
290
|
+
expect(mockCreatePaymentLink).toHaveBeenCalledWith({
|
|
291
|
+
lookupKey: "gutter-clean-standard",
|
|
292
|
+
completedSessionsLimit: 1,
|
|
293
|
+
});
|
|
294
|
+
// No amount was ever known, so none is stamped and none is emailed.
|
|
295
|
+
const claim = (prisma.callback.updateMany as jest.Mock).mock.calls[0][0];
|
|
296
|
+
expect(claim.data).not.toHaveProperty("amountMinor");
|
|
297
|
+
expect(mockSendRequestPaidLinkEmail.mock.calls[0][0]).not.toHaveProperty(
|
|
298
|
+
"amountMinor",
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── POST — the refusals, every one before any Stripe call ──────────────────
|
|
304
|
+
|
|
305
|
+
describe("POST — refusals before any Stripe call", () => {
|
|
306
|
+
it("refuses a form carrying BOTH a price and a lookup key", async () => {
|
|
307
|
+
const res = await post({ ...VALID_AMOUNT_FORM, lookupKey: "x" });
|
|
308
|
+
expect(res.statusCode).toBe(400);
|
|
309
|
+
noStripeCalls();
|
|
310
|
+
expect(prisma.callback.updateMany).not.toHaveBeenCalled();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("refuses a form carrying NEITHER", async () => {
|
|
314
|
+
const { amountMinor: _a, currency: _c, ...rest } = VALID_AMOUNT_FORM;
|
|
315
|
+
const res = await post(rest);
|
|
316
|
+
expect(res.statusCode).toBe(400);
|
|
317
|
+
noStripeCalls();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it.each([
|
|
321
|
+
["a zero amount", { amountMinor: "0" }],
|
|
322
|
+
["a negative amount", { amountMinor: "-100" }],
|
|
323
|
+
["a fractional amount", { amountMinor: "129.5" }],
|
|
324
|
+
["a non-numeric amount", { amountMinor: "lots" }],
|
|
325
|
+
// #5970 security review round 3, L3 — a money amount must never be
|
|
326
|
+
// silently reinterpreted from exponent or hex notation.
|
|
327
|
+
["an exponent-notation amount", { amountMinor: "1e5" }],
|
|
328
|
+
["a hexadecimal amount", { amountMinor: "0x2710" }],
|
|
329
|
+
["a padded amount", { amountMinor: " 500 " }],
|
|
330
|
+
["a malformed currency", { currency: "dollars" }],
|
|
331
|
+
["an unparseable buyer email", { buyerEmail: "not-an-address" }],
|
|
332
|
+
["an empty confirmed time", { confirmedTime: "" }],
|
|
333
|
+
])("refuses %s", async (_label, override) => {
|
|
334
|
+
const res = await post({ ...VALID_AMOUNT_FORM, ...override });
|
|
335
|
+
expect(res.statusCode).toBe(400);
|
|
336
|
+
noStripeCalls();
|
|
337
|
+
expect(prisma.callback.updateMany).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("refuses an amount above the configured ceiling", async () => {
|
|
341
|
+
enable({ AILK_REQUEST_CONFIRM_MAX_MINOR: "10000" });
|
|
342
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
343
|
+
expect(res.statusCode).toBe(400);
|
|
344
|
+
noStripeCalls();
|
|
345
|
+
expect(prisma.callback.updateMany).not.toHaveBeenCalled();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("refuses a token that does not resolve, and mints nothing", async () => {
|
|
349
|
+
// The guarded claim reports zero rows — a tampered, expired, spent or
|
|
350
|
+
// cross-row token all land here identically.
|
|
351
|
+
(prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
|
352
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
353
|
+
expect(res.statusCode).toBe(404);
|
|
354
|
+
noStripeCalls();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("is single-use: a second POST with the same token is a 404 and mints nothing more", async () => {
|
|
358
|
+
const first = await post(VALID_AMOUNT_FORM);
|
|
359
|
+
expect(first.statusCode).toBe(200);
|
|
360
|
+
(prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
|
|
361
|
+
const second = await post(VALID_AMOUNT_FORM);
|
|
362
|
+
expect(second.statusCode).toBe(404);
|
|
363
|
+
// One Payment Link across both requests.
|
|
364
|
+
expect(mockCreatePaymentLink).toHaveBeenCalledTimes(1);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("refuses a live Stripe key before claiming the credential", async () => {
|
|
368
|
+
mockAssertSandboxKey.mockImplementation(() => {
|
|
369
|
+
throw new MockStripeConfigError("live mode blocked");
|
|
370
|
+
});
|
|
371
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
372
|
+
expect(res.statusCode).toBe(500);
|
|
373
|
+
noStripeCalls();
|
|
374
|
+
expect(prisma.callback.updateMany).not.toHaveBeenCalled();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("answers 500 when Stripe is not configured at all", async () => {
|
|
378
|
+
delete process.env["STRIPE_SECRET_KEY"];
|
|
379
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
380
|
+
expect(res.statusCode).toBe(500);
|
|
381
|
+
noStripeCalls();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("answers 404 when the feature is not configured", async () => {
|
|
385
|
+
delete process.env["AILK_REQUEST_CONFIRM_ENABLED"];
|
|
386
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
387
|
+
expect(res.statusCode).toBe(404);
|
|
388
|
+
noStripeCalls();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// #6019 AC-3 — the same params-first check as the GET handler; a malformed
|
|
392
|
+
// `:id` is refused before the credential preflight touches Prisma.
|
|
393
|
+
it("answers 404 for a malformed id, without ever querying Prisma or Stripe", async () => {
|
|
394
|
+
const res = await server.inject({
|
|
395
|
+
method: "POST",
|
|
396
|
+
url: "/v1/requests/not-a-callback-id/confirm",
|
|
397
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
398
|
+
payload: new URLSearchParams(VALID_AMOUNT_FORM).toString(),
|
|
399
|
+
});
|
|
400
|
+
expect(res.statusCode).toBe(404);
|
|
401
|
+
noStripeCalls();
|
|
402
|
+
expect(prisma.callback.findFirst).not.toHaveBeenCalled();
|
|
403
|
+
expect(prisma.callback.updateMany).not.toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ── POST — Stripe's own failures ───────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
describe("POST — Stripe failures", () => {
|
|
410
|
+
it("maps an unknown lookup key to a 400 and creates no link", async () => {
|
|
411
|
+
mockCreatePaymentLink.mockRejectedValue(
|
|
412
|
+
new MockUnknownProductCodeError("nope"),
|
|
413
|
+
);
|
|
414
|
+
const res = await post({
|
|
415
|
+
token: TOKEN,
|
|
416
|
+
confirmedTime: "Tuesday 9am",
|
|
417
|
+
buyerEmail: "buyer@example.com",
|
|
418
|
+
lookupKey: "nope",
|
|
419
|
+
});
|
|
420
|
+
expect(res.statusCode).toBe(400);
|
|
421
|
+
expect(prisma.callback.update).not.toHaveBeenCalled();
|
|
422
|
+
expect(mockSendRequestPaidLinkEmail).not.toHaveBeenCalled();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("answers 502 when the Stripe call fails for any other reason, and says the link is spent", async () => {
|
|
426
|
+
mockCreatePaymentLink.mockRejectedValue(new Error("socket hang up"));
|
|
427
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
428
|
+
expect(res.statusCode).toBe(502);
|
|
429
|
+
expect(mockSendRequestPaidLinkEmail).not.toHaveBeenCalled();
|
|
430
|
+
// #5970 security review round 3, L2: the page must not promise a retry
|
|
431
|
+
// the system cannot honour — there is no path that mints a fresh link.
|
|
432
|
+
expect(res.body).toContain("now used up");
|
|
433
|
+
expect(res.body).not.toContain("try again from a fresh link");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("still returns the link, with different wording, when the buyer email fails", async () => {
|
|
437
|
+
mockSendRequestPaidLinkEmail.mockResolvedValue({
|
|
438
|
+
ok: false,
|
|
439
|
+
error: { kind: "send_failed", message: "rejected" },
|
|
440
|
+
});
|
|
441
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
442
|
+
expect(res.statusCode).toBe(200);
|
|
443
|
+
expect(res.body).toContain("https://buy.stripe.com/test_abc");
|
|
444
|
+
expect(res.body).toContain("could not email the customer");
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ── The claim-free property (AC-4 / D9) ────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
describe("no licence claim is read by this route's own files (AC-4)", () => {
|
|
451
|
+
it("has no @working-theory/license import in any file this feature added", () => {
|
|
452
|
+
const { readFileSync } = require("node:fs");
|
|
453
|
+
const files = [
|
|
454
|
+
"src/routes/requests/index.ts",
|
|
455
|
+
"src/routes/requests/confirm.ts",
|
|
456
|
+
"src/services/request-confirm.ts",
|
|
457
|
+
];
|
|
458
|
+
for (const file of files) {
|
|
459
|
+
const source = readFileSync(`${__dirname}/../../../../${file}`, "utf8");
|
|
460
|
+
expect(source).not.toMatch(/from ["']@working-theory\/license["']/);
|
|
461
|
+
expect(source).not.toMatch(/\bcheckLicense\(/);
|
|
462
|
+
expect(source).not.toMatch(/\bresolveCommerceClaim\(/);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ── The credential is checked before anything configuration-dependent ──────
|
|
468
|
+
//
|
|
469
|
+
// #5970 security review, finding 6. Every one of these cases holds NO valid
|
|
470
|
+
// token, and every one must answer the same 404 an unresolvable token gets —
|
|
471
|
+
// otherwise an anonymous caller reads the deployment's money posture off the
|
|
472
|
+
// status codes, and can bisect `amountMinor` to recover the configured
|
|
473
|
+
// ceiling.
|
|
474
|
+
describe("POST — a caller with no credential learns nothing (finding 6)", () => {
|
|
475
|
+
beforeEach(() => {
|
|
476
|
+
// Nothing resolves: this caller holds no live token.
|
|
477
|
+
(prisma.callback.findFirst as jest.Mock).mockResolvedValue(null);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it.each([
|
|
481
|
+
[
|
|
482
|
+
"an amount above the ceiling",
|
|
483
|
+
VALID_AMOUNT_FORM,
|
|
484
|
+
{ AILK_REQUEST_CONFIRM_MAX_MINOR: "10000" },
|
|
485
|
+
],
|
|
486
|
+
["both price paths at once", { ...VALID_AMOUNT_FORM, lookupKey: "x" }, {}],
|
|
487
|
+
["a malformed body", { token: "nope" }, {}],
|
|
488
|
+
["a live Stripe key", VALID_AMOUNT_FORM, {}],
|
|
489
|
+
])("answers the uniform 404 for %s", async (label, form, env) => {
|
|
490
|
+
if (Object.keys(env).length > 0) enable(env);
|
|
491
|
+
if (label === "a live Stripe key") {
|
|
492
|
+
mockAssertSandboxKey.mockImplementation(() => {
|
|
493
|
+
throw new MockStripeConfigError("live mode blocked");
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
const res = await post(form as Record<string, string>);
|
|
497
|
+
expect(res.statusCode).toBe(404);
|
|
498
|
+
noStripeCalls();
|
|
499
|
+
expect(prisma.callback.updateMany).not.toHaveBeenCalled();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("cannot be used to read the configured ceiling by bisection", async () => {
|
|
503
|
+
enable({ AILK_REQUEST_CONFIRM_MAX_MINOR: "10000" });
|
|
504
|
+
const under = await post({ ...VALID_AMOUNT_FORM, amountMinor: "5000" });
|
|
505
|
+
const over = await post({ ...VALID_AMOUNT_FORM, amountMinor: "50000" });
|
|
506
|
+
// Identical: the ceiling is invisible without a credential.
|
|
507
|
+
expect(under.statusCode).toBe(over.statusCode);
|
|
508
|
+
expect(under.statusCode).toBe(404);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// ── A typo'd lookup key gives the credential back (finding 4) ──────────────
|
|
513
|
+
|
|
514
|
+
describe("POST — an unknown lookup key does not burn the credential", () => {
|
|
515
|
+
it("restores the digest and clears the stamp so the operator can retry", async () => {
|
|
516
|
+
mockCreatePaymentLink.mockRejectedValue(
|
|
517
|
+
new MockUnknownProductCodeError("typo"),
|
|
518
|
+
);
|
|
519
|
+
const res = await post({
|
|
520
|
+
token: TOKEN,
|
|
521
|
+
confirmedTime: "Tuesday 9am",
|
|
522
|
+
buyerEmail: "buyer@example.com",
|
|
523
|
+
lookupKey: "typo",
|
|
524
|
+
});
|
|
525
|
+
expect(res.statusCode).toBe(400);
|
|
526
|
+
|
|
527
|
+
// Two updateMany calls: the claim, then the release.
|
|
528
|
+
const calls = (prisma.callback.updateMany as jest.Mock).mock.calls;
|
|
529
|
+
expect(calls).toHaveLength(2);
|
|
530
|
+
const release = calls[1][0];
|
|
531
|
+
// `stripePaymentLinkId: null` is what makes "a row that produced nothing"
|
|
532
|
+
// a predicate rather than a claim about the one caller (round 2).
|
|
533
|
+
expect(release.where).toEqual({
|
|
534
|
+
id: ID,
|
|
535
|
+
confirmTokenHash: null,
|
|
536
|
+
stripePaymentLinkId: null,
|
|
537
|
+
paidAt: null,
|
|
538
|
+
});
|
|
539
|
+
// The digest comes back, and the stamp is cleared.
|
|
540
|
+
expect(release.data.confirmTokenHash).toBe(hashConfirmToken(TOKEN));
|
|
541
|
+
expect(release.data.confirmedAt).toBeNull();
|
|
542
|
+
expect(release.data.confirmedTime).toBeNull();
|
|
543
|
+
// The expiry is NOT rewritten — a release cannot extend a credential.
|
|
544
|
+
expect(release.data).not.toHaveProperty("confirmTokenExpiresAt");
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("does NOT release on a generic Stripe failure, which may have created a link", async () => {
|
|
548
|
+
mockCreatePaymentLink.mockRejectedValue(new Error("socket hang up"));
|
|
549
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
550
|
+
expect(res.statusCode).toBe(502);
|
|
551
|
+
expect((prisma.callback.updateMany as jest.Mock).mock.calls).toHaveLength(
|
|
552
|
+
1,
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// ── The commerce gate covers BOTH verbs (AC-4 / spec D3) ───────────────────
|
|
558
|
+
//
|
|
559
|
+
// #5970 security review, § 8: every other case in this file registers
|
|
560
|
+
// `requestRoutes` directly, which is the right isolation for the handlers but
|
|
561
|
+
// leaves the GATE untested — and the gate is applied by COMPOSITION in
|
|
562
|
+
// `server.ts` rather than by anything in the route files, which is precisely
|
|
563
|
+
// why no route-level test would notice it disappearing. So this block builds
|
|
564
|
+
// the scope the way `server.ts` builds it and drives both verbs through it.
|
|
565
|
+
describe("the commerce claim gates both verbs (spec D3)", () => {
|
|
566
|
+
const gateLogLines: string[] = [];
|
|
567
|
+
|
|
568
|
+
async function buildGatedServer(): Promise<FastifyInstance> {
|
|
569
|
+
gateLogLines.length = 0;
|
|
570
|
+
const gated = Fastify({
|
|
571
|
+
logger: {
|
|
572
|
+
level: "warn",
|
|
573
|
+
stream: { write: (msg: string) => gateLogLines.push(msg) },
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
// The shape of `server.ts`'s own registration: the preHandler is attached
|
|
577
|
+
// to the scope BEFORE the routes are registered into it.
|
|
578
|
+
await gated.register(async (requestsScope) => {
|
|
579
|
+
// The wrapper `server.ts` registers, not the bare gate — so the
|
|
580
|
+
// unclaimed WARN line (spec D3's disclosed residual) is covered too.
|
|
581
|
+
requestsScope.addHook("preHandler", async (request, reply) => {
|
|
582
|
+
await requireCommerceClaim(request, reply);
|
|
583
|
+
if (reply.sent) {
|
|
584
|
+
request.log.warn(
|
|
585
|
+
{
|
|
586
|
+
outcome: "confirm_link_unclaimed",
|
|
587
|
+
path: request.routeOptions.url,
|
|
588
|
+
},
|
|
589
|
+
"request-confirm: a confirm link was opened on a site without the commerce claim",
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
await requestsScope.register(requestRoutes);
|
|
594
|
+
});
|
|
595
|
+
await gated.ready();
|
|
596
|
+
return gated;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
it("answers the module-disabled 404 on GET and POST without the claim, touching nothing", async () => {
|
|
600
|
+
// What `requireCommerceClaim` does on an unclaimed site: short-circuits
|
|
601
|
+
// with the canonical not-found body.
|
|
602
|
+
// A licence that reads successfully and simply lacks the claim — the
|
|
603
|
+
// positively-established "no" the gate answers 404 on.
|
|
604
|
+
delete process.env["AILK_LICENSE_KEY"];
|
|
605
|
+
mockCheckLicense.mockResolvedValue({
|
|
606
|
+
tier: "free",
|
|
607
|
+
plan: "free",
|
|
608
|
+
features: [],
|
|
609
|
+
expiresAt: null,
|
|
610
|
+
revoked: false,
|
|
611
|
+
});
|
|
612
|
+
const gated = await buildGatedServer();
|
|
613
|
+
try {
|
|
614
|
+
const got = await gated.inject({
|
|
615
|
+
method: "GET",
|
|
616
|
+
url: `${PATH}?token=${TOKEN}`,
|
|
617
|
+
});
|
|
618
|
+
expect(got.statusCode).toBe(404);
|
|
619
|
+
|
|
620
|
+
const posted = await gated.inject({
|
|
621
|
+
method: "POST",
|
|
622
|
+
url: PATH,
|
|
623
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
624
|
+
payload: new URLSearchParams(VALID_AMOUNT_FORM).toString(),
|
|
625
|
+
});
|
|
626
|
+
expect(posted.statusCode).toBe(404);
|
|
627
|
+
|
|
628
|
+
// The gate ran BEFORE any handler: no row was read, none was stamped,
|
|
629
|
+
// and Stripe was never reached.
|
|
630
|
+
expect(prisma.callback.findFirst).not.toHaveBeenCalled();
|
|
631
|
+
expect(prisma.callback.updateMany).not.toHaveBeenCalled();
|
|
632
|
+
noStripeCalls();
|
|
633
|
+
|
|
634
|
+
// Spec D3 names this line as a bound on its own disclosed residual: a
|
|
635
|
+
// site configured for request-and-confirm but carrying no claim emails a
|
|
636
|
+
// confirm link that 404s. Without the line, the only place that
|
|
637
|
+
// misconfiguration surfaces is the tenant's inbox.
|
|
638
|
+
const warned = gateLogLines.join("\n");
|
|
639
|
+
expect(warned).toContain("confirm_link_unclaimed");
|
|
640
|
+
// Once per refused verb, GET and POST alike.
|
|
641
|
+
expect(warned.split("confirm_link_unclaimed")).toHaveLength(3);
|
|
642
|
+
} finally {
|
|
643
|
+
await gated.close();
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("lets the GET through once the claim is present", async () => {
|
|
648
|
+
mockCheckLicense.mockResolvedValue({
|
|
649
|
+
tier: "pro",
|
|
650
|
+
plan: "pro-monthly",
|
|
651
|
+
features: ["commerce"],
|
|
652
|
+
expiresAt: null,
|
|
653
|
+
revoked: false,
|
|
654
|
+
});
|
|
655
|
+
const gated = await buildGatedServer();
|
|
656
|
+
try {
|
|
657
|
+
const got = await gated.inject({
|
|
658
|
+
method: "GET",
|
|
659
|
+
url: `${PATH}?token=${TOKEN}`,
|
|
660
|
+
});
|
|
661
|
+
expect(got.statusCode).toBe(200);
|
|
662
|
+
expect(prisma.callback.findFirst).toHaveBeenCalled();
|
|
663
|
+
} finally {
|
|
664
|
+
await gated.close();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// ── A DB fault after Stripe succeeded is reported as itself (L3) ───────────
|
|
670
|
+
|
|
671
|
+
describe("POST — the artifact id fails to record", () => {
|
|
672
|
+
it("still shows the live link, and does not claim the payment will be tracked", async () => {
|
|
673
|
+
// #5970 security review round 2, L3. Reporting this as a Stripe failure
|
|
674
|
+
// told the operator to retry from a fresh link when Stripe HAD created
|
|
675
|
+
// one — a live, chargeable link with no row to stamp.
|
|
676
|
+
(prisma.callback.update as jest.Mock).mockRejectedValue(
|
|
677
|
+
new Error("connection reset"),
|
|
678
|
+
);
|
|
679
|
+
const res = await post(VALID_AMOUNT_FORM);
|
|
680
|
+
expect(res.statusCode).toBe(200);
|
|
681
|
+
expect(res.body).toContain("https://buy.stripe.com/test_abc");
|
|
682
|
+
expect(res.body).toContain("could not record it against this request");
|
|
683
|
+
// And it does NOT tell them to try again from a fresh link.
|
|
684
|
+
expect(res.body).not.toContain("try again from a fresh link");
|
|
685
|
+
});
|
|
686
|
+
});
|