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
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
* - AC-2 — the 503 → 401 → 400 ladder (NFR2), each rung asserting that
|
|
16
16
|
* revalidateTag was not called, and the unset-secret rung asserting
|
|
17
17
|
* exactly one log line.
|
|
18
|
+
* - #5902 — the signed material is `v0:{timestamp}:{event}:{rawBody}`; a
|
|
19
|
+
* timestamp outside the 5-minute tolerance, a missing or flipped event,
|
|
20
|
+
* and a pre-#5902 body-only signature are all 401; a type, slug or locale
|
|
21
|
+
* outside its bounded shape is 400.
|
|
18
22
|
* - AC-3 — the AC-WCE-02 publish-to-live harness, env-gated and SKIPPED by
|
|
19
23
|
* default (D12). It needs a live CONTENT_ADAPTER=database deployment; see
|
|
20
24
|
* the block at the bottom of this file.
|
|
@@ -60,6 +64,12 @@ const mockRevalidateTag = revalidateTag as jest.Mock;
|
|
|
60
64
|
const SECRET = "test-secret-the-right-one";
|
|
61
65
|
const WRONG_SECRET = "test-secret-the-other-one";
|
|
62
66
|
|
|
67
|
+
const TOLERANCE_SECONDS = 5 * 60;
|
|
68
|
+
|
|
69
|
+
function nowSeconds(): number {
|
|
70
|
+
return Math.floor(Date.now() / 1000);
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
const VALID_BODY = {
|
|
64
74
|
workspaceId: "ws_fixture",
|
|
65
75
|
type: "Service",
|
|
@@ -69,22 +79,32 @@ const VALID_BODY = {
|
|
|
69
79
|
|
|
70
80
|
/**
|
|
71
81
|
* The fake signer — AC-1's "proved by test against a fake signer". It is the
|
|
72
|
-
* #5851 wire contract written out here independently:
|
|
73
|
-
*
|
|
82
|
+
* #5851 wire contract as amended by #5902, written out here independently:
|
|
83
|
+
* HMAC-SHA256 over `v0:{timestamp}:{event}:{rawBody}`, lowercase hex,
|
|
84
|
+
* `sha256=` prefix.
|
|
74
85
|
*/
|
|
75
|
-
function sign(
|
|
76
|
-
|
|
86
|
+
function sign(
|
|
87
|
+
rawBody: string,
|
|
88
|
+
secret: string,
|
|
89
|
+
timestamp: string,
|
|
90
|
+
event = "publish",
|
|
91
|
+
): string {
|
|
92
|
+
return `sha256=${createHmac("sha256", secret).update(`v0:${timestamp}:${event}:${rawBody}`, "utf8").digest("hex")}`;
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
function makeRequest(opts: {
|
|
80
96
|
rawBody: string;
|
|
81
97
|
signature?: string;
|
|
82
|
-
event?: string;
|
|
98
|
+
event?: string | undefined;
|
|
99
|
+
timestamp?: string | undefined;
|
|
83
100
|
requestId?: string;
|
|
84
101
|
}): Request {
|
|
85
102
|
const headers: Record<string, string> = {
|
|
86
103
|
"content-type": "application/json",
|
|
87
104
|
};
|
|
105
|
+
if (opts.timestamp !== undefined) {
|
|
106
|
+
headers["x-ailk-revalidate-timestamp"] = opts.timestamp;
|
|
107
|
+
}
|
|
88
108
|
if (opts.signature !== undefined) {
|
|
89
109
|
headers["x-ailk-revalidate-signature"] = opts.signature;
|
|
90
110
|
}
|
|
@@ -101,14 +121,31 @@ function makeRequest(opts: {
|
|
|
101
121
|
});
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
/** A correctly-signed POST of `body` under `secret
|
|
124
|
+
/** A correctly-signed POST of `body` under `secret`, timestamped now. */
|
|
105
125
|
function signedRequest(
|
|
106
126
|
body: unknown,
|
|
107
127
|
secret: string,
|
|
108
128
|
event = "publish",
|
|
129
|
+
timestamp = String(nowSeconds()),
|
|
109
130
|
): Request {
|
|
110
131
|
const rawBody = JSON.stringify(body);
|
|
111
|
-
return makeRequest({
|
|
132
|
+
return makeRequest({
|
|
133
|
+
rawBody,
|
|
134
|
+
signature: sign(rawBody, secret, timestamp, event),
|
|
135
|
+
event,
|
|
136
|
+
timestamp,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A request signed over `rawBody` exactly — for bodies JSON.stringify would not produce. */
|
|
141
|
+
function signedRaw(rawBody: string, secret = SECRET): Request {
|
|
142
|
+
const timestamp = String(nowSeconds());
|
|
143
|
+
return makeRequest({
|
|
144
|
+
rawBody,
|
|
145
|
+
signature: sign(rawBody, secret, timestamp),
|
|
146
|
+
event: "publish",
|
|
147
|
+
timestamp,
|
|
148
|
+
});
|
|
112
149
|
}
|
|
113
150
|
|
|
114
151
|
describe("POST /api/revalidate", () => {
|
|
@@ -162,7 +199,7 @@ describe("POST /api/revalidate", () => {
|
|
|
162
199
|
it("purges tags built from the body's own type/slug/locale", async () => {
|
|
163
200
|
const body = {
|
|
164
201
|
workspaceId: "ws_other",
|
|
165
|
-
type: "
|
|
202
|
+
type: "BlogPosting",
|
|
166
203
|
slug: "another-slug",
|
|
167
204
|
locale: "fr",
|
|
168
205
|
};
|
|
@@ -170,11 +207,15 @@ describe("POST /api/revalidate", () => {
|
|
|
170
207
|
await POST(signedRequest(body, SECRET));
|
|
171
208
|
|
|
172
209
|
expect(mockRevalidateTag).toHaveBeenCalledWith(
|
|
173
|
-
contentPageTag({
|
|
210
|
+
contentPageTag({
|
|
211
|
+
type: "BlogPosting",
|
|
212
|
+
slug: "another-slug",
|
|
213
|
+
locale: "fr",
|
|
214
|
+
}),
|
|
174
215
|
{ expire: 0 },
|
|
175
216
|
);
|
|
176
217
|
expect(mockRevalidateTag).toHaveBeenCalledWith(
|
|
177
|
-
contentListTag({ type: "
|
|
218
|
+
contentListTag({ type: "BlogPosting" }),
|
|
178
219
|
{ expire: 0 },
|
|
179
220
|
);
|
|
180
221
|
});
|
|
@@ -193,18 +234,6 @@ describe("POST /api/revalidate", () => {
|
|
|
193
234
|
expect(mockRevalidateTag.mock.calls).toEqual(publishCalls);
|
|
194
235
|
});
|
|
195
236
|
|
|
196
|
-
it("accepts a request with no event header, logging it as unknown (FR7)", async () => {
|
|
197
|
-
const rawBody = JSON.stringify(VALID_BODY);
|
|
198
|
-
const response = await POST(
|
|
199
|
-
makeRequest({ rawBody, signature: sign(rawBody, SECRET) }),
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
expect(response.status).toBe(200);
|
|
203
|
-
expect(mockRevalidateTag).toHaveBeenCalledTimes(2);
|
|
204
|
-
expect(console.info).toHaveBeenCalledTimes(1);
|
|
205
|
-
expect((console.info as jest.Mock).mock.calls[0][0]).toContain("unknown");
|
|
206
|
-
});
|
|
207
|
-
|
|
208
237
|
it("is idempotent under retry — a second identical delivery purges the same tags (NFR3)", async () => {
|
|
209
238
|
await POST(signedRequest(VALID_BODY, SECRET));
|
|
210
239
|
const firstCalls = mockRevalidateTag.mock.calls;
|
|
@@ -218,11 +247,13 @@ describe("POST /api/revalidate", () => {
|
|
|
218
247
|
|
|
219
248
|
it("logs one line prefixed [revalidate] carrying the correlation id (FR8, D8)", async () => {
|
|
220
249
|
const rawBody = JSON.stringify(VALID_BODY);
|
|
250
|
+
const timestamp = String(nowSeconds());
|
|
221
251
|
await POST(
|
|
222
252
|
makeRequest({
|
|
223
253
|
rawBody,
|
|
224
|
-
signature: sign(rawBody, SECRET),
|
|
254
|
+
signature: sign(rawBody, SECRET, timestamp),
|
|
225
255
|
event: "publish",
|
|
256
|
+
timestamp,
|
|
226
257
|
requestId: "req-abc-123",
|
|
227
258
|
}),
|
|
228
259
|
);
|
|
@@ -235,9 +266,12 @@ describe("POST /api/revalidate", () => {
|
|
|
235
266
|
|
|
236
267
|
it("never logs the secret or the signature header value (FR9)", async () => {
|
|
237
268
|
const rawBody = JSON.stringify(VALID_BODY);
|
|
238
|
-
const
|
|
269
|
+
const timestamp = String(nowSeconds());
|
|
270
|
+
const signature = sign(rawBody, SECRET, timestamp);
|
|
239
271
|
|
|
240
|
-
await POST(
|
|
272
|
+
await POST(
|
|
273
|
+
makeRequest({ rawBody, signature, event: "publish", timestamp }),
|
|
274
|
+
);
|
|
241
275
|
|
|
242
276
|
const logged = [
|
|
243
277
|
...(console.info as jest.Mock).mock.calls,
|
|
@@ -396,6 +430,7 @@ describe("POST /api/revalidate", () => {
|
|
|
396
430
|
makeRequest({
|
|
397
431
|
rawBody: JSON.stringify(VALID_BODY),
|
|
398
432
|
event: "publish",
|
|
433
|
+
timestamp: String(nowSeconds()),
|
|
399
434
|
}),
|
|
400
435
|
);
|
|
401
436
|
|
|
@@ -408,18 +443,24 @@ describe("POST /api/revalidate", () => {
|
|
|
408
443
|
|
|
409
444
|
it("returns 401 when the signature header is malformed", async () => {
|
|
410
445
|
const rawBody = JSON.stringify(VALID_BODY);
|
|
446
|
+
const timestamp = String(nowSeconds());
|
|
411
447
|
|
|
412
448
|
for (const malformed of [
|
|
413
449
|
"not-a-signature",
|
|
414
450
|
"sha256=short",
|
|
415
451
|
`sha1=${"a".repeat(64)}`,
|
|
416
452
|
`sha256=${"A".repeat(64)}`, // uppercase hex is not the wire encoding
|
|
417
|
-
sign(rawBody, SECRET).slice("sha256=".length), // prefix stripped
|
|
453
|
+
sign(rawBody, SECRET, timestamp).slice("sha256=".length), // prefix stripped
|
|
418
454
|
]) {
|
|
419
455
|
mockRevalidateTag.mockClear();
|
|
420
456
|
|
|
421
457
|
const response = await POST(
|
|
422
|
-
makeRequest({
|
|
458
|
+
makeRequest({
|
|
459
|
+
rawBody,
|
|
460
|
+
signature: malformed,
|
|
461
|
+
event: "publish",
|
|
462
|
+
timestamp,
|
|
463
|
+
}),
|
|
423
464
|
);
|
|
424
465
|
|
|
425
466
|
expect(response.status).toBe(401);
|
|
@@ -437,12 +478,14 @@ describe("POST /api/revalidate", () => {
|
|
|
437
478
|
it("returns 401 when the body was altered after signing", async () => {
|
|
438
479
|
const signedOver = JSON.stringify(VALID_BODY);
|
|
439
480
|
const delivered = JSON.stringify({ ...VALID_BODY, slug: "tampered" });
|
|
481
|
+
const timestamp = String(nowSeconds());
|
|
440
482
|
|
|
441
483
|
const response = await POST(
|
|
442
484
|
makeRequest({
|
|
443
485
|
rawBody: delivered,
|
|
444
|
-
signature: sign(signedOver, SECRET),
|
|
486
|
+
signature: sign(signedOver, SECRET, timestamp),
|
|
445
487
|
event: "publish",
|
|
488
|
+
timestamp,
|
|
446
489
|
}),
|
|
447
490
|
);
|
|
448
491
|
|
|
@@ -461,13 +504,7 @@ describe("POST /api/revalidate", () => {
|
|
|
461
504
|
]) {
|
|
462
505
|
mockRevalidateTag.mockClear();
|
|
463
506
|
|
|
464
|
-
const response = await POST(
|
|
465
|
-
makeRequest({
|
|
466
|
-
rawBody,
|
|
467
|
-
signature: sign(rawBody, SECRET),
|
|
468
|
-
event: "publish",
|
|
469
|
-
}),
|
|
470
|
-
);
|
|
507
|
+
const response = await POST(signedRaw(rawBody));
|
|
471
508
|
|
|
472
509
|
expect(response.status).toBe(400);
|
|
473
510
|
await expect(response.json()).resolves.toEqual({
|
|
@@ -478,15 +515,7 @@ describe("POST /api/revalidate", () => {
|
|
|
478
515
|
});
|
|
479
516
|
|
|
480
517
|
it("prefers 401 over 400 — a wrong-key request with a malformed body is 401, never 400 (NFR2)", async () => {
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
const response = await POST(
|
|
484
|
-
makeRequest({
|
|
485
|
-
rawBody,
|
|
486
|
-
signature: sign(rawBody, WRONG_SECRET),
|
|
487
|
-
event: "publish",
|
|
488
|
-
}),
|
|
489
|
-
);
|
|
518
|
+
const response = await POST(signedRaw("not json at all", WRONG_SECRET));
|
|
490
519
|
|
|
491
520
|
expect(response.status).toBe(401);
|
|
492
521
|
await expect(response.json()).resolves.toEqual({
|
|
@@ -506,6 +535,169 @@ describe("POST /api/revalidate", () => {
|
|
|
506
535
|
expect((await POST(signedRequest(VALID_BODY, SECRET))).status).toBe(200);
|
|
507
536
|
});
|
|
508
537
|
});
|
|
538
|
+
|
|
539
|
+
// --- #5902: replay window, signed event, bounded fields ------------------
|
|
540
|
+
|
|
541
|
+
describe("#5902 — the timestamp and event are signed, and the fields are bounded", () => {
|
|
542
|
+
it("accepts a timestamp just inside the 5-minute tolerance, either side", async () => {
|
|
543
|
+
for (const offset of [-(TOLERANCE_SECONDS - 5), TOLERANCE_SECONDS - 5]) {
|
|
544
|
+
const response = await POST(
|
|
545
|
+
signedRequest(
|
|
546
|
+
VALID_BODY,
|
|
547
|
+
SECRET,
|
|
548
|
+
"publish",
|
|
549
|
+
String(nowSeconds() + offset),
|
|
550
|
+
),
|
|
551
|
+
);
|
|
552
|
+
expect(response.status).toBe(200);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("rejects a correctly-signed replay older than the tolerance with 401", async () => {
|
|
557
|
+
const stale = String(nowSeconds() - TOLERANCE_SECONDS - 1);
|
|
558
|
+
|
|
559
|
+
const response = await POST(
|
|
560
|
+
signedRequest(VALID_BODY, SECRET, "publish", stale),
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
expect(response.status).toBe(401);
|
|
564
|
+
await expect(response.json()).resolves.toEqual({
|
|
565
|
+
error: "invalid_signature",
|
|
566
|
+
});
|
|
567
|
+
expect(mockRevalidateTag).not.toHaveBeenCalled();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("rejects a correctly-signed timestamp too far in the future with 401", async () => {
|
|
571
|
+
const future = String(nowSeconds() + TOLERANCE_SECONDS + 1);
|
|
572
|
+
|
|
573
|
+
const response = await POST(
|
|
574
|
+
signedRequest(VALID_BODY, SECRET, "publish", future),
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
expect(response.status).toBe(401);
|
|
578
|
+
expect(mockRevalidateTag).not.toHaveBeenCalled();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("rejects a missing or non-numeric timestamp header with 401", async () => {
|
|
582
|
+
const rawBody = JSON.stringify(VALID_BODY);
|
|
583
|
+
|
|
584
|
+
for (const timestamp of [undefined, "", "abc", "1e9", "-1", "12.5"]) {
|
|
585
|
+
mockRevalidateTag.mockClear();
|
|
586
|
+
|
|
587
|
+
const response = await POST(
|
|
588
|
+
makeRequest({
|
|
589
|
+
rawBody,
|
|
590
|
+
signature: sign(rawBody, SECRET, timestamp ?? ""),
|
|
591
|
+
event: "publish",
|
|
592
|
+
timestamp,
|
|
593
|
+
}),
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
expect(response.status).toBe(401);
|
|
597
|
+
expect(mockRevalidateTag).not.toHaveBeenCalled();
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("rejects a timestamp altered after signing with 401", async () => {
|
|
602
|
+
const rawBody = JSON.stringify(VALID_BODY);
|
|
603
|
+
const signedAt = String(nowSeconds() - 60);
|
|
604
|
+
|
|
605
|
+
const response = await POST(
|
|
606
|
+
makeRequest({
|
|
607
|
+
rawBody,
|
|
608
|
+
signature: sign(rawBody, SECRET, signedAt),
|
|
609
|
+
event: "publish",
|
|
610
|
+
timestamp: String(nowSeconds()),
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
expect(response.status).toBe(401);
|
|
615
|
+
expect(mockRevalidateTag).not.toHaveBeenCalled();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("rejects an event header flipped after signing with 401", async () => {
|
|
619
|
+
const rawBody = JSON.stringify(VALID_BODY);
|
|
620
|
+
const timestamp = String(nowSeconds());
|
|
621
|
+
|
|
622
|
+
const response = await POST(
|
|
623
|
+
makeRequest({
|
|
624
|
+
rawBody,
|
|
625
|
+
signature: sign(rawBody, SECRET, timestamp, "publish"),
|
|
626
|
+
event: "unpublish",
|
|
627
|
+
timestamp,
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
expect(response.status).toBe(401);
|
|
632
|
+
expect(mockRevalidateTag).not.toHaveBeenCalled();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("rejects a missing or unknown event header with 401, logging it as unknown", async () => {
|
|
636
|
+
const rawBody = JSON.stringify(VALID_BODY);
|
|
637
|
+
const timestamp = String(nowSeconds());
|
|
638
|
+
|
|
639
|
+
for (const event of [undefined, "", "delete"]) {
|
|
640
|
+
mockRevalidateTag.mockClear();
|
|
641
|
+
(console.warn as jest.Mock).mockClear();
|
|
642
|
+
|
|
643
|
+
const response = await POST(
|
|
644
|
+
makeRequest({
|
|
645
|
+
rawBody,
|
|
646
|
+
signature: sign(rawBody, SECRET, timestamp, event ?? ""),
|
|
647
|
+
event,
|
|
648
|
+
timestamp,
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
expect(response.status).toBe(401);
|
|
653
|
+
expect(mockRevalidateTag).not.toHaveBeenCalled();
|
|
654
|
+
expect((console.warn as jest.Mock).mock.calls[0][0]).toContain(
|
|
655
|
+
"unknown",
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("rejects the pre-#5902 body-only signature with 401", async () => {
|
|
661
|
+
const rawBody = JSON.stringify(VALID_BODY);
|
|
662
|
+
const bodyOnly = `sha256=${createHmac("sha256", SECRET).update(rawBody, "utf8").digest("hex")}`;
|
|
663
|
+
|
|
664
|
+
const response = await POST(
|
|
665
|
+
makeRequest({
|
|
666
|
+
rawBody,
|
|
667
|
+
signature: bodyOnly,
|
|
668
|
+
event: "publish",
|
|
669
|
+
timestamp: String(nowSeconds()),
|
|
670
|
+
}),
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
expect(response.status).toBe(401);
|
|
674
|
+
expect(mockRevalidateTag).not.toHaveBeenCalled();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it.each([
|
|
678
|
+
["type", "page"], // not a registered page type
|
|
679
|
+
["type", "Service:some-slug:en"], // collides with another page's tag (#6005 M3)
|
|
680
|
+
["type", "S".repeat(300)], // would exceed Next's 256-character tag limit
|
|
681
|
+
["slug", "Bad Slug"],
|
|
682
|
+
["slug", "a:b"],
|
|
683
|
+
["slug", "a".repeat(260)], // page tag over Next's 256-character limit
|
|
684
|
+
["locale", "english"],
|
|
685
|
+
["locale", "en:x"],
|
|
686
|
+
])(
|
|
687
|
+
"returns 400 for a correctly-signed body with an out-of-bounds %s (%j)",
|
|
688
|
+
async (field, value) => {
|
|
689
|
+
const response = await POST(
|
|
690
|
+
signedRequest({ ...VALID_BODY, [field]: value }, SECRET),
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
expect(response.status).toBe(400);
|
|
694
|
+
await expect(response.json()).resolves.toEqual({
|
|
695
|
+
error: "invalid_body",
|
|
696
|
+
});
|
|
697
|
+
expect(mockRevalidateTag).not.toHaveBeenCalled();
|
|
698
|
+
},
|
|
699
|
+
);
|
|
700
|
+
});
|
|
509
701
|
});
|
|
510
702
|
|
|
511
703
|
// --- AC-3: the AC-WCE-02 publish-to-live harness (D12) --------------------
|
|
@@ -10,37 +10,75 @@
|
|
|
10
10
|
* database handle; it purges cache keys for a read path that goes through
|
|
11
11
|
* apps/api (ADR 0002, headless-first).
|
|
12
12
|
*
|
|
13
|
-
* The wire contract is #5851's
|
|
14
|
-
* four-key JSON { workspaceId, type, slug, locale }; headers
|
|
13
|
+
* The wire contract is #5851's as amended by #5902 (its spec's D1/D2): POST;
|
|
14
|
+
* body the four-key JSON { workspaceId, type, slug, locale }; headers
|
|
15
|
+
* `x-ailk-revalidate-timestamp: <unix seconds>`,
|
|
16
|
+
* `x-ailk-revalidate-event: publish | unpublish` and
|
|
15
17
|
* `x-ailk-revalidate-signature: sha256=<lowercase hex>` — HMAC-SHA256 over
|
|
16
|
-
*
|
|
18
|
+
* `v0:{timestamp}:{event}:{rawBody}`.
|
|
17
19
|
*
|
|
18
20
|
* The order is 503 → 401 → 400, and the signature is verified over the RAW
|
|
19
21
|
* body before it is parsed (D2). Re-serialising a parsed object changes the
|
|
20
22
|
* bytes, and answering 400 before 401 would hand an unauthenticated caller a
|
|
21
|
-
* JSON-parser oracle.
|
|
23
|
+
* JSON-parser oracle. A timestamp more than 5 minutes from this server's
|
|
24
|
+
* clock, or an event other than publish/unpublish, is a 401 like a bad
|
|
25
|
+
* signature: both are inside the signed material.
|
|
22
26
|
*
|
|
23
27
|
* Both events take one code path (D6): purging the page tag is what makes an
|
|
24
28
|
* unpublished page disappear, and purging the list tag drops it from the
|
|
25
|
-
* index — the same two purges a publish needs. The event header is
|
|
26
|
-
* never branched on.
|
|
29
|
+
* index — the same two purges a publish needs. The event header is signed,
|
|
30
|
+
* logged and otherwise never branched on.
|
|
27
31
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* tag —
|
|
32
|
-
*
|
|
32
|
+
* Replay window (D7, reopened and closed by #5902): a captured delivery
|
|
33
|
+
* verifies for 5 minutes and no longer, the Slack receiver's tolerance
|
|
34
|
+
* (apps/api/src/routes/slack/actions.ts). Within the window a replay is a
|
|
35
|
+
* second revalidateTag on an already-purged tag — a no-op — so the receiver
|
|
36
|
+
* still keeps no seen-set.
|
|
37
|
+
*
|
|
38
|
+
* `type`, `slug` and `locale` are held to the sender's bounded shapes before
|
|
39
|
+
* they become cache tags (#5902, #6005 M3): none can carry a `:`, so no body
|
|
40
|
+
* can forge another page's tag. A page tag over Next's 256-character limit is
|
|
41
|
+
* a 400 too — the read path cannot cache under such a tag either.
|
|
33
42
|
*
|
|
34
43
|
* PII / secret boundary (FR9): no log line carries the secret, the signature
|
|
35
44
|
* header value, the request body, or page content.
|
|
36
45
|
*/
|
|
37
46
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
38
47
|
|
|
48
|
+
import {
|
|
49
|
+
SCHEMA_ORG_TYPES,
|
|
50
|
+
SLUG_PATTERN,
|
|
51
|
+
} from "@working-theory/content-adapters";
|
|
39
52
|
import { contentListTag, contentPageTag } from "@working-theory/page-renderer";
|
|
53
|
+
import { localeSchema } from "@working-theory/validation";
|
|
40
54
|
import { revalidateTag } from "next/cache";
|
|
55
|
+
import { z } from "zod";
|
|
41
56
|
|
|
42
57
|
const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
|
|
43
58
|
const EVENT_HEADER = "x-ailk-revalidate-event";
|
|
59
|
+
const TIMESTAMP_HEADER = "x-ailk-revalidate-timestamp";
|
|
60
|
+
|
|
61
|
+
/** How far the signed timestamp may sit from this server's clock, either way. */
|
|
62
|
+
const SIGNATURE_TOLERANCE_SECONDS = 5 * 60;
|
|
63
|
+
|
|
64
|
+
/** Unix seconds as the sender writes them: digits only, no sign or exponent. */
|
|
65
|
+
const TIMESTAMP_SHAPE = /^[0-9]{1,12}$/;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The sender's bounds (apps/api/src/services/revalidation.ts), applied again
|
|
69
|
+
* here because this route is unauthenticated until the MAC verifies and the
|
|
70
|
+
* values become cache tags. `workspaceId` is logged, never tagged, so it stays
|
|
71
|
+
* a plain string.
|
|
72
|
+
*/
|
|
73
|
+
const bodySchema = z.object({
|
|
74
|
+
workspaceId: z.string(),
|
|
75
|
+
type: z.enum(SCHEMA_ORG_TYPES),
|
|
76
|
+
slug: z.string().regex(SLUG_PATTERN),
|
|
77
|
+
locale: localeSchema,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** next@16.3.3 `NEXT_CACHE_TAG_MAX_LENGTH` (dist/lib/constants.js). */
|
|
81
|
+
const CACHE_TAG_MAX_LENGTH = 256;
|
|
44
82
|
|
|
45
83
|
/**
|
|
46
84
|
* A FORMAT guard, not a secret-dependent one — it compares the header against
|
|
@@ -48,34 +86,39 @@ const EVENT_HEADER = "x-ailk-revalidate-event";
|
|
|
48
86
|
*/
|
|
49
87
|
const SIGNATURE_SHAPE = /^sha256=[0-9a-f]{64}$/;
|
|
50
88
|
|
|
51
|
-
type RevalidateBody =
|
|
52
|
-
workspaceId: string;
|
|
53
|
-
type: string;
|
|
54
|
-
slug: string;
|
|
55
|
-
locale: string;
|
|
56
|
-
};
|
|
89
|
+
type RevalidateBody = z.infer<typeof bodySchema>;
|
|
57
90
|
|
|
58
91
|
/**
|
|
59
92
|
* Line-for-line the repo's one shipped verifier
|
|
60
|
-
* (apps/api/src/routes/slack/actions.ts:50-62), with
|
|
61
|
-
*
|
|
93
|
+
* (apps/api/src/routes/slack/actions.ts:50-62), with Slack's `v0:<ts>:<body>`
|
|
94
|
+
* base string extended by the event (#5902). The length guard is required,
|
|
62
95
|
* not decorative: timingSafeEqual throws on unequal lengths. The `sha256=`
|
|
63
96
|
* prefix stays inside the compared material so a same-length `sha512=` header
|
|
64
97
|
* is rejected outright rather than compared against a sha256 digest.
|
|
65
98
|
*/
|
|
66
99
|
function isValidSignature(
|
|
67
100
|
rawBody: string,
|
|
101
|
+
timestamp: string,
|
|
102
|
+
event: string,
|
|
68
103
|
provided: string,
|
|
69
104
|
secret: string,
|
|
70
105
|
): boolean {
|
|
71
|
-
const
|
|
106
|
+
const base = `v0:${timestamp}:${event}:${rawBody}`;
|
|
107
|
+
const expected = `sha256=${createHmac("sha256", secret).update(base, "utf8").digest("hex")}`;
|
|
72
108
|
const expectedBuf = Buffer.from(expected, "utf8");
|
|
73
109
|
const providedBuf = Buffer.from(provided, "utf8");
|
|
74
110
|
if (expectedBuf.length !== providedBuf.length) return false;
|
|
75
111
|
return timingSafeEqual(expectedBuf, providedBuf);
|
|
76
112
|
}
|
|
77
113
|
|
|
78
|
-
/**
|
|
114
|
+
/** True when `timestamp` is unix seconds within the tolerance of now. */
|
|
115
|
+
function isFreshTimestamp(timestamp: string): boolean {
|
|
116
|
+
if (!TIMESTAMP_SHAPE.test(timestamp)) return false;
|
|
117
|
+
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
|
|
118
|
+
return ageSeconds <= SIGNATURE_TOLERANCE_SECONDS;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The parsed, bounded four-key body, or null if the payload is not one (FR5). */
|
|
79
122
|
function parseBody(rawBody: string): RevalidateBody | null {
|
|
80
123
|
let parsed: unknown;
|
|
81
124
|
try {
|
|
@@ -83,22 +126,17 @@ function parseBody(rawBody: string): RevalidateBody | null {
|
|
|
83
126
|
} catch {
|
|
84
127
|
return null;
|
|
85
128
|
}
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const {
|
|
93
|
-
if (
|
|
94
|
-
typeof workspaceId !== "string" ||
|
|
95
|
-
typeof type !== "string" ||
|
|
96
|
-
typeof slug !== "string" ||
|
|
97
|
-
typeof locale !== "string"
|
|
98
|
-
) {
|
|
129
|
+
// z.object rejects `[]`, `null` and every non-object; the four field
|
|
130
|
+
// shapes do the rest.
|
|
131
|
+
const result = bodySchema.safeParse(parsed);
|
|
132
|
+
if (!result.success) return null;
|
|
133
|
+
// The field shapes bound type and locale but not slug's length; a page tag
|
|
134
|
+
// over Next's limit could not have been cached under, so it is not a body.
|
|
135
|
+
const { type, slug, locale } = result.data;
|
|
136
|
+
if (contentPageTag({ type, slug, locale }).length > CACHE_TAG_MAX_LENGTH) {
|
|
99
137
|
return null;
|
|
100
138
|
}
|
|
101
|
-
return
|
|
139
|
+
return result.data;
|
|
102
140
|
}
|
|
103
141
|
|
|
104
142
|
export async function POST(request: Request): Promise<Response> {
|
|
@@ -145,11 +183,15 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
145
183
|
// (FR3).
|
|
146
184
|
const rawBody = await request.text();
|
|
147
185
|
|
|
186
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
148
187
|
const provided = request.headers.get(SIGNATURE_HEADER);
|
|
149
188
|
if (
|
|
189
|
+
event === "unknown" ||
|
|
190
|
+
timestamp === null ||
|
|
191
|
+
!isFreshTimestamp(timestamp) ||
|
|
150
192
|
provided === null ||
|
|
151
193
|
!SIGNATURE_SHAPE.test(provided) ||
|
|
152
|
-
!isValidSignature(rawBody, provided, secret)
|
|
194
|
+
!isValidSignature(rawBody, timestamp, event, provided, secret)
|
|
153
195
|
) {
|
|
154
196
|
log("warn", { status: 401 });
|
|
155
197
|
return Response.json({ error: "invalid_signature" }, { status: 401 });
|
|
@@ -117,3 +117,82 @@ describe("SITE_NAME / SITE_BASE_URL (#4157)", () => {
|
|
|
117
117
|
expect(SITE_NAME).toBe(FALLBACK_SITE_NAME);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `siteLogo()` — the brand triad's logomark (#6820 D5). No `brand:` renders
|
|
123
|
+
* exactly today's element; a declared mark is the one rendered, with its dark
|
|
124
|
+
* source kept when it differs; a malformed mark throws naming the field on
|
|
125
|
+
* the render, not at module init.
|
|
126
|
+
*/
|
|
127
|
+
describe("siteLogo() — brand.logomark (#6820)", () => {
|
|
128
|
+
/** `site-logo.tsx` re-imported alongside a fresh `site-brand`. */
|
|
129
|
+
async function loadSiteLogo(): Promise<
|
|
130
|
+
typeof import("../site-logo") & typeof import("../site-brand")
|
|
131
|
+
> {
|
|
132
|
+
const brand = await loadSiteBrand();
|
|
133
|
+
return { ...brand, ...(await import("../site-logo")) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function writeRaw(frontmatter: string): void {
|
|
137
|
+
writeFileSync(
|
|
138
|
+
join(tmpRoot, "_site.mdx"),
|
|
139
|
+
`---\nsiteName: Fixture Co\n${frontmatter}---\n`,
|
|
140
|
+
);
|
|
141
|
+
process.env["AILK_CONTENT_ROOT"] = tmpRoot;
|
|
142
|
+
delete process.env["NEXT_PUBLIC_SITE_NAME"];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it("with no brand: renders today's /logomark.svg element", async () => {
|
|
146
|
+
writeRaw("");
|
|
147
|
+
const { siteLogo, SITE_NAME } = await loadSiteLogo();
|
|
148
|
+
const logo = siteLogo();
|
|
149
|
+
|
|
150
|
+
expect(logo.type).toBe("img");
|
|
151
|
+
expect(logo.props).toStrictEqual({
|
|
152
|
+
src: "/logomark.svg",
|
|
153
|
+
alt: SITE_NAME,
|
|
154
|
+
width: 28,
|
|
155
|
+
height: 28,
|
|
156
|
+
className: "block",
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("with light === dark: renders an img with the declared src and alt", async () => {
|
|
161
|
+
writeRaw("brand:\n logomark: { light: /acme.svg, dark: /acme.svg, alt: Acme }\n");
|
|
162
|
+
const { siteLogo } = await loadSiteLogo();
|
|
163
|
+
const logo = siteLogo();
|
|
164
|
+
|
|
165
|
+
expect(logo.type).toBe("img");
|
|
166
|
+
expect(logo.props).toStrictEqual({
|
|
167
|
+
src: "/acme.svg",
|
|
168
|
+
alt: "Acme",
|
|
169
|
+
width: 28,
|
|
170
|
+
height: 28,
|
|
171
|
+
className: "block",
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("with light !== dark: renders the Image primitive with darkSrc", async () => {
|
|
176
|
+
writeRaw("brand:\n logomark: { light: /acme.svg, dark: /acme-dark.svg, alt: Acme }\n");
|
|
177
|
+
const { siteLogo } = await loadSiteLogo();
|
|
178
|
+
const { Image } = await import("@working-theory/ui/primitives");
|
|
179
|
+
const logo = siteLogo();
|
|
180
|
+
|
|
181
|
+
expect(logo.type).toBe(Image);
|
|
182
|
+
expect(logo.props).toStrictEqual({
|
|
183
|
+
src: "/acme.svg",
|
|
184
|
+
darkSrc: "/acme-dark.svg",
|
|
185
|
+
alt: "Acme",
|
|
186
|
+
width: 28,
|
|
187
|
+
height: 28,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("a logomark missing alt throws naming alt, while SITE_NAME still resolves", async () => {
|
|
192
|
+
writeRaw("brand:\n logomark: { light: /acme.svg, dark: /acme.svg }\n");
|
|
193
|
+
const { siteLogo, SITE_NAME } = await loadSiteLogo();
|
|
194
|
+
|
|
195
|
+
expect(SITE_NAME).toBe("Fixture Co");
|
|
196
|
+
expect(() => siteLogo()).toThrow(/alt/);
|
|
197
|
+
});
|
|
198
|
+
});
|