create-ailk 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -8
- package/component-catalog.json +336 -27
- package/component-catalog.md +25 -12
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/module-architecture.d.ts +1 -0
- package/dist/module-architecture.js +253 -127
- package/dist/surfaces.js +105 -7
- package/dist/template-manifest.d.ts +33 -0
- package/dist/template-manifest.js +99 -16
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +1 -0
- package/package.json +3 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
- package/site-templates/marketing-site/ailk-template.json +1 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
- package/site-templates/minimalism-site/ailk-template.json +2 -1
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.env.example +72 -7
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +26 -2
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +439 -49
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +70 -31
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +13 -14
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +23 -2
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +57 -24
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +2 -0
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +59 -35
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +6 -4
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +71 -5
- package/templates/apps/web/CLAUDE.md +44 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/layout.tsx +133 -87
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/jest.config.cjs +8 -4
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +187 -0
- package/templates/apps/web/package.json +2 -1
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +142 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +1 -1
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file fence-fan-out.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of the #6374 fence fan-out constraints (AC-4, AC-5).
|
|
5
|
+
* Named after — and modelled line-for-line on — its sibling
|
|
6
|
+
* `composite-fence.db.test.ts`, which proves the same shape for the #6254
|
|
7
|
+
* keystone's four already-keyed models. Requires DATABASE_URL pointing at a
|
|
8
|
+
* Postgres with the migrations applied (`prisma migrate deploy`); the whole
|
|
9
|
+
* suite SKIPS when DATABASE_URL is unset, matching every other `.db.test.ts`
|
|
10
|
+
* here and keeping the workspace Jest run DB-free.
|
|
11
|
+
*
|
|
12
|
+
* Raw SQL through `pg`, for the same reason those files give: the generated
|
|
13
|
+
* Prisma client is ESM-only and cannot load under this package's CJS ts-jest
|
|
14
|
+
* transform.
|
|
15
|
+
*
|
|
16
|
+
* What it proves (spec § 6 AC-4 / AC-5):
|
|
17
|
+
*
|
|
18
|
+
* AC-4, for EACH of the eleven `site_id` tables the amendment fences:
|
|
19
|
+
* 1. an insert with `workspace_id` NULL is rejected — 23502;
|
|
20
|
+
* 2. an insert whose `workspace_id` differs from its `site_id` is
|
|
21
|
+
* rejected BY `<table>_workspace_site_agree` — 23514, and the
|
|
22
|
+
* constraint is named in the assertion, so a row rejected by some
|
|
23
|
+
* OTHER constraint cannot pass this test;
|
|
24
|
+
* 3. an insert where the two AGREE succeeds — without this, assertion 2
|
|
25
|
+
* would also pass against a table that rejects everything;
|
|
26
|
+
* 4. no row carries `site_id = 'default'` after the migration — the
|
|
27
|
+
* normalization (D1 step 2) actually ran.
|
|
28
|
+
*
|
|
29
|
+
* AC-5, the four shapes that are NOT the eleven-table pattern:
|
|
30
|
+
* 5. `site_settings` is keyed by (workspace_id, key), not by key alone
|
|
31
|
+
* (D3): two workspaces may each hold `feature_flags`; a SECOND one in
|
|
32
|
+
* ONE workspace is rejected — 23505.
|
|
33
|
+
* 6. `listing_csv_sends` rejects a NULL `workspace_id` (D2) — it has no
|
|
34
|
+
* `site_id`, so it takes NOT NULL + the format CHECK and no equality
|
|
35
|
+
* CHECK.
|
|
36
|
+
* 7. `deliverable_grants` (D4) stays NULL-TOLERANT — NULL is the serve
|
|
37
|
+
* path's fail-closed sentinel — while a STAMPED grant may never
|
|
38
|
+
* disagree with its site.
|
|
39
|
+
* 8. Deleting a `workspaces` row leaves that workspace's `consent_grants`
|
|
40
|
+
* rows in place: the fence is a CHECK, never a foreign key (NFR1,
|
|
41
|
+
* ADR-0009 invariant #1). A cascade here would be silent data loss.
|
|
42
|
+
*/
|
|
43
|
+
import { randomUUID } from "node:crypto";
|
|
44
|
+
import { readFileSync } from "node:fs";
|
|
45
|
+
import { join } from "node:path";
|
|
46
|
+
|
|
47
|
+
import { Client } from "pg";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The SHIPPED migration text. AC-14's two cases execute the guard block out of
|
|
51
|
+
* this file rather than a re-typed copy, so an edit to the guard that changed
|
|
52
|
+
* its classification would fail here — the same technique
|
|
53
|
+
* `composite-fence.db.test.ts` uses to pin the sentinel literal.
|
|
54
|
+
*/
|
|
55
|
+
const MIGRATION_SQL = readFileSync(
|
|
56
|
+
join(__dirname, "..", "migrations", "20260922120000_tenancy_fence", "migration.sql"),
|
|
57
|
+
"utf-8",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The WHOLE `site_settings` attribution sequence (AC-14), extracted from the
|
|
62
|
+
* shipped migration: the abort-not-guess guard, then the sentinel UPDATE,
|
|
63
|
+
* then the post-backfill assertion — the migration's first, second and third
|
|
64
|
+
* statements after the marker, in order.
|
|
65
|
+
*
|
|
66
|
+
* Slicing through the SECOND `END $$;` rather than the first is what makes the
|
|
67
|
+
* non-abort cases meaningful: the guard alone only proves a row was not
|
|
68
|
+
* REJECTED, while the guard-plus-UPDATE proves it was actually ATTRIBUTED, and
|
|
69
|
+
* to which workspace. A slice that stopped at the first block would pass
|
|
70
|
+
* identically whether the UPDATE backfilled the sentinel or did nothing at all.
|
|
71
|
+
*/
|
|
72
|
+
function siteSettingsGuardSql(): string {
|
|
73
|
+
const marker = "-- ── ABORT, NEVER GUESS";
|
|
74
|
+
const start = MIGRATION_SQL.indexOf(marker);
|
|
75
|
+
if (start < 0) throw new Error("AC-14 guard block not found in migration.sql");
|
|
76
|
+
const doStart = MIGRATION_SQL.indexOf("DO $$", start);
|
|
77
|
+
if (doStart < 0) throw new Error("AC-14 guard DO block malformed");
|
|
78
|
+
const END = "END $$;";
|
|
79
|
+
// The guard's own terminator, then the post-backfill assertion's.
|
|
80
|
+
const firstEnd = MIGRATION_SQL.indexOf(END, doStart);
|
|
81
|
+
const secondEnd = firstEnd < 0 ? -1 : MIGRATION_SQL.indexOf(END, firstEnd + END.length);
|
|
82
|
+
if (firstEnd < 0 || secondEnd < 0) {
|
|
83
|
+
throw new Error("AC-14 slice malformed — expected two DO blocks after the marker");
|
|
84
|
+
}
|
|
85
|
+
const sql = MIGRATION_SQL.slice(doStart, secondEnd + END.length);
|
|
86
|
+
// Fail loudly if the migration is reshaped so the slice no longer spans the
|
|
87
|
+
// UPDATE — otherwise the non-abort cases would silently stop proving it.
|
|
88
|
+
if (!sql.includes('UPDATE "site_settings"')) {
|
|
89
|
+
throw new Error("AC-14 slice no longer spans the sentinel UPDATE");
|
|
90
|
+
}
|
|
91
|
+
return sql;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Postgres's own error shape carries `code` as a 5-character SQLSTATE. */
|
|
95
|
+
type PgError = { code?: string; constraint?: string };
|
|
96
|
+
|
|
97
|
+
const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
|
|
98
|
+
|
|
99
|
+
/** A run-unique suffix so repeated local runs never collide. */
|
|
100
|
+
const RUN = randomUUID().slice(0, 8);
|
|
101
|
+
|
|
102
|
+
/** The seeded sentinel workspace — `database/tenancy.ts`'s DEFAULT_WORKSPACE_ID. */
|
|
103
|
+
const SENTINEL_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The eleven `site_id` tables of spec § 1.1, each with the columns its own
|
|
107
|
+
* NOT NULLs require BESIDES `site_id`/`workspace_id`. `cols` and `vals` are
|
|
108
|
+
* positional twins: `vals` starts at `$3` because `$1`/`$2` are always
|
|
109
|
+
* `site_id`/`workspace_id`, which every case below supplies itself.
|
|
110
|
+
*/
|
|
111
|
+
interface FenceTable {
|
|
112
|
+
/** The Postgres table name — also the constraint-name stem. */
|
|
113
|
+
table: string;
|
|
114
|
+
/** Extra NOT NULL columns, already quoted-safe (plain lower_snake). */
|
|
115
|
+
cols: string[];
|
|
116
|
+
/** One value per `cols` entry, in order. */
|
|
117
|
+
vals: () => unknown[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const TABLES: FenceTable[] = [
|
|
121
|
+
{
|
|
122
|
+
table: "subscribers",
|
|
123
|
+
cols: ["email", "source"],
|
|
124
|
+
vals: () => [`sub-${RUN}-${randomUUID()}@example.com`, `6374-${RUN}`],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
table: "callbacks",
|
|
128
|
+
cols: ["name", "phone", "payload"],
|
|
129
|
+
vals: () => [`6374 ${RUN}`, "+10000000000", "{}"],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
table: "consent_grants",
|
|
133
|
+
cols: [
|
|
134
|
+
"subject_type",
|
|
135
|
+
"subject_id",
|
|
136
|
+
"purpose_id",
|
|
137
|
+
"granted",
|
|
138
|
+
"locale",
|
|
139
|
+
"statement",
|
|
140
|
+
"statement_hash",
|
|
141
|
+
],
|
|
142
|
+
vals: () => [
|
|
143
|
+
"subscriber",
|
|
144
|
+
`subj-${RUN}-${randomUUID()}`,
|
|
145
|
+
`purpose-${RUN}`,
|
|
146
|
+
true,
|
|
147
|
+
"en",
|
|
148
|
+
"I agree",
|
|
149
|
+
`hash-${RUN}`,
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
table: "appointments",
|
|
154
|
+
cols: [
|
|
155
|
+
"provider_booking_uid",
|
|
156
|
+
"start_time",
|
|
157
|
+
"end_time",
|
|
158
|
+
"attendee_name",
|
|
159
|
+
"attendee_email",
|
|
160
|
+
"status",
|
|
161
|
+
"lifecycle_event_at",
|
|
162
|
+
"updated_at",
|
|
163
|
+
],
|
|
164
|
+
vals: () => [
|
|
165
|
+
`uid-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
166
|
+
new Date("2026-10-01T10:00:00Z"),
|
|
167
|
+
new Date("2026-10-01T11:00:00Z"),
|
|
168
|
+
"Attendee",
|
|
169
|
+
`appt-${RUN}@example.com`,
|
|
170
|
+
"confirmed",
|
|
171
|
+
new Date("2026-09-22T00:00:00Z"),
|
|
172
|
+
new Date("2026-09-22T00:00:00Z"),
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
table: "deliverable_assets",
|
|
177
|
+
cols: ["key", "filename", "content_type", "bytes", "size", "updated_at"],
|
|
178
|
+
vals: () => [
|
|
179
|
+
`key-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
180
|
+
"file.pdf",
|
|
181
|
+
"application/pdf",
|
|
182
|
+
Buffer.from("bytes"),
|
|
183
|
+
5,
|
|
184
|
+
new Date("2026-09-22T00:00:00Z"),
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
table: "flow_checkouts",
|
|
189
|
+
cols: [
|
|
190
|
+
"flow_slug",
|
|
191
|
+
"experiment_key",
|
|
192
|
+
"session_id",
|
|
193
|
+
"lead_id",
|
|
194
|
+
"offer_key",
|
|
195
|
+
"seats",
|
|
196
|
+
"period",
|
|
197
|
+
"credit_stop",
|
|
198
|
+
"amount_minor",
|
|
199
|
+
"currency",
|
|
200
|
+
"quote",
|
|
201
|
+
"permalink",
|
|
202
|
+
"status",
|
|
203
|
+
"updated_at",
|
|
204
|
+
],
|
|
205
|
+
vals: () => [
|
|
206
|
+
`flow-${RUN}`,
|
|
207
|
+
`exp-${RUN}`,
|
|
208
|
+
`sess-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
209
|
+
`lead-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
210
|
+
"offer",
|
|
211
|
+
1,
|
|
212
|
+
"monthly",
|
|
213
|
+
"{}",
|
|
214
|
+
1000,
|
|
215
|
+
"usd",
|
|
216
|
+
"{}",
|
|
217
|
+
"permalink",
|
|
218
|
+
"created",
|
|
219
|
+
new Date("2026-09-22T00:00:00Z"),
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
table: "waitlist_experiments",
|
|
224
|
+
cols: ["key", "mode", "status", "config", "updated_at"],
|
|
225
|
+
vals: () => [
|
|
226
|
+
`exp-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
227
|
+
"lead-capture",
|
|
228
|
+
"live",
|
|
229
|
+
"{}",
|
|
230
|
+
new Date("2026-09-22T00:00:00Z"),
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
table: "waitlist_scores",
|
|
235
|
+
cols: [
|
|
236
|
+
"lead_id",
|
|
237
|
+
"experiment_key",
|
|
238
|
+
"waitlist_key",
|
|
239
|
+
"tier",
|
|
240
|
+
"score",
|
|
241
|
+
"category_scores",
|
|
242
|
+
"rule_trace",
|
|
243
|
+
"updated_at",
|
|
244
|
+
],
|
|
245
|
+
vals: () => [
|
|
246
|
+
`lead-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
247
|
+
`exp-${RUN}`,
|
|
248
|
+
`wl-${RUN}`,
|
|
249
|
+
"a",
|
|
250
|
+
10,
|
|
251
|
+
"{}",
|
|
252
|
+
"{}",
|
|
253
|
+
new Date("2026-09-22T00:00:00Z"),
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
table: "waitlist_followup_sends",
|
|
258
|
+
cols: ["lead_id", "follow_up_key", "sent_at", "status"],
|
|
259
|
+
vals: () => [
|
|
260
|
+
`lead-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
261
|
+
`fu-${RUN}`,
|
|
262
|
+
new Date("2026-09-22T00:00:00Z"),
|
|
263
|
+
"sent",
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
table: "playbook_instances",
|
|
268
|
+
cols: [
|
|
269
|
+
"lead_id",
|
|
270
|
+
"experiment_key",
|
|
271
|
+
"score_id",
|
|
272
|
+
"tier",
|
|
273
|
+
"level",
|
|
274
|
+
"rule_trace",
|
|
275
|
+
"source",
|
|
276
|
+
"markdown",
|
|
277
|
+
],
|
|
278
|
+
vals: () => [
|
|
279
|
+
`lead-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
280
|
+
`exp-${RUN}`,
|
|
281
|
+
`score-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
282
|
+
"a",
|
|
283
|
+
1,
|
|
284
|
+
"{}",
|
|
285
|
+
"{}",
|
|
286
|
+
"# playbook",
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
table: "site_listing_configs",
|
|
291
|
+
cols: ["data", "updated_at"],
|
|
292
|
+
vals: () => ["{}", new Date("2026-09-22T00:00:00Z")],
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
/** `INSERT INTO t (site_id, workspace_id, …) VALUES ($1, $2, …)`. */
|
|
297
|
+
function insertSql(spec: FenceTable): string {
|
|
298
|
+
const cols = ['"site_id"', '"workspace_id"', ...spec.cols.map((c) => `"${c}"`)];
|
|
299
|
+
const params = cols.map((_, i) => `$${i + 1}`);
|
|
300
|
+
return `INSERT INTO "${spec.table}" (${cols.join(", ")}) VALUES (${params.join(", ")})`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The same INSERT with `workspace_id` omitted entirely — the NULL case. */
|
|
304
|
+
function insertSqlWithoutWorkspace(spec: FenceTable): string {
|
|
305
|
+
const cols = ['"site_id"', ...spec.cols.map((c) => `"${c}"`)];
|
|
306
|
+
const params = cols.map((_, i) => `$${i + 1}`);
|
|
307
|
+
return `INSERT INTO "${spec.table}" (${cols.join(", ")}) VALUES (${params.join(", ")})`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
describeDb("the #6374 fence fan-out constraints (live DB)", () => {
|
|
311
|
+
let db: Client;
|
|
312
|
+
let workspaceAId: string;
|
|
313
|
+
let workspaceBId: string;
|
|
314
|
+
/** A workspace used ONLY by the cascade proof, so its delete is unblocked. */
|
|
315
|
+
let workspaceCId: string;
|
|
316
|
+
let orgId: string;
|
|
317
|
+
|
|
318
|
+
async function makeWorkspace(label: string): Promise<string> {
|
|
319
|
+
const row = await db.query<{ id: string }>(
|
|
320
|
+
`INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
|
|
321
|
+
VALUES ($1, $2, $3, now()) RETURNING "id"`,
|
|
322
|
+
[orgId, `6374 Workspace ${label} ${RUN}`, `6374-ws-${label}-${RUN}`],
|
|
323
|
+
);
|
|
324
|
+
return row.rows[0]!.id;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
beforeAll(async () => {
|
|
328
|
+
db = new Client({ connectionString: process.env.DATABASE_URL });
|
|
329
|
+
await db.connect();
|
|
330
|
+
|
|
331
|
+
// Two REAL, distinct workspaces — A and B — so a "disagreement" insert
|
|
332
|
+
// names two values that both actually exist, not a real one versus
|
|
333
|
+
// garbage (which the FORMAT CHECK, not the equality CHECK, would catch;
|
|
334
|
+
// the assertions below name the equality constraint for that reason).
|
|
335
|
+
orgId = (
|
|
336
|
+
await db.query<{ id: string }>(
|
|
337
|
+
`INSERT INTO "organizations" ("name", "slug", "updated_at")
|
|
338
|
+
VALUES ($1, $2, now()) RETURNING "id"`,
|
|
339
|
+
[`6374 Org ${RUN}`, `6374-org-${RUN}`],
|
|
340
|
+
)
|
|
341
|
+
).rows[0]!.id;
|
|
342
|
+
|
|
343
|
+
workspaceAId = await makeWorkspace("a");
|
|
344
|
+
workspaceBId = await makeWorkspace("b");
|
|
345
|
+
workspaceCId = await makeWorkspace("c");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
afterAll(async () => {
|
|
349
|
+
// Every statement here is best-effort and the connection is closed in a
|
|
350
|
+
// `finally`: when an assertion above has already failed because a column
|
|
351
|
+
// or constraint this unit adds is ABSENT, the cleanup statement naming it
|
|
352
|
+
// fails too — and an un-ended `pg` Client is an open handle that hangs
|
|
353
|
+
// the whole Jest run instead of reporting the real failure.
|
|
354
|
+
const cleanup = [
|
|
355
|
+
...TABLES.map(
|
|
356
|
+
(spec) =>
|
|
357
|
+
[
|
|
358
|
+
`DELETE FROM "${spec.table}" WHERE "site_id" = ANY($1)`,
|
|
359
|
+
[[workspaceAId, workspaceBId, workspaceCId]],
|
|
360
|
+
] as const,
|
|
361
|
+
),
|
|
362
|
+
[
|
|
363
|
+
`DELETE FROM "listing_csv_sends" WHERE "listing_id" = $1`,
|
|
364
|
+
[`listing-${RUN}`],
|
|
365
|
+
] as const,
|
|
366
|
+
[
|
|
367
|
+
`DELETE FROM "deliverable_grants" WHERE "site_id" = ANY($1)`,
|
|
368
|
+
[[workspaceAId, workspaceBId]],
|
|
369
|
+
] as const,
|
|
370
|
+
[
|
|
371
|
+
`DELETE FROM "site_settings" WHERE "workspace_id" = ANY($1)`,
|
|
372
|
+
[[workspaceAId, workspaceBId]],
|
|
373
|
+
] as const,
|
|
374
|
+
[`DELETE FROM "workspaces" WHERE "slug" LIKE $1`, [`6374-ws-%-${RUN}`]] as const,
|
|
375
|
+
[`DELETE FROM "organizations" WHERE "slug" = $1`, [`6374-org-${RUN}`]] as const,
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
for (const [sql, params] of cleanup) {
|
|
380
|
+
try {
|
|
381
|
+
await db.query(sql, params as unknown[]);
|
|
382
|
+
} catch {
|
|
383
|
+
// Best-effort — see the note above.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} finally {
|
|
387
|
+
await db.end();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe.each(TABLES.map((spec) => [spec.table, spec] as const))(
|
|
392
|
+
"AC-4 — %s",
|
|
393
|
+
(table, spec) => {
|
|
394
|
+
it("rejects an insert with workspace_id NULL (23502)", async () => {
|
|
395
|
+
await expect(
|
|
396
|
+
db.query(insertSqlWithoutWorkspace(spec), [
|
|
397
|
+
workspaceAId,
|
|
398
|
+
...spec.vals(),
|
|
399
|
+
]),
|
|
400
|
+
).rejects.toMatchObject<PgError>({ code: "23502" });
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it(`rejects workspace_id <> site_id, BY ${table}_workspace_site_agree (23514)`, async () => {
|
|
404
|
+
await expect(
|
|
405
|
+
db.query(insertSql(spec), [
|
|
406
|
+
workspaceAId,
|
|
407
|
+
workspaceBId,
|
|
408
|
+
...spec.vals(),
|
|
409
|
+
]),
|
|
410
|
+
).rejects.toMatchObject<PgError>({
|
|
411
|
+
code: "23514",
|
|
412
|
+
constraint: `${table}_workspace_site_agree`,
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("accepts an insert where workspace_id AGREES with site_id", async () => {
|
|
417
|
+
const result = await db.query(
|
|
418
|
+
`${insertSql(spec)} RETURNING "id"`,
|
|
419
|
+
[workspaceAId, workspaceAId, ...spec.vals()],
|
|
420
|
+
);
|
|
421
|
+
expect(result.rows).toHaveLength(1);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("carries no row with site_id = 'default' after the migration", async () => {
|
|
425
|
+
const result = await db.query<{ count: string }>(
|
|
426
|
+
`SELECT count(*)::text AS count FROM "${table}" WHERE "site_id" = 'default'`,
|
|
427
|
+
);
|
|
428
|
+
expect(result.rows[0]!.count).toBe("0");
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
describe("AC-5 — site_settings is keyed by (workspace_id, key), not by key alone (D3)", () => {
|
|
434
|
+
it("two DIFFERENT workspaces may each hold a feature_flags row", async () => {
|
|
435
|
+
const a = await db.query(
|
|
436
|
+
`INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
|
|
437
|
+
VALUES ($1, $2, '{}'::jsonb, now()) RETURNING "id"`,
|
|
438
|
+
[workspaceAId, "feature_flags"],
|
|
439
|
+
);
|
|
440
|
+
const b = await db.query(
|
|
441
|
+
`INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
|
|
442
|
+
VALUES ($1, $2, '{}'::jsonb, now()) RETURNING "id"`,
|
|
443
|
+
[workspaceBId, "feature_flags"],
|
|
444
|
+
);
|
|
445
|
+
expect(a.rows).toHaveLength(1);
|
|
446
|
+
expect(b.rows).toHaveLength(1);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("a SECOND feature_flags row in ONE workspace is rejected (23505)", async () => {
|
|
450
|
+
await expect(
|
|
451
|
+
db.query(
|
|
452
|
+
`INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
|
|
453
|
+
VALUES ($1, $2, '{}'::jsonb, now())`,
|
|
454
|
+
[workspaceAId, "feature_flags"],
|
|
455
|
+
),
|
|
456
|
+
).rejects.toMatchObject<PgError>({ code: "23505" });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("rejects a site_settings row with workspace_id NULL (23502)", async () => {
|
|
460
|
+
await expect(
|
|
461
|
+
db.query(
|
|
462
|
+
`INSERT INTO "site_settings" ("key", "value", "updated_at")
|
|
463
|
+
VALUES ($1, '{}'::jsonb, now())`,
|
|
464
|
+
[`nullws-${RUN}`],
|
|
465
|
+
),
|
|
466
|
+
).rejects.toMatchObject<PgError>({ code: "23502" });
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe("AC-5 — listing_csv_sends takes NOT NULL + the format CHECK, and no equality CHECK (D2)", () => {
|
|
471
|
+
it("rejects an insert with workspace_id NULL (23502)", async () => {
|
|
472
|
+
await expect(
|
|
473
|
+
db.query(
|
|
474
|
+
`INSERT INTO "listing_csv_sends" ("listing_id", "sent_at", "row_count", "grant_id")
|
|
475
|
+
VALUES ($1, now(), 1, $2)`,
|
|
476
|
+
[`listing-${RUN}`, `grant-${RUN}`],
|
|
477
|
+
),
|
|
478
|
+
).rejects.toMatchObject<PgError>({ code: "23502" });
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("accepts an insert stamped with a well-formed workspace_id", async () => {
|
|
482
|
+
const result = await db.query(
|
|
483
|
+
`INSERT INTO "listing_csv_sends"
|
|
484
|
+
("listing_id", "workspace_id", "sent_at", "row_count", "grant_id")
|
|
485
|
+
VALUES ($1, $2, now(), 1, $3) RETURNING "id"`,
|
|
486
|
+
[`listing-${RUN}`, workspaceAId, `grant-${RUN}`],
|
|
487
|
+
);
|
|
488
|
+
expect(result.rows).toHaveLength(1);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("has NO <table>_workspace_site_agree constraint — it carries no site_id (D2)", async () => {
|
|
492
|
+
const result = await db.query<{ conname: string }>(
|
|
493
|
+
`SELECT c.conname FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
|
|
494
|
+
WHERE t.relname = 'listing_csv_sends'
|
|
495
|
+
AND c.conname = 'listing_csv_sends_workspace_site_agree'`,
|
|
496
|
+
);
|
|
497
|
+
expect(result.rows).toHaveLength(0);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("AC-5 — deliverable_grants stays NULL-tolerant but never disagreeing (D4)", () => {
|
|
502
|
+
it("ACCEPTS a row with workspace_id NULL — the serve path's fail-closed sentinel", async () => {
|
|
503
|
+
const result = await db.query(
|
|
504
|
+
`INSERT INTO "deliverable_grants" ("site_id", "asset_id", "lead_id", "session_id")
|
|
505
|
+
VALUES ($1, $2, $3, $4) RETURNING "id"`,
|
|
506
|
+
[
|
|
507
|
+
workspaceAId,
|
|
508
|
+
`dasset-${RUN}`,
|
|
509
|
+
`lead-${RUN}`,
|
|
510
|
+
`sess-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
511
|
+
],
|
|
512
|
+
);
|
|
513
|
+
expect(result.rows).toHaveLength(1);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("rejects a STAMPED row whose workspace_id differs from its site_id (23514)", async () => {
|
|
517
|
+
await expect(
|
|
518
|
+
db.query(
|
|
519
|
+
`INSERT INTO "deliverable_grants"
|
|
520
|
+
("site_id", "workspace_id", "asset_id", "lead_id", "session_id")
|
|
521
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
522
|
+
[
|
|
523
|
+
workspaceAId,
|
|
524
|
+
workspaceBId,
|
|
525
|
+
`asset-${RUN}`,
|
|
526
|
+
`lead-${RUN}`,
|
|
527
|
+
`sess-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
528
|
+
],
|
|
529
|
+
),
|
|
530
|
+
).rejects.toMatchObject<PgError>({
|
|
531
|
+
code: "23514",
|
|
532
|
+
constraint: "deliverable_grants_workspace_site_agree",
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("accepts a STAMPED row whose workspace_id agrees with its site_id", async () => {
|
|
537
|
+
const result = await db.query(
|
|
538
|
+
`INSERT INTO "deliverable_grants"
|
|
539
|
+
("site_id", "workspace_id", "asset_id", "lead_id", "session_id")
|
|
540
|
+
VALUES ($1, $1, $2, $3, $4) RETURNING "id"`,
|
|
541
|
+
[
|
|
542
|
+
workspaceAId,
|
|
543
|
+
`asset-${RUN}`,
|
|
544
|
+
`lead-${RUN}`,
|
|
545
|
+
`sess-${RUN}-${randomUUID().slice(0, 8)}`,
|
|
546
|
+
],
|
|
547
|
+
);
|
|
548
|
+
expect(result.rows).toHaveLength(1);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe("AC-14 — site_settings: abort, never guess (the key carries the tenant)", () => {
|
|
553
|
+
/** The sentinel Organization the sentinel workspace must hang off. */
|
|
554
|
+
const SENTINEL_ORGANIZATION_ID = "org_00000000-0000-7000-8000-000000000001";
|
|
555
|
+
|
|
556
|
+
// Every case runs inside a transaction that is ROLLED BACK, and each first
|
|
557
|
+
// drops NOT NULL on `workspace_id` so the PRE-migration row shape (an
|
|
558
|
+
// unattributed row) can exist at all. Nothing escapes the rollback.
|
|
559
|
+
async function withPreMigrationShape<T>(
|
|
560
|
+
body: () => Promise<T>,
|
|
561
|
+
): Promise<T> {
|
|
562
|
+
await db.query("BEGIN");
|
|
563
|
+
try {
|
|
564
|
+
await db.query(
|
|
565
|
+
`ALTER TABLE "site_settings" ALTER COLUMN "workspace_id" DROP NOT NULL`,
|
|
566
|
+
);
|
|
567
|
+
return await body();
|
|
568
|
+
} finally {
|
|
569
|
+
await db.query("ROLLBACK");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The sentinel UPDATE joins to `workspaces`, so a database without the
|
|
575
|
+
* sentinel row backfills NOTHING and the post-backfill assertion aborts.
|
|
576
|
+
* A fresh `prisma migrate deploy` seeds no sentinel, so the non-abort
|
|
577
|
+
* cases create it here — inside the same rolled-back transaction.
|
|
578
|
+
*/
|
|
579
|
+
async function ensureSentinelWorkspace(): Promise<void> {
|
|
580
|
+
await db.query(
|
|
581
|
+
`INSERT INTO "organizations" ("id", "name", "slug", "updated_at")
|
|
582
|
+
VALUES ($1, $2, $3, now()) ON CONFLICT DO NOTHING`,
|
|
583
|
+
[SENTINEL_ORGANIZATION_ID, `Sentinel Org ${RUN}`, `sentinel-org-${RUN}`],
|
|
584
|
+
);
|
|
585
|
+
await db.query(
|
|
586
|
+
`INSERT INTO "workspaces" ("id", "organization_id", "name", "slug", "updated_at")
|
|
587
|
+
VALUES ($1, $2, $3, $4, now()) ON CONFLICT DO NOTHING`,
|
|
588
|
+
[
|
|
589
|
+
SENTINEL_WORKSPACE_ID,
|
|
590
|
+
SENTINEL_ORGANIZATION_ID,
|
|
591
|
+
`Sentinel WS ${RUN}`,
|
|
592
|
+
`sentinel-ws-${RUN}`,
|
|
593
|
+
],
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** The `workspace_id` the one row carrying `key` ended up attributed to. */
|
|
598
|
+
async function attributedWorkspaceOf(key: string): Promise<string | null> {
|
|
599
|
+
const rows = await db.query<{ workspace_id: string | null }>(
|
|
600
|
+
`SELECT "workspace_id" FROM "site_settings" WHERE "key" = $1`,
|
|
601
|
+
[key],
|
|
602
|
+
);
|
|
603
|
+
expect(rows.rows).toHaveLength(1);
|
|
604
|
+
return rows.rows[0]!.workspace_id;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
it("(a) ABORTS on an unattributed row whose key embeds a NON-sentinel workspace id", async () => {
|
|
608
|
+
await withPreMigrationShape(async () => {
|
|
609
|
+
await ensureSentinelWorkspace();
|
|
610
|
+
await db.query(
|
|
611
|
+
`INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
|
|
612
|
+
VALUES (NULL, $1, '{}'::jsonb, now())`,
|
|
613
|
+
[`commerce.dunning_policy:${workspaceAId}`],
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
// workspaceAId is a real, well-formed, NON-sentinel wksp id — exactly
|
|
617
|
+
// the row the sentinel assignment would misfile. The guard aborts
|
|
618
|
+
// BEFORE the UPDATE, so a sentinel workspace being available here
|
|
619
|
+
// changes nothing.
|
|
620
|
+
// No explicit type argument here: `PgError` declares no `message`,
|
|
621
|
+
// and pinning one would force a cast. The matcher checks both the
|
|
622
|
+
// SQLSTATE and that the exception names the table it is about, so a
|
|
623
|
+
// P0001 raised by some other block cannot satisfy this.
|
|
624
|
+
await expect(db.query(siteSettingsGuardSql())).rejects.toMatchObject({
|
|
625
|
+
code: "P0001",
|
|
626
|
+
message: expect.stringContaining("site_settings"),
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("(b) ATTRIBUTES an unattributed row whose key embeds NO workspace id to the sentinel", async () => {
|
|
632
|
+
const aliasKey = `commerce.dunning_policy:default-${RUN}`;
|
|
633
|
+
const plainKey = `feature_flags-${RUN}`;
|
|
634
|
+
await withPreMigrationShape(async () => {
|
|
635
|
+
await ensureSentinelWorkspace();
|
|
636
|
+
// Both shapes the guard must let through: the bare alias the
|
|
637
|
+
// single-tenant build writes, and an ordinary config key naming no
|
|
638
|
+
// site at all.
|
|
639
|
+
await db.query(
|
|
640
|
+
`INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
|
|
641
|
+
VALUES (NULL, $1, '{}'::jsonb, now()), (NULL, $2, '{}'::jsonb, now())`,
|
|
642
|
+
[aliasKey, plainKey],
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
await expect(db.query(siteSettingsGuardSql())).resolves.toBeDefined();
|
|
646
|
+
|
|
647
|
+
// The slice ran guard -> UPDATE -> assertion, so these are ATTRIBUTED,
|
|
648
|
+
// not merely un-rejected.
|
|
649
|
+
expect(await attributedWorkspaceOf(aliasKey)).toBe(SENTINEL_WORKSPACE_ID);
|
|
650
|
+
expect(await attributedWorkspaceOf(plainKey)).toBe(SENTINEL_WORKSPACE_ID);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("(c) ATTRIBUTES an unattributed row whose key embeds the SENTINEL id to the sentinel", async () => {
|
|
655
|
+
const key = `commerce.dunning_policy:${SENTINEL_WORKSPACE_ID}`;
|
|
656
|
+
await withPreMigrationShape(async () => {
|
|
657
|
+
await ensureSentinelWorkspace();
|
|
658
|
+
await db.query(
|
|
659
|
+
`INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
|
|
660
|
+
VALUES (NULL, $1, '{}'::jsonb, now())`,
|
|
661
|
+
[key],
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
await expect(db.query(siteSettingsGuardSql())).resolves.toBeDefined();
|
|
665
|
+
|
|
666
|
+
// A key already naming the sentinel is not a misfiling risk — it
|
|
667
|
+
// backfills to exactly the workspace its key names.
|
|
668
|
+
expect(await attributedWorkspaceOf(key)).toBe(SENTINEL_WORKSPACE_ID);
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("(d) is INERT on an already-migrated database — the verbatim re-run stays a no-op (NFR2)", async () => {
|
|
673
|
+
// No transaction and no DROP NOT NULL: this is the real, migrated table,
|
|
674
|
+
// where every row carries a workspace_id. The `IS NULL` guard makes the
|
|
675
|
+
// check match nothing, the UPDATE match nothing, and the assertion pass
|
|
676
|
+
// — which is what keeps AC-3's re-run clean even on a database whose
|
|
677
|
+
// keys embed non-sentinel ids.
|
|
678
|
+
const key = `commerce.dunning_policy:${workspaceAId}`;
|
|
679
|
+
await db.query(
|
|
680
|
+
`INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
|
|
681
|
+
VALUES ($1, $2, '{}'::jsonb, now())`,
|
|
682
|
+
[workspaceAId, key],
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
await expect(db.query(siteSettingsGuardSql())).resolves.toBeDefined();
|
|
686
|
+
|
|
687
|
+
// Untouched: still its OWN workspace, never rewritten to the sentinel.
|
|
688
|
+
expect(await attributedWorkspaceOf(key)).toBe(workspaceAId);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe("AC-5 — the fence is a CHECK, never a foreign key (NFR1)", () => {
|
|
693
|
+
it("deleting a workspaces row leaves that workspace's consent_grants rows in place", async () => {
|
|
694
|
+
await db.query(
|
|
695
|
+
`INSERT INTO "consent_grants"
|
|
696
|
+
("site_id", "workspace_id", "subject_type", "subject_id", "purpose_id",
|
|
697
|
+
"granted", "locale", "statement", "statement_hash")
|
|
698
|
+
VALUES ($1, $1, 'subscriber', $2, $3, true, 'en', 'I agree', $4)`,
|
|
699
|
+
[workspaceCId, `cascade-${RUN}`, `purpose-${RUN}`, `hash-${RUN}`],
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
await db.query(`DELETE FROM "workspaces" WHERE "id" = $1`, [workspaceCId]);
|
|
703
|
+
|
|
704
|
+
const remaining = await db.query<{ count: string }>(
|
|
705
|
+
`SELECT count(*)::text AS count FROM "consent_grants" WHERE "site_id" = $1`,
|
|
706
|
+
[workspaceCId],
|
|
707
|
+
);
|
|
708
|
+
expect(remaining.rows[0]!.count).toBe("1");
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
});
|