create-ailk 0.5.0 → 0.6.1
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/cli.js +54 -5
- package/dist/copy.d.ts +14 -0
- package/dist/copy.js +38 -1
- 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 +20 -1
- package/dist/lib/extract-module-bundle.js +64 -5
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/lib/module-license-gate.d.ts +6 -1
- package/dist/lib/module-license-gate.js +6 -1
- package/dist/lib/pro-callout.js +1 -1
- package/dist/module-architecture.d.ts +1 -0
- package/dist/module-architecture.js +253 -127
- package/dist/modules/commerce/0.6.0.tar.gz +0 -0
- package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
- package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
- package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
- package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
- package/dist/modules/workspace/0.6.0.tar.gz +0 -0
- package/dist/programmatic.js +14 -10
- 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/DESIGN.md +148 -0
- 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/DESIGN.md +148 -0
- 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/DESIGN.md +148 -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/saas-product/DESIGN.md +148 -0
- package/site-templates/simple-service/DESIGN.md +148 -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/.env.example +72 -7
- package/templates/CLAUDE.md +2 -3
- package/templates/DESIGN.md +155 -0
- package/templates/README.md +42 -8
- 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/PRODUCT.md +86 -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__/site-theme.test.ts +135 -20
- 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/site-brand.tsx +4 -8
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +187 -0
- package/templates/apps/web/middleware.ts +1 -1
- 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 +93 -0
- package/templates/content/_site.mdx +18 -3
- 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 +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__/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 +3 -2
- 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/public/logo-dark.svg +0 -21
- package/templates/apps/web/public/logo-light.svg +0 -21
- /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
|
@@ -5,16 +5,32 @@
|
|
|
5
5
|
* POST /v1/leads/submit — lead submission (contact form / inquiry)
|
|
6
6
|
* POST /v1/leads/subscribe — newsletter subscription
|
|
7
7
|
* POST /v1/leads/callback — callback request (phone-back)
|
|
8
|
+
*
|
|
9
|
+
* TENANT RESOLUTION (#6203, spec §10.4 operator ruling — reverses this
|
|
10
|
+
* file's former byte-unchanged posture). `siteId` used to be taken VERBATIM
|
|
11
|
+
* from the request body (`body["siteId"] as string`) — an anonymous, no-
|
|
12
|
+
* ownership-check value (`packages/validation/src/schemas/leads.ts`'s
|
|
13
|
+
* `safeSiteIdString` permits a `wksp_<uuidv7>`-shaped literal). Every
|
|
14
|
+
* capture kind now resolves its tenant SERVER-SIDE, via
|
|
15
|
+
* `resolveSiteWorkspaceId()` (`lib/site.ts`) — the SAME alias -> workspace
|
|
16
|
+
* resolution `currentSiteId()`/`AILK_SITE_ID` already drive throughout this
|
|
17
|
+
* app, unconditional on the content adapter — and a client-posted `siteId`
|
|
18
|
+
* is IGNORED entirely: it can no longer place a row anywhere. An unresolved
|
|
19
|
+
* result (no DATABASE_URL, or the alias names no workspace this database
|
|
20
|
+
* holds) is a 503 before any write is attempted — never a guess. See
|
|
21
|
+
* `lead-routing.ts`'s own header for the persistence-side half.
|
|
8
22
|
*/
|
|
9
23
|
|
|
10
24
|
import { track } from "@working-theory/analytics/server";
|
|
11
25
|
import type { FastifyPluginAsync } from "fastify";
|
|
12
26
|
|
|
27
|
+
import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
|
|
13
28
|
import {
|
|
14
29
|
type LeadKind,
|
|
15
30
|
type RouteLeadInput,
|
|
16
31
|
routeLead,
|
|
17
32
|
} from "../../services/lead-routing.js";
|
|
33
|
+
import { sendRequestConfirmLink } from "../../services/request-confirm.js";
|
|
18
34
|
|
|
19
35
|
/**
|
|
20
36
|
* Read a named string field from the body, falling back to the same-named
|
|
@@ -42,17 +58,22 @@ function liftString(
|
|
|
42
58
|
* `email`/`name`/`phone` out of `payload` (see liftString above) reads by
|
|
43
59
|
* explicit field name only — it does not spread `payload`'s other keys, so
|
|
44
60
|
* the same discipline holds. `payload` itself is passed through unchanged.
|
|
61
|
+
*
|
|
62
|
+
* `siteId` is the RESOLVED `workspaceId` (#6203) — never `body["siteId"]`.
|
|
63
|
+
* A body-supplied `siteId` is read nowhere in this function; a client that
|
|
64
|
+
* posts one has it silently ignored, never honoured.
|
|
45
65
|
*/
|
|
46
66
|
function buildInput(
|
|
47
67
|
kind: LeadKind,
|
|
48
68
|
body: Record<string, unknown>,
|
|
69
|
+
workspaceId: string,
|
|
49
70
|
): RouteLeadInput {
|
|
50
71
|
const payload = (body["payload"] ?? {}) as Record<string, unknown>;
|
|
51
72
|
|
|
52
73
|
if (kind === "submit") {
|
|
53
74
|
return {
|
|
54
75
|
kind,
|
|
55
|
-
siteId:
|
|
76
|
+
siteId: workspaceId,
|
|
56
77
|
source: body["source"] as string,
|
|
57
78
|
email: liftString(body, payload, "email"),
|
|
58
79
|
payload,
|
|
@@ -61,14 +82,14 @@ function buildInput(
|
|
|
61
82
|
if (kind === "subscribe") {
|
|
62
83
|
return {
|
|
63
84
|
kind,
|
|
64
|
-
siteId:
|
|
85
|
+
siteId: workspaceId,
|
|
65
86
|
email: liftString(body, payload, "email"),
|
|
66
87
|
source: body["source"] as string,
|
|
67
88
|
};
|
|
68
89
|
}
|
|
69
90
|
return {
|
|
70
91
|
kind,
|
|
71
|
-
siteId:
|
|
92
|
+
siteId: workspaceId,
|
|
72
93
|
name: liftString(body, payload, "name"),
|
|
73
94
|
phone: liftString(body, payload, "phone"),
|
|
74
95
|
...(body["preferredTime"] !== undefined && {
|
|
@@ -99,7 +120,46 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
99
120
|
receivedAt: new Date().toISOString(),
|
|
100
121
|
};
|
|
101
122
|
|
|
102
|
-
|
|
123
|
+
// #6203 — resolve the tenant SERVER-SIDE before any write is
|
|
124
|
+
// attempted. Two branches, deliberately different:
|
|
125
|
+
//
|
|
126
|
+
// - No DATABASE_URL: the zero-config OSS file-fallback deployment.
|
|
127
|
+
// There is no `workspaces` table to resolve against (and
|
|
128
|
+
// `assertSiteAliasResolves()` already skips its own boot check for
|
|
129
|
+
// exactly this reason), so the "resolved" value here is the site
|
|
130
|
+
// ALIAS itself (`currentSiteId()`) — the same value the file
|
|
131
|
+
// fallback would have carried before this unit. Never a rejection:
|
|
132
|
+
// that would break lead capture for every zero-config scaffold.
|
|
133
|
+
// - DATABASE_URL set: resolve via `resolveSiteWorkspaceId()`, the
|
|
134
|
+
// SAME alias -> workspace resolution `currentSiteId()` already
|
|
135
|
+
// drives everywhere else in this app. `null` here is a genuine
|
|
136
|
+
// misconfiguration `build()` already refuses to boot past
|
|
137
|
+
// (`assertSiteAliasResolves()`) — reaching it at request time is
|
|
138
|
+
// the residual case that startup check cannot catch (a workspace
|
|
139
|
+
// deleted after boot, a transient read), and it fails closed the
|
|
140
|
+
// same way: reject, never guess (spec AC-4).
|
|
141
|
+
let workspaceId: string;
|
|
142
|
+
if (!process.env.DATABASE_URL) {
|
|
143
|
+
workspaceId = currentSiteId();
|
|
144
|
+
} else {
|
|
145
|
+
const resolved = await resolveSiteWorkspaceId();
|
|
146
|
+
if (resolved === null) {
|
|
147
|
+
request.log.error(
|
|
148
|
+
{ kind },
|
|
149
|
+
"lead capture: DATABASE_URL is set but the site alias resolves to no workspace",
|
|
150
|
+
);
|
|
151
|
+
return reply.status(503).send({
|
|
152
|
+
error: "tenant_unresolved",
|
|
153
|
+
message: "This site is not configured to accept submissions.",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
workspaceId = resolved;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = await routeLead(
|
|
160
|
+
buildInput(kind, body, workspaceId),
|
|
161
|
+
sourceMeta,
|
|
162
|
+
);
|
|
103
163
|
|
|
104
164
|
if (!result.ok) {
|
|
105
165
|
if (result.error.kind === "invalid_input") {
|
|
@@ -110,7 +170,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
110
170
|
if (result.error.kind === "persist_failed") {
|
|
111
171
|
// Do not surface internal error detail — log it server-side only
|
|
112
172
|
request.log.error(
|
|
113
|
-
{ kind, siteId:
|
|
173
|
+
{ kind, siteId: workspaceId, error: result.error.message },
|
|
114
174
|
"lead persist_failed",
|
|
115
175
|
);
|
|
116
176
|
return reply.status(503).send({ error: "persist_failed" });
|
|
@@ -131,7 +191,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
131
191
|
: "error";
|
|
132
192
|
request.log.info(
|
|
133
193
|
{
|
|
134
|
-
siteId:
|
|
194
|
+
siteId: workspaceId,
|
|
135
195
|
kind,
|
|
136
196
|
ip: sourceMeta.ip,
|
|
137
197
|
persisted: result.persisted,
|
|
@@ -140,6 +200,35 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
140
200
|
"lead routed",
|
|
141
201
|
);
|
|
142
202
|
|
|
203
|
+
// Request-and-confirm (#5970): mint the tenant's one-time confirm
|
|
204
|
+
// credential and email them the link. Fire-and-forget and `void`-
|
|
205
|
+
// returning, so the 201 below stays independent of both Resend and that
|
|
206
|
+
// write — the request is already persisted by this point, and a send
|
|
207
|
+
// that fails must never turn a captured lead into an error.
|
|
208
|
+
//
|
|
209
|
+
// Callback only: `submit` and `subscribe` have no confirm step. The
|
|
210
|
+
// service itself no-ops (with a logged reason) when the feature is not
|
|
211
|
+
// configured, which is the default.
|
|
212
|
+
// `result.persisted === "db"` for the same reason `lead-routing.ts`
|
|
213
|
+
// consults it (#5970 security review round 3, M1): the credential is
|
|
214
|
+
// minted by an update on the `callbacks` row, so a FILE-FALLBACK id
|
|
215
|
+
// would match nothing and the operator would be left with no email at
|
|
216
|
+
// all. On that path the generic notification is sent instead, which is
|
|
217
|
+
// what keeps "exactly one" true in both directions.
|
|
218
|
+
if (kind === "callback" && result.persisted === "db") {
|
|
219
|
+
const callbackInput = buildInput(kind, body, workspaceId) as Extract<
|
|
220
|
+
RouteLeadInput,
|
|
221
|
+
{ kind: "callback" }
|
|
222
|
+
>;
|
|
223
|
+
sendRequestConfirmLink(result.id, {
|
|
224
|
+
name: callbackInput.name,
|
|
225
|
+
phone: callbackInput.phone,
|
|
226
|
+
...(callbackInput.preferredTime !== undefined && {
|
|
227
|
+
preferredTime: callbackInput.preferredTime,
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
143
232
|
// Fire-and-forget: track() never throws; no need to hold the response.
|
|
144
233
|
void track("lead-captured", {
|
|
145
234
|
source: body["source"] as string | undefined,
|
|
@@ -148,13 +237,11 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
148
237
|
] as string | undefined,
|
|
149
238
|
});
|
|
150
239
|
|
|
151
|
-
return reply
|
|
152
|
-
.
|
|
153
|
-
.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
email: result.email,
|
|
157
|
-
});
|
|
240
|
+
return reply.status(201).send({
|
|
241
|
+
id: result.id,
|
|
242
|
+
persisted: result.persisted,
|
|
243
|
+
email: result.email,
|
|
244
|
+
});
|
|
158
245
|
});
|
|
159
246
|
};
|
|
160
247
|
}
|
package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts
CHANGED
|
@@ -74,6 +74,10 @@ import {
|
|
|
74
74
|
} from "@working-theory/validation";
|
|
75
75
|
|
|
76
76
|
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
77
|
+
import {
|
|
78
|
+
mockNoWorkspace,
|
|
79
|
+
mockWorkspaceExists,
|
|
80
|
+
} from "../../../lib/__mocks__/prisma.js";
|
|
77
81
|
import { prisma } from "../../../lib/prisma.js";
|
|
78
82
|
import { patchProjectListingDraftRoute } from "../patch-draft.js";
|
|
79
83
|
import { startProjectListingRoute } from "../start.js";
|
|
@@ -181,6 +185,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
|
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
function installStore(): void {
|
|
188
|
+
// #6335 — the shared prisma mock no longer resolves ANY requested
|
|
189
|
+
// workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
|
|
190
|
+
// by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
|
|
191
|
+
// services/project-listings.ts), so this suite states the ONE site it is
|
|
192
|
+
// built around. Keyed on the requested id — nothing else resolves.
|
|
193
|
+
mockWorkspaceExists(prisma, SITE_ID);
|
|
194
|
+
|
|
184
195
|
rows = [];
|
|
185
196
|
nextId = 1;
|
|
186
197
|
configData = null;
|
|
@@ -239,6 +250,7 @@ function installStore(): void {
|
|
|
239
250
|
headline: null,
|
|
240
251
|
logoMarkUrl: null,
|
|
241
252
|
logoWordmarkUrl: null,
|
|
253
|
+
solution: null,
|
|
242
254
|
featured: false,
|
|
243
255
|
decidedAt: null,
|
|
244
256
|
decidedBy: null,
|
|
@@ -573,3 +585,42 @@ describe("the site's own application config (#5048 D5/D6)", () => {
|
|
|
573
585
|
});
|
|
574
586
|
});
|
|
575
587
|
});
|
|
588
|
+
|
|
589
|
+
// ── #6335 — the explicit workspace setup above is load-bearing ────────────
|
|
590
|
+
//
|
|
591
|
+
// Every case in this file states that this suite's site has a workspace.
|
|
592
|
+
// This one takes that away, and proves the statement was doing work rather
|
|
593
|
+
// than decorating the setup: `startProjectListingDraft` resolves the site it
|
|
594
|
+
// is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
|
|
595
|
+
// and refuses BEFORE any row is written. With the shared prisma mock's old
|
|
596
|
+
// permissive default in place this case could not be written at all — every
|
|
597
|
+
// id resolved, so the fail-closed path was unreachable from a route test.
|
|
598
|
+
describe("#6335 — start fails closed when the site names no workspace", () => {
|
|
599
|
+
let app: FastifyInstance;
|
|
600
|
+
|
|
601
|
+
beforeEach(async () => {
|
|
602
|
+
jest.clearAllMocks();
|
|
603
|
+
installStore();
|
|
604
|
+
mockNoWorkspace(prisma);
|
|
605
|
+
app = await buildApp();
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
afterEach(async () => {
|
|
609
|
+
await app.close();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
|
|
613
|
+
const res = await app.inject({
|
|
614
|
+
method: "POST",
|
|
615
|
+
url: "/v1/project-listings",
|
|
616
|
+
payload: START_PAYLOAD,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
expect(res.statusCode).toBe(503);
|
|
620
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe(
|
|
621
|
+
"tenant_unresolved",
|
|
622
|
+
);
|
|
623
|
+
expect(prisma.projectListing.create).not.toHaveBeenCalled();
|
|
624
|
+
expect(rows).toHaveLength(0);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
@@ -62,11 +62,16 @@ jest.mock("@working-theory/license", () => ({
|
|
|
62
62
|
}),
|
|
63
63
|
}));
|
|
64
64
|
|
|
65
|
+
import { logger } from "@working-theory/observability";
|
|
65
66
|
import {
|
|
66
67
|
resolveConsentStatement,
|
|
67
68
|
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
68
69
|
} from "@working-theory/validation";
|
|
69
70
|
|
|
71
|
+
import {
|
|
72
|
+
mockNoWorkspace,
|
|
73
|
+
mockWorkspaceExists,
|
|
74
|
+
} from "../../../lib/__mocks__/prisma.js";
|
|
70
75
|
import { prisma } from "../../../lib/prisma.js";
|
|
71
76
|
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
72
77
|
import {
|
|
@@ -118,6 +123,48 @@ const START_PAYLOAD = {
|
|
|
118
123
|
consent: true,
|
|
119
124
|
};
|
|
120
125
|
|
|
126
|
+
/**
|
|
127
|
+
* #6314 — the magnets `capturedByFlowSlug` may name. The resolve runs against
|
|
128
|
+
* the RESOLVED workspace id (`resolveWorkspaceIdForSite(SITE_ID)`), which this
|
|
129
|
+
* suite's `mockWorkspaceExists(prisma, SITE_ID)` makes `SITE_ID` itself.
|
|
130
|
+
*/
|
|
131
|
+
const WS_OTHER = "wksp_00000000-0000-7000-8000-0000000000bb";
|
|
132
|
+
const LISTICLE_ID = "flow_00000000-0000-7000-8000-0000000000d1";
|
|
133
|
+
const FOREIGN_LISTICLE_ID = "flow_00000000-0000-7000-8000-0000000000d2";
|
|
134
|
+
const CONTACT_FLOW_ID = "flow_00000000-0000-7000-8000-0000000000d3";
|
|
135
|
+
|
|
136
|
+
const FLOW_DEFINITION_ROWS: Row[] = [
|
|
137
|
+
{
|
|
138
|
+
id: LISTICLE_ID,
|
|
139
|
+
siteId: SITE_ID,
|
|
140
|
+
workspaceId: SITE_ID,
|
|
141
|
+
slug: "founder-list",
|
|
142
|
+
kind: "listicle",
|
|
143
|
+
status: "live",
|
|
144
|
+
},
|
|
145
|
+
// A listicle magnet of ANOTHER workspace. Its slug is a public path segment,
|
|
146
|
+
// so a stranger can name it — the resolve's `siteId`/`workspaceId` terms are
|
|
147
|
+
// what make naming it inert.
|
|
148
|
+
{
|
|
149
|
+
id: FOREIGN_LISTICLE_ID,
|
|
150
|
+
siteId: WS_OTHER,
|
|
151
|
+
workspaceId: WS_OTHER,
|
|
152
|
+
slug: "other-list",
|
|
153
|
+
kind: "listicle",
|
|
154
|
+
status: "live",
|
|
155
|
+
},
|
|
156
|
+
// A NON-listicle magnet in THIS workspace: the `kind: "listicle"` term in
|
|
157
|
+
// the resolve is what stops an application being filed against it.
|
|
158
|
+
{
|
|
159
|
+
id: CONTACT_FLOW_ID,
|
|
160
|
+
siteId: SITE_ID,
|
|
161
|
+
workspaceId: SITE_ID,
|
|
162
|
+
slug: "contact-us",
|
|
163
|
+
kind: "contact",
|
|
164
|
+
status: "live",
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
|
|
121
168
|
/** Everything the ten steps after step one collect. */
|
|
122
169
|
const COMPLETE_APPLICATION = {
|
|
123
170
|
name: "My Project",
|
|
@@ -162,6 +209,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
|
|
|
162
209
|
}
|
|
163
210
|
|
|
164
211
|
function installStore(): void {
|
|
212
|
+
// #6335 — the shared prisma mock no longer resolves ANY requested
|
|
213
|
+
// workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
|
|
214
|
+
// by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
|
|
215
|
+
// services/project-listings.ts), so this suite states the ONE site it is
|
|
216
|
+
// built around. Keyed on the requested id — nothing else resolves.
|
|
217
|
+
mockWorkspaceExists(prisma, SITE_ID);
|
|
218
|
+
|
|
165
219
|
rows = [];
|
|
166
220
|
nextId = 1;
|
|
167
221
|
|
|
@@ -215,6 +269,7 @@ function installStore(): void {
|
|
|
215
269
|
headline: null,
|
|
216
270
|
logoMarkUrl: null,
|
|
217
271
|
logoWordmarkUrl: null,
|
|
272
|
+
solution: null,
|
|
218
273
|
featured: false,
|
|
219
274
|
decidedAt: null,
|
|
220
275
|
decidedBy: null,
|
|
@@ -261,6 +316,16 @@ function installStore(): void {
|
|
|
261
316
|
(prisma.consentGrant.createMany as jest.Mock).mockImplementation(
|
|
262
317
|
({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
|
|
263
318
|
);
|
|
319
|
+
|
|
320
|
+
// #6314 — the capturing-magnet resolve. Honours EVERY term the service
|
|
321
|
+
// passes, so a resolve that dropped `workspaceId` or `kind` would match a
|
|
322
|
+
// row it must not and fail the assertions below.
|
|
323
|
+
(prisma.flowDefinition.findFirst as jest.Mock).mockImplementation(
|
|
324
|
+
({ where }: { where: Record<string, unknown> }) =>
|
|
325
|
+
Promise.resolve(
|
|
326
|
+
FLOW_DEFINITION_ROWS.find((row) => matches(row, where)) ?? null,
|
|
327
|
+
),
|
|
328
|
+
);
|
|
264
329
|
}
|
|
265
330
|
|
|
266
331
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -479,6 +544,253 @@ describe("the draft lifecycle (#5036 D1)", () => {
|
|
|
479
544
|
expect(data[0]!["statement"]).not.toContain("You agree to being contacted");
|
|
480
545
|
});
|
|
481
546
|
|
|
547
|
+
// ── #6314 — the capturing magnet, written at START ────────────────────────
|
|
548
|
+
|
|
549
|
+
it("AC-5: a capturedByFlowSlug naming a live listicle magnet of the resolved site writes that magnet's ID, and the response body is byte-unchanged", async () => {
|
|
550
|
+
const res = await app.inject({
|
|
551
|
+
method: "POST",
|
|
552
|
+
url: "/v1/project-listings",
|
|
553
|
+
payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
|
|
554
|
+
});
|
|
555
|
+
expect(res.statusCode).toBe(201);
|
|
556
|
+
// The hard-floor response contract does not move: `{ id, resumeToken }`
|
|
557
|
+
// and nothing else. The column is NEVER echoed back to the anonymous
|
|
558
|
+
// caller (spec § 9 SEC-12).
|
|
559
|
+
expect(Object.keys(res.json().data).sort()).toEqual(["id", "resumeToken"]);
|
|
560
|
+
|
|
561
|
+
expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
|
|
562
|
+
|
|
563
|
+
// D1 — ALL FOUR terms in ONE query, and only the slug came from the body.
|
|
564
|
+
const call = (prisma.flowDefinition.findFirst as jest.Mock).mock
|
|
565
|
+
.calls[0]?.[0] as { where: Record<string, unknown> };
|
|
566
|
+
expect(call.where).toEqual({
|
|
567
|
+
slug: "founder-list",
|
|
568
|
+
siteId: SITE_ID,
|
|
569
|
+
workspaceId: SITE_ID,
|
|
570
|
+
kind: "listicle",
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("a start body carrying NO capturedByFlowSlug writes NULL, and makes no resolve query at all", async () => {
|
|
575
|
+
await startDraft(app);
|
|
576
|
+
expect(rows[0]!["capturedByFlowId"] ?? null).toBeNull();
|
|
577
|
+
expect(prisma.flowDefinition.findFirst).not.toHaveBeenCalled();
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it.each([
|
|
581
|
+
["names nothing at all", "no-such-magnet"],
|
|
582
|
+
["names ANOTHER workspace's listicle magnet", "other-list"],
|
|
583
|
+
["names a NON-listicle magnet in this workspace", "contact-us"],
|
|
584
|
+
])(
|
|
585
|
+
"AC-6: a capturedByFlowSlug that %s writes NULL and still returns a 201 indistinguishable from a body that named no slug",
|
|
586
|
+
async (_label, slug) => {
|
|
587
|
+
const withSlug = await app.inject({
|
|
588
|
+
method: "POST",
|
|
589
|
+
url: "/v1/project-listings",
|
|
590
|
+
payload: { ...START_PAYLOAD, capturedByFlowSlug: slug },
|
|
591
|
+
});
|
|
592
|
+
expect(withSlug.statusCode).toBe(201);
|
|
593
|
+
expect(rows[0]!["capturedByFlowId"] ?? null).toBeNull();
|
|
594
|
+
|
|
595
|
+
// Indistinguishable on the wire from the no-slug case: same status, same
|
|
596
|
+
// key set, same envelope. A distinguishable error would turn this
|
|
597
|
+
// anonymous route into a cross-workspace magnet-slug oracle (SEC-02).
|
|
598
|
+
const withoutSlug = await app.inject({
|
|
599
|
+
method: "POST",
|
|
600
|
+
url: "/v1/project-listings",
|
|
601
|
+
payload: START_PAYLOAD,
|
|
602
|
+
});
|
|
603
|
+
expect(withoutSlug.statusCode).toBe(withSlug.statusCode);
|
|
604
|
+
expect(Object.keys(withoutSlug.json().data).sort()).toEqual(
|
|
605
|
+
Object.keys(withSlug.json().data).sort(),
|
|
606
|
+
);
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
it("AC-13: an UNRESOLVED slug is logged, PII-safe — routing metadata only, never the founder's identity or either credential", async () => {
|
|
611
|
+
const warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
|
|
612
|
+
try {
|
|
613
|
+
const res = await app.inject({
|
|
614
|
+
method: "POST",
|
|
615
|
+
url: "/v1/project-listings",
|
|
616
|
+
payload: {
|
|
617
|
+
...START_PAYLOAD,
|
|
618
|
+
capturedByFlowSlug: "no-such-magnet",
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
expect(res.statusCode).toBe(201);
|
|
622
|
+
|
|
623
|
+
const call = warn.mock.calls.find(
|
|
624
|
+
(c) =>
|
|
625
|
+
typeof c[0] === "object" &&
|
|
626
|
+
c[0] !== null &&
|
|
627
|
+
"capturedByFlowSlug" in (c[0] as Record<string, unknown>),
|
|
628
|
+
);
|
|
629
|
+
expect(call).toBeDefined();
|
|
630
|
+
const meta = call![0] as Record<string, unknown>;
|
|
631
|
+
// Routing metadata ONLY. Silence here is what would make a stale or
|
|
632
|
+
// mis-wired consuming site produce a permanently empty Applications list
|
|
633
|
+
// indistinguishable from "no applications yet" (SEC-03).
|
|
634
|
+
expect(Object.keys(meta).sort()).toEqual([
|
|
635
|
+
"capturedByFlowSlug",
|
|
636
|
+
"listingId",
|
|
637
|
+
"siteId",
|
|
638
|
+
]);
|
|
639
|
+
const serialized = JSON.stringify(meta);
|
|
640
|
+
expect(serialized).not.toContain("ada@example.com");
|
|
641
|
+
expect(serialized).not.toContain("Ada Founder");
|
|
642
|
+
expect(serialized).not.toContain(rows[0]!["resumeTokenHash"]);
|
|
643
|
+
expect(serialized).not.toContain(rows[0]!["emailVerifyNonceHash"]);
|
|
644
|
+
} finally {
|
|
645
|
+
warn.mockRestore();
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("AC-6: a capturedByFlowSlug lookup that THROWS degrades like an unresolvable slug — 201, NULL attribution, and a PII-safe warn distinct from the unresolved line", async () => {
|
|
650
|
+
(prisma.flowDefinition.findFirst as jest.Mock).mockRejectedValueOnce(
|
|
651
|
+
new Error("statement timeout"),
|
|
652
|
+
);
|
|
653
|
+
const warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
|
|
654
|
+
try {
|
|
655
|
+
const res = await app.inject({
|
|
656
|
+
method: "POST",
|
|
657
|
+
url: "/v1/project-listings",
|
|
658
|
+
payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
|
|
659
|
+
});
|
|
660
|
+
expect(res.statusCode).toBe(201);
|
|
661
|
+
expect(rows[0]!["capturedByFlowId"] ?? null).toBeNull();
|
|
662
|
+
|
|
663
|
+
const call = warn.mock.calls.find(
|
|
664
|
+
(c) => typeof c[1] === "string" && c[1].includes("lookup failed"),
|
|
665
|
+
);
|
|
666
|
+
expect(call).toBeDefined();
|
|
667
|
+
const meta = call![0] as Record<string, unknown>;
|
|
668
|
+
expect(Object.keys(meta).sort()).toEqual([
|
|
669
|
+
"capturedByFlowSlug",
|
|
670
|
+
"err",
|
|
671
|
+
"workspaceId",
|
|
672
|
+
]);
|
|
673
|
+
// `JSON.stringify` cannot see an Error's NON-ENUMERABLE own properties —
|
|
674
|
+
// `message` and `stack` among them — so stringifying `meta` alone would
|
|
675
|
+
// pass whatever the error held and assert nothing. Pull them out by name.
|
|
676
|
+
const err = meta["err"] as Error;
|
|
677
|
+
const serialized = [
|
|
678
|
+
JSON.stringify(meta),
|
|
679
|
+
err?.message ?? "",
|
|
680
|
+
err?.stack ?? "",
|
|
681
|
+
].join("\n");
|
|
682
|
+
expect(serialized).not.toContain("ada@example.com");
|
|
683
|
+
expect(serialized).not.toContain("Ada Founder");
|
|
684
|
+
|
|
685
|
+
// The real guarantee is STRUCTURAL, not this assertion: the catch wraps
|
|
686
|
+
// only the `findFirst`, whose scope holds no founder field at all. This
|
|
687
|
+
// check is a tripwire over that property, never the proof of it.
|
|
688
|
+
const line = call![1] as string;
|
|
689
|
+
expect(line).toContain("lookup failed");
|
|
690
|
+
expect(line).not.toContain("resolved to no listicle magnet");
|
|
691
|
+
} finally {
|
|
692
|
+
warn.mockRestore();
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("a lookup that THROWS does NOT also emit the unresolved-slug line — during a DB incident that would be a false magnet-configuration signal", async () => {
|
|
697
|
+
(prisma.flowDefinition.findFirst as jest.Mock).mockRejectedValueOnce(
|
|
698
|
+
new Error("statement timeout"),
|
|
699
|
+
);
|
|
700
|
+
const warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
|
|
701
|
+
try {
|
|
702
|
+
const res = await app.inject({
|
|
703
|
+
method: "POST",
|
|
704
|
+
url: "/v1/project-listings",
|
|
705
|
+
payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
|
|
706
|
+
});
|
|
707
|
+
expect(res.statusCode).toBe(201);
|
|
708
|
+
expect(rows[0]!["capturedByFlowId"] ?? null).toBeNull();
|
|
709
|
+
|
|
710
|
+
const messages = warn.mock.calls.map((c) => String(c[1] ?? ""));
|
|
711
|
+
// Exactly one line, and it is the accurate one. The throw never
|
|
712
|
+
// established that no such magnet exists.
|
|
713
|
+
expect(
|
|
714
|
+
messages.filter((m) => m.includes("resolved to no listicle magnet")),
|
|
715
|
+
).toHaveLength(0);
|
|
716
|
+
expect(messages.filter((m) => m.includes("lookup failed"))).toHaveLength(1);
|
|
717
|
+
} finally {
|
|
718
|
+
warn.mockRestore();
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("a RESOLVED slug logs nothing — the warn line is the unresolved case's signal, not a per-application line", async () => {
|
|
723
|
+
const warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
|
|
724
|
+
try {
|
|
725
|
+
await app.inject({
|
|
726
|
+
method: "POST",
|
|
727
|
+
url: "/v1/project-listings",
|
|
728
|
+
payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
|
|
729
|
+
});
|
|
730
|
+
expect(
|
|
731
|
+
warn.mock.calls.filter(
|
|
732
|
+
(c) =>
|
|
733
|
+
typeof c[0] === "object" &&
|
|
734
|
+
c[0] !== null &&
|
|
735
|
+
"capturedByFlowSlug" in (c[0] as Record<string, unknown>),
|
|
736
|
+
),
|
|
737
|
+
).toHaveLength(0);
|
|
738
|
+
} finally {
|
|
739
|
+
warn.mockRestore();
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("AC-7: capturedByFlowId is WRITE-ONCE — no patch and no submit changes it, and no patch body can reach it", async () => {
|
|
744
|
+
const { id, resumeToken } = (await (async () => {
|
|
745
|
+
const res = await app.inject({
|
|
746
|
+
method: "POST",
|
|
747
|
+
url: "/v1/project-listings",
|
|
748
|
+
payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
|
|
749
|
+
});
|
|
750
|
+
expect(res.statusCode).toBe(201);
|
|
751
|
+
return res.json().data as { id: string; resumeToken: string };
|
|
752
|
+
})());
|
|
753
|
+
|
|
754
|
+
expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
|
|
755
|
+
|
|
756
|
+
// A patch that tries to name the column outright is a `.strict()` 400 —
|
|
757
|
+
// the key is not on any patch schema, so no body can reach the column.
|
|
758
|
+
const injected = await app.inject({
|
|
759
|
+
method: "PATCH",
|
|
760
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
761
|
+
headers: bearer(resumeToken),
|
|
762
|
+
payload: { capturedByFlowId: FOREIGN_LISTICLE_ID },
|
|
763
|
+
});
|
|
764
|
+
expect(injected.statusCode).toBe(400);
|
|
765
|
+
expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
|
|
766
|
+
|
|
767
|
+
// A legitimate patch leaves it untouched.
|
|
768
|
+
const patched = await app.inject({
|
|
769
|
+
method: "PATCH",
|
|
770
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
771
|
+
headers: bearer(resumeToken),
|
|
772
|
+
payload: { name: "My Project" },
|
|
773
|
+
});
|
|
774
|
+
expect(patched.statusCode).toBe(200);
|
|
775
|
+
expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
|
|
776
|
+
|
|
777
|
+
// And so does submit.
|
|
778
|
+
await app.inject({
|
|
779
|
+
method: "PATCH",
|
|
780
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
781
|
+
headers: bearer(resumeToken),
|
|
782
|
+
payload: COMPLETE_APPLICATION,
|
|
783
|
+
});
|
|
784
|
+
const submitted = await app.inject({
|
|
785
|
+
method: "POST",
|
|
786
|
+
url: `/v1/project-listings/drafts/${id}/submit`,
|
|
787
|
+
headers: bearer(resumeToken),
|
|
788
|
+
});
|
|
789
|
+
expect(submitted.statusCode).toBe(200);
|
|
790
|
+
expect(rows[0]!["status"]).toBe("requested");
|
|
791
|
+
expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
|
|
792
|
+
});
|
|
793
|
+
|
|
482
794
|
// ── patch ──
|
|
483
795
|
|
|
484
796
|
it("patch: a SINGLE-field patch round-trips", async () => {
|
|
@@ -884,3 +1196,42 @@ describe("the draft lifecycle (#5036 D1)", () => {
|
|
|
884
1196
|
await logging.close();
|
|
885
1197
|
});
|
|
886
1198
|
});
|
|
1199
|
+
|
|
1200
|
+
// ── #6335 — the explicit workspace setup above is load-bearing ────────────
|
|
1201
|
+
//
|
|
1202
|
+
// Every case in this file states that this suite's site has a workspace.
|
|
1203
|
+
// This one takes that away, and proves the statement was doing work rather
|
|
1204
|
+
// than decorating the setup: `startProjectListingDraft` resolves the site it
|
|
1205
|
+
// is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
|
|
1206
|
+
// and refuses BEFORE any row is written. With the shared prisma mock's old
|
|
1207
|
+
// permissive default in place this case could not be written at all — every
|
|
1208
|
+
// id resolved, so the fail-closed path was unreachable from a route test.
|
|
1209
|
+
describe("#6335 — start fails closed when the site names no workspace", () => {
|
|
1210
|
+
let app: FastifyInstance;
|
|
1211
|
+
|
|
1212
|
+
beforeEach(async () => {
|
|
1213
|
+
jest.clearAllMocks();
|
|
1214
|
+
installStore();
|
|
1215
|
+
mockNoWorkspace(prisma);
|
|
1216
|
+
app = await buildApp();
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
afterEach(async () => {
|
|
1220
|
+
await app.close();
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
|
|
1224
|
+
const res = await app.inject({
|
|
1225
|
+
method: "POST",
|
|
1226
|
+
url: "/v1/project-listings",
|
|
1227
|
+
payload: START_PAYLOAD,
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
expect(res.statusCode).toBe(503);
|
|
1231
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe(
|
|
1232
|
+
"tenant_unresolved",
|
|
1233
|
+
);
|
|
1234
|
+
expect(prisma.projectListing.create).not.toHaveBeenCalled();
|
|
1235
|
+
expect(rows).toHaveLength(0);
|
|
1236
|
+
});
|
|
1237
|
+
});
|