create-ailk 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -8
- package/component-catalog.json +12248 -0
- package/component-catalog.md +193 -68
- package/dist/cli.js +29 -1
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/extract-module-bundle.d.ts +50 -2
- package/dist/lib/extract-module-bundle.js +223 -15
- package/dist/lib/fetch-guard.d.ts +28 -0
- package/dist/lib/fetch-guard.js +101 -0
- package/dist/lib/fetch-module.js +4 -0
- package/dist/lib/fetch-template.js +4 -0
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/module-architecture.d.ts +60 -8
- package/dist/module-architecture.js +587 -242
- package/dist/parse-args.js +6 -2
- package/dist/programmatic.d.ts +10 -0
- package/dist/programmatic.js +134 -19
- package/dist/site-chrome.d.ts +80 -0
- package/dist/site-chrome.js +135 -0
- package/dist/surfaces.d.ts +21 -6
- package/dist/surfaces.js +209 -36
- package/dist/template-manifest.d.ts +88 -3
- package/dist/template-manifest.js +281 -29
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +2 -0
- package/package.json +10 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
- package/site-templates/marketing-site/ailk-template.json +11 -0
- package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
- package/site-templates/marketing-site/provenance.json +4 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
- package/site-templates/marketing-site-free/ailk-template.json +2 -1
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
- package/site-templates/minimalism-site/ailk-template.json +22 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
- package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
- package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
- package/site-templates/minimalism-site/provenance.json +4 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
- package/site-templates/saas-product/ailk-template.json +16 -0
- package/site-templates/saas-product/provenance.json +10 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.claude/skills/README.md +16 -25
- package/templates/.env.example +102 -12
- package/templates/.github/workflows/ci.yml +48 -0
- package/templates/README.md +3 -1
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +29 -2
- package/templates/apps/api/eslint.config.mjs +69 -0
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +7 -2
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
- package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/leads/list.ts +104 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +521 -55
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +300 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +160 -0
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +284 -0
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +58 -25
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/eslint.config.mjs +69 -0
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +8 -2
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +61 -37
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +9 -7
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +74 -8
- package/templates/apps/web/CLAUDE.md +46 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +308 -65
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/components/google-analytics.tsx +18 -1
- package/templates/apps/web/eslint.config.mjs +69 -0
- package/templates/apps/web/jest.config.cjs +15 -19
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/nav-config.ts +27 -7
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/site-chrome.ts +29 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
- package/templates/apps/web/messages/en.json +216 -2
- package/templates/apps/web/next.config.mjs +20 -0
- package/templates/apps/web/package.json +8 -3
- package/templates/apps/web/sentry.client.config.ts +131 -0
- package/templates/apps/web/sentry.edge.config.ts +72 -0
- package/templates/apps/web/sentry.server.config.ts +72 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
- package/templates/apps/web/src/lib/routes.ts +13 -18
- package/templates/brand_assets/README.md +24 -0
- package/templates/content/en/AboutPage/about.mdx +160 -15
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ContactPage/contact.mdx +90 -13
- package/templates/content/en/FAQPage/faq.mdx +62 -22
- package/templates/content/en/HomePage/home.mdx +244 -20
- package/templates/content/en/LandingPage/blog.mdx +83 -0
- package/templates/content/en/LandingPage/features.mdx +103 -0
- package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +149 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/seed.test.ts +1 -1
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/eslint.config.mjs +69 -0
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +7 -3
- package/templates/database/seed.ts +1 -1
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/templates/package.json +1 -14
- package/versions.json +52 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
- /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
- /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file search.test.ts
|
|
3
|
+
* GET /v1/content/search — keyword search across published pages (#5831).
|
|
4
|
+
*
|
|
5
|
+
* Uses the real content adapter backed by a per-test temp dir (Issue #507).
|
|
6
|
+
* No internal module mocks — the route exercises the real adapter stack.
|
|
7
|
+
*
|
|
8
|
+
* The `category`-tagged fixtures are written here rather than through
|
|
9
|
+
* `writeFixturePage`, whose frontmatter parameter is `Record<string, string>`
|
|
10
|
+
* and so cannot express a YAML sequence.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from "fs/promises";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
|
|
16
|
+
import type { FastifyInstance } from "fastify";
|
|
17
|
+
|
|
18
|
+
import { setupContentFixture } from "../../../__tests__/factories/content-fixture";
|
|
19
|
+
import { getContentAdapter } from "../../../lib/content-adapter";
|
|
20
|
+
import { contentRoutes } from "../index";
|
|
21
|
+
import { CONTENT_RATE_LIMIT_UNAUTH, CONTENT_RATE_LIMIT_WINDOW } from "../rate-limit";
|
|
22
|
+
|
|
23
|
+
const fixture = setupContentFixture();
|
|
24
|
+
|
|
25
|
+
/** Writes an MDX page with a `categories:` sequence into the locale path. */
|
|
26
|
+
async function writeCategorisedPage(
|
|
27
|
+
slug: string,
|
|
28
|
+
title: string,
|
|
29
|
+
description: string,
|
|
30
|
+
categories: readonly string[],
|
|
31
|
+
body: string,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const dir = path.join(fixture.getContentRoot(), "en", "Product");
|
|
34
|
+
await fs.mkdir(dir, { recursive: true });
|
|
35
|
+
const frontmatter = [
|
|
36
|
+
`type: "Product"`,
|
|
37
|
+
`slug: "${slug}"`,
|
|
38
|
+
`title: "${title}"`,
|
|
39
|
+
`description: "${description}"`,
|
|
40
|
+
...(categories.length > 0
|
|
41
|
+
? ["categories:", ...categories.map((c) => ` - ${c}`)]
|
|
42
|
+
: []),
|
|
43
|
+
].join("\n");
|
|
44
|
+
await fs.writeFile(
|
|
45
|
+
path.join(dir, `${slug}.mdx`),
|
|
46
|
+
`---\n${frontmatter}\n---\n\n${body}\n`,
|
|
47
|
+
"utf8",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function seedRoofPages(): Promise<void> {
|
|
52
|
+
await writeCategorisedPage(
|
|
53
|
+
"roof-replacement",
|
|
54
|
+
"Roof Replacement",
|
|
55
|
+
"A full roof replacement service.",
|
|
56
|
+
["roofing"],
|
|
57
|
+
"We replace a roof in a day.",
|
|
58
|
+
);
|
|
59
|
+
await writeCategorisedPage(
|
|
60
|
+
"attic-ventilation",
|
|
61
|
+
"Attic Ventilation",
|
|
62
|
+
"Ventilation that protects the roof deck.",
|
|
63
|
+
["hvac"],
|
|
64
|
+
"Attic airflow keeps a roof healthy.",
|
|
65
|
+
);
|
|
66
|
+
await writeCategorisedPage(
|
|
67
|
+
"untagged-roof-guide",
|
|
68
|
+
"Roof Care Guide",
|
|
69
|
+
"How to look after a roof.",
|
|
70
|
+
[],
|
|
71
|
+
"A roof lasts longer when it is inspected.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("GET /v1/content/search", () => {
|
|
76
|
+
let app: FastifyInstance;
|
|
77
|
+
|
|
78
|
+
beforeEach(async () => {
|
|
79
|
+
app = require("fastify")();
|
|
80
|
+
await app.register(contentRoutes);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
await app.close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns 200 with a JSON-LD ItemList envelope", async () => {
|
|
88
|
+
await seedRoofPages();
|
|
89
|
+
|
|
90
|
+
const res = await app.inject({
|
|
91
|
+
method: "GET",
|
|
92
|
+
url: "/v1/content/search?q=roof",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(res.statusCode).toBe(200);
|
|
96
|
+
const body = JSON.parse(res.body);
|
|
97
|
+
expect(body["@context"]).toBe("https://schema.org");
|
|
98
|
+
expect(body["@type"]).toBe("ItemList");
|
|
99
|
+
expect(body.query).toBe("roof");
|
|
100
|
+
expect(Array.isArray(body.items)).toBe(true);
|
|
101
|
+
expect(body.totalItems).toBe(body.items.length);
|
|
102
|
+
expect(body.items.map((i: { slug: string }) => i.slug).sort()).toEqual([
|
|
103
|
+
"attic-ventilation",
|
|
104
|
+
"roof-replacement",
|
|
105
|
+
"untagged-roof-guide",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("filters on category — only the tagged page comes back", async () => {
|
|
110
|
+
await seedRoofPages();
|
|
111
|
+
|
|
112
|
+
const res = await app.inject({
|
|
113
|
+
method: "GET",
|
|
114
|
+
url: "/v1/content/search?q=roof&category=roofing",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(res.statusCode).toBe(200);
|
|
118
|
+
const body = JSON.parse(res.body);
|
|
119
|
+
expect(body.items.map((i: { slug: string }) => i.slug)).toEqual([
|
|
120
|
+
"roof-replacement",
|
|
121
|
+
]);
|
|
122
|
+
expect(body.totalItems).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns an empty ItemList for a registry slug nothing is tagged with", async () => {
|
|
126
|
+
await seedRoofPages();
|
|
127
|
+
|
|
128
|
+
const res = await app.inject({
|
|
129
|
+
method: "GET",
|
|
130
|
+
url: "/v1/content/search?q=roof&category=legal",
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(res.statusCode).toBe(200);
|
|
134
|
+
const body = JSON.parse(res.body);
|
|
135
|
+
expect(body.items).toEqual([]);
|
|
136
|
+
expect(body.totalItems).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("composes the type and category filters", async () => {
|
|
140
|
+
await seedRoofPages();
|
|
141
|
+
|
|
142
|
+
const res = await app.inject({
|
|
143
|
+
method: "GET",
|
|
144
|
+
url: "/v1/content/search?q=roof&type=HomePage&category=roofing",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(res.statusCode).toBe(200);
|
|
148
|
+
expect(JSON.parse(res.body).items).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("honours limit", async () => {
|
|
152
|
+
await seedRoofPages();
|
|
153
|
+
|
|
154
|
+
const res = await app.inject({
|
|
155
|
+
method: "GET",
|
|
156
|
+
url: "/v1/content/search?q=roof&limit=1",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(res.statusCode).toBe(200);
|
|
160
|
+
expect(JSON.parse(res.body).items).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("is not shadowed by the /v1/content/:type route", async () => {
|
|
164
|
+
const res = await app.inject({
|
|
165
|
+
method: "GET",
|
|
166
|
+
url: "/v1/content/search?q=roof",
|
|
167
|
+
});
|
|
168
|
+
// A `:type` match would 400 on the type enum; a search match 200s.
|
|
169
|
+
expect(res.statusCode).toBe(200);
|
|
170
|
+
expect(JSON.parse(res.body).query).toBe("roof");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("400s on an invalid query", () => {
|
|
174
|
+
it("rejects a missing q", async () => {
|
|
175
|
+
const res = await app.inject({
|
|
176
|
+
method: "GET",
|
|
177
|
+
url: "/v1/content/search",
|
|
178
|
+
});
|
|
179
|
+
expect(res.statusCode).toBe(400);
|
|
180
|
+
const body = JSON.parse(res.body);
|
|
181
|
+
expect(body.error).toBe("Invalid query parameters");
|
|
182
|
+
expect(Array.isArray(body.details)).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("rejects an empty q", async () => {
|
|
186
|
+
const res = await app.inject({
|
|
187
|
+
method: "GET",
|
|
188
|
+
url: "/v1/content/search?q=",
|
|
189
|
+
});
|
|
190
|
+
expect(res.statusCode).toBe(400);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("rejects a category outside the registry", async () => {
|
|
194
|
+
const res = await app.inject({
|
|
195
|
+
method: "GET",
|
|
196
|
+
url: "/v1/content/search?q=roof&category=not-a-real-category",
|
|
197
|
+
});
|
|
198
|
+
expect(res.statusCode).toBe(400);
|
|
199
|
+
const body = JSON.parse(res.body);
|
|
200
|
+
expect(body.details.some((d: { path: string[] }) => d.path.includes("category"))).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("rejects a type outside the schema.org enum", async () => {
|
|
204
|
+
const res = await app.inject({
|
|
205
|
+
method: "GET",
|
|
206
|
+
url: "/v1/content/search?q=roof&type=NotAType",
|
|
207
|
+
});
|
|
208
|
+
expect(res.statusCode).toBe(400);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("rejects a q longer than the 200-char ceiling", async () => {
|
|
212
|
+
const res = await app.inject({
|
|
213
|
+
method: "GET",
|
|
214
|
+
url: `/v1/content/search?q=${"a".repeat(201)}`,
|
|
215
|
+
});
|
|
216
|
+
expect(res.statusCode).toBe(400);
|
|
217
|
+
const body = JSON.parse(res.body);
|
|
218
|
+
expect(
|
|
219
|
+
body.details.some((d: { path: string[] }) => d.path.includes("q")),
|
|
220
|
+
).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("accepts a q of exactly 200 chars — the same ceiling the MCP tool enforces", async () => {
|
|
224
|
+
const res = await app.inject({
|
|
225
|
+
method: "GET",
|
|
226
|
+
url: `/v1/content/search?q=${"a".repeat(200)}`,
|
|
227
|
+
});
|
|
228
|
+
expect(res.statusCode).toBe(200);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("rejects a limit above the 50 ceiling", async () => {
|
|
232
|
+
const res = await app.inject({
|
|
233
|
+
method: "GET",
|
|
234
|
+
url: "/v1/content/search?q=roof&limit=51",
|
|
235
|
+
});
|
|
236
|
+
expect(res.statusCode).toBe(400);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* D6 — API ↔ MCP parity evidence for #5831.
|
|
242
|
+
*
|
|
243
|
+
* The tool and the route must return the same result set for the same
|
|
244
|
+
* arguments. `apps/api` cannot import `apps/mcp` (the hard import boundary
|
|
245
|
+
* in the root CLAUDE.md), so the shared seam is asserted instead: the route
|
|
246
|
+
* must return EXACTLY what `adapter.searchPages` returns for the options the
|
|
247
|
+
* MCP handler builds from the same arguments. `ailkSearchContent`'s own
|
|
248
|
+
* suite (`apps/mcp/__tests__/search_content.test.ts`) pins the other half —
|
|
249
|
+
* tool results ≡ `adapter.searchPages` for the same arguments — so the two
|
|
250
|
+
* halves compose into the 1:1 claim without either app importing the other.
|
|
251
|
+
*/
|
|
252
|
+
describe("parity with the adapter the MCP tool calls (D6)", () => {
|
|
253
|
+
it("deep-equals adapter.searchPages items for the same arguments", async () => {
|
|
254
|
+
await seedRoofPages();
|
|
255
|
+
|
|
256
|
+
const res = await app.inject({
|
|
257
|
+
method: "GET",
|
|
258
|
+
url: "/v1/content/search?q=roof&category=roofing&limit=10&locale=en",
|
|
259
|
+
});
|
|
260
|
+
expect(res.statusCode).toBe(200);
|
|
261
|
+
|
|
262
|
+
// The exact option object `ailkSearchContent` builds for these arguments.
|
|
263
|
+
const expected = await getContentAdapter().searchPages("roof", {
|
|
264
|
+
limit: 10,
|
|
265
|
+
category: "roofing",
|
|
266
|
+
locale: "en",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(JSON.parse(res.body).items).toEqual(
|
|
270
|
+
JSON.parse(JSON.stringify(expected)),
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("deep-equals with no filters at all", async () => {
|
|
275
|
+
await seedRoofPages();
|
|
276
|
+
|
|
277
|
+
const res = await app.inject({
|
|
278
|
+
method: "GET",
|
|
279
|
+
url: "/v1/content/search?q=roof",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const expected = await getContentAdapter().searchPages("roof", {
|
|
283
|
+
limit: 10,
|
|
284
|
+
locale: "en",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(JSON.parse(res.body).items).toEqual(
|
|
288
|
+
JSON.parse(JSON.stringify(expected)),
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `server.ts` registers the limiter GLOBALLY with the content ceiling, so
|
|
295
|
+
* this route is covered without registering one of its own. Asserting the
|
|
296
|
+
* shared constants is the honest check: a route test that registered its own
|
|
297
|
+
* limiter would prove nothing about the server.
|
|
298
|
+
*/
|
|
299
|
+
describe("content rate limit", () => {
|
|
300
|
+
it("is governed by the same constants as the other content reads", async () => {
|
|
301
|
+
expect(CONTENT_RATE_LIMIT_UNAUTH).toBe(60);
|
|
302
|
+
expect(CONTENT_RATE_LIMIT_WINDOW).toBe("1 minute");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("429s past the allowance under a limiter built from those constants", async () => {
|
|
306
|
+
const limited: FastifyInstance = require("fastify")();
|
|
307
|
+
const rateLimit = require("@fastify/rate-limit");
|
|
308
|
+
await limited.register(rateLimit, {
|
|
309
|
+
max: CONTENT_RATE_LIMIT_UNAUTH,
|
|
310
|
+
timeWindow: CONTENT_RATE_LIMIT_WINDOW,
|
|
311
|
+
keyGenerator: () => "shared-test-ip",
|
|
312
|
+
});
|
|
313
|
+
await limited.register(contentRoutes);
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < CONTENT_RATE_LIMIT_UNAUTH; i++) {
|
|
316
|
+
const ok = await limited.inject({
|
|
317
|
+
method: "GET",
|
|
318
|
+
url: "/v1/content/search?q=roof",
|
|
319
|
+
});
|
|
320
|
+
expect(ok.statusCode).toBe(200);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const overLimit = await limited.inject({
|
|
324
|
+
method: "GET",
|
|
325
|
+
url: "/v1/content/search?q=roof",
|
|
326
|
+
});
|
|
327
|
+
expect(overLimit.statusCode).toBe(429);
|
|
328
|
+
|
|
329
|
+
await limited.close();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ─── #5852 / #5910 — the database path: which workspace a search read serves ─
|
|
335
|
+
//
|
|
336
|
+
// Appended, so every MDX case above is untouched. Database-free: the
|
|
337
|
+
// module-level Prisma singleton is faked via the repo's manual-mock
|
|
338
|
+
// convention (see get-page.test.ts's identical block — #5852), and
|
|
339
|
+
// `jest.mock` is hoisted above the imports by ts-jest regardless of where it
|
|
340
|
+
// is written. The MDX cases above never load `lib/prisma.js` — the
|
|
341
|
+
// resolver's import is deferred behind the `CONTENT_ADAPTER=database`
|
|
342
|
+
// branch — so the mock is inert for them.
|
|
343
|
+
|
|
344
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
345
|
+
jest.mock("../../../lib/prisma.js");
|
|
346
|
+
|
|
347
|
+
import {
|
|
348
|
+
DEFAULT_ORG_ID,
|
|
349
|
+
DEFAULT_WORKSPACE_ID,
|
|
350
|
+
} from "@working-theory/database/tenancy";
|
|
351
|
+
|
|
352
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
353
|
+
|
|
354
|
+
/** A one-workspace deployment seeded with the well-known default (mirrors get-page.test.ts). */
|
|
355
|
+
function seedOneWorkspaceDeployment(): void {
|
|
356
|
+
(prisma.workspace.count as jest.Mock).mockResolvedValue(1);
|
|
357
|
+
(prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
|
|
358
|
+
id: DEFAULT_WORKSPACE_ID,
|
|
359
|
+
organizationId: DEFAULT_ORG_ID,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
describe("GET /v1/content/search under CONTENT_ADAPTER=database (#5852 / #5910)", () => {
|
|
364
|
+
let app: FastifyInstance;
|
|
365
|
+
|
|
366
|
+
beforeEach(async () => {
|
|
367
|
+
jest.clearAllMocks();
|
|
368
|
+
process.env.CONTENT_ADAPTER = "database";
|
|
369
|
+
app = require("fastify")();
|
|
370
|
+
await app.register(contentRoutes);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
afterEach(async () => {
|
|
374
|
+
delete process.env.CONTENT_ADAPTER;
|
|
375
|
+
await app.close();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// AC-1 — a resolvable single-workspace deployment returns the search
|
|
379
|
+
// ItemList, with no workspace id anywhere in the URL, and the underlying
|
|
380
|
+
// artifact read is bound to the resolved workspace.
|
|
381
|
+
it("returns the search ItemList for the seeded default workspace, with no workspace id in the URL", async () => {
|
|
382
|
+
seedOneWorkspaceDeployment();
|
|
383
|
+
(prisma.artifact.findMany as jest.Mock).mockResolvedValue([]);
|
|
384
|
+
(prisma.artifactVersion.findMany as jest.Mock).mockResolvedValue([]);
|
|
385
|
+
|
|
386
|
+
const url = "/v1/content/search?q=roof";
|
|
387
|
+
const res = await app.inject({ method: "GET", url });
|
|
388
|
+
|
|
389
|
+
expect(res.statusCode).toBe(200);
|
|
390
|
+
const body = JSON.parse(res.body);
|
|
391
|
+
expect(body["@type"]).toBe("ItemList");
|
|
392
|
+
expect(body.items).toEqual([]);
|
|
393
|
+
// The tenant is resolved server-side; it is never addressable from the wire.
|
|
394
|
+
expect(url).not.toMatch(/wksp_/);
|
|
395
|
+
expect(prisma.artifact.findMany).toHaveBeenCalledWith(
|
|
396
|
+
expect.objectContaining({
|
|
397
|
+
where: expect.objectContaining({ workspaceId: DEFAULT_WORKSPACE_ID }),
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// AC-1 — zero workspaces: 404 before any artifact query.
|
|
403
|
+
it("404s with zero workspaces, before any artifact query", async () => {
|
|
404
|
+
(prisma.workspace.count as jest.Mock).mockResolvedValue(0);
|
|
405
|
+
|
|
406
|
+
const res = await app.inject({
|
|
407
|
+
method: "GET",
|
|
408
|
+
url: "/v1/content/search?q=roof",
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(res.statusCode).toBe(404);
|
|
412
|
+
expect(prisma.artifact.findMany).not.toHaveBeenCalled();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// AC-1 — two-or-more workspaces: ambiguous, so no site resolves; 404 before
|
|
416
|
+
// any artifact query.
|
|
417
|
+
it("404s with two-or-more workspaces, before any artifact query", async () => {
|
|
418
|
+
(prisma.workspace.count as jest.Mock).mockResolvedValue(2);
|
|
419
|
+
|
|
420
|
+
const res = await app.inject({
|
|
421
|
+
method: "GET",
|
|
422
|
+
url: "/v1/content/search?q=roof",
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
expect(res.statusCode).toBe(404);
|
|
426
|
+
expect(prisma.artifact.findMany).not.toHaveBeenCalled();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// The two unresolved arms answer with the SAME 404 body (AC-WCE-05 parity
|
|
430
|
+
// with get-page/list-pages) — a resolution failure carries no detail an
|
|
431
|
+
// unauthenticated caller could use to distinguish it from another cause.
|
|
432
|
+
it("answers both ambiguity arms with identical 404 bodies", async () => {
|
|
433
|
+
(prisma.workspace.count as jest.Mock).mockResolvedValue(0);
|
|
434
|
+
const empty = await app.inject({
|
|
435
|
+
method: "GET",
|
|
436
|
+
url: "/v1/content/search?q=roof",
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
jest.clearAllMocks();
|
|
440
|
+
(prisma.workspace.count as jest.Mock).mockResolvedValue(2);
|
|
441
|
+
const ambiguous = await app.inject({
|
|
442
|
+
method: "GET",
|
|
443
|
+
url: "/v1/content/search?q=roof",
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(empty.statusCode).toBe(404);
|
|
447
|
+
expect(ambiguous.statusCode).toBe(404);
|
|
448
|
+
expect(ambiguous.body).toBe(empty.body);
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -5,34 +5,69 @@
|
|
|
5
5
|
* document root, plus the raw frontmatter under `page` for clients that need
|
|
6
6
|
* the unrendered shape.
|
|
7
7
|
*
|
|
8
|
-
* 404 on missing slug, 400 on unknown type.
|
|
8
|
+
* 404 on missing slug, 400 on unknown type. A deployment whose site cannot be
|
|
9
|
+
* resolved (#5852) answers with the SAME 404 body as a missing slug.
|
|
9
10
|
*
|
|
10
11
|
* Phase 3 S3: optional `?locale=<locale>` query parameter. Validated against
|
|
11
12
|
* `readLocales()` allowlist; invalid or unknown locale silently falls back to
|
|
12
13
|
* the project's default locale (AC-i18n-07). The adapter applies the fallback
|
|
13
14
|
* chain (locale → default-locale → legacy non-prefixed layout).
|
|
15
|
+
*
|
|
16
|
+
* #5890: optional `?include=body` adds the raw published MDX body beside
|
|
17
|
+
* `page`. It is opt-in: without it the handler takes the path it took before
|
|
18
|
+
* the parameter existed. An adapter with no `getPageSource` refuses the
|
|
19
|
+
* parameter with a 400 naming it, rather than 500ing on a missing method.
|
|
20
|
+
*
|
|
21
|
+
* #5927: on the `?include=body` path, `page` and `body` come from ONE
|
|
22
|
+
* `getPageSource` call — never a `getPageBySlug` call plus a second,
|
|
23
|
+
* independent `getPageSource` call. Two independent adapter reads can
|
|
24
|
+
* straddle a publish that lands between them (frontmatter from revision N,
|
|
25
|
+
* body from revision N+1); one read cannot.
|
|
14
26
|
*/
|
|
15
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
SCHEMA_ORG_TYPES,
|
|
29
|
+
type ContentAdapter,
|
|
30
|
+
type ContentPage,
|
|
31
|
+
type MdxContentAdapter,
|
|
32
|
+
type MdxPageSource,
|
|
33
|
+
type SchemaOrgType,
|
|
34
|
+
} from "@working-theory/content-adapters";
|
|
16
35
|
import { buildJsonLd, type ContentPageInput } from "@working-theory/schema";
|
|
17
36
|
import type { FastifyPluginAsync } from "fastify";
|
|
18
37
|
import { z } from "zod";
|
|
19
38
|
|
|
20
|
-
import { getContentAdapter } from "../../lib/content-adapter.js";
|
|
21
39
|
import { resolveLocale } from "../../lib/locales.js";
|
|
40
|
+
import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
|
|
41
|
+
|
|
42
|
+
import { SLUG_PATTERN } from "./slug.js";
|
|
22
43
|
|
|
23
44
|
const paramsSchema = z.object({
|
|
24
45
|
type: z.enum(SCHEMA_ORG_TYPES),
|
|
25
|
-
slug: z.string().
|
|
46
|
+
slug: z.string().regex(SLUG_PATTERN),
|
|
26
47
|
});
|
|
27
48
|
|
|
28
49
|
const querySchema = z.object({
|
|
29
50
|
locale: z.string().optional(),
|
|
51
|
+
include: z.enum(["body"]).optional(),
|
|
30
52
|
});
|
|
31
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The raw-body capability, probed by the exact name the MDX adapter declares
|
|
56
|
+
* and the database adapter borrows (#5874) — the same idiom, and the same
|
|
57
|
+
* name, as `apps/mcp/src/lib/content-adapter.ts`.
|
|
58
|
+
*/
|
|
59
|
+
function hasPageSource(
|
|
60
|
+
adapter: ContentAdapter,
|
|
61
|
+
): adapter is ContentAdapter & Pick<MdxContentAdapter, "getPageSource"> {
|
|
62
|
+
return (
|
|
63
|
+
typeof (adapter as Partial<MdxContentAdapter>).getPageSource === "function"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
export const getPageRoute: FastifyPluginAsync = async (fastify) => {
|
|
33
68
|
fastify.get<{
|
|
34
69
|
Params: { type: string; slug: string };
|
|
35
|
-
Querystring: { locale?: string };
|
|
70
|
+
Querystring: { locale?: string; include?: string };
|
|
36
71
|
}>(
|
|
37
72
|
"/v1/content/:type/:slug",
|
|
38
73
|
async (request, reply) => {
|
|
@@ -53,15 +88,58 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
|
|
|
53
88
|
}
|
|
54
89
|
|
|
55
90
|
const { type, slug } = parsed.data;
|
|
91
|
+
const { include } = queryParsed.data;
|
|
56
92
|
const locale = resolveLocale(queryParsed.data.locale);
|
|
57
93
|
|
|
58
94
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
);
|
|
95
|
+
// Which site this read serves is resolved BEFORE any content query
|
|
96
|
+
// (#5852). A refusal reuses the SAME not-found body a missing page
|
|
97
|
+
// gets, below: a distinct body or code would tell an unauthenticated
|
|
98
|
+
// caller that its slug reached tenancy, which is the enumeration
|
|
99
|
+
// signal AC-WCE-05 denies.
|
|
100
|
+
const adapter = await resolvePublicContentAdapter(request);
|
|
101
|
+
if (adapter === null) {
|
|
102
|
+
return reply.status(404).send({
|
|
103
|
+
error: "Page not found",
|
|
104
|
+
type,
|
|
105
|
+
slug,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Refused before any content query, so a caller asking for a body this
|
|
110
|
+
// deployment cannot serve is never charged a read whose result is then
|
|
111
|
+
// discarded.
|
|
112
|
+
let readSource: MdxContentAdapter["getPageSource"] | undefined;
|
|
113
|
+
if (include === "body") {
|
|
114
|
+
if (!hasPageSource(adapter)) {
|
|
115
|
+
return reply.status(400).send({
|
|
116
|
+
error: "Invalid query parameters",
|
|
117
|
+
details: [
|
|
118
|
+
{
|
|
119
|
+
path: ["include"],
|
|
120
|
+
message: "body is not available from this content adapter",
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
readSource = adapter.getPageSource.bind(adapter);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// #5927: ONE adapter call supplies everything the response sends.
|
|
129
|
+
// `readSource` (bound above, only when `include=body`) returns BOTH
|
|
130
|
+
// the frontmatter and the body from the SAME read, so the two halves
|
|
131
|
+
// can never straddle a publish that lands between two independent
|
|
132
|
+
// calls. Without the parameter, `getPageBySlug` is the only call, as
|
|
133
|
+
// it always was. `source` stays `undefined` on that path — the
|
|
134
|
+
// `source && {...}` below never attaches a body to the response.
|
|
135
|
+
let source: MdxPageSource | null | undefined;
|
|
136
|
+
let page: ContentPage | null;
|
|
137
|
+
if (readSource) {
|
|
138
|
+
source = await readSource(type as SchemaOrgType, slug, locale);
|
|
139
|
+
page = source === null ? null : source.frontmatter;
|
|
140
|
+
} else {
|
|
141
|
+
page = await adapter.getPageBySlug(type as SchemaOrgType, slug, locale);
|
|
142
|
+
}
|
|
65
143
|
if (!page) {
|
|
66
144
|
return reply.status(404).send({
|
|
67
145
|
error: "Page not found",
|
|
@@ -88,7 +166,11 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
|
|
|
88
166
|
...(page.sections !== undefined && { sections: page.sections }),
|
|
89
167
|
};
|
|
90
168
|
const jsonLd = buildJsonLd(input);
|
|
91
|
-
return reply.send({
|
|
169
|
+
return reply.send({
|
|
170
|
+
...jsonLd,
|
|
171
|
+
page,
|
|
172
|
+
...(source && { body: source.body }),
|
|
173
|
+
});
|
|
92
174
|
} catch (error) {
|
|
93
175
|
request.log.error({ err: error }, "[Content] getPage failed");
|
|
94
176
|
return reply.status(500).send({ error: "Internal server error" });
|