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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file appointment-create-probe.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-CLIENT probe for `appointment.db.test.ts` (#6162). Runs `prisma.
|
|
5
|
+
* appointment.create()` for real, against a real (migrated) Postgres,
|
|
6
|
+
* through the REAL generated client — the exact runtime path that surfaced
|
|
7
|
+
* the bug this test guards ("column noShow does not exist" once
|
|
8
|
+
* `Appointment.noShow` lost its `@map("no_show")`).
|
|
9
|
+
*
|
|
10
|
+
* WHY A SEPARATE SCRIPT, RUN VIA `tsx` AS A SUBPROCESS, RATHER THAN IMPORTED
|
|
11
|
+
* DIRECTLY BY THE JEST TEST. `database/generated/client/client.ts` is
|
|
12
|
+
* generator-emitted, uses `import.meta.url`, and this package's `jest.
|
|
13
|
+
* config.cjs` transforms every test file through `ts-jest` targeting
|
|
14
|
+
* CommonJS — which cannot load an ESM-only module (see `prefixed-ids.db.
|
|
15
|
+
* test.ts`'s header for the same constraint). `tsx` runs this file as real
|
|
16
|
+
* ESM/TS with no such restriction, exactly as `seed-test.ts` already does
|
|
17
|
+
* for the same client (`pnpm --filter @working-theory/database db:seed:
|
|
18
|
+
* test`). The jest test spawns this script and asserts on its JSON stdout.
|
|
19
|
+
*
|
|
20
|
+
* Reads DATABASE_URL from the environment (the caller's `spawnSync` inherits
|
|
21
|
+
* `process.env`; `dotenv/config` covers a bare local run against `database/
|
|
22
|
+
* .env`). Creates one Appointment row with `noShow: true`, prints it as JSON,
|
|
23
|
+
* deletes it, and exits 0. On any Prisma error (e.g. the pre-fix "column
|
|
24
|
+
* noShow does not exist"), prints the error and exits 1 — the RED signal
|
|
25
|
+
* `appointment.db.test.ts` asserts against.
|
|
26
|
+
*/
|
|
27
|
+
import "dotenv/config";
|
|
28
|
+
|
|
29
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
30
|
+
|
|
31
|
+
import { PrismaClient } from "../../generated/client/client.js";
|
|
32
|
+
|
|
33
|
+
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
|
34
|
+
const prisma = new PrismaClient({ adapter });
|
|
35
|
+
|
|
36
|
+
// A fresh providerBookingUid per run — the unique key is
|
|
37
|
+
// (siteId, provider, providerBookingUid), and this probe's site never
|
|
38
|
+
// appears in any real fixture, so collision across concurrent runs would
|
|
39
|
+
// only ever come from two probes racing within the same millisecond.
|
|
40
|
+
//
|
|
41
|
+
// #6374 — the id SHAPE is now load-bearing, where before any string did.
|
|
42
|
+
// `appointments` joined the tenancy fence, so the row must satisfy
|
|
43
|
+
// `appointments_workspace_id_format` (a well-formed `wksp_`+UUIDv7) AND
|
|
44
|
+
// `appointments_workspace_site_agree` (workspace_id = site_id). This probe
|
|
45
|
+
// still names a site that appears in no real fixture — the last group is a
|
|
46
|
+
// literal marker, not a real workspace — but it must now be SHAPED like one.
|
|
47
|
+
// Nothing here depends on the workspace existing: the fence is a CHECK, never
|
|
48
|
+
// a foreign key (ADR-0009 invariant #1).
|
|
49
|
+
const SITE_ID = "wksp_00000000-0000-7000-8000-00000000f1f1";
|
|
50
|
+
const PROVIDER_BOOKING_UID = `probe-${Date.now()}-${process.pid}`;
|
|
51
|
+
|
|
52
|
+
async function main(): Promise<void> {
|
|
53
|
+
const row = await prisma.appointment.create({
|
|
54
|
+
data: {
|
|
55
|
+
siteId: SITE_ID,
|
|
56
|
+
// #6374 — equal to `siteId` by construction, which is exactly what
|
|
57
|
+
// `appointments_workspace_site_agree` enforces at the database.
|
|
58
|
+
workspaceId: SITE_ID,
|
|
59
|
+
providerBookingUid: PROVIDER_BOOKING_UID,
|
|
60
|
+
startTime: new Date("2026-09-20T10:00:00.000Z"),
|
|
61
|
+
endTime: new Date("2026-09-20T10:15:00.000Z"),
|
|
62
|
+
attendeeName: "Appointment Probe",
|
|
63
|
+
attendeeEmail: "appointment-probe@example.invalid",
|
|
64
|
+
status: "confirmed",
|
|
65
|
+
noShow: true,
|
|
66
|
+
noShowEventAt: new Date("2026-09-20T10:20:00.000Z"),
|
|
67
|
+
lifecycleEventAt: new Date("2026-09-18T00:00:00.000Z"),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
process.stdout.write(JSON.stringify(row));
|
|
71
|
+
await prisma.appointment.delete({ where: { id: row.id } });
|
|
72
|
+
await prisma.$disconnect();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main().catch(async (err: unknown) => {
|
|
76
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
77
|
+
await prisma.$disconnect();
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file project-listing-consent-probe.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-CLIENT probe for `project-listing-consent-at.db.test.ts` (#6171). Runs
|
|
5
|
+
* `prisma.projectListing.create()` / `.findUniqueOrThrow()` for real, against a
|
|
6
|
+
* real (migrated) Postgres, through the REAL generated client — spawned via
|
|
7
|
+
* `tsx` as a subprocess for the same reason `appointment-create-probe.ts`
|
|
8
|
+
* documents: `database/generated/client/client.ts` is generator-emitted,
|
|
9
|
+
* uses `import.meta.url`, and this package's `jest.config.cjs` transforms
|
|
10
|
+
* test files through `ts-jest` targeting CommonJS, which cannot load an
|
|
11
|
+
* ESM-only module.
|
|
12
|
+
*
|
|
13
|
+
* The bug this guards: `ProjectListing.consentAt` carried no
|
|
14
|
+
* `@db.Timestamptz(3)` — Prisma defaults an unannotated `DateTime` on
|
|
15
|
+
* Postgres to `TIMESTAMP(3)` (no time zone) — while the migration that added
|
|
16
|
+
* the column (`20260909180000_listing_draft_and_fields`) created it as the
|
|
17
|
+
* genuinely tz-aware `TIMESTAMPTZ`. This probe connects with the Postgres
|
|
18
|
+
* session clock pinned to a non-UTC, half-hour-offset zone
|
|
19
|
+
* (`Asia/Kolkata`, UTC+5:30) specifically because a naive wall-clock
|
|
20
|
+
* misinterpretation would shift the read-back instant by a non-round amount
|
|
21
|
+
* impossible to mistake for a coincidence, and writes a `consentAt` instant
|
|
22
|
+
* chosen to fall on a different Kolkata calendar DATE than its UTC date
|
|
23
|
+
* (18:30 UTC == 00:00 +5:30 the next day) so a date-only slip is equally
|
|
24
|
+
* visible.
|
|
25
|
+
*
|
|
26
|
+
* Reads DATABASE_URL from the environment. Creates one ProjectListing row,
|
|
27
|
+
* reads it back via a fresh `findUniqueOrThrow`, prints both timestamps plus
|
|
28
|
+
* the live column's `information_schema` type as JSON, deletes the row, and
|
|
29
|
+
* exits 0. On any Prisma error, prints the error and exits 1.
|
|
30
|
+
*/
|
|
31
|
+
import "dotenv/config";
|
|
32
|
+
|
|
33
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
34
|
+
|
|
35
|
+
import { PrismaClient } from "../../generated/client/client.js";
|
|
36
|
+
import { DEFAULT_WORKSPACE_ID } from "../../tenancy.js";
|
|
37
|
+
|
|
38
|
+
// Postgres session timezone pinned to a non-UTC, half-hour offset so any
|
|
39
|
+
// wall-clock (mis)interpretation of the write or read path shows up as a
|
|
40
|
+
// non-round, unmistakable shift rather than a suspiciously-clean hour delta.
|
|
41
|
+
const adapter = new PrismaPg({
|
|
42
|
+
connectionString: process.env.DATABASE_URL,
|
|
43
|
+
options: "-c timezone=Asia/Kolkata",
|
|
44
|
+
});
|
|
45
|
+
const prisma = new PrismaClient({ adapter });
|
|
46
|
+
|
|
47
|
+
// #6254 (the tenancy-fence keystone) retrofitted ProjectListing.workspaceId
|
|
48
|
+
// to NOT NULL plus `project_listings_workspace_site_agree CHECK (workspace_id
|
|
49
|
+
// = site_id)` — siteId must therefore be a well-formed `wksp_` id equal to
|
|
50
|
+
// workspaceId. No production write path resolves an arbitrary literal like
|
|
51
|
+
// the prior `"st_consent-tz-probe"` here (there is no FK to `workspaces`, so
|
|
52
|
+
// the reserved sentinel need not exist as a real row — only be well-shaped),
|
|
53
|
+
// so the sentinel default workspace id stands in for both columns.
|
|
54
|
+
const SITE_ID = DEFAULT_WORKSPACE_ID;
|
|
55
|
+
// 18:30 UTC == 2026-09-21T00:00:00+05:30 — a different calendar date in the
|
|
56
|
+
// session's pinned zone than in UTC, so a naive-offset bug shows up as either
|
|
57
|
+
// a shifted instant OR a shifted date, whichever the bug takes.
|
|
58
|
+
const CONSENT_AT = new Date("2026-09-20T18:30:00.000Z");
|
|
59
|
+
|
|
60
|
+
async function main(): Promise<void> {
|
|
61
|
+
const created = await prisma.projectListing.create({
|
|
62
|
+
data: {
|
|
63
|
+
siteId: SITE_ID,
|
|
64
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
65
|
+
founderName: "Consent TZ Probe",
|
|
66
|
+
founderEmail: "consent-tz-probe@example.invalid",
|
|
67
|
+
status: "draft",
|
|
68
|
+
consentAt: CONSENT_AT,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// A second, independent read — through a fresh query, not the object
|
|
73
|
+
// `create()` already returned — so the proof covers the READ path too.
|
|
74
|
+
const reread = await prisma.projectListing.findUniqueOrThrow({
|
|
75
|
+
where: { id: created.id },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const columnType = await prisma.$queryRaw<Array<{ data_type: string }>>`
|
|
79
|
+
SELECT data_type FROM information_schema.columns
|
|
80
|
+
WHERE table_name = 'project_listings' AND column_name = 'consent_at'
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
process.stdout.write(
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
writtenIso: CONSENT_AT.toISOString(),
|
|
86
|
+
createdConsentAtIso: created.consentAt.toISOString(),
|
|
87
|
+
rereadConsentAtIso: reread.consentAt.toISOString(),
|
|
88
|
+
liveColumnDataType: columnType[0]?.data_type ?? null,
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
await prisma.projectListing.delete({ where: { id: created.id } });
|
|
93
|
+
await prisma.$disconnect();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
main().catch(async (err: unknown) => {
|
|
97
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
98
|
+
await prisma.$disconnect();
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file flow-workspace-key.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of the #6155 backfill (spec § 8.4, AC-1). Requires
|
|
5
|
+
* DATABASE_URL pointing at a Postgres with the migrations applied (`prisma
|
|
6
|
+
* migrate deploy`); the whole suite SKIPS when DATABASE_URL is unset,
|
|
7
|
+
* matching `prefixed-ids.db.test.ts` / `artifact-content-store.db.test.ts`
|
|
8
|
+
* and keeping the workspace Jest run DB-free.
|
|
9
|
+
*
|
|
10
|
+
* Raw SQL through `pg`, for the same reason those two files give: the
|
|
11
|
+
* generated Prisma client is ESM-only and cannot load under this package's
|
|
12
|
+
* CJS ts-jest transform.
|
|
13
|
+
*
|
|
14
|
+
* RECONCILED WITH #6254 (the tenancy-fence keystone, #6373 AC-4). The
|
|
15
|
+
* ORIGINAL "RE-RUN TECHNIQUE" this file used — insert fresh
|
|
16
|
+
* `workspace_id IS NULL` rows, then re-execute this migration's own SQL
|
|
17
|
+
* verbatim to prove its backfill logic — assumed a row COULD land with
|
|
18
|
+
* `workspace_id` unset. That assumption is no longer true on current main:
|
|
19
|
+
* `database/migrations/20260922120000_tenancy_fence` retrofits
|
|
20
|
+
* `flow_definitions.workspace_id` / `flow_sessions.workspace_id` to NOT
|
|
21
|
+
* NULL, plus a `CHECK (workspace_id = site_id)` equality and a
|
|
22
|
+
* `CHECK (workspace_id ~ '^wksp_...')` format constraint on BOTH tables.
|
|
23
|
+
* Together those three constraints mean:
|
|
24
|
+
*
|
|
25
|
+
* - `site_id` must now ALWAYS be a well-formed `wksp_` id equal to
|
|
26
|
+
* `workspace_id` — the legacy alias `"default"` (never `wksp_`-shaped)
|
|
27
|
+
* and an arbitrary unresolvable string can no longer be written at all,
|
|
28
|
+
* with or without `workspace_id` supplied. Attempting either now aborts
|
|
29
|
+
* the write outright (a NOT NULL / CHECK violation), rather than landing
|
|
30
|
+
* a row with `workspace_id` left NULL — this is the fence doing exactly
|
|
31
|
+
* what its own migration text says an unresolvable row must do ("abort
|
|
32
|
+
* the transaction rather than pass through silently").
|
|
33
|
+
* - The `"default"` alias itself is resolved at the APPLICATION layer
|
|
34
|
+
* before a write reaches the database now (the fence migration's own
|
|
35
|
+
* header: "the application-code half of D1a... is fixed in the same
|
|
36
|
+
* PR") — a fresh row's `site_id` is never the literal string `"default"`
|
|
37
|
+
* on live main, only ever the resolved `DEFAULT_WORKSPACE_ID` sentinel.
|
|
38
|
+
*
|
|
39
|
+
* So this suite's three cases are reconciled to what main actually does:
|
|
40
|
+
*
|
|
41
|
+
* 1. A row whose `site_id` names a real workspace carries a matching
|
|
42
|
+
* `workspace_id` from the moment it is written (the shape the OLD
|
|
43
|
+
* backfill used to produce is now enforced at write time), and is
|
|
44
|
+
* readable through the shape `tenantDb(tenant).flows.byId` /
|
|
45
|
+
* `.flowSessions.byId` use (`findFirst({ id, workspaceId })`). The
|
|
46
|
+
* shipped `20260919120000_flow_workspace_key` migration is STILL
|
|
47
|
+
* re-run verbatim afterward — every `workspace_id IS NULL` row it
|
|
48
|
+
* would touch is now unreachable, so this proves the migration stays
|
|
49
|
+
* safely re-runnable (NFR3) rather than corrupting an already-correct
|
|
50
|
+
* row, not that it backfills one.
|
|
51
|
+
* 2. A row whose `site_id` matches no workspace can no longer be created
|
|
52
|
+
* at all — inserting it (with `workspace_id` omitted, exactly the
|
|
53
|
+
* pre-fence shape) is REJECTED by the NOT NULL constraint the fence
|
|
54
|
+
* added, never landed unset.
|
|
55
|
+
* 3. A row whose `site_id` is the resolved sentinel `DEFAULT_WORKSPACE_ID`
|
|
56
|
+
* — what the legacy `"default"` alias now resolves to before any write
|
|
57
|
+
* reaches the database — carries a matching `workspace_id` from the
|
|
58
|
+
* moment it is written, exactly like case 1.
|
|
59
|
+
* 4. The literal sentinel id written into the migration's own SQL text
|
|
60
|
+
* equals `DEFAULT_WORKSPACE_ID` from `../tenancy.js` — read from the
|
|
61
|
+
* `.sql` file and compared, so the two cannot drift apart (§ 5.3
|
|
62
|
+
* step 5). Unaffected by the fence — no DB write involved.
|
|
63
|
+
*/
|
|
64
|
+
import { randomUUID } from "node:crypto";
|
|
65
|
+
import { readFileSync } from "node:fs";
|
|
66
|
+
import { join } from "node:path";
|
|
67
|
+
|
|
68
|
+
import { Client } from "pg";
|
|
69
|
+
|
|
70
|
+
import { DEFAULT_WORKSPACE_ID } from "../tenancy.js";
|
|
71
|
+
|
|
72
|
+
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
73
|
+
|
|
74
|
+
/** A run-unique suffix so repeated local runs never collide. */
|
|
75
|
+
const RUN = randomUUID().slice(0, 8);
|
|
76
|
+
|
|
77
|
+
const MIGRATION_SQL_PATH = join(
|
|
78
|
+
__dirname,
|
|
79
|
+
"..",
|
|
80
|
+
"migrations",
|
|
81
|
+
"20260919120000_flow_workspace_key",
|
|
82
|
+
"migration.sql",
|
|
83
|
+
);
|
|
84
|
+
const MIGRATION_SQL = readFileSync(MIGRATION_SQL_PATH, "utf-8");
|
|
85
|
+
|
|
86
|
+
describeDb(
|
|
87
|
+
"flow_definitions / flow_sessions workspace_id backfill (#6155, live DB)",
|
|
88
|
+
() => {
|
|
89
|
+
let db: Client;
|
|
90
|
+
let otherWorkspaceId: string;
|
|
91
|
+
const noSuchSite = `no-such-site-${RUN}`;
|
|
92
|
+
|
|
93
|
+
const definitionIds: string[] = [];
|
|
94
|
+
const sessionIds: string[] = [];
|
|
95
|
+
|
|
96
|
+
beforeAll(async () => {
|
|
97
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
98
|
+
await db.connect();
|
|
99
|
+
|
|
100
|
+
// A REAL, non-sentinel workspace — the "pinned, resolvable" case (rule 3:
|
|
101
|
+
// exact `workspaces.id` equality, nothing to do with the sentinel).
|
|
102
|
+
const org = (
|
|
103
|
+
await db.query<{ id: string }>(
|
|
104
|
+
`INSERT INTO "organizations" ("name", "slug", "updated_at")
|
|
105
|
+
VALUES ($1, $2, now()) RETURNING "id"`,
|
|
106
|
+
[`6155 Org ${RUN}`, `6155-org-${RUN}`],
|
|
107
|
+
)
|
|
108
|
+
).rows[0]!;
|
|
109
|
+
otherWorkspaceId = (
|
|
110
|
+
await db.query<{ id: string }>(
|
|
111
|
+
`INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
|
|
112
|
+
VALUES ($1, $2, $3, now()) RETURNING "id"`,
|
|
113
|
+
[org.id, `6155 Workspace ${RUN}`, `6155-ws-${RUN}`],
|
|
114
|
+
)
|
|
115
|
+
).rows[0]!.id;
|
|
116
|
+
|
|
117
|
+
// One FlowDefinition / FlowSession pair, carrying its correct
|
|
118
|
+
// workspace_id from the moment of insert — the fence's NOT NULL means
|
|
119
|
+
// there is no longer a `workspace_id IS NULL` pre-state to construct
|
|
120
|
+
// for a row that CAN legitimately exist (see the file header). `title`
|
|
121
|
+
// / `status` are also NOT NULL as of the same-day
|
|
122
|
+
// `20260919130000_flow_definition_admin_columns` migration (no column
|
|
123
|
+
// default, by that migration's own design — a nameless/unpublished
|
|
124
|
+
// magnet must never enter the table silently) — both supplied here so
|
|
125
|
+
// the ONLY thing under test is the workspace_id/site_id shape.
|
|
126
|
+
const insertPair = async (siteId: string): Promise<void> => {
|
|
127
|
+
const def = (
|
|
128
|
+
await db.query<{ id: string }>(
|
|
129
|
+
`INSERT INTO "flow_definitions"
|
|
130
|
+
("site_id", "workspace_id", "slug", "config", "title", "status", "updated_at")
|
|
131
|
+
VALUES ($1, $1, $2, '{}'::jsonb, 'Test', 'live', now()) RETURNING "id"`,
|
|
132
|
+
[siteId, `6155-${RUN}-${siteId}`],
|
|
133
|
+
)
|
|
134
|
+
).rows[0]!;
|
|
135
|
+
definitionIds.push(def.id);
|
|
136
|
+
|
|
137
|
+
const sess = (
|
|
138
|
+
await db.query<{ id: string }>(
|
|
139
|
+
`INSERT INTO "flow_sessions"
|
|
140
|
+
("site_id", "workspace_id", "flow_id", "flow_slug", "config_snapshot", "updated_at")
|
|
141
|
+
VALUES ($1, $1, $2, $3, '{}'::jsonb, now()) RETURNING "id"`,
|
|
142
|
+
[siteId, def.id, `6155-${RUN}-${siteId}`],
|
|
143
|
+
)
|
|
144
|
+
).rows[0]!;
|
|
145
|
+
sessionIds.push(sess.id);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Case 1's row FIRST, then the migration re-run, THEN case 3's row —
|
|
149
|
+
// deliberately sequenced, not merged into one loop. The migration's
|
|
150
|
+
// OWN post-assertions (re-read verbatim below) reject ANY row whose
|
|
151
|
+
// workspace_id names a workspace absent from "workspaces" ("nothing
|
|
152
|
+
// guessed"); `DEFAULT_WORKSPACE_ID` has no corresponding "workspaces"
|
|
153
|
+
// row on a freshly-migrated-but-unseeded database (that row is
|
|
154
|
+
// `database/seed.ts`'s job, not a migration's), so the re-run must
|
|
155
|
+
// happen BEFORE case 3's row exists, or it would trip that guard for
|
|
156
|
+
// a row it never touched.
|
|
157
|
+
await insertPair(otherWorkspaceId);
|
|
158
|
+
|
|
159
|
+
// Re-run the shipped migration file verbatim (NFR3 — still
|
|
160
|
+
// re-runnable). Every `workspace_id IS NULL` row it would touch is now
|
|
161
|
+
// unreachable (the fence's NOT NULL forbids one from ever existing),
|
|
162
|
+
// so this is a no-op on the row just inserted — proven by the
|
|
163
|
+
// "unchanged after re-run" assertion in the first `it()` block below,
|
|
164
|
+
// not a backfill.
|
|
165
|
+
await db.query(MIGRATION_SQL);
|
|
166
|
+
|
|
167
|
+
await insertPair(DEFAULT_WORKSPACE_ID);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterAll(async () => {
|
|
171
|
+
await db.query(`DELETE FROM "flow_sessions" WHERE "flow_slug" LIKE $1`, [
|
|
172
|
+
`6155-${RUN}-%`,
|
|
173
|
+
]);
|
|
174
|
+
await db.query(`DELETE FROM "flow_definitions" WHERE "slug" LIKE $1`, [
|
|
175
|
+
`6155-${RUN}-%`,
|
|
176
|
+
]);
|
|
177
|
+
await db.query(`DELETE FROM "workspaces" WHERE "slug" = $1`, [
|
|
178
|
+
`6155-ws-${RUN}`,
|
|
179
|
+
]);
|
|
180
|
+
await db.query(`DELETE FROM "organizations" WHERE "slug" = $1`, [
|
|
181
|
+
`6155-org-${RUN}`,
|
|
182
|
+
]);
|
|
183
|
+
await db.end();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("a row whose site_id names a real workspace carries a matching workspace_id, unchanged by a migration re-run, and is readable through the accessor's own query shape", async () => {
|
|
187
|
+
const [defId, sessId] = [definitionIds[0]!, sessionIds[0]!];
|
|
188
|
+
|
|
189
|
+
const def = await db.query<{ workspace_id: string | null }>(
|
|
190
|
+
`SELECT "workspace_id" FROM "flow_definitions" WHERE "id" = $1`,
|
|
191
|
+
[defId],
|
|
192
|
+
);
|
|
193
|
+
expect(def.rows[0]!.workspace_id).toBe(otherWorkspaceId);
|
|
194
|
+
|
|
195
|
+
// The shape tenantDb(tenant).flows.byId uses: findFirst({ id, workspaceId }).
|
|
196
|
+
const readable = await db.query(
|
|
197
|
+
`SELECT "id" FROM "flow_definitions" WHERE "id" = $1 AND "workspace_id" = $2`,
|
|
198
|
+
[defId, otherWorkspaceId],
|
|
199
|
+
);
|
|
200
|
+
expect(readable.rows).toHaveLength(1);
|
|
201
|
+
|
|
202
|
+
const sess = await db.query<{ workspace_id: string | null }>(
|
|
203
|
+
`SELECT "workspace_id" FROM "flow_sessions" WHERE "id" = $1`,
|
|
204
|
+
[sessId],
|
|
205
|
+
);
|
|
206
|
+
expect(sess.rows[0]!.workspace_id).toBe(otherWorkspaceId);
|
|
207
|
+
|
|
208
|
+
const sessReadable = await db.query(
|
|
209
|
+
`SELECT "id" FROM "flow_sessions" WHERE "id" = $1 AND "workspace_id" = $2`,
|
|
210
|
+
[sessId, otherWorkspaceId],
|
|
211
|
+
);
|
|
212
|
+
expect(sessReadable.rows).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("a row whose site_id matches no workspace can no longer be created — the fence retrofit rejects it outright, never leaving it unset", async () => {
|
|
216
|
+
// The EXACT pre-fence shape (no workspace_id supplied) — proving that
|
|
217
|
+
// what used to land a `workspace_id IS NULL` row is now rejected
|
|
218
|
+
// before it can, on both tables the #6155 backfill covered.
|
|
219
|
+
await expect(
|
|
220
|
+
db.query(
|
|
221
|
+
`INSERT INTO "flow_definitions" ("site_id", "slug", "config", "title", "status", "updated_at")
|
|
222
|
+
VALUES ($1, $2, '{}'::jsonb, 'Test', 'live', now())`,
|
|
223
|
+
[noSuchSite, `6155-${RUN}-abort-def`],
|
|
224
|
+
),
|
|
225
|
+
).rejects.toThrow(/workspace_id/);
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
db.query(
|
|
229
|
+
`INSERT INTO "flow_sessions" ("site_id", "flow_id", "flow_slug", "config_snapshot", "updated_at")
|
|
230
|
+
VALUES ($1, $2, $3, '{}'::jsonb, now())`,
|
|
231
|
+
[noSuchSite, definitionIds[0]!, `6155-${RUN}-abort-sess`],
|
|
232
|
+
),
|
|
233
|
+
).rejects.toThrow(/workspace_id/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("a row whose site_id is the resolved sentinel DEFAULT_WORKSPACE_ID carries a matching workspace_id (D2 rule 2's write-time destination)", async () => {
|
|
237
|
+
const [defId, sessId] = [definitionIds[1]!, sessionIds[1]!];
|
|
238
|
+
|
|
239
|
+
const def = await db.query<{ workspace_id: string | null }>(
|
|
240
|
+
`SELECT "workspace_id" FROM "flow_definitions" WHERE "id" = $1`,
|
|
241
|
+
[defId],
|
|
242
|
+
);
|
|
243
|
+
expect(def.rows[0]!.workspace_id).toBe(DEFAULT_WORKSPACE_ID);
|
|
244
|
+
|
|
245
|
+
const sess = await db.query<{ workspace_id: string | null }>(
|
|
246
|
+
`SELECT "workspace_id" FROM "flow_sessions" WHERE "id" = $1`,
|
|
247
|
+
[sessId],
|
|
248
|
+
);
|
|
249
|
+
expect(sess.rows[0]!.workspace_id).toBe(DEFAULT_WORKSPACE_ID);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("the migration's own literal sentinel id equals DEFAULT_WORKSPACE_ID — the two cannot drift apart", () => {
|
|
253
|
+
// SQL cannot import database/tenancy.ts (§ 5.3 step 5), so the migration
|
|
254
|
+
// carries the sentinel as a literal. Pin it against the exported
|
|
255
|
+
// constant by reading the shipped file, not a re-typed copy of it.
|
|
256
|
+
const occurrences = MIGRATION_SQL.match(/'wksp_[0-9a-f-]+'/g) ?? [];
|
|
257
|
+
expect(occurrences.length).toBeGreaterThan(0);
|
|
258
|
+
for (const literal of occurrences) {
|
|
259
|
+
expect(literal).toBe(`'${DEFAULT_WORKSPACE_ID}'`);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
);
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
* 4. The relation-free scope columns (workspaceId refs) are CHECKed too.
|
|
26
26
|
* 5. The reserved-range sentinel ids are admitted by the CHECKs.
|
|
27
27
|
*/
|
|
28
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
|
|
28
31
|
import { Client } from "pg";
|
|
29
32
|
|
|
30
33
|
import { DEFAULT_ORG_ID, DEFAULT_WORKSPACE_ID } from "../tenancy.js";
|
|
@@ -34,16 +37,82 @@ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
|
34
37
|
const V7_RE =
|
|
35
38
|
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Every distinct `_format` CHECK constraint name declared across
|
|
42
|
+
* `database/migrations/*\/migration.sql` — both the inline `CREATE TABLE`
|
|
43
|
+
* form and the `ALTER TABLE ... ADD CONSTRAINT` form (including the
|
|
44
|
+
* existence-guarded `DO $$` blocks later migrations use for a NULLABLE
|
|
45
|
+
* scope column, e.g. 20260919120000_flow_workspace_key). Comment lines are
|
|
46
|
+
* skipped so a rollback note that merely mentions a constraint name doesn't
|
|
47
|
+
* count as a second declaration.
|
|
48
|
+
*
|
|
49
|
+
* Derived from the migration source rather than hard-coded (#6119): a
|
|
50
|
+
* hard-coded literal here goes stale the moment a later migration adds a
|
|
51
|
+
* new prefixed-id model or scope column, failing this test for a reason
|
|
52
|
+
* that has nothing to do with the database under test. Deriving it instead
|
|
53
|
+
* still catches the real defect this test guards — the live database
|
|
54
|
+
* missing a CHECK its own migrations declare (a partial/failed deploy,
|
|
55
|
+
* migrations applied out of order, one silently dropped) — because the
|
|
56
|
+
* expected count comes from the repo's migration files while the actual
|
|
57
|
+
* count comes from a live query against the database those migrations were
|
|
58
|
+
* run against; the two are independent unless the migrations actually ran
|
|
59
|
+
* to completion.
|
|
60
|
+
*/
|
|
61
|
+
function declaredFormatCheckCount(): number {
|
|
62
|
+
const migrationsDir = join(__dirname, "..", "migrations");
|
|
63
|
+
const names = new Set<string>();
|
|
64
|
+
for (const entry of readdirSync(migrationsDir, { withFileTypes: true })) {
|
|
65
|
+
if (!entry.isDirectory()) continue;
|
|
66
|
+
const sqlPath = join(migrationsDir, entry.name, "migration.sql");
|
|
67
|
+
let sql: string;
|
|
68
|
+
try {
|
|
69
|
+
sql = readFileSync(sqlPath, "utf-8");
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const line of sql.split("\n")) {
|
|
74
|
+
if (line.trim().startsWith("--")) continue;
|
|
75
|
+
const match = line.match(/CONSTRAINT\s+"([a-z_]+_format)"/);
|
|
76
|
+
if (match) names.add(match[1]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return names.size;
|
|
80
|
+
}
|
|
81
|
+
|
|
37
82
|
describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
|
|
38
83
|
let db: Client;
|
|
84
|
+
// #6254 (the tenancy-fence keystone) retrofitted `leads.workspace_id` to
|
|
85
|
+
// NOT NULL plus `leads_workspace_site_agree CHECK (workspace_id =
|
|
86
|
+
// site_id)` and `leads_workspace_id_format CHECK (workspace_id ~
|
|
87
|
+
// '^wksp_...')`. Every "leads" insert below now needs a well-formed
|
|
88
|
+
// `wksp_` id in BOTH columns — a real seeded workspace (mirroring
|
|
89
|
+
// `composite-fence.db.test.ts`'s own fixture pattern), not the prior
|
|
90
|
+
// free-text `'adr31-test'` literal, which satisfied neither CHECK.
|
|
91
|
+
let workspaceId: string;
|
|
39
92
|
|
|
40
93
|
beforeAll(async () => {
|
|
41
94
|
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
42
95
|
await db.connect();
|
|
96
|
+
|
|
97
|
+
const org = (
|
|
98
|
+
await db.query<{ id: string }>(
|
|
99
|
+
`INSERT INTO "organizations" ("name", "slug", "updated_at")
|
|
100
|
+
VALUES ('ADR31 Fence Org', 'adr31-fence-org', now()) RETURNING "id"`,
|
|
101
|
+
)
|
|
102
|
+
).rows[0]!;
|
|
103
|
+
workspaceId = (
|
|
104
|
+
await db.query<{ id: string }>(
|
|
105
|
+
`INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
|
|
106
|
+
VALUES ($1, 'ADR31 Fence Workspace', 'adr31-fence-ws', now()) RETURNING "id"`,
|
|
107
|
+
[org.id],
|
|
108
|
+
)
|
|
109
|
+
).rows[0]!.id;
|
|
43
110
|
});
|
|
44
111
|
|
|
45
112
|
afterAll(async () => {
|
|
46
|
-
await db.query(`DELETE FROM "leads" WHERE "
|
|
113
|
+
await db.query(`DELETE FROM "leads" WHERE "workspace_id" = $1`, [
|
|
114
|
+
workspaceId,
|
|
115
|
+
]);
|
|
47
116
|
await db.query(
|
|
48
117
|
`DELETE FROM "organizations" WHERE "slug" LIKE 'adr31-%'`,
|
|
49
118
|
);
|
|
@@ -83,8 +152,9 @@ describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
|
|
|
83
152
|
expect(org.id.slice("org_".length)).toMatch(V7_RE);
|
|
84
153
|
|
|
85
154
|
const lead = (await db.query<{ id: string }>(
|
|
86
|
-
`INSERT INTO "leads" ("site_id", "source", "email", "payload")
|
|
87
|
-
VALUES (
|
|
155
|
+
`INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload")
|
|
156
|
+
VALUES ($1, $1, 'test', 'adr31@example.com', '{}') RETURNING "id"`,
|
|
157
|
+
[workspaceId],
|
|
88
158
|
)).rows[0];
|
|
89
159
|
expect(lead.id).toMatch(/^lead_/);
|
|
90
160
|
expect(lead.id.slice("lead_".length)).toMatch(V7_RE);
|
|
@@ -93,10 +163,15 @@ describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
|
|
|
93
163
|
|
|
94
164
|
describe("CHECK constraints — the mechanical tier", () => {
|
|
95
165
|
it("REJECTS an insert with a malformed primary key", async () => {
|
|
166
|
+
// workspace_id (= site_id) is supplied and well-formed here so the
|
|
167
|
+
// ONLY constraint this insert can violate is leads_id_format — proving
|
|
168
|
+
// the malformed-id rejection still fires for its original reason,
|
|
169
|
+
// not the (now also NOT NULL) workspace_id (#6373 AC-3).
|
|
96
170
|
await expect(
|
|
97
171
|
db.query(
|
|
98
|
-
`INSERT INTO "leads" ("id", "site_id", "source", "email", "payload")
|
|
99
|
-
VALUES ('bad',
|
|
172
|
+
`INSERT INTO "leads" ("id", "site_id", "workspace_id", "source", "email", "payload")
|
|
173
|
+
VALUES ('bad', $1, $1, 'x', 'a@b.co', '{}')`,
|
|
174
|
+
[workspaceId],
|
|
100
175
|
),
|
|
101
176
|
).rejects.toThrow(/leads_id_format/);
|
|
102
177
|
});
|
|
@@ -104,8 +179,9 @@ describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
|
|
|
104
179
|
it("REJECTS an insert with a bare (unprefixed) uuid primary key", async () => {
|
|
105
180
|
await expect(
|
|
106
181
|
db.query(
|
|
107
|
-
`INSERT INTO "leads" ("id", "site_id", "source", "email", "payload")
|
|
108
|
-
VALUES ('0190f3a2-7c1d-7e4a-9b2c-0123456789ab',
|
|
182
|
+
`INSERT INTO "leads" ("id", "site_id", "workspace_id", "source", "email", "payload")
|
|
183
|
+
VALUES ('0190f3a2-7c1d-7e4a-9b2c-0123456789ab', $1, $1, 'x', 'a@b.co', '{}')`,
|
|
184
|
+
[workspaceId],
|
|
109
185
|
),
|
|
110
186
|
).rejects.toThrow(/leads_id_format/);
|
|
111
187
|
});
|
|
@@ -135,12 +211,12 @@ describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
|
|
|
135
211
|
expect(ws.id).toBe(DEFAULT_WORKSPACE_ID);
|
|
136
212
|
});
|
|
137
213
|
|
|
138
|
-
it("carries the full CHECK complement
|
|
214
|
+
it("carries the full CHECK complement declared by the migrations", async () => {
|
|
139
215
|
const { rows } = await db.query<{ n: string }>(
|
|
140
216
|
`SELECT count(*)::text AS n FROM pg_constraint
|
|
141
217
|
WHERE contype = 'c' AND conname LIKE '%_format'`,
|
|
142
218
|
);
|
|
143
|
-
expect(rows[0].n).toBe(
|
|
219
|
+
expect(rows[0].n).toBe(String(declaredFormatCheckCount()));
|
|
144
220
|
});
|
|
145
221
|
});
|
|
146
222
|
});
|