create-ailk 0.7.0 → 0.9.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 +112 -10
- package/component-catalog.json +798 -49
- package/component-catalog.md +9 -6
- package/dist/add-module.d.ts +20 -0
- package/dist/add-module.js +43 -7
- package/dist/add-route.d.ts +56 -0
- package/dist/add-route.js +286 -0
- package/dist/apply-design-md.d.ts +7 -0
- package/dist/apply-design-md.js +50 -0
- package/dist/apply-design.d.ts +64 -0
- package/dist/apply-design.js +273 -0
- package/dist/claude-md.d.ts +111 -0
- package/dist/claude-md.js +334 -0
- package/dist/cli.js +114 -28
- package/dist/derive-architecture.js +41 -36
- package/dist/design-compile.d.ts +71 -0
- package/dist/design-compile.js +98 -0
- package/dist/design-package.d.ts +83 -0
- package/dist/design-package.js +468 -0
- package/dist/design-presets.d.ts +86 -0
- package/dist/design-presets.js +642 -0
- package/dist/design-scaffold.d.ts +76 -0
- package/dist/design-scaffold.js +220 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/legacy-nav-renderers.d.ts +45 -0
- package/dist/legacy-nav-renderers.js +545 -0
- package/dist/lib/auth-secret-file.d.ts +19 -0
- package/dist/lib/auth-secret-file.js +112 -0
- package/dist/lib/design-checks.d.ts +78 -0
- package/dist/lib/design-checks.js +174 -0
- package/dist/lib/extract-module-bundle.js +5 -1
- package/dist/lib/license-key-files.d.ts +9 -0
- package/dist/lib/license-key-files.js +2 -2
- package/dist/lib/module-license-gate.d.ts +89 -0
- package/dist/lib/module-license-gate.js +171 -12
- package/dist/lib/module-tier.d.ts +45 -16
- package/dist/lib/module-tier.js +76 -0
- package/dist/lib/pro-callout.js +10 -2
- package/dist/lib/render-verdict.d.ts +52 -0
- package/dist/lib/render-verdict.js +77 -0
- package/dist/lib/scaffold-render-sweep.d.ts +105 -0
- package/dist/lib/scaffold-render-sweep.js +236 -0
- package/dist/module-architecture.d.ts +19 -16
- package/dist/module-architecture.js +64 -21
- package/dist/parse-args.d.ts +88 -10
- package/dist/parse-args.js +272 -21
- package/dist/programmatic.d.ts +66 -12
- package/dist/programmatic.js +361 -34
- package/dist/site-chrome.d.ts +19 -9
- package/dist/site-chrome.js +23 -13
- package/dist/site-manifest.d.ts +25 -0
- package/dist/site-manifest.js +254 -0
- package/dist/site-roster.d.ts +41 -0
- package/dist/site-roster.js +149 -11
- package/dist/starter-grid.d.ts +232 -0
- package/dist/starter-grid.js +545 -0
- package/dist/surfaces.d.ts +41 -9
- package/dist/surfaces.js +268 -92
- package/dist/template-manifest.d.ts +65 -29
- package/dist/template-manifest.js +161 -382
- package/dist/template-names.d.ts +14 -12
- package/dist/template-names.js +13 -17
- package/package.json +5 -4
- package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
- package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
- package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
- package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
- package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
- package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +70 -0
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +70 -0
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +70 -0
- package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
- package/site-templates/designs/minimalism/DESIGN.md +98 -6
- package/site-templates/designs/minimalism/sample/README.md +48 -0
- package/site-templates/designs/standard/DESIGN.md +103 -6
- package/site-templates/marketing-site/DESIGN.md +103 -6
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +144 -47
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +82 -41
- package/site-templates/marketing-site-free/DESIGN.md +103 -6
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +150 -48
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +114 -67
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
- package/site-templates/saas-product/DESIGN.md +103 -6
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
- package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +210 -81
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +356 -0
- package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
- package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
- package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/{minimalism-site → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
- package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
- package/site-templates/shapes/local-business-service/provenance.json +4 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +471 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +354 -0
- package/site-templates/shapes/organization-service/ailk-template.json +10 -0
- package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
- package/site-templates/{minimalism-site → shapes/organization-service}/content/en/ServicesList/services.mdx +3 -4
- package/site-templates/shapes/organization-service/provenance.json +4 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +478 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +353 -0
- package/site-templates/shapes/person-service/ailk-template.json +10 -0
- package/site-templates/shapes/person-service/content/_site.mdx +85 -0
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/{simple-service → shapes/person-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +10 -11
- package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
- package/site-templates/shapes/person-service/provenance.json +4 -0
- package/templates/.claude/rules/architecture.md +7 -0
- package/templates/.claude/skills/README.md +5 -3
- package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
- package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
- package/templates/.claude/skills/add-page/SKILL.md +241 -238
- package/templates/.claude/skills/add-page/references/page-types.md +85 -72
- package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
- package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
- package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
- package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
- package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
- package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
- package/templates/.claude/skills/check-site/SKILL.md +23 -0
- package/templates/.claude/skills/fill-page/SKILL.md +26 -7
- package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
- package/templates/.claude/skills/setup-database/SKILL.md +156 -0
- package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
- package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +44 -36
- package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
- package/templates/.claude/skills/update-page/SKILL.md +117 -0
- package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
- package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
- package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/.prettierignore +1 -0
- package/templates/CLAUDE.md +146 -103
- package/templates/DESIGN.md +103 -6
- package/templates/README.md +63 -43
- package/templates/apps/api/.env.example +2 -1
- package/templates/apps/api/CLAUDE.md +3 -2
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
- package/templates/apps/api/src/__tests__/server.test.ts +116 -0
- package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
- package/templates/apps/api/src/config/index.ts +35 -16
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
- package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
- package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
- package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
- package/templates/apps/api/src/lib/stripe.ts +9 -0
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
- package/templates/apps/api/src/middleware/auth.ts +54 -0
- package/templates/apps/api/src/openapi/spec.ts +63 -12
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
- package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
- package/templates/apps/api/src/routes/leads/index.ts +46 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
- package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
- package/templates/apps/api/src/server.ts +35 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
- package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
- package/templates/apps/api/src/services/lead-routing.ts +140 -30
- package/templates/apps/api/src/services/leads-inbox.ts +48 -11
- package/templates/apps/api/src/services/revalidation.ts +60 -15
- package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
- package/templates/apps/mcp/src/tools/create_project.ts +21 -6
- package/templates/apps/mcp/src/tools/purchase.ts +4 -2
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +22 -28
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
- package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
- package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
- package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +21 -5
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
- package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +65 -17
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
- package/templates/apps/web/app/[locale]/{book-a-call → portfolio}/page.tsx +12 -13
- package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
- package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
- package/templates/apps/web/app/api/revalidate/route.ts +78 -36
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
- package/templates/apps/web/lib/locale-guard.ts +18 -0
- package/templates/apps/web/lib/meet-placeholder.ts +5 -0
- package/templates/apps/web/lib/nav-config.ts +50 -37
- package/templates/apps/web/lib/site-brand.tsx +13 -9
- package/templates/apps/web/lib/site-identity.ts +14 -0
- package/templates/apps/web/lib/site-logo.tsx +36 -0
- package/templates/apps/web/lib/site-theme.ts +46 -26
- package/templates/apps/web/messages/en.json +45 -5
- package/templates/apps/web/middleware.ts +28 -4
- package/templates/content/_site.mdx +4 -2
- package/templates/content/en/BlogList/blog.mdx +37 -16
- package/templates/content/en/BookCall/book-a-call.mdx +80 -0
- package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
- package/templates/content/en/BookCall/onsite-service.mdx +56 -0
- package/templates/content/en/BookCall/start-project.mdx +56 -0
- package/templates/content/en/ContactPage/contact.mdx +43 -34
- package/templates/content/en/HomePage/home.mdx +10 -22
- package/templates/content/en/LandingPage/meet.mdx +59 -0
- package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
- package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +2 -1
- package/templates/tools/check-claude-md.ts +128 -0
- package/versions.json +33 -33
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
- package/site-templates/minimalism-site/DESIGN.md +0 -162
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -470
- package/site-templates/minimalism-site/ailk-template.json +0 -23
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
- package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
- package/site-templates/minimalism-site/provenance.json +0 -4
- package/site-templates/simple-service/DESIGN.md +0 -162
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
- package/site-templates/simple-service/ailk-template.json +0 -11
- package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
- package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
- package/site-templates/simple-service/provenance.json +0 -4
- package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
- package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
- package/templates/content/en/SoftwareProduct/product.mdx +0 -175
- /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
- /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/first-engagement.mdx +0 -0
- /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/second-engagement.mdx +0 -0
- /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/third-engagement.mdx +0 -0
|
@@ -3,30 +3,48 @@
|
|
|
3
3
|
* @description The signed webhook `apps/api` fires at the site's
|
|
4
4
|
* `/api/revalidate` on publish and unpublish (#5851, roadmap D6). One POST of
|
|
5
5
|
* `{ workspaceId, type, slug, locale }` to `CONTENT_REVALIDATE_URL`, signed
|
|
6
|
-
* HMAC-SHA256 over the
|
|
6
|
+
* HMAC-SHA256 with `CONTENT_REVALIDATE_SECRET` over the base string
|
|
7
|
+
* `v0:{timestamp}:{event}:{body}` (#5902) — the Slack shape
|
|
8
|
+
* (`routes/slack/actions.ts:56`) with the event folded in, so neither a replay
|
|
9
|
+
* outside the receiver's 5-minute window nor an in-flight event flip verifies.
|
|
10
|
+
*
|
|
11
|
+
* `type`, `slug` and `locale` are validated against bounded Zod shapes FIRST,
|
|
12
|
+
* before the config check and before any outbound call; an invalid value
|
|
13
|
+
* throws. That is the one way this function rejects, and it is a programmer
|
|
14
|
+
* error rather than a delivery failure: the publish routes already hold these
|
|
15
|
+
* values to the same shapes.
|
|
7
16
|
*
|
|
8
17
|
* Either var absent means "revalidation off": one `warn` and return. Both set
|
|
9
18
|
* means three bounded attempts (500 ms then 2000 ms backoff, 10 s per-attempt
|
|
10
19
|
* timeout — 32.5 s worst case), every attempt logged, and a final failure
|
|
11
|
-
* LOGGED rather than thrown.
|
|
12
|
-
*
|
|
20
|
+
* LOGGED rather than thrown. Delivery never rejects, which is what lets the
|
|
21
|
+
* publish path `await` it and still always complete.
|
|
13
22
|
*
|
|
14
23
|
* PII boundary: log lines carry only
|
|
15
24
|
* `{ event, workspaceId, type, slug, locale, attempt/attempts, status/lastStatus, errName }`.
|
|
16
25
|
* Never the secret, never the signature, never a response body, never page
|
|
17
26
|
* content.
|
|
18
27
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* contract
|
|
22
|
-
* headers `content-type: application/json`,
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
28
|
+
* The publish and unpublish handlers in `routes/workspaces/content.ts` call
|
|
29
|
+
* this; the receiver is `apps/web/app/api/revalidate/route.ts`. This module
|
|
30
|
+
* fixes the wire contract that receiver implements: method POST; the four-key
|
|
31
|
+
* JSON body; headers `content-type: application/json`,
|
|
32
|
+
* `x-ailk-revalidate-timestamp: <unix seconds>`,
|
|
33
|
+
* `x-ailk-revalidate-event: publish | unpublish` and
|
|
34
|
+
* `x-ailk-revalidate-signature: sha256=<lowercase hex>`. A receiver rejects a
|
|
35
|
+
* timestamp more than 5 minutes from its own clock, then recomputes the HMAC
|
|
36
|
+
* over `v0:{timestamp}:{event}:` + the RAW request body and compares in
|
|
37
|
+
* constant time.
|
|
26
38
|
*/
|
|
27
39
|
import { createHmac } from "node:crypto";
|
|
28
40
|
|
|
41
|
+
import {
|
|
42
|
+
SCHEMA_ORG_TYPES,
|
|
43
|
+
SLUG_PATTERN,
|
|
44
|
+
} from "@working-theory/content-adapters";
|
|
29
45
|
import { createLogger } from "@working-theory/observability";
|
|
46
|
+
import { localeSchema } from "@working-theory/validation";
|
|
47
|
+
import { z } from "zod";
|
|
30
48
|
|
|
31
49
|
import { config } from "../config/index.js";
|
|
32
50
|
import { outboundFetch } from "../lib/outbound-http.js";
|
|
@@ -35,6 +53,22 @@ const log = createLogger({ service: "revalidation" });
|
|
|
35
53
|
|
|
36
54
|
const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
|
|
37
55
|
const EVENT_HEADER = "x-ailk-revalidate-event";
|
|
56
|
+
const TIMESTAMP_HEADER = "x-ailk-revalidate-timestamp";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The bounds on the three fields that become the site's cache tags
|
|
60
|
+
* (`content:{workspace}:{type}:{slug}:{locale}`). `type` and `slug` are exactly
|
|
61
|
+
* the content routes' `addressParamsSchema` shapes and `locale` is the BCP-47
|
|
62
|
+
* subset `resolveLocale` produces, so the publish path can never trip this
|
|
63
|
+
* after its write has committed. None admits a `:`, which is what stops a
|
|
64
|
+
* crafted `type` colliding with another page's tag (#6005 M3); the receiver
|
|
65
|
+
* applies the same shapes.
|
|
66
|
+
*/
|
|
67
|
+
const targetFieldsSchema = z.object({
|
|
68
|
+
type: z.enum(SCHEMA_ORG_TYPES),
|
|
69
|
+
slug: z.string().regex(SLUG_PATTERN),
|
|
70
|
+
locale: localeSchema,
|
|
71
|
+
});
|
|
38
72
|
|
|
39
73
|
/**
|
|
40
74
|
* Backoff before attempt 2 and attempt 3. Three attempts total, no fourth:
|
|
@@ -69,14 +103,21 @@ function isRetryableStatus(status: number): boolean {
|
|
|
69
103
|
}
|
|
70
104
|
|
|
71
105
|
/**
|
|
72
|
-
* Tells the site to revalidate one piece of content. Resolves
|
|
73
|
-
*
|
|
74
|
-
*
|
|
106
|
+
* Tells the site to revalidate one piece of content. Resolves on success,
|
|
107
|
+
* exhausted retries, or unconfigured, so an `await` in the publish path cannot
|
|
108
|
+
* fail the publish it follows. Rejects only when `type`, `slug` or `locale` is
|
|
109
|
+
* out of bounds, before anything is signed or sent.
|
|
75
110
|
*/
|
|
76
111
|
export async function notifyRevalidate(
|
|
77
112
|
target: RevalidateTarget,
|
|
78
113
|
options: RevalidateOptions,
|
|
79
114
|
): Promise<void> {
|
|
115
|
+
targetFieldsSchema.parse({
|
|
116
|
+
type: target.type,
|
|
117
|
+
slug: target.slug,
|
|
118
|
+
locale: target.locale,
|
|
119
|
+
});
|
|
120
|
+
|
|
80
121
|
const url = config.CONTENT_REVALIDATE_URL;
|
|
81
122
|
const secret = config.CONTENT_REVALIDATE_SECRET;
|
|
82
123
|
|
|
@@ -100,8 +141,11 @@ export async function notifyRevalidate(
|
|
|
100
141
|
slug: target.slug,
|
|
101
142
|
locale: target.locale,
|
|
102
143
|
});
|
|
144
|
+
// Taken once per call and shared by every attempt: the retry schedule's
|
|
145
|
+
// 32.5 s worst case sits well inside the receiver's 5-minute tolerance.
|
|
146
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
103
147
|
const signature = createHmac("sha256", secret)
|
|
104
|
-
.update(body
|
|
148
|
+
.update(`v0:${timestamp}:${options.event}:${body}`, "utf8")
|
|
105
149
|
.digest("hex");
|
|
106
150
|
|
|
107
151
|
let attempt = 0;
|
|
@@ -117,8 +161,9 @@ export async function notifyRevalidate(
|
|
|
117
161
|
method: "POST",
|
|
118
162
|
headers: {
|
|
119
163
|
"content-type": "application/json",
|
|
120
|
-
[
|
|
164
|
+
[TIMESTAMP_HEADER]: timestamp,
|
|
121
165
|
[EVENT_HEADER]: options.event,
|
|
166
|
+
[SIGNATURE_HEADER]: `sha256=${signature}`,
|
|
122
167
|
},
|
|
123
168
|
body,
|
|
124
169
|
// The SSRF gate is waived deliberately and narrowly: this URL comes
|
|
@@ -103,7 +103,6 @@ const MCP_ONLY_TOOLS = new Set([
|
|
|
103
103
|
"ailkGetPricing", // get_pricing — pure read, no action equivalent (I4)
|
|
104
104
|
"ailkGetFaq", // get_faq — pure read, no action equivalent (I4)
|
|
105
105
|
"ailkAudit", // audit — AEO scoring tool, no web action equivalent (I5; ADR 0013)
|
|
106
|
-
"ailkSubscribeNewsletter", // subscribe_newsletter — anonymous write via MCP; no Server Action (B7)
|
|
107
106
|
"ailkRequestCallback", // request_callback — anonymous write via MCP; no Server Action (B7)
|
|
108
107
|
"ailkCreateProject", // create_project — agent-only project creator; no Server Action equivalent (#299)
|
|
109
108
|
"ailkGetFlow", // get_flow — agent-callable flow-engine read; no web Server Action (#3956 D7)
|
|
@@ -228,6 +227,8 @@ describe("MCP ↔ Server-Action parity (filesystem)", () => {
|
|
|
228
227
|
// ailkSubmitFlowStep): twenty tools + three paired Server Actions.
|
|
229
228
|
// - ailkPurchase: paired with purchaseAction (apps/web/app/dev/purchase/).
|
|
230
229
|
// - ailkCaptureLead: paired with captureLeadAction (apps/web/app/contact/).
|
|
230
|
+
// - ailkSubscribeNewsletter: paired with subscribeNewsletterAction
|
|
231
|
+
// (apps/web/app/[locale]/newsletter/newsletter.actions.ts, #6730 D9).
|
|
231
232
|
// - ailkSubmitFlowStep: paired with submitFlowStepAction
|
|
232
233
|
// (apps/web/app/[locale]/flows/[slug]/flow.actions.ts, #3957).
|
|
233
234
|
// - startCheckoutAction, saveRecommendationAction: WEB-ONLY (#4842 D7,
|
|
@@ -241,8 +242,8 @@ describe("MCP ↔ Server-Action parity (filesystem)", () => {
|
|
|
241
242
|
// ailkGetLocations, ailkGetOfferings, ailkGetPricing, ailkGetFaq:
|
|
242
243
|
// MCP-only (pure read; I4; in MCP_ONLY_TOOLS allow-list).
|
|
243
244
|
// - ailkAudit: MCP-only (AEO scoring; I5; in MCP_ONLY_TOOLS allow-list).
|
|
244
|
-
// -
|
|
245
|
-
//
|
|
245
|
+
// - ailkRequestCallback: MCP-only anonymous-write tool (B7; in
|
|
246
|
+
// MCP_ONLY_TOOLS allow-list).
|
|
246
247
|
// - ailkCreateProject: MCP-only agent project creator (#299; in MCP_ONLY_TOOLS).
|
|
247
248
|
// - ailkCreatePage, ailkUpdatePage, ailkDeletePage: RETIRED by #5853
|
|
248
249
|
// (D7). Their HTTP twins — the untenanted /v1/content writes — were
|
|
@@ -264,7 +265,7 @@ describe("MCP ↔ Server-Action parity (filesystem)", () => {
|
|
|
264
265
|
// - ailkGetEventMeta: MCP-only meta-only scheduling read, twin of
|
|
265
266
|
// GET /v1/schedule/event-meta (#4633) — same posture as the other
|
|
266
267
|
// scheduling tools.
|
|
267
|
-
expect(actions.size).toBe(
|
|
268
|
+
expect(actions.size).toBe(6);
|
|
268
269
|
expect(tools.size).toBe(20);
|
|
269
270
|
expect(r.missingTools).toEqual([]);
|
|
270
271
|
expect(r.orphanTools).toEqual([]);
|
|
@@ -102,6 +102,30 @@ describe("scaffoldOutputSchema", () => {
|
|
|
102
102
|
// __tests__/tools → __tests__ → apps/mcp → apps → repo root
|
|
103
103
|
const REPO_ROOT = resolve(__dirname, "../../../..");
|
|
104
104
|
const CLI_PATH = join(REPO_ROOT, "packages/create-ailk/dist/cli.js");
|
|
105
|
+
// NO_HAND_IMPLEMENT_OK: trunk fix for a red main (CI run 36386534464), operator-attended session.
|
|
106
|
+
// The CLI substitutes `workspace:*` versions from create-ailk's gitignored
|
|
107
|
+
// `versions.json`, which only `build-templates` writes ("empty before the first
|
|
108
|
+
// build"); with it absent every scaffold throws "no version found for …".
|
|
109
|
+
// Turbo gives this package's test no dependency on create-ailk's build-templates
|
|
110
|
+
// step, so whether the file exists here depends on shard packing. Every case
|
|
111
|
+
// that runs the CLI therefore skips — with a warning, never a failure — until
|
|
112
|
+
// both the CLI and its version map are on disk.
|
|
113
|
+
const VERSIONS_PATH = join(REPO_ROOT, "packages/create-ailk/versions.json");
|
|
114
|
+
|
|
115
|
+
/** True (after warning) when the CLI cannot scaffold in this checkout yet. */
|
|
116
|
+
function skipUnlessCliReady(): boolean {
|
|
117
|
+
if (!existsSync(CLI_PATH)) {
|
|
118
|
+
console.warn("[create_project.test] skipping integration test — CLI not built");
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (!existsSync(VERSIONS_PATH)) {
|
|
122
|
+
console.warn(
|
|
123
|
+
"[create_project.test] skipping integration test — run `pnpm --filter create-ailk run build-templates` first",
|
|
124
|
+
);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
105
129
|
|
|
106
130
|
describe("ailkCreateProject handler", () => {
|
|
107
131
|
let scratch: string;
|
|
@@ -124,10 +148,7 @@ describe("ailkCreateProject handler", () => {
|
|
|
124
148
|
});
|
|
125
149
|
|
|
126
150
|
it("scaffolds at least one file into a fresh tmp dir and reports targetDir", async () => {
|
|
127
|
-
if (
|
|
128
|
-
console.warn("[create_project.test] skipping integration test — CLI not built");
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
151
|
+
if (skipUnlessCliReady()) return;
|
|
131
152
|
const target = join(scratch, "my-site");
|
|
132
153
|
const output = await ailkCreateProject(
|
|
133
154
|
{ targetDir: target, projectName: "my-site" },
|
|
@@ -138,10 +159,7 @@ describe("ailkCreateProject handler", () => {
|
|
|
138
159
|
});
|
|
139
160
|
|
|
140
161
|
it("scaffolds package.json when full templates are built (build-templates required)", async () => {
|
|
141
|
-
if (
|
|
142
|
-
console.warn("[create_project.test] skipping integration test — CLI not built");
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
162
|
+
if (skipUnlessCliReady()) return;
|
|
145
163
|
const TEMPLATES_DIR = join(REPO_ROOT, "packages/create-ailk/templates");
|
|
146
164
|
const PKG_JSON_IN_TEMPLATES = join(TEMPLATES_DIR, "package.json");
|
|
147
165
|
if (!existsSync(PKG_JSON_IN_TEMPLATES)) {
|
|
@@ -156,10 +174,7 @@ describe("ailkCreateProject handler", () => {
|
|
|
156
174
|
});
|
|
157
175
|
|
|
158
176
|
it("scaffolds app dir when full templates are built (build-templates required)", async () => {
|
|
159
|
-
if (
|
|
160
|
-
console.warn("[create_project.test] skipping integration test — CLI not built");
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
177
|
+
if (skipUnlessCliReady()) return;
|
|
163
178
|
const TEMPLATES_DIR = join(REPO_ROOT, "packages/create-ailk/templates");
|
|
164
179
|
const PKG_JSON_IN_TEMPLATES = join(TEMPLATES_DIR, "package.json");
|
|
165
180
|
if (!existsSync(PKG_JSON_IN_TEMPLATES)) {
|
|
@@ -178,10 +193,7 @@ describe("ailkCreateProject handler", () => {
|
|
|
178
193
|
});
|
|
179
194
|
|
|
180
195
|
it("returns at least one file (schema-typed page seed or README.md)", async () => {
|
|
181
|
-
if (
|
|
182
|
-
console.warn("[create_project.test] skipping integration test — CLI not built");
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
196
|
+
if (skipUnlessCliReady()) return;
|
|
185
197
|
const target = join(scratch, "my-site");
|
|
186
198
|
await ailkCreateProject({ targetDir: target, projectName: "my-site" }, CLI_PATH);
|
|
187
199
|
// At minimum README.md is always emitted by the template.
|
|
@@ -189,10 +201,7 @@ describe("ailkCreateProject handler", () => {
|
|
|
189
201
|
});
|
|
190
202
|
|
|
191
203
|
it("returns output conforming to scaffoldOutputSchema", async () => {
|
|
192
|
-
if (
|
|
193
|
-
console.warn("[create_project.test] skipping integration test — CLI not built");
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
204
|
+
if (skipUnlessCliReady()) return;
|
|
196
205
|
const target = join(scratch, "my-site");
|
|
197
206
|
const output = await ailkCreateProject(
|
|
198
207
|
{ targetDir: target, projectName: "my-site" },
|
|
@@ -203,10 +212,7 @@ describe("ailkCreateProject handler", () => {
|
|
|
203
212
|
});
|
|
204
213
|
|
|
205
214
|
it("rejects with a clear error when targetDir is non-empty", async () => {
|
|
206
|
-
if (
|
|
207
|
-
console.warn("[create_project.test] skipping integration test — CLI not built");
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
215
|
+
if (skipUnlessCliReady()) return;
|
|
210
216
|
const target = join(scratch, "populated");
|
|
211
217
|
mkdirSync(target);
|
|
212
218
|
writeFileSync(join(target, "existing.txt"), "data");
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* correctly in production and in integration tests.
|
|
17
17
|
*
|
|
18
18
|
* Input: { targetDir, projectName, preset? }
|
|
19
|
+
* The tool builds the Free organization-product starter (`--shape
|
|
20
|
+
* organization-product`, #6754 D9); `preset` stays reserved. Always builds
|
|
21
|
+
* the Free module set (`--modules marketing`, #6759 D6, SEC-7): a licence
|
|
22
|
+
* key sitting in the MCP server's own environment never widens what an
|
|
23
|
+
* anonymous caller receives — the explicit keep list takes precedence over
|
|
24
|
+
* any key-derived level probe before the child process reads its env.
|
|
19
25
|
* targetDir — absolute or relative path to scaffold into; must not exist
|
|
20
26
|
* or must be empty (enforced by create-ailk ValidationError)
|
|
21
27
|
* projectName — project name used in README and package.json
|
|
@@ -94,12 +100,21 @@ export async function ailkCreateProject(
|
|
|
94
100
|
const targetAbs = resolve(input.targetDir);
|
|
95
101
|
|
|
96
102
|
// Spawn the CLI synchronously — no network, no async I/O needed.
|
|
97
|
-
// Hard-coded node invocation; only targetDir flows through user input.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
// Hard-coded node invocation; only targetDir flows through user input. The
|
|
104
|
+
// CLI refuses a run naming no shape and no template, so the tool names one.
|
|
105
|
+
// `--modules marketing` is pinned explicitly (#6759 D6, SEC-7): an explicit
|
|
106
|
+
// keep list takes the keep branch and resolves before the child ever reads
|
|
107
|
+
// AILK_LICENSE_KEY, so a licence key in the server's own environment can
|
|
108
|
+
// never turn this anonymous call into the keyed level probe.
|
|
109
|
+
const result = spawnSync(
|
|
110
|
+
"node",
|
|
111
|
+
[bin, targetAbs, "--shape", "organization-product", "--modules", "marketing"],
|
|
112
|
+
{
|
|
113
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
114
|
+
shell: false,
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
},
|
|
117
|
+
);
|
|
103
118
|
|
|
104
119
|
if (result.status !== 0) {
|
|
105
120
|
// Extract the error message from stderr (CLI writes "[create-ailk] <msg>").
|
|
@@ -41,8 +41,10 @@
|
|
|
41
41
|
* simply not part of that shape. Either path is safe: the payload built
|
|
42
42
|
* below can never carry a caller-supplied redirect. The shared
|
|
43
43
|
* `purchaseInputSchema` (and the Server Action / API route that use it) is
|
|
44
|
-
* unaffected:
|
|
45
|
-
*
|
|
44
|
+
* unaffected: both keep accepting a caller-supplied redirect. The route's own
|
|
45
|
+
* anonymous exposure is gated only when `OSS_API_INTERNAL_SECRET` is set
|
|
46
|
+
* (`passesInternalAuthGate()` in `apps/api/src/routes/checkout/sessions.ts`);
|
|
47
|
+
* unset — the OSS standalone default — the route accepts anonymous callers.
|
|
46
48
|
*
|
|
47
49
|
* The file stays in the BASE tree and is never added to
|
|
48
50
|
* `MODULE_SLICES.commerce.ownedFiles`: `../server.js` statically imports
|
|
@@ -52,6 +52,19 @@ CONTENT_ADAPTER=mdx
|
|
|
52
52
|
# browser never sees.
|
|
53
53
|
# NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
|
|
54
54
|
|
|
55
|
+
# =============================================================================
|
|
56
|
+
# apps/api internal-auth secret (server-side only) — #6876
|
|
57
|
+
# =============================================================================
|
|
58
|
+
# SERVER-SIDE ONLY — never rename it with a NEXT_PUBLIC_ prefix. The /dev/purchase
|
|
59
|
+
# Server Action sends it as X-Internal-Auth on POST /v1/checkout/sessions,
|
|
60
|
+
# alongside the buyer's session cookie, so the route can stamp a signed-in
|
|
61
|
+
# buyer's Checkout. Must equal OSS_API_INTERNAL_SECRET in apps/api's
|
|
62
|
+
# environment. Unset on both sides for OSS standalone deployments: the API
|
|
63
|
+
# applies no gate and this app sends no header. Set on the API but not here,
|
|
64
|
+
# and the purchase fails with the route's 401.
|
|
65
|
+
# Generate with: openssl rand -hex 32 (see apps/api/.env.example)
|
|
66
|
+
# OSS_API_INTERNAL_SECRET=your-shared-secret-here
|
|
67
|
+
|
|
55
68
|
# =============================================================================
|
|
56
69
|
# Better Auth (apps/web)
|
|
57
70
|
# =============================================================================
|
|
@@ -43,7 +43,7 @@ Raw HTML is allowed only when no `@working-theory/ui` component fits. Flag it in
|
|
|
43
43
|
- Animations: only `transform` and `opacity`. Never `transition-all`.
|
|
44
44
|
- Every interactive element needs hover, focus-visible, and active states.
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
The four widths above are the responsive contract. Brand reference files (logos, photos, colour and type notes) live in `brand_assets/` at the repo root; the site's own brand colours and fonts are set in `content/_site.mdx` under `brand.colors` and `brand.fonts`.
|
|
47
47
|
|
|
48
48
|
## Local dev
|
|
49
49
|
|
|
@@ -110,33 +110,27 @@ function — the page under test throws instead of taking a branch. Declare
|
|
|
110
110
|
every predicate the pages call, binding the two reset ones to the same
|
|
111
111
|
`jest.fn()` when the case does not need them to disagree.
|
|
112
112
|
|
|
113
|
-
##
|
|
114
|
-
|
|
115
|
-
The contact page (`apps/web/app/[locale]/contact
|
|
116
|
-
`
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`
|
|
128
|
-
`
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
Positionary consumer pattern that DID swap the whole page was corrected
|
|
135
|
-
under #4577 (decision D71 — the intake belongs behind the hero CTA's
|
|
136
|
-
lightbox gate; landed in PR #4583 via `packages/ui/src/sections/FormLightbox.tsx`
|
|
137
|
-
and `HeroIntakeContext.tsx`, spec `docs/specs/4577-intake-lightbox-correction-spec.md`).
|
|
138
|
-
Don't take a whole-page mount as the pattern to replicate; the lightbox-gate
|
|
139
|
-
seam is the canonical embedding shape.
|
|
113
|
+
## Contact page
|
|
114
|
+
|
|
115
|
+
The contact page (`apps/web/app/[locale]/contact/page.tsx`) renders the
|
|
116
|
+
page's sections through `renderPage` and places ONE contact surface: into the
|
|
117
|
+
page's first `contact-form` section, else into a leading split hero's right
|
|
118
|
+
column, else after the page. That surface is the shared `LeadCaptureForm`
|
|
119
|
+
block from `@working-theory/ui`, bound to `captureLeadAction` — unless the
|
|
120
|
+
API reports this deployment accepts no contact message (production with
|
|
121
|
+
neither a database nor an email key), in which case it is `ContactFallback`:
|
|
122
|
+
the site's contact email, never a form that would report a message as sent
|
|
123
|
+
when it reached nobody. A failed status read counts as accepting, so the
|
|
124
|
+
form renders and its submit reports honestly.
|
|
125
|
+
|
|
126
|
+
The chat-style intake is not mounted by default.
|
|
127
|
+
`ConversationalIntakeWrapper.tsx` remains for a site that prefers it: it hosts
|
|
128
|
+
`@working-theory/ui`'s `ConversationalIntake` block (contract in
|
|
129
|
+
`packages/ui/src/blocks/conversational-intake/README.md`), submits through the
|
|
130
|
+
same `captureLeadAction`, and on Skip or a failed submit swaps to
|
|
131
|
+
`LeadCaptureForm`, prefilled with what was already captured. The canonical
|
|
132
|
+
embedding for it is behind the hero CTA's lightbox gate
|
|
133
|
+
(`packages/ui/src/sections/FormLightbox.tsx`, #4577), not a whole-page mount.
|
|
140
134
|
|
|
141
135
|
## AEO & performance
|
|
142
136
|
|
|
@@ -24,7 +24,7 @@ import { headers } from "next/headers";
|
|
|
24
24
|
import { getTranslations } from "next-intl/server";
|
|
25
25
|
import type { ReactElement, ReactNode } from "react";
|
|
26
26
|
|
|
27
|
-
import { siteLogo } from "../../../lib/site-
|
|
27
|
+
import { siteLogo } from "../../../lib/site-logo";
|
|
28
28
|
|
|
29
29
|
interface Props {
|
|
30
30
|
children: ReactNode;
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* 4. Renders an EmptyState — not a headerless table — for an empty page.
|
|
11
11
|
* 5. Renders ForbiddenState on a 401/403; falls back to the first page
|
|
12
12
|
* when a supplied `?cursor=` 400s; rethrows every other error.
|
|
13
|
+
* 6. With no session, renders ForbiddenState and makes no apps/api request
|
|
14
|
+
* — the (authed) layout's gate renders concurrently, so the page must
|
|
15
|
+
* not spend (or fail) an API call on a visitor it will never serve
|
|
16
|
+
* (#6865).
|
|
13
17
|
*/
|
|
14
18
|
import { render, screen } from "@testing-library/react";
|
|
15
19
|
|
|
@@ -26,6 +30,13 @@ jest.mock("next/headers", () => ({
|
|
|
26
30
|
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
27
31
|
}));
|
|
28
32
|
|
|
33
|
+
const mockGetSession = jest.fn();
|
|
34
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test can mock without standing up Prisma.
|
|
35
|
+
jest.mock("../../../../../auth", () => ({
|
|
36
|
+
__esModule: true,
|
|
37
|
+
auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
|
|
38
|
+
}));
|
|
39
|
+
|
|
29
40
|
const mockList = jest.fn();
|
|
30
41
|
jest.mock("@working-theory/api-client", () => ({
|
|
31
42
|
__esModule: true,
|
|
@@ -64,6 +75,19 @@ function renderPage(cursor?: string) {
|
|
|
64
75
|
describe("LeadsInboxPage", () => {
|
|
65
76
|
beforeEach(() => {
|
|
66
77
|
mockList.mockReset();
|
|
78
|
+
mockGetSession.mockReset();
|
|
79
|
+
mockGetSession.mockResolvedValue({ user: { id: "user_1" } });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// #6865: the (authed) layout renders SignInGate to an anonymous visitor,
|
|
83
|
+
// but Next renders this page concurrently — it must not call apps/api.
|
|
84
|
+
it("makes no apps/api request and renders ForbiddenState with no session", async () => {
|
|
85
|
+
mockGetSession.mockResolvedValue(null);
|
|
86
|
+
render(await renderPage("lead_9"));
|
|
87
|
+
|
|
88
|
+
expect(mockList).not.toHaveBeenCalled();
|
|
89
|
+
expect(screen.getByTestId("waitlist-forbidden")).toBeInTheDocument();
|
|
90
|
+
expect(screen.queryByTestId("leads-inbox-page")).not.toBeInTheDocument();
|
|
67
91
|
});
|
|
68
92
|
|
|
69
93
|
it("renders one row per lead, with email and source", async () => {
|
|
@@ -12,15 +12,24 @@
|
|
|
12
12
|
* waitlist comparison page uses); every other error (a 400 on a malformed
|
|
13
13
|
* cursor, a 500) rethrows to the route's error boundary rather than being
|
|
14
14
|
* swallowed.
|
|
15
|
+
*
|
|
16
|
+
* The page reads the session itself before any API call (#6865): Next renders
|
|
17
|
+
* it concurrently with the (authed) layout, so for an anonymous visitor the
|
|
18
|
+
* layout's `SignInGate` wins but this page would still have called
|
|
19
|
+
* `leads.list()` — spending (or, with no API reachable, failing) a request
|
|
20
|
+
* for a result nobody sees. No session renders `ForbiddenState` with no
|
|
21
|
+
* apps/api request, the same guard `../account/page.tsx` carries.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
import { leads, type LeadInboxPage } from "@working-theory/api-client";
|
|
18
25
|
import { PageHeader } from "@working-theory/ui/blocks";
|
|
19
26
|
import { Container, Heading, Text } from "@working-theory/ui/primitives";
|
|
20
27
|
import type { Metadata } from "next";
|
|
28
|
+
import { headers } from "next/headers";
|
|
21
29
|
import { getTranslations } from "next-intl/server";
|
|
22
30
|
import type { ReactElement } from "react";
|
|
23
31
|
|
|
32
|
+
import { auth } from "../../../../auth";
|
|
24
33
|
import { ApiError, forwardedCookieInit, unwrapApiResult } from "../../../../lib/server-api";
|
|
25
34
|
import { ForbiddenState } from "../waitlist/components/ForbiddenState";
|
|
26
35
|
import { formatDateTime } from "../waitlist/format";
|
|
@@ -46,6 +55,13 @@ export default async function LeadsInboxPage({
|
|
|
46
55
|
const { cursor } = await searchParams;
|
|
47
56
|
const t = await getTranslations({ locale, namespace: "leadsInbox" });
|
|
48
57
|
|
|
58
|
+
const forbidden = (
|
|
59
|
+
<ForbiddenState headline={t("forbiddenHeadline")} description={t("forbiddenDescription")} />
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
63
|
+
if (!session) return forbidden;
|
|
64
|
+
|
|
49
65
|
const init = await forwardedCookieInit();
|
|
50
66
|
|
|
51
67
|
let page: LeadInboxPage;
|
|
@@ -53,12 +69,7 @@ export default async function LeadsInboxPage({
|
|
|
53
69
|
page = await unwrapApiResult(leads.list(cursor ? { cursor } : {}, init));
|
|
54
70
|
} catch (err) {
|
|
55
71
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
56
|
-
return
|
|
57
|
-
<ForbiddenState
|
|
58
|
-
headline={t("forbiddenHeadline")}
|
|
59
|
-
description={t("forbiddenDescription")}
|
|
60
|
-
/>
|
|
61
|
-
);
|
|
72
|
+
return forbidden;
|
|
62
73
|
}
|
|
63
74
|
// A 400 means the `?cursor=` in the URL did not resolve — it was typed,
|
|
64
75
|
// or it is a stale next-page link whose row has since gone. That is a bad
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `[slug]/[detail]` route — the Free `/meet/<slug>` placeholder
|
|
3
|
+
* detail pages (#6897 D2).
|
|
4
|
+
*
|
|
5
|
+
* - `slug !== "meet"` → notFound() immediately, never reaching getMdxSource.
|
|
6
|
+
* - `slug === "meet"` and a BookCall source exists at `detail` → renders
|
|
7
|
+
* through renderPage sections mode, no notFound() call.
|
|
8
|
+
* - `slug === "meet"` and no BookCall source exists at `detail` → renderPage's
|
|
9
|
+
* own detail-route branch (no translationsNamespace passed) calls
|
|
10
|
+
* notFound().
|
|
11
|
+
*/
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { render } from "@testing-library/react";
|
|
14
|
+
|
|
15
|
+
jest.mock("next/navigation", () => ({
|
|
16
|
+
__esModule: true,
|
|
17
|
+
notFound: jest.fn(() => {
|
|
18
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
jest.mock("@working-theory/ui", () => ({
|
|
23
|
+
Heading: ({
|
|
24
|
+
children,
|
|
25
|
+
level,
|
|
26
|
+
}: {
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
level: number;
|
|
29
|
+
}): React.ReactElement => React.createElement(`h${level}`, null, children),
|
|
30
|
+
SectionRenderer: ({ sections }: { sections: unknown[] }) =>
|
|
31
|
+
React.createElement("div", {
|
|
32
|
+
"data-testid": "section-renderer",
|
|
33
|
+
"data-section-count": String(sections.length),
|
|
34
|
+
}),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import { notFound } from "next/navigation";
|
|
38
|
+
|
|
39
|
+
import DetailPage from "../page";
|
|
40
|
+
|
|
41
|
+
const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
|
|
42
|
+
|
|
43
|
+
function makeSource(slug: string) {
|
|
44
|
+
return {
|
|
45
|
+
frontmatter: {
|
|
46
|
+
type: "BookCall" as const,
|
|
47
|
+
slug,
|
|
48
|
+
title: `Title for ${slug}`,
|
|
49
|
+
description: `Description for ${slug}`,
|
|
50
|
+
sections: [
|
|
51
|
+
{
|
|
52
|
+
id: "hero",
|
|
53
|
+
type: "hero" as const,
|
|
54
|
+
data: { headline: `Title for ${slug}` },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
body: "",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("[slug]/[detail] — the Free /meet/<slug> placeholder route (#6897 D2)", () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
mockNotFound.mockClear();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("renders a BookCall seed at meet/<detail> through renderPage sections mode", async () => {
|
|
68
|
+
const getMdxSource = jest.fn().mockResolvedValue(makeSource("book-a-call"));
|
|
69
|
+
|
|
70
|
+
const element = await DetailPage({
|
|
71
|
+
params: Promise.resolve({ locale: "en", slug: "meet", detail: "book-a-call" }),
|
|
72
|
+
getMdxSource,
|
|
73
|
+
});
|
|
74
|
+
const { container } = render(element);
|
|
75
|
+
|
|
76
|
+
expect(getMdxSource).toHaveBeenCalledWith("BookCall", "book-a-call", "en");
|
|
77
|
+
expect(
|
|
78
|
+
container.querySelector("[data-testid='section-renderer']"),
|
|
79
|
+
).not.toBeNull();
|
|
80
|
+
expect(
|
|
81
|
+
container.querySelector("script[type='application/ld+json']"),
|
|
82
|
+
).not.toBeNull();
|
|
83
|
+
expect(mockNotFound).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("404s for another hub — the guard fires before getMdxSource is ever called", async () => {
|
|
87
|
+
const getMdxSource = jest.fn().mockResolvedValue(makeSource("book-a-call"));
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
DetailPage({
|
|
91
|
+
params: Promise.resolve({ locale: "en", slug: "not-meet", detail: "book-a-call" }),
|
|
92
|
+
getMdxSource,
|
|
93
|
+
}),
|
|
94
|
+
).rejects.toThrow("NEXT_NOT_FOUND");
|
|
95
|
+
expect(mockNotFound).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(getMdxSource).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("404s for an unknown detail slug under the meet hub", async () => {
|
|
100
|
+
const getMdxSource = jest.fn().mockResolvedValue(null);
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
DetailPage({
|
|
104
|
+
params: Promise.resolve({ locale: "en", slug: "meet", detail: "not-a-page" }),
|
|
105
|
+
getMdxSource,
|
|
106
|
+
}),
|
|
107
|
+
).rejects.toThrow("NEXT_NOT_FOUND");
|
|
108
|
+
expect(mockNotFound).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
});
|