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
|
@@ -109,8 +109,12 @@ export type ListLeadsResult =
|
|
|
109
109
|
* `WAITLIST_DASHBOARD_ROLES` (`waitlist-dashboard.ts`); declared here rather
|
|
110
110
|
* than imported because that file is a `signal-collectors` slice the strip
|
|
111
111
|
* removes and this one ships at every level.
|
|
112
|
+
*
|
|
113
|
+
* Exported so `inbox-messages.ts` (#6204's tenant-scoped read/write, moved
|
|
114
|
+
* off this base file's own dependency edge — see that file's header) enforces
|
|
115
|
+
* the SAME floor rather than a copy.
|
|
112
116
|
*/
|
|
113
|
-
const LEAD_INBOX_ROLES: ReadonlySet<string> = new Set(["owner", "admin"]);
|
|
117
|
+
export const LEAD_INBOX_ROLES: ReadonlySet<string> = new Set(["owner", "admin"]);
|
|
114
118
|
|
|
115
119
|
/**
|
|
116
120
|
* The ONLY columns read out of the Lead table on this path.
|
|
@@ -162,8 +166,11 @@ const MAX_NAME_LENGTH = 200;
|
|
|
162
166
|
* `payload` is JSON of a shape the site's own form decides, so reading one
|
|
163
167
|
* key out of it is a boundary read: anything that is not a string is `null`,
|
|
164
168
|
* and a string is truncated (see `MAX_NAME_LENGTH`).
|
|
169
|
+
*
|
|
170
|
+
* Exported so `inbox-messages.ts` reuses this exact narrowing — never a copy
|
|
171
|
+
* (see that file's header for why the shaping stays here).
|
|
165
172
|
*/
|
|
166
|
-
function readName(payload: Prisma.JsonValue): string | null {
|
|
173
|
+
export function readName(payload: Prisma.JsonValue): string | null {
|
|
167
174
|
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
168
175
|
return null;
|
|
169
176
|
}
|
|
@@ -261,3 +268,17 @@ export async function listOwnedLeads(params: {
|
|
|
261
268
|
},
|
|
262
269
|
};
|
|
263
270
|
}
|
|
271
|
+
|
|
272
|
+
// #6204's `listUnhandledMessages` / `markMessageHandled` — the tenant-scoped
|
|
273
|
+
// "Needs-you" read/write pair over `tenantDb(tenant).leads` — moved OFF this
|
|
274
|
+
// file to `./inbox-messages.js`. This file is a BASE-tier file every scaffold
|
|
275
|
+
// ships (this header's own claim); `tenantDb` (`../lib/tenant-db.js`) is a
|
|
276
|
+
// `multi-tenant`-slice file, and the paid-module exclusion closure
|
|
277
|
+
// (`packages/create-ailk/src/lib/import-graph.ts`) is backward — a base file
|
|
278
|
+
// that imports a paid seed is ITSELF added to the exclusion set, and so is
|
|
279
|
+
// everything that imports IT. Importing `tenantDb` from here once already
|
|
280
|
+
// pulled `server.ts`/`cli.ts`/`index.ts`/`vercel-handler.ts` out of a keyless
|
|
281
|
+
// scaffold (caught by `__tests__/paid-module-exclusions.test.ts`, #5678's own
|
|
282
|
+
// cascade recurring a third time). `readName()` and `LEAD_INBOX_ROLES` above
|
|
283
|
+
// stay here (both exported) and `inbox-messages.ts` imports them — the row
|
|
284
|
+
// shape and the role floor are the SAME constants, never a copy.
|
|
@@ -42,11 +42,18 @@ import {
|
|
|
42
42
|
} from "@working-theory/validation";
|
|
43
43
|
|
|
44
44
|
import { prisma } from "../lib/prisma.js";
|
|
45
|
-
import {
|
|
45
|
+
import { resolveSiteWorkspaceId } from "../lib/site.js";
|
|
46
46
|
|
|
47
47
|
export async function listingConfigFor(siteId: string): Promise<SiteListingConfig> {
|
|
48
|
+
// #6257 — every caller hands this function an ALREADY-RESOLVED workspace id
|
|
49
|
+
// (a `ProjectListing` row's own `siteId`, an authenticated tenant's
|
|
50
|
+
// `workspaceId`, or `siteIdForPublicKey`'s return below), so `workspaceId`
|
|
51
|
+
// rides beside the unique `siteId` selector rather than being resolved a
|
|
52
|
+
// second time. `site_listing_configs_workspace_site_agree` makes the pair
|
|
53
|
+
// redundant by construction — which is the point: the tenant scope is now
|
|
54
|
+
// something the QUERY states, not something the caller is trusted to have.
|
|
48
55
|
const row = await prisma.siteListingConfig.findUnique({
|
|
49
|
-
where: { siteId },
|
|
56
|
+
where: { siteId, workspaceId: siteId },
|
|
50
57
|
select: { data: true },
|
|
51
58
|
});
|
|
52
59
|
if (!row) return SITE_LISTING_CONFIG_DEFAULTS;
|
|
@@ -132,14 +139,18 @@ function warnOnDroppedField(
|
|
|
132
139
|
* 1. A `SiteListingConfig` row whose `publicKey` matches. This is the
|
|
133
140
|
* multi-tenant path and the only one that can name a site other than the
|
|
134
141
|
* deployment's own.
|
|
135
|
-
* 2. `AILK_LISTING_PUBLIC_KEY`, matched against the env, resolving to
|
|
136
|
-
* `currentSiteId()
|
|
137
|
-
* `
|
|
138
|
-
*
|
|
139
|
-
*
|
|
142
|
+
* 2. `AILK_LISTING_PUBLIC_KEY`, matched against the env, resolving to the
|
|
143
|
+
* RESOLVED workspace id for `currentSiteId()` (#6254 AC-6/AC-10 —
|
|
144
|
+
* `resolveSiteWorkspaceId()`, not the raw alias: `project_listings`
|
|
145
|
+
* now rejects a `site_id` that disagrees with `workspace_id`,
|
|
146
|
+
* database/migrations/20260922120000_tenancy_fence). This is the
|
|
147
|
+
* SINGLE-TENANT path: the default `create-ailk` scaffold is one
|
|
148
|
+
* customer per deployment and has no config row, so requiring one
|
|
149
|
+
* would mean a site could not accept an application until an operator
|
|
150
|
+
* wrote a database row.
|
|
140
151
|
*
|
|
141
152
|
* An unmatched key resolves to `null` and the route answers 400. It does NOT
|
|
142
|
-
* fall through to
|
|
153
|
+
* fall through to the env path: a typo would otherwise silently file one
|
|
143
154
|
* site's applications against another.
|
|
144
155
|
*
|
|
145
156
|
* The env comparison is a plain `===` on an opaque public identifier — there is
|
|
@@ -147,6 +158,13 @@ function warnOnDroppedField(
|
|
|
147
158
|
* would imply a guarantee this value does not carry.
|
|
148
159
|
*/
|
|
149
160
|
export async function siteIdForPublicKey(publicKey: string): Promise<string | null> {
|
|
161
|
+
// fence: global-key — `SiteListingConfig.publicKey`, a site's own public
|
|
162
|
+
// listing key, unique across every workspace. This is the query that
|
|
163
|
+
// RESOLVES the tenant: it is handed nothing but the key, and a workspace
|
|
164
|
+
// term here would have to come from the very answer it is computing. The
|
|
165
|
+
// key is not a credential (it ships in browser JavaScript by design, #5039)
|
|
166
|
+
// and holding it grants only the ability to open a draft; an unmatched key
|
|
167
|
+
// returns `null` and the route answers 400 rather than falling through.
|
|
150
168
|
const row = await prisma.siteListingConfig.findUnique({
|
|
151
169
|
where: { publicKey },
|
|
152
170
|
select: { siteId: true },
|
|
@@ -154,7 +172,7 @@ export async function siteIdForPublicKey(publicKey: string): Promise<string | nu
|
|
|
154
172
|
if (row) return row.siteId;
|
|
155
173
|
|
|
156
174
|
const envKey = process.env["AILK_LISTING_PUBLIC_KEY"];
|
|
157
|
-
if (envKey && envKey === publicKey) return
|
|
175
|
+
if (envKey && envKey === publicKey) return resolveSiteWorkspaceId();
|
|
158
176
|
|
|
159
177
|
return null;
|
|
160
178
|
}
|
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
import { createHash, randomBytes } from "node:crypto";
|
|
45
45
|
|
|
46
46
|
import type { Prisma, Role } from "@working-theory/database";
|
|
47
|
+
import {
|
|
48
|
+
DEFAULT_WORKSPACE_SLUG,
|
|
49
|
+
resolveSentinelWorkspaceRow,
|
|
50
|
+
} from "@working-theory/database/tenancy";
|
|
47
51
|
import { logger } from "@working-theory/observability";
|
|
48
52
|
import {
|
|
49
53
|
clampWindowDays,
|
|
@@ -217,6 +221,7 @@ const PROJECT_LISTING_SELECT = {
|
|
|
217
221
|
name: true,
|
|
218
222
|
oneLiner: true,
|
|
219
223
|
problem: true,
|
|
224
|
+
solution: true,
|
|
220
225
|
url: true,
|
|
221
226
|
product: true,
|
|
222
227
|
commitment: true,
|
|
@@ -362,6 +367,7 @@ function toResponseRow(
|
|
|
362
367
|
headline: row.headline,
|
|
363
368
|
oneLiner: row.oneLiner,
|
|
364
369
|
problem: row.problem,
|
|
370
|
+
solution: row.solution,
|
|
365
371
|
url: row.url,
|
|
366
372
|
product: row.product as ProjectListing["product"],
|
|
367
373
|
commitment: row.commitment as ProjectListing["commitment"],
|
|
@@ -428,8 +434,21 @@ async function attachWaitlistCounts(
|
|
|
428
434
|
const counts = new Map<string, number>();
|
|
429
435
|
await Promise.all(
|
|
430
436
|
experimentKeys.map(async (experimentKey) => {
|
|
437
|
+
// fence: workspaceId === the scope id this query already filters siteId by
|
|
438
|
+
// (workspace_site_agree CHECK, 20260922120000_tenancy_fence). Filtering the
|
|
439
|
+
// CONSTRAINED column makes the scope correct by construction; the format
|
|
440
|
+
// CHECK makes a raw alias match nothing rather than by accident.
|
|
441
|
+
//
|
|
442
|
+
// What that id IS depends on the caller, and only two of the six are
|
|
443
|
+
// route-resolved: `listProjectListings` and `getProjectListing` receive
|
|
444
|
+
// `tenant.workspaceId`, so for them this is an isolation control.
|
|
445
|
+
// `listMyProjectListings` and the two `publicProjectListings` call sites
|
|
446
|
+
// pass a ProjectListing row's OWN `siteId`, so for them it is a
|
|
447
|
+
// CONSISTENCY control — the count agrees with the row it is reported
|
|
448
|
+
// against — exactly as `project-listing-decision.ts#waitlistCountFor`
|
|
449
|
+
// spells out (#6258 § 1.6 / § 5 D8, security review L-2).
|
|
431
450
|
const count = await prisma.waitlistScore.count({
|
|
432
|
-
where: { siteId, experimentKey },
|
|
451
|
+
where: { siteId, workspaceId: siteId, experimentKey },
|
|
433
452
|
});
|
|
434
453
|
counts.set(experimentKey, count);
|
|
435
454
|
}),
|
|
@@ -602,7 +621,113 @@ export type StartProjectListingDraftResult =
|
|
|
602
621
|
*/
|
|
603
622
|
verifyNonce: string;
|
|
604
623
|
}
|
|
605
|
-
| { ok: false; error: { kind: "invalid_input"; message: string } }
|
|
624
|
+
| { ok: false; error: { kind: "invalid_input"; message: string } }
|
|
625
|
+
// #6203 — the resolved `siteId` names no workspace this database holds.
|
|
626
|
+
// Distinct from `invalid_input` (a body problem): this is a server-side
|
|
627
|
+
// tenancy misconfiguration, and the route answers 503, never 400.
|
|
628
|
+
| { ok: false; error: { kind: "tenant_unresolved"; message: string } };
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Resolves an ALREADY-RESOLVED `siteId` (never a caller-supplied string — see
|
|
632
|
+
* `startProjectListingDraft`'s own header) to its workspace id (#6203).
|
|
633
|
+
*
|
|
634
|
+
* Mirrors `apps/api/src/lib/site.ts`'s `resolveSiteWorkspaceId()` — the SAME
|
|
635
|
+
* two-branch alias mapping the #6203 migration's own backfill `CASE`
|
|
636
|
+
* encodes: the literal alias `"default"` resolves to the seeded sentinel
|
|
637
|
+
* workspace IFF that row exists and hangs off the sentinel Organization;
|
|
638
|
+
* any other alias resolves only by exact `workspaces.id` equality. Kept
|
|
639
|
+
* LOCAL and PARAMETERIZED rather than reusing `resolveSiteWorkspaceId()`
|
|
640
|
+
* directly: that function is hardwired to `currentSiteId()`
|
|
641
|
+
* (`AILK_SITE_ID`), while a `ProjectListing`'s `siteId` can legitimately
|
|
642
|
+
* name a DIFFERENT site under a multi-key deployment
|
|
643
|
+
* (`listing-config.ts#siteIdForPublicKey`'s `SiteListingConfig.publicKey`
|
|
644
|
+
* path) — the workspace stamped here must correspond to the SAME site the
|
|
645
|
+
* row's own `siteId` already names, not the deployment's default. NOT
|
|
646
|
+
* memoized (unlike `resolveSiteWorkspaceId()`): a different `siteId` per
|
|
647
|
+
* call means the sentinel-only cache that function keeps would be wrong
|
|
648
|
+
* here.
|
|
649
|
+
*/
|
|
650
|
+
async function resolveWorkspaceIdForSite(siteId: string): Promise<string | null> {
|
|
651
|
+
if (siteId === DEFAULT_WORKSPACE_SLUG) {
|
|
652
|
+
const row = await resolveSentinelWorkspaceRow(prisma);
|
|
653
|
+
return row?.id ?? null;
|
|
654
|
+
}
|
|
655
|
+
const row = await prisma.workspace.findUnique({ where: { id: siteId } });
|
|
656
|
+
return row ? siteId : null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* #6314 — resolve the capturing listicle magnet, SERVER-SIDE, from the slug
|
|
661
|
+
* the body named.
|
|
662
|
+
*
|
|
663
|
+
* All four terms in ONE query, and only `slug` came from the caller:
|
|
664
|
+
* `siteId` and `workspaceId` are the value `startProjectListingDraft` has
|
|
665
|
+
* already resolved from the site's PUBLIC KEY (`siteIdForPublicKey`, then
|
|
666
|
+
* `resolveWorkspaceIdForSite`), never from the body. `@@unique([siteId,
|
|
667
|
+
* slug])` makes the match at most one row.
|
|
668
|
+
*
|
|
669
|
+
* `kind: "listicle"` is LOAD-BEARING, not cosmetic: without it a visitor
|
|
670
|
+
* could name a contact or newsletter magnet's slug — both are public path
|
|
671
|
+
* segments — and file an application against it.
|
|
672
|
+
*
|
|
673
|
+
* NO `status` term, deliberately. A magnet can be flipped `live -> draft`
|
|
674
|
+
* while a visitor is mid-application, and dropping attribution on that flip
|
|
675
|
+
* would silently punish the operator's own edit. The cost is bounded: a draft
|
|
676
|
+
* magnet's slug is the operator's own, the resulting row lands only in that
|
|
677
|
+
* operator's own workspace, and the Applications wire row carries `status`
|
|
678
|
+
* so a never-submitted row stays legible as one.
|
|
679
|
+
*
|
|
680
|
+
* Returns `unresolved` for a slug that names nothing, names another
|
|
681
|
+
* workspace's magnet, or names a magnet of the wrong kind — those three are
|
|
682
|
+
* deliberately indistinguishable, and all three write NULL.
|
|
683
|
+
*
|
|
684
|
+
* `failed` is a FOURTH outcome and is NOT one of them: the lookup itself threw.
|
|
685
|
+
* It also writes NULL, so the wire is unchanged, but the caller must not
|
|
686
|
+
* describe it as "no such magnet" — during a database incident that would send
|
|
687
|
+
* an operator hunting a magnet-configuration problem that does not exist. This
|
|
688
|
+
* function has already logged the accurate line by the time it returns `failed`.
|
|
689
|
+
*/
|
|
690
|
+
type CapturingFlowResolution =
|
|
691
|
+
| { kind: "resolved"; id: string }
|
|
692
|
+
| { kind: "unresolved" }
|
|
693
|
+
| { kind: "failed" };
|
|
694
|
+
|
|
695
|
+
async function resolveCapturingFlowId(
|
|
696
|
+
workspaceId: string,
|
|
697
|
+
slug: string,
|
|
698
|
+
): Promise<CapturingFlowResolution> {
|
|
699
|
+
try {
|
|
700
|
+
const row = await prisma.flowDefinition.findFirst({
|
|
701
|
+
where: {
|
|
702
|
+
slug,
|
|
703
|
+
// Both scope terms, both from the value resolved above. They are equal
|
|
704
|
+
// by the `flow_definitions_workspace_site_agree` CHECK the fence added;
|
|
705
|
+
// carrying both is what makes the scope explicit at the query rather
|
|
706
|
+
// than implied by a constraint a future migration could move.
|
|
707
|
+
siteId: workspaceId,
|
|
708
|
+
workspaceId,
|
|
709
|
+
kind: "listicle",
|
|
710
|
+
},
|
|
711
|
+
select: { id: true },
|
|
712
|
+
});
|
|
713
|
+
return row === null ? { kind: "unresolved" } : { kind: "resolved", id: row.id };
|
|
714
|
+
} catch (err) {
|
|
715
|
+
// The lookup is OPTIONAL attribution on a never-gate anonymous route: an
|
|
716
|
+
// unavailable read (statement timeout, connection blip) must degrade
|
|
717
|
+
// exactly like a slug that names nothing — NULL, and the application is
|
|
718
|
+
// kept — rather than turn into a 500 that loses a real founder's
|
|
719
|
+
// submission. Both outcomes are NULL on the row, so this line is the only
|
|
720
|
+
// thing that tells an operator "lookup failed" from "no such magnet".
|
|
721
|
+
// PII-SAFE, same idiom as the unresolved-slug line below: routing
|
|
722
|
+
// metadata plus the caught error; never the founder's identity, the
|
|
723
|
+
// resume token or the verify nonce (none is in scope here).
|
|
724
|
+
logger.warn(
|
|
725
|
+
{ workspaceId, capturedByFlowSlug: slug, err },
|
|
726
|
+
"[project-listings] capturedByFlowSlug lookup failed; application stored unattributed",
|
|
727
|
+
);
|
|
728
|
+
return { kind: "failed" };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
606
731
|
|
|
607
732
|
/**
|
|
608
733
|
* Create a `draft` from step one — the founder's name, email and consent.
|
|
@@ -621,11 +746,27 @@ export type StartProjectListingDraftResult =
|
|
|
621
746
|
*
|
|
622
747
|
* No slug yet: it is derived from `name` at SUBMIT (#5033 D2), which is the
|
|
623
748
|
* first moment a name exists.
|
|
749
|
+
*
|
|
750
|
+
* WORKSPACE STAMP (#6203). The row's `workspaceId` is resolved from `siteId`
|
|
751
|
+
* itself (`resolveWorkspaceIdForSite` above) — never a client-supplied
|
|
752
|
+
* value, and never a fixed deployment default. An unresolved result rejects
|
|
753
|
+
* the write before any row is created (spec AC-4).
|
|
624
754
|
*/
|
|
625
755
|
export async function startProjectListingDraft(
|
|
626
756
|
siteId: string,
|
|
627
757
|
input: ProjectListingStartInput,
|
|
628
758
|
): Promise<StartProjectListingDraftResult> {
|
|
759
|
+
const workspaceId = await resolveWorkspaceIdForSite(siteId);
|
|
760
|
+
if (workspaceId === null) {
|
|
761
|
+
return {
|
|
762
|
+
ok: false,
|
|
763
|
+
error: {
|
|
764
|
+
kind: "tenant_unresolved",
|
|
765
|
+
message: "This site is not configured to accept applications.",
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
629
770
|
const { requestedWindowDays } = input;
|
|
630
771
|
// #5407 — the NOT NULL display column. `composeFounderName` builds it from
|
|
631
772
|
// the PARTS wherever both are written (even beside a `founderName` the body
|
|
@@ -684,10 +825,30 @@ export async function startProjectListingDraft(
|
|
|
684
825
|
// wording, which nobody was shown.
|
|
685
826
|
const consents = input.consents;
|
|
686
827
|
const locale = input.locale ?? "en";
|
|
828
|
+
// #6314 — the capturing magnet, resolved SERVER-SIDE from the slug the body
|
|
829
|
+
// named. Only runs when the key is present, so an existing caller costs
|
|
830
|
+
// nothing; when it is, it is one probe of `@@unique([siteId, slug])`.
|
|
831
|
+
const capture =
|
|
832
|
+
input.capturedByFlowSlug === undefined
|
|
833
|
+
? undefined
|
|
834
|
+
: await resolveCapturingFlowId(workspaceId, input.capturedByFlowSlug);
|
|
835
|
+
const capturedByFlowId = capture?.kind === "resolved" ? capture.id : null;
|
|
687
836
|
const row = await prisma.$transaction(async (tx) => {
|
|
688
837
|
const created = await tx.projectListing.create({
|
|
689
838
|
data: {
|
|
690
|
-
|
|
839
|
+
// #6254 AC-3/AC-6/AC-10 — the RESOLVED workspace id, not the raw
|
|
840
|
+
// `siteId` parameter: `project_listings` now requires
|
|
841
|
+
// `workspace_id = site_id`
|
|
842
|
+
// (database/migrations/20260922120000_tenancy_fence), and this
|
|
843
|
+
// column is the isolation key every downstream reader (including
|
|
844
|
+
// `submitProjectListingDraft`'s `routeLead` call) reads back as
|
|
845
|
+
// `draft.siteId`. `siteId` (the public-facing param, possibly the
|
|
846
|
+
// raw `"default"` alias) is still used above to look up
|
|
847
|
+
// `listingConfigFor`/`consentConfigForSite` — those are independent,
|
|
848
|
+
// unfenced reads keyed on the site concept, not this row.
|
|
849
|
+
siteId: workspaceId,
|
|
850
|
+
// #6203 — the isolation key, resolved above from this SAME siteId.
|
|
851
|
+
workspaceId,
|
|
691
852
|
founderName,
|
|
692
853
|
// #5407 — the parts as sent, beside the composed line. Absent where a
|
|
693
854
|
// caller sent only a display line, which is the shape every row
|
|
@@ -705,6 +866,11 @@ export async function startProjectListingDraft(
|
|
|
705
866
|
resumeTokenHash: hashResumeToken(resumeToken),
|
|
706
867
|
emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
|
|
707
868
|
windowDays,
|
|
869
|
+
// #6314 — WRITE-ONCE, and only here. `draftUpdateData` never learns
|
|
870
|
+
// this key, so no patch body and no submit can re-attribute a draft
|
|
871
|
+
// to a different magnet after the fact — not by its holder, and not
|
|
872
|
+
// by anyone who obtains its resume token.
|
|
873
|
+
...(capturedByFlowId !== null && { capturedByFlowId }),
|
|
708
874
|
},
|
|
709
875
|
select: { id: true },
|
|
710
876
|
});
|
|
@@ -717,6 +883,33 @@ export async function startProjectListingDraft(
|
|
|
717
883
|
return created;
|
|
718
884
|
});
|
|
719
885
|
|
|
886
|
+
// #6314 — an unresolved slug is the operator's ONLY signal, precisely
|
|
887
|
+
// because the response deliberately carries none (a distinguishable error
|
|
888
|
+
// would be a cross-workspace magnet-slug oracle on an anonymous route).
|
|
889
|
+
// Without this line, a mis-wired or stale consuming site yields a
|
|
890
|
+
// permanently empty Applications list an operator cannot tell from "no
|
|
891
|
+
// applications yet".
|
|
892
|
+
//
|
|
893
|
+
// PII-SAFE, in this route family's existing idiom: routing metadata only.
|
|
894
|
+
// Never `founderEmail`, `founderName`, the resume token or the verify
|
|
895
|
+
// nonce — the last two never leave this function except inside the email.
|
|
896
|
+
// ONLY the `unresolved` outcome, never `failed`. Both store NULL and both
|
|
897
|
+
// return the same 201, but only one of them establishes "no listicle magnet
|
|
898
|
+
// of that slug in this site". Asserting it on the throw path would put a
|
|
899
|
+
// false magnet-configuration signal into the log during a database incident,
|
|
900
|
+
// which is exactly when an operator is grepping for a cause. `failed` has its
|
|
901
|
+
// own, accurate line, emitted inside `resolveCapturingFlowId`.
|
|
902
|
+
if (capture?.kind === "unresolved") {
|
|
903
|
+
logger.warn(
|
|
904
|
+
{
|
|
905
|
+
siteId,
|
|
906
|
+
listingId: row.id,
|
|
907
|
+
capturedByFlowSlug: input.capturedByFlowSlug,
|
|
908
|
+
},
|
|
909
|
+
"[project-listings] capturedByFlowSlug resolved to no listicle magnet in this site; application stored unattributed",
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
720
913
|
return { ok: true, id: row.id, resumeToken, verifyNonce };
|
|
721
914
|
}
|
|
722
915
|
|
|
@@ -891,6 +1084,7 @@ function draftUpdateData(
|
|
|
891
1084
|
...(patch.headline !== undefined && { headline: patch.headline }),
|
|
892
1085
|
...(patch.oneLiner !== undefined && { oneLiner: patch.oneLiner }),
|
|
893
1086
|
...(patch.problem !== undefined && { problem: patch.problem }),
|
|
1087
|
+
...(patch.solution !== undefined && { solution: patch.solution }),
|
|
894
1088
|
...(patch.url !== undefined && { url: patch.url }),
|
|
895
1089
|
...(patch.product !== undefined && { product: patch.product }),
|
|
896
1090
|
...(patch.commitment !== undefined && { commitment: patch.commitment }),
|
|
@@ -1067,15 +1261,16 @@ function applicationFromRow(
|
|
|
1067
1261
|
address: row.address,
|
|
1068
1262
|
...(row.founderCompany !== null && { founderCompany: row.founderCompany }),
|
|
1069
1263
|
questions: row.questions,
|
|
1070
|
-
// #5285 — the
|
|
1071
|
-
// application schema, so a row that never carried one simply omits it
|
|
1072
|
-
// `submit` is unaffected. `requestedWindowDays` reads back the CLAMPED
|
|
1264
|
+
// #5285/#5096 — the four fields that became writable. Each is optional in
|
|
1265
|
+
// the application schema, so a row that never carried one simply omits it
|
|
1266
|
+
// and `submit` is unaffected. `requestedWindowDays` reads back the CLAMPED
|
|
1073
1267
|
// `windowDays` the row actually holds, not what was asked for: the
|
|
1074
1268
|
// request is not stored anywhere, and re-asserting it would be asserting
|
|
1075
1269
|
// a number nothing ever agreed to.
|
|
1076
1270
|
...(row.logoMarkUrl !== null && { logoMarkUrl: row.logoMarkUrl }),
|
|
1077
1271
|
...(row.logoWordmarkUrl !== null && { logoWordmarkUrl: row.logoWordmarkUrl }),
|
|
1078
1272
|
...(row.windowDays !== null && { requestedWindowDays: row.windowDays }),
|
|
1273
|
+
...(row.solution !== null && { solution: row.solution }),
|
|
1079
1274
|
};
|
|
1080
1275
|
return Object.fromEntries(
|
|
1081
1276
|
Object.entries(all).filter(([key]) => collected.has(key)),
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-read site resolution — which workspace an UNAUTHENTICATED content
|
|
3
|
+
* read serves (#5852, roadmap D4 read half).
|
|
4
|
+
*
|
|
5
|
+
* THE GUARD MODEL IS FAIL-CLOSED. The only branch that yields a workspace is an
|
|
6
|
+
* unambiguously one-workspace deployment, answered by the same predicate the
|
|
7
|
+
* authenticated path uses (`services/default-tenant.ts#resolveDefaultWorkspaceId`)
|
|
8
|
+
* — one predicate, reached two ways, never two predicates. Zero workspaces, two
|
|
9
|
+
* or more, or a sole workspace that is not the seeded default all resolve to
|
|
10
|
+
* `unresolved`, and the caller answers 404 before any content query runs.
|
|
11
|
+
*
|
|
12
|
+
* IT READS NO HOST. Every deployment this resolver serves today is
|
|
13
|
+
* one-workspace, so a host lookup would be dead code — and there is nothing to
|
|
14
|
+
* look up: `WorkspaceSiteIdentity` carries no host column (the value lives
|
|
15
|
+
* inside the unindexed `data.url`), and `request.hostname` resolves from
|
|
16
|
+
* `X-Forwarded-Host` under this server's `trustProxy: true` (`server.ts`),
|
|
17
|
+
* which is caller-suppliable and therefore not a tenancy input. The
|
|
18
|
+
* multi-tenant host branch is #3635's, and must resolve against a server-side
|
|
19
|
+
* binding rather than a forwarded header.
|
|
20
|
+
*
|
|
21
|
+
* THE CLIENT NEVER SUPPLIES A WORKSPACE. No path parameter, query string,
|
|
22
|
+
* header or body field is read here, on any read. That is roadmap D4's own
|
|
23
|
+
* STOP boundary, not a default.
|
|
24
|
+
*
|
|
25
|
+
* IT IS A NO-OP OFF THE DATABASE ADAPTER. Unless `CONTENT_ADAPTER=database`,
|
|
26
|
+
* the function returns `not-required` having issued no query and loaded no
|
|
27
|
+
* database module — which is what keeps the MDX default path byte-identical
|
|
28
|
+
* and keeps the no-`DATABASE_URL` scaffold starting.
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
resolvedAdapterKind,
|
|
32
|
+
type ArtifactPrismaClient,
|
|
33
|
+
type ContentAdapter,
|
|
34
|
+
} from "@working-theory/content-adapters";
|
|
35
|
+
import type { FastifyRequest } from "fastify";
|
|
36
|
+
|
|
37
|
+
import { getContentAdapter } from "../lib/content-adapter.js";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The three answers a public read can get. `reason` exists for `request.log`
|
|
41
|
+
* only: the route decides its response from `kind` alone, so no resolution
|
|
42
|
+
* detail reaches an unauthenticated caller (#5852 D7, D8).
|
|
43
|
+
*/
|
|
44
|
+
export type PublicSiteResolution =
|
|
45
|
+
| { kind: "not-required" }
|
|
46
|
+
| { kind: "resolved"; workspaceId: string }
|
|
47
|
+
| { kind: "unresolved"; reason: "ambiguous" | "host-unmatched" };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves the workspace a public content read serves, or refuses to.
|
|
51
|
+
*
|
|
52
|
+
* The `request` argument is the site identifier's eventual source (#3635) and
|
|
53
|
+
* is deliberately unused today — see the no-host note in this file's header.
|
|
54
|
+
*/
|
|
55
|
+
export async function resolvePublicWorkspace(
|
|
56
|
+
_request: FastifyRequest,
|
|
57
|
+
): Promise<PublicSiteResolution> {
|
|
58
|
+
if (resolvedAdapterKind() !== "database") {
|
|
59
|
+
return { kind: "not-required" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Deferred — see the module-load note in this file's header (#5852 D6).
|
|
63
|
+
const { resolveDefaultWorkspaceId } = await import("./default-tenant.js");
|
|
64
|
+
const workspaceId = await resolveDefaultWorkspaceId();
|
|
65
|
+
if (workspaceId === null) {
|
|
66
|
+
return { kind: "unresolved", reason: "ambiguous" };
|
|
67
|
+
}
|
|
68
|
+
return { kind: "resolved", workspaceId };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The adapter a public content read should use, or `null` when it must refuse.
|
|
73
|
+
*
|
|
74
|
+
* This is the ONE place the three-armed resolution collapses into a decision,
|
|
75
|
+
* so a second public read route cannot grow a second copy of it: `resolved`
|
|
76
|
+
* and `not-required` both yield an adapter, `unresolved` yields `null`. The
|
|
77
|
+
* caller answers `null` with its OWN existing not-found body — which is what
|
|
78
|
+
* keeps a refusal byte-identical to a missing page on `get-page`, and keeps
|
|
79
|
+
* each route's 404 shape its own (#5852 D8). The reason is logged here and
|
|
80
|
+
* never leaves the server.
|
|
81
|
+
*/
|
|
82
|
+
export async function resolvePublicContentAdapter(
|
|
83
|
+
request: FastifyRequest,
|
|
84
|
+
): Promise<ContentAdapter | null> {
|
|
85
|
+
const site = await resolvePublicWorkspace(request);
|
|
86
|
+
if (site.kind === "unresolved") {
|
|
87
|
+
request.log.warn(
|
|
88
|
+
{ reason: site.reason },
|
|
89
|
+
"[Content] no site resolved for public read",
|
|
90
|
+
);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (site.kind === "not-required") {
|
|
94
|
+
return getContentAdapter();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The database path, and the only caller that needs apps/api's client. The
|
|
98
|
+
// import is deferred for the reason the header gives (#5852 D6), and the
|
|
99
|
+
// generated client is structurally wider than the narrow read surface the
|
|
100
|
+
// adapter declares — the cast names that, and nothing else.
|
|
101
|
+
const { prisma } = await import("../lib/prisma.js");
|
|
102
|
+
return getContentAdapter({
|
|
103
|
+
workspaceId: site.workspaceId,
|
|
104
|
+
prisma: prisma as unknown as ArtifactPrismaClient,
|
|
105
|
+
});
|
|
106
|
+
}
|