create-ailk 0.8.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 +68 -0
- package/component-catalog.json +279 -45
- 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 +92 -4
- 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/license-key-files.d.ts +9 -0
- package/dist/lib/license-key-files.js +2 -2
- package/dist/lib/module-license-gate.d.ts +19 -9
- package/dist/lib/module-license-gate.js +19 -8
- 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 +12 -2
- package/dist/parse-args.js +74 -13
- package/dist/programmatic.d.ts +30 -6
- package/dist/programmatic.js +206 -17
- 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 +141 -8
- package/dist/starter-grid.d.ts +32 -16
- package/dist/starter-grid.js +129 -33
- package/dist/surfaces.d.ts +41 -9
- package/dist/surfaces.js +200 -83
- package/dist/template-manifest.d.ts +30 -13
- package/dist/template-manifest.js +49 -24
- package/package.json +5 -4
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
- 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 +98 -6
- package/site-templates/marketing-site/DESIGN.md +98 -6
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
- package/site-templates/marketing-site-free/DESIGN.md +98 -6
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/saas-product/DESIGN.md +98 -6
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
- package/site-templates/shapes/person-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
- package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
- package/templates/.claude/rules/architecture.md +7 -0
- package/templates/.claude/skills/README.md +3 -2
- 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 -76
- 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-deploy/references/env-vars-template.md +28 -11
- 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 +98 -6
- package/templates/README.md +50 -38
- package/templates/apps/api/.env.example +2 -1
- 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/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/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/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 +28 -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/revalidation.ts +60 -15
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
- 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]/{use-cases → for}/[slug]/page.tsx +4 -3
- 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 +35 -19
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
- 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 +14 -13
- package/templates/apps/web/lib/site-brand.tsx +13 -9
- 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 +23 -9
- package/templates/apps/web/middleware.ts +28 -4
- 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 +40 -1
- package/templates/content/en/HomePage/home.mdx +2 -6
- package/templates/content/en/LandingPage/meet.mdx +59 -0
- package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
- package/templates/database/CHANGELOG.md +7 -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 -52
- package/site-templates/minimalism-site/DESIGN.md +0 -162
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
- 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 -52
- 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/PurchasePolicyPage/purchase-policy.mdx +0 -123
- 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/content/en/Service/first-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
- package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
- package/site-templates/minimalism-site/provenance.json +0 -4
- package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
- package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
- /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
|
@@ -34,16 +34,20 @@
|
|
|
34
34
|
* Billing upsert (customer.created / checkout.session.completed):
|
|
35
35
|
* Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
|
|
36
36
|
* carrying metadata.organizationId links the Organization (multi-tenant
|
|
37
|
-
* module); absent → the
|
|
37
|
+
* module); absent → the User path (single-tenant).
|
|
38
38
|
*
|
|
39
39
|
* Organization path (metadata.organizationId present):
|
|
40
40
|
* 1. Typed composite match already pointing at this org → idempotent no-op
|
|
41
41
|
* 2. Else link the target Organization (by id) billing fields
|
|
42
42
|
*
|
|
43
|
-
* User path (no organizationId
|
|
43
|
+
* User path (no organizationId):
|
|
44
44
|
* 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customer)
|
|
45
|
-
* 2.
|
|
46
|
-
*
|
|
45
|
+
* 2. Verified user signal: metadata.ailk_user_id →
|
|
46
|
+
* User.findUnique({ where: { id } }) → bind only when the row's
|
|
47
|
+
* billingCustomerId is empty; otherwise log billing_identity_conflict
|
|
48
|
+
* and write nothing (#6113). No signal → log billing_identity_unverified,
|
|
49
|
+
* write nothing (#6852) — the payer-typed Checkout email never binds.
|
|
50
|
+
* 3. Signal names no User → warn billing_identity_unknown_user; no row created
|
|
47
51
|
*
|
|
48
52
|
* Recommender purchase stamp (checkout.session.completed / .expired, #4838):
|
|
49
53
|
* A session carrying metadata.flowCheckoutId (the FlowCheckout buyer row
|
|
@@ -275,7 +279,7 @@ export const stripeWebhookHandler = async (
|
|
|
275
279
|
// Billing-entity routing (spec D2): a multi-tenant checkout stamps the
|
|
276
280
|
// target Organization id in metadata.organizationId (an individual → their
|
|
277
281
|
// sentinel default org). Present → link the Organization; absent → the
|
|
278
|
-
//
|
|
282
|
+
// User path (single-tenant, degrade-clean).
|
|
279
283
|
const organizationId = customer.metadata?.["organizationId"] ?? null;
|
|
280
284
|
if (organizationId) {
|
|
281
285
|
await upsertOrgBillingIdentity(request, {
|
|
@@ -286,7 +290,8 @@ export const stripeWebhookHandler = async (
|
|
|
286
290
|
} else {
|
|
287
291
|
await upsertBillingIdentity(request, {
|
|
288
292
|
customerId: customer.id,
|
|
289
|
-
|
|
293
|
+
userId: signalOf(customer.metadata),
|
|
294
|
+
eventType: event.type,
|
|
290
295
|
eventIdPrefix,
|
|
291
296
|
});
|
|
292
297
|
}
|
|
@@ -306,7 +311,7 @@ export const stripeWebhookHandler = async (
|
|
|
306
311
|
};
|
|
307
312
|
|
|
308
313
|
// Billing-entity routing (spec D2): metadata.organizationId → Organization
|
|
309
|
-
// path; absent → the
|
|
314
|
+
// path; absent → the User path (single-tenant).
|
|
310
315
|
const organizationId = session.metadata?.["organizationId"] ?? null;
|
|
311
316
|
let billingTask: Promise<void> = Promise.resolve();
|
|
312
317
|
if (session.customer) {
|
|
@@ -318,7 +323,8 @@ export const stripeWebhookHandler = async (
|
|
|
318
323
|
})
|
|
319
324
|
: upsertBillingIdentity(request, {
|
|
320
325
|
customerId: session.customer,
|
|
321
|
-
|
|
326
|
+
userId: signalOf(session.metadata),
|
|
327
|
+
eventType: event.type,
|
|
322
328
|
eventIdPrefix,
|
|
323
329
|
});
|
|
324
330
|
}
|
|
@@ -704,14 +710,44 @@ async function stampRequestPaidByInvoice(
|
|
|
704
710
|
}
|
|
705
711
|
}
|
|
706
712
|
|
|
713
|
+
/**
|
|
714
|
+
* The verified user signal (#6852): `metadata.ailk_user_id` on the Checkout
|
|
715
|
+
* session or the Stripe customer, when it is a non-empty string. Never
|
|
716
|
+
* `client_reference_id` (a Payment Link accepts it from the payer's URL) and
|
|
717
|
+
* never the payer-typed email.
|
|
718
|
+
*/
|
|
719
|
+
function signalOf(
|
|
720
|
+
metadata: Record<string, string> | null | undefined,
|
|
721
|
+
): string | null {
|
|
722
|
+
const value: unknown = metadata?.["ailk_user_id"];
|
|
723
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
724
|
+
}
|
|
725
|
+
|
|
707
726
|
/**
|
|
708
727
|
* Link User.billingProvider + User.billingCustomerId for a Stripe customer.
|
|
709
728
|
*
|
|
710
729
|
* Lookup priority:
|
|
711
730
|
* 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customerId)
|
|
712
731
|
* → already linked; no-op (idempotent)
|
|
713
|
-
* 2.
|
|
714
|
-
*
|
|
732
|
+
* 2. Verified user signal (#6852): `metadata.ailk_user_id` →
|
|
733
|
+
* User.findUnique({ where: { id } }) → bind only when the row's
|
|
734
|
+
* billingCustomerId is empty; otherwise log billing_identity_conflict and
|
|
735
|
+
* write nothing (#6113). The write is guarded on `billingCustomerId: null`;
|
|
736
|
+
* a P2025 means a concurrent event bound the row first — the same customer
|
|
737
|
+
* is a no-op, any other is a conflict.
|
|
738
|
+
* No signal → log billing_identity_unverified and write nothing. The payer
|
|
739
|
+
* types the Checkout email and Stripe never verifies it, so an email
|
|
740
|
+
* never makes a binding, first or otherwise.
|
|
741
|
+
* Trust boundary: Checkout-session and customer `metadata` can be written
|
|
742
|
+
* only by a principal the seller authorised on their Stripe account (our
|
|
743
|
+
* server's key, a write-capable restricted key, a connected app, the
|
|
744
|
+
* dashboard, a Payment Link's own metadata) — never by the payer. A
|
|
745
|
+
* producer sets the key only from an authenticated server session, never
|
|
746
|
+
* from request input.
|
|
747
|
+
* 3. Signal names no User → warn billing_identity_unknown_user; do NOT
|
|
748
|
+
* create a User row (pre-signup state preserved for Phase B #852)
|
|
749
|
+
*
|
|
750
|
+
* No line logs the email, the Stripe customer id, or an unresolved signal.
|
|
715
751
|
*
|
|
716
752
|
* DB failures are caught and logged — a broken DB link should not cause Stripe to retry
|
|
717
753
|
* the event (which would fire analytics + billing again on eventual success).
|
|
@@ -720,9 +756,15 @@ async function upsertBillingIdentity(
|
|
|
720
756
|
request: FastifyRequest,
|
|
721
757
|
{
|
|
722
758
|
customerId,
|
|
723
|
-
|
|
759
|
+
userId,
|
|
760
|
+
eventType,
|
|
724
761
|
eventIdPrefix,
|
|
725
|
-
}: {
|
|
762
|
+
}: {
|
|
763
|
+
customerId: string;
|
|
764
|
+
userId: string | null;
|
|
765
|
+
eventType: string;
|
|
766
|
+
eventIdPrefix: string;
|
|
767
|
+
},
|
|
726
768
|
): Promise<void> {
|
|
727
769
|
try {
|
|
728
770
|
const existingByBilling = await prisma.user.findFirst({
|
|
@@ -740,30 +782,80 @@ async function upsertBillingIdentity(
|
|
|
740
782
|
return;
|
|
741
783
|
}
|
|
742
784
|
|
|
743
|
-
if (
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
785
|
+
if (!userId) {
|
|
786
|
+
// A customer Checkout creates never carries session metadata, so a
|
|
787
|
+
// no-signal customer.created is every ordinary purchase — info, not warn.
|
|
788
|
+
const fields = {
|
|
789
|
+
eventIdPrefix,
|
|
790
|
+
eventType,
|
|
791
|
+
outcome: "billing_identity_unverified",
|
|
792
|
+
};
|
|
793
|
+
const msg =
|
|
794
|
+
"stripe webhook: no verified user signal — billing identity not bound";
|
|
795
|
+
if (eventType === "customer.created") request.log.info(fields, msg);
|
|
796
|
+
else request.log.warn(fields, msg);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
747
799
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
);
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
800
|
+
const row = await prisma.user.findUnique({
|
|
801
|
+
where: { id: userId },
|
|
802
|
+
select: { id: true, billingProvider: true, billingCustomerId: true },
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
if (!row) {
|
|
806
|
+
request.log.warn(
|
|
807
|
+
{ eventIdPrefix, eventType, outcome: "billing_identity_unknown_user" },
|
|
808
|
+
"stripe webhook: user signal names no User — pre-signup state preserved",
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
762
811
|
}
|
|
763
812
|
|
|
764
|
-
|
|
813
|
+
const isSameBinding = (r: {
|
|
814
|
+
billingProvider: string | null;
|
|
815
|
+
billingCustomerId: string | null;
|
|
816
|
+
}) => r.billingProvider === "stripe" && r.billingCustomerId === customerId;
|
|
817
|
+
const logAlreadyLinked = () =>
|
|
818
|
+
request.log.info(
|
|
819
|
+
{ eventIdPrefix, outcome: "billing_identity_already_linked" },
|
|
820
|
+
"billing identity already linked — idempotent no-op",
|
|
821
|
+
);
|
|
822
|
+
const logConflict = () =>
|
|
823
|
+
request.log.warn(
|
|
824
|
+
{
|
|
825
|
+
eventIdPrefix,
|
|
826
|
+
outcome: "billing_identity_conflict",
|
|
827
|
+
userId: row.id,
|
|
828
|
+
},
|
|
829
|
+
"stripe webhook: user already bound to a different billing customer — not re-pointed",
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
if (row.billingCustomerId !== null) {
|
|
833
|
+
if (isSameBinding(row)) logAlreadyLinked();
|
|
834
|
+
else logConflict();
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
try {
|
|
839
|
+
await prisma.user.update({
|
|
840
|
+
where: { id: row.id, billingCustomerId: null },
|
|
841
|
+
data: {
|
|
842
|
+
billingProvider: "stripe",
|
|
843
|
+
billingCustomerId: customerId,
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
} catch (err) {
|
|
847
|
+
if ((err as { code?: unknown }).code !== "P2025") throw err;
|
|
848
|
+
const now = await prisma.user.findUnique({
|
|
849
|
+
where: { id: row.id },
|
|
850
|
+
select: { billingProvider: true, billingCustomerId: true },
|
|
851
|
+
});
|
|
852
|
+
if (now && isSameBinding(now)) logAlreadyLinked();
|
|
853
|
+
else logConflict();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
request.log.info(
|
|
765
857
|
{ eventIdPrefix },
|
|
766
|
-
"
|
|
858
|
+
"billing identity linked via verified user signal",
|
|
767
859
|
);
|
|
768
860
|
} catch (err) {
|
|
769
861
|
request.log.error(
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import cors from "@fastify/cors";
|
|
13
13
|
import rateLimit from "@fastify/rate-limit";
|
|
14
14
|
import { idRegex } from "@working-theory/ids";
|
|
15
|
+
import { logger } from "@working-theory/observability";
|
|
15
16
|
import Fastify from "fastify";
|
|
16
17
|
import type {
|
|
17
18
|
FastifyPluginAsync,
|
|
@@ -507,6 +508,31 @@ async function assertCommerceSandboxKey(key: string): Promise<void> {
|
|
|
507
508
|
assertSandboxKeyFn(key);
|
|
508
509
|
}
|
|
509
510
|
|
|
511
|
+
/**
|
|
512
|
+
* #6889 (SEC-02 of the #6876 security review) — make the checkout route's
|
|
513
|
+
* posture observable. POST /v1/checkout/sessions admits a caller without
|
|
514
|
+
* X-Internal-Auth when OSS_API_INTERNAL_SECRET is unset. With FRONTEND_URL
|
|
515
|
+
* unset, the route's same-origin redirect lock (#6877) answers 500
|
|
516
|
+
* config-error to that caller, and the CORS and cookie-CSRF origin checks pin
|
|
517
|
+
* to the `http://localhost:3000` config default. Both stay optional (OSS
|
|
518
|
+
* standalone compat), so this only ever WARNS — once per unset variable, and
|
|
519
|
+
* only in production. FRONTEND_URL is read from the raw environment because
|
|
520
|
+
* `config.FRONTEND_URL` carries that localhost default.
|
|
521
|
+
*/
|
|
522
|
+
function warnOnUnsetProductionProtections(): void {
|
|
523
|
+
if (config.NODE_ENV !== "production") return;
|
|
524
|
+
if (!process.env["FRONTEND_URL"]) {
|
|
525
|
+
logger.warn(
|
|
526
|
+
"[startup] FRONTEND_URL not set in production — CORS/cookie origin checks are pinned to http://localhost:3000, and checkout answers 500 config-error to any caller admitted without X-Internal-Auth",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (!process.env["OSS_API_INTERNAL_SECRET"]) {
|
|
530
|
+
logger.warn(
|
|
531
|
+
"[startup] OSS_API_INTERNAL_SECRET not set in production — the checkout route admits callers without X-Internal-Auth",
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
510
536
|
/**
|
|
511
537
|
* Build Fastify server instance (for serverless or standalone)
|
|
512
538
|
* @returns Configured Fastify instance ready for requests
|
|
@@ -515,6 +541,8 @@ export async function build() {
|
|
|
515
541
|
// Startup-time validation: fail fast if LEAD_CAPTURE_FILE points at a missing directory.
|
|
516
542
|
await validateLeadCaptureDir();
|
|
517
543
|
|
|
544
|
+
warnOnUnsetProductionProtections();
|
|
545
|
+
|
|
518
546
|
// #6155 D6 — the site-alias workspace assertion (ownership brief § Layer
|
|
519
547
|
// 2, AC-5): resolves + asserts once at startup that AILK_SITE_ID (or its
|
|
520
548
|
// "default" fallback) names a real workspace, throwing when it does not —
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* loopback HTTP server (spec D11), not a `global.fetch` double: the contract
|
|
5
5
|
* under test is the wire format, and only a real server proves that the bytes
|
|
6
6
|
* on the wire are the bytes the signature covers. The server recomputes the
|
|
7
|
-
* HMAC inside its own handler
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* HMAC inside its own handler over the D1 base string
|
|
8
|
+
* `v0:{timestamp}:{event}:{rawBody}` (#5902) — the receiver's verification,
|
|
9
|
+
* performed for real — and compares with `timingSafeEqual`, exactly as the
|
|
10
|
+
* receiver does (spec FR7).
|
|
10
11
|
*
|
|
11
12
|
* The server binds 127.0.0.1, which `ssrfBlocked` rejects; D7's
|
|
12
13
|
* `allowPrivate: true` is what makes this substrate runnable at all.
|
|
@@ -23,10 +24,13 @@ import type { AddressInfo } from "node:net";
|
|
|
23
24
|
const TEST_SECRET = "not-a-real-secret-fixture-value";
|
|
24
25
|
const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
|
|
25
26
|
const EVENT_HEADER = "x-ailk-revalidate-event";
|
|
27
|
+
const TIMESTAMP_HEADER = "x-ailk-revalidate-timestamp";
|
|
26
28
|
|
|
29
|
+
// `type` must be a registered page type and `slug` a SLUG_PATTERN value — the
|
|
30
|
+
// sender validates both before signing (#5902).
|
|
27
31
|
const TARGET = {
|
|
28
32
|
workspaceId: "ws_test_0001",
|
|
29
|
-
type: "
|
|
33
|
+
type: "Service",
|
|
30
34
|
slug: "pricing",
|
|
31
35
|
locale: "en",
|
|
32
36
|
};
|
|
@@ -63,8 +67,11 @@ jest.mock("@working-theory/observability", () => ({
|
|
|
63
67
|
interface Recorded {
|
|
64
68
|
rawBody: string;
|
|
65
69
|
signatureValid: boolean;
|
|
70
|
+
/** Whether the signature is HMAC over the raw body ALONE (the pre-#5902 wire). */
|
|
71
|
+
bodyOnlySignatureValid: boolean;
|
|
66
72
|
signatureHeader: string;
|
|
67
73
|
eventHeader: string;
|
|
74
|
+
timestampHeader: string;
|
|
68
75
|
contentType: string;
|
|
69
76
|
receivedAt: number;
|
|
70
77
|
}
|
|
@@ -81,26 +88,40 @@ beforeAll(async () => {
|
|
|
81
88
|
req.on("data", (c: Buffer) => void chunks.push(c));
|
|
82
89
|
req.on("end", () => {
|
|
83
90
|
const raw = Buffer.concat(chunks);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// RAW request body bytes and compare in constant time.
|
|
87
|
-
const expected = Buffer.from(
|
|
88
|
-
createHmac("sha256", TEST_SECRET).update(raw).digest("hex"),
|
|
89
|
-
"utf8",
|
|
90
|
-
);
|
|
91
|
+
const timestampHeader = String(req.headers[TIMESTAMP_HEADER] ?? "");
|
|
92
|
+
const eventHeader = String(req.headers[EVENT_HEADER] ?? "");
|
|
91
93
|
const received = Buffer.from(
|
|
92
94
|
String(req.headers[SIGNATURE_HEADER] ?? "").replace(/^sha256=/, ""),
|
|
93
95
|
"utf8",
|
|
94
96
|
);
|
|
95
|
-
const
|
|
96
|
-
expected
|
|
97
|
-
|
|
97
|
+
const matches = (material: Buffer): boolean => {
|
|
98
|
+
const expected = Buffer.from(
|
|
99
|
+
createHmac("sha256", TEST_SECRET).update(material).digest("hex"),
|
|
100
|
+
"utf8",
|
|
101
|
+
);
|
|
102
|
+
return (
|
|
103
|
+
expected.length === received.length &&
|
|
104
|
+
timingSafeEqual(expected, received)
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// The receiver's own verification (FR7, D1 as amended by #5902):
|
|
109
|
+
// recompute HMAC-SHA256 over `v0:{timestamp}:{event}:` followed by the
|
|
110
|
+
// RAW request body bytes, and compare in constant time.
|
|
111
|
+
const signatureValid = matches(
|
|
112
|
+
Buffer.concat([
|
|
113
|
+
Buffer.from(`v0:${timestampHeader}:${eventHeader}:`, "utf8"),
|
|
114
|
+
raw,
|
|
115
|
+
]),
|
|
116
|
+
);
|
|
98
117
|
|
|
99
118
|
recorded.push({
|
|
100
119
|
rawBody: raw.toString("utf8"),
|
|
101
120
|
signatureValid,
|
|
121
|
+
bodyOnlySignatureValid: matches(raw),
|
|
102
122
|
signatureHeader: String(req.headers[SIGNATURE_HEADER] ?? ""),
|
|
103
|
-
eventHeader
|
|
123
|
+
eventHeader,
|
|
124
|
+
timestampHeader,
|
|
104
125
|
contentType: String(req.headers["content-type"] ?? ""),
|
|
105
126
|
receivedAt: Date.now(),
|
|
106
127
|
});
|
|
@@ -219,6 +240,60 @@ describe("notifyRevalidate", () => {
|
|
|
219
240
|
});
|
|
220
241
|
}, 20_000);
|
|
221
242
|
|
|
243
|
+
it("signs v0:{timestamp}:{event}:{body} and sends the timestamp header (#5902)", async () => {
|
|
244
|
+
responseStatuses = [200];
|
|
245
|
+
const before = Math.floor(Date.now() / 1000);
|
|
246
|
+
|
|
247
|
+
await loadService().notifyRevalidate(TARGET, { event: "unpublish" });
|
|
248
|
+
|
|
249
|
+
const after = Math.floor(Date.now() / 1000);
|
|
250
|
+
expect(recorded).toHaveLength(1);
|
|
251
|
+
const [req] = recorded;
|
|
252
|
+
|
|
253
|
+
// Unix seconds, digits only, taken at send time — what the receiver checks
|
|
254
|
+
// its 5-minute tolerance against.
|
|
255
|
+
expect(req.timestampHeader).toMatch(/^[0-9]+$/);
|
|
256
|
+
const ts = Number(req.timestampHeader);
|
|
257
|
+
expect(ts).toBeGreaterThanOrEqual(before);
|
|
258
|
+
expect(ts).toBeLessThanOrEqual(after);
|
|
259
|
+
|
|
260
|
+
// The base-string shape, recomputed independently here: the timestamp and
|
|
261
|
+
// the event are inside the MAC, so neither can be altered in flight.
|
|
262
|
+
const expected = createHmac("sha256", TEST_SECRET)
|
|
263
|
+
.update(`v0:${req.timestampHeader}:unpublish:${req.rawBody}`, "utf8")
|
|
264
|
+
.digest("hex");
|
|
265
|
+
expect(req.signatureHeader).toBe(`sha256=${expected}`);
|
|
266
|
+
expect(req.signatureValid).toBe(true);
|
|
267
|
+
// And the body-alone signature of the pre-#5902 wire no longer verifies.
|
|
268
|
+
expect(req.bodyOnlySignatureValid).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it.each([
|
|
272
|
+
["type", "page"], // not a registered page type
|
|
273
|
+
["type", "Service:some-slug:en"], // the tag-collision shape (#6005 M3)
|
|
274
|
+
["type", "S".repeat(300)],
|
|
275
|
+
["slug", "Bad Slug"],
|
|
276
|
+
["slug", "a:b"],
|
|
277
|
+
["slug", ""],
|
|
278
|
+
["locale", "english"],
|
|
279
|
+
["locale", "en:x"],
|
|
280
|
+
["locale", ""],
|
|
281
|
+
])(
|
|
282
|
+
"throws before any outbound call on an invalid %s (%j) (#5902)",
|
|
283
|
+
async (field, value) => {
|
|
284
|
+
responseStatuses = [200];
|
|
285
|
+
|
|
286
|
+
await expect(
|
|
287
|
+
loadService().notifyRevalidate(
|
|
288
|
+
{ ...TARGET, [field]: value },
|
|
289
|
+
{ event: "publish" },
|
|
290
|
+
),
|
|
291
|
+
).rejects.toThrow();
|
|
292
|
+
|
|
293
|
+
expect(recorded).toHaveLength(0);
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
222
297
|
it("is a single-warning no-op with CONTENT_REVALIDATE_URL unset", async () => {
|
|
223
298
|
delete process.env.CONTENT_REVALIDATE_URL;
|
|
224
299
|
delete process.env.CONTENT_REVALIDATE_SECRET;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file checkout-buyer.ts
|
|
3
|
+
* @description Resolves a signed-in user into the Checkout buyer
|
|
4
|
+
* `POST /v1/checkout/sessions` pins and stamps (#6866 D3).
|
|
5
|
+
*
|
|
6
|
+
* A buyer is a user whose email is VERIFIED — the customer created for them
|
|
7
|
+
* carries the account email, and it must never carry an unproven one — and
|
|
8
|
+
* whose billing binding, if any, is a Stripe customer. The binding decides
|
|
9
|
+
* whether the library reuses a customer (`customerId`) or creates one
|
|
10
|
+
* (`null`). A row bound under any other provider is not a buyer: the webhook
|
|
11
|
+
* never re-points a non-null `billingCustomerId` (#6113), so a new stamped
|
|
12
|
+
* customer could only be an orphan.
|
|
13
|
+
*
|
|
14
|
+
* Base tree (N4): imports only `verified-email.ts` and `lib/prisma.ts`, never
|
|
15
|
+
* the commerce overlay's `services/subscription.ts`. The billing read selects
|
|
16
|
+
* no email, so `tools/lint-email-authz.ts` has nothing to flag.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { prisma } from "../lib/prisma.js";
|
|
20
|
+
|
|
21
|
+
import { findVerifiedEmail } from "./verified-email.js";
|
|
22
|
+
|
|
23
|
+
export type CheckoutBuyer = {
|
|
24
|
+
userId: string;
|
|
25
|
+
email: string;
|
|
26
|
+
/** The user's bound Stripe customer, or `null` when one is to be created. */
|
|
27
|
+
customerId: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CheckoutBuyerResult =
|
|
31
|
+
| { ok: true; buyer: CheckoutBuyer }
|
|
32
|
+
| {
|
|
33
|
+
ok: false;
|
|
34
|
+
reason: "user_not_found" | "email_unverified" | "foreign_provider";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param userId the id resolved from the authenticated session cookie — never
|
|
39
|
+
* a request-supplied value.
|
|
40
|
+
* @throws whatever Prisma throws; the caller degrades that to anonymous (F7).
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveCheckoutBuyer(
|
|
43
|
+
userId: string,
|
|
44
|
+
): Promise<CheckoutBuyerResult> {
|
|
45
|
+
const verified = await findVerifiedEmail(userId);
|
|
46
|
+
if (!verified.ok) return { ok: false, reason: verified.reason };
|
|
47
|
+
|
|
48
|
+
const billing = await prisma.user.findUnique({
|
|
49
|
+
where: { id: userId },
|
|
50
|
+
select: { billingProvider: true, billingCustomerId: true },
|
|
51
|
+
});
|
|
52
|
+
if (!billing) return { ok: false, reason: "user_not_found" };
|
|
53
|
+
|
|
54
|
+
if (billing.billingCustomerId === null) {
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
buyer: { userId, email: verified.value.email, customerId: null },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// The same provider predicate `resolveBillingCustomer` applies.
|
|
61
|
+
if (billing.billingProvider !== "stripe") {
|
|
62
|
+
return { ok: false, reason: "foreign_provider" };
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
buyer: {
|
|
67
|
+
userId,
|
|
68
|
+
email: verified.value.email,
|
|
69
|
+
customerId: billing.billingCustomerId,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -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
|