create-ailk 0.13.0 → 0.15.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 +12 -4
- package/component-catalog.json +250 -36
- package/component-catalog.md +10 -6
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +34 -2
- package/dist/add-route.js +18 -2
- package/dist/apply-design.d.ts +32 -2
- package/dist/apply-design.js +155 -22
- package/dist/cli.js +24 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- package/dist/design-compile.d.ts +70 -2
- package/dist/design-compile.js +97 -1
- package/dist/design-home.d.ts +74 -0
- package/dist/design-home.js +256 -0
- package/dist/design-package.d.ts +39 -2
- package/dist/design-package.js +224 -9
- package/dist/design-presets.d.ts +17 -1
- package/dist/design-presets.js +17 -5
- package/dist/design-scaffold.d.ts +6 -25
- package/dist/design-scaffold.js +26 -74
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/design-checks.d.ts +12 -1
- package/dist/lib/design-checks.js +38 -2
- package/dist/lib/extract-module-bundle.d.ts +11 -0
- package/dist/lib/extract-module-bundle.js +36 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-license-gate.d.ts +9 -0
- package/dist/lib/module-license-gate.js +18 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +6 -0
- package/dist/parse-args.d.ts +22 -0
- package/dist/parse-args.js +73 -0
- package/dist/programmatic.d.ts +20 -0
- package/dist/programmatic.js +180 -35
- package/dist/site-chrome.d.ts +42 -0
- package/dist/site-chrome.js +140 -2
- package/dist/site-fonts.d.ts +21 -0
- package/dist/site-fonts.js +54 -0
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- package/dist/site-roster.d.ts +16 -0
- package/dist/site-roster.js +81 -0
- package/dist/starter-grid.js +15 -0
- package/dist/surfaces.js +12 -0
- package/dist/sync-routes.js +11 -2
- package/dist/team-profiles.d.ts +29 -0
- package/dist/team-profiles.js +43 -0
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -0
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
- package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
- package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
- package/site-templates/designs/minimalism/DESIGN.md +166 -36
- package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
- package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
- package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
- package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/templates/README.md +2 -0
- package/templates/apps/api/CLAUDE.md +6 -1
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +18 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/lead-routing.ts +148 -29
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/package.json +1 -1
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/CLAUDE.md +12 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/layout.tsx +212 -57
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/page.tsx +37 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
- package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/not-found.tsx +47 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
- package/templates/apps/web/app/openapi.json/route.ts +69 -1
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/profile-back-link.ts +51 -0
- package/templates/apps/web/lib/relay.ts +206 -2
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +15 -0
- package/templates/apps/web/lib/site-fonts.ts +15 -0
- package/templates/apps/web/lib/site-logo.tsx +39 -1
- package/templates/apps/web/lib/site-theme-head.ts +33 -0
- package/templates/apps/web/lib/site-theme.ts +13 -1
- package/templates/apps/web/lib/topology.generated.ts +23 -0
- package/templates/apps/web/messages/en.json +7 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +32 -0
- package/templates/database/__tests__/agents.db.test.ts +65 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/auth/schema.prisma +23 -0
- package/templates/database/inbox/schema.prisma +13 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/package.json +1 -1
- package/versions.json +35 -34
- package/site-templates/designs/minimalism/sample/README.md +0 -48
- package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
- package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
- package/site-templates/designs/minimalism/sample/public/wordmark-placeholder.svg +0 -28
|
@@ -42,6 +42,28 @@
|
|
|
42
42
|
* unchanged. So does a keyless request whose hub-status read fails before the
|
|
43
43
|
* process has any answer: lead capture is never lost (#7633, conductor
|
|
44
44
|
* amendment to NFR3). The body's `siteId` stays ignored on every branch.
|
|
45
|
+
*
|
|
46
|
+
* IDEMPOTENCY KEY (#7869, roadmap D5). `POST /v1/leads/submit` accepts an
|
|
47
|
+
* optional `Idempotency-Key` header: 16 to 255 visible ASCII characters, and
|
|
48
|
+
* it must be UNGUESSABLE (a UUID, a Stripe Checkout Session id) — a key is
|
|
49
|
+
* first-write-wins inside its workspace, so a short or predictable one could
|
|
50
|
+
* be claimed by someone else first (SEC-7869-1). It is checked after the
|
|
51
|
+
* tenant gate and before `routeLead`; a malformed key is `400 invalid_input`
|
|
52
|
+
* with no read or write. A repeat of a stored key in the same workspace with
|
|
53
|
+
* the same `email`, `source` and `payload` answers the first lead's 201 and
|
|
54
|
+
* writes nothing; with anything different it is `422
|
|
55
|
+
* idempotency_key_reused`. The key dedupes only where a `Lead` row exists —
|
|
56
|
+
* `inbox` delivery — so a first write that fell back to email or a local
|
|
57
|
+
* file leaves nothing to find, and its retry writes again. `subscribe` and
|
|
58
|
+
* `callback` never read the header. The key is never logged or echoed.
|
|
59
|
+
*
|
|
60
|
+
* API↔MCP PARITY for the header (#7869 D9, roadmap §12 Q5): NO guard entry.
|
|
61
|
+
* The guard binds a route's arguments to the validation export its handler
|
|
62
|
+
* `.safeParse`s on `request.params|query|body`; the header check below is a
|
|
63
|
+
* regex test, not such a parse, so this route still binds as
|
|
64
|
+
* `api-args-unbound` and the `capture_lead` row is unchanged. The 201 body's
|
|
65
|
+
* field set is unchanged too, and `capture_lead` sends no header. If the
|
|
66
|
+
* guard ever models headers, this becomes an API-side `arg-only-in`.
|
|
45
67
|
*/
|
|
46
68
|
|
|
47
69
|
import { track } from "@working-theory/analytics/server";
|
|
@@ -58,6 +80,9 @@ import {
|
|
|
58
80
|
import { resolveCallerSite } from "../../services/public-site-resolver.js";
|
|
59
81
|
import { sendRequestConfirmLink } from "../../services/request-confirm.js";
|
|
60
82
|
|
|
83
|
+
/** #7869 D1 — 16 to 255 visible ASCII characters; no spaces, no list. */
|
|
84
|
+
const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{16,255}$/;
|
|
85
|
+
|
|
61
86
|
/**
|
|
62
87
|
* Read a named string field from the body, falling back to the same-named
|
|
63
88
|
* field inside `body.payload` when the top-level value is absent — the
|
|
@@ -199,12 +224,46 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
199
224
|
workspaceId = resolved;
|
|
200
225
|
}
|
|
201
226
|
|
|
227
|
+
// #7869 D1 — after the tenant gate, before any read or write. Node
|
|
228
|
+
// lower-cases header names, and joins a duplicated header with ", ",
|
|
229
|
+
// which the pattern refuses.
|
|
230
|
+
let idempotencyKey: string | undefined;
|
|
231
|
+
if (kind === "submit") {
|
|
232
|
+
const rawKey = request.headers["idempotency-key"];
|
|
233
|
+
if (rawKey !== undefined) {
|
|
234
|
+
if (
|
|
235
|
+
typeof rawKey !== "string" ||
|
|
236
|
+
!IDEMPOTENCY_KEY_PATTERN.test(rawKey)
|
|
237
|
+
) {
|
|
238
|
+
return reply.status(400).send({
|
|
239
|
+
error: "invalid_input",
|
|
240
|
+
message:
|
|
241
|
+
"Idempotency-Key must be 16 to 255 visible ASCII characters.",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
idempotencyKey = rawKey;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
202
248
|
const result = await routeLead(
|
|
203
249
|
buildInput(kind, body, workspaceId),
|
|
204
250
|
sourceMeta,
|
|
251
|
+
idempotencyKey === undefined ? {} : { idempotencyKey },
|
|
205
252
|
);
|
|
206
253
|
|
|
207
254
|
if (!result.ok) {
|
|
255
|
+
if (result.error.kind === "idempotency_conflict") {
|
|
256
|
+
// #7869 D6 — the operator's signal that a keyed lead was refused.
|
|
257
|
+
// Never the key, the email or the payload.
|
|
258
|
+
request.log.warn(
|
|
259
|
+
{ kind, siteId: workspaceId, outcome: "idempotency_conflict" },
|
|
260
|
+
"lead idempotency_conflict",
|
|
261
|
+
);
|
|
262
|
+
return reply.status(422).send({
|
|
263
|
+
error: "idempotency_key_reused",
|
|
264
|
+
message: "This Idempotency-Key was used for a different lead.",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
208
267
|
if (result.error.kind === "invalid_input") {
|
|
209
268
|
return reply
|
|
210
269
|
.status(400)
|
|
@@ -252,6 +311,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
252
311
|
ip: sourceMeta.ip,
|
|
253
312
|
persisted: result.persisted,
|
|
254
313
|
emailKind,
|
|
314
|
+
replayed: result.replayed === true,
|
|
255
315
|
},
|
|
256
316
|
"lead routed",
|
|
257
317
|
);
|
|
@@ -292,12 +352,15 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
292
352
|
}
|
|
293
353
|
|
|
294
354
|
// Fire-and-forget: track() never throws; no need to hold the response.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
"
|
|
299
|
-
|
|
300
|
-
|
|
355
|
+
// #7869 D6 — a replay records nothing new: the first write counted it.
|
|
356
|
+
if (!result.replayed) {
|
|
357
|
+
void track("lead-captured", {
|
|
358
|
+
source: body["source"] as string | undefined,
|
|
359
|
+
locale: (body["payload"] as Record<string, unknown> | undefined)?.[
|
|
360
|
+
"locale"
|
|
361
|
+
] as string | undefined,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
301
364
|
|
|
302
365
|
return reply.status(201).send({
|
|
303
366
|
id: result.id,
|
|
@@ -132,6 +132,45 @@ and the row is still stamped — the quote is never overwritten. The stamp
|
|
|
132
132
|
runs beside the telemetry + billing tasks, never instead of them, and an
|
|
133
133
|
event without the key never touches `flowCheckout`. Always 200.
|
|
134
134
|
|
|
135
|
+
## Site filter (#7868)
|
|
136
|
+
|
|
137
|
+
The hub's Stripe account is shared with its spoke sites, so this endpoint
|
|
138
|
+
receives every site's events. A Checkout session says which site opened it
|
|
139
|
+
through `metadata.site`.
|
|
140
|
+
|
|
141
|
+
- **The stamp.** Every session the hub creates — `POST /v1/checkout/sessions`
|
|
142
|
+
and the recommender's quoted checkout (`POST /v1/flows/:slug/checkout`) —
|
|
143
|
+
carries `metadata.site` = `currentSiteId()`: `AILK_SITE_ID`, else
|
|
144
|
+
`"default"`. The key, the stamp and the foreign test are defined once in
|
|
145
|
+
`lib/site.ts` (`SITE_STAMP_KEY`, `siteStamp()`, `isForeignSiteStamp()`).
|
|
146
|
+
No request field can set or override it.
|
|
147
|
+
- **The filter.** A `checkout.session.completed` or `checkout.session.expired`
|
|
148
|
+
whose `metadata.site` is a non-empty string other than `currentSiteId()` is
|
|
149
|
+
another site's. The handler takes no action — no telemetry, billing bind,
|
|
150
|
+
recommender stamp or request-paid stamp — logs `outcome: "foreign_site"`
|
|
151
|
+
with the foreign `stamp` (first 64 characters) and the hub's `ownSite`, and
|
|
152
|
+
answers 200. The comparison is exact and case-sensitive. The event is still
|
|
153
|
+
marked seen, so a redelivery is the usual duplicate short-circuit.
|
|
154
|
+
- **Unstamped is own.** A session with no stamp, or an empty one, is the hub's
|
|
155
|
+
and is handled as before: hub Payment Links, hosted invoices, portal events
|
|
156
|
+
and sessions opened before the stamp shipped carry none, so strict equality
|
|
157
|
+
would stop hub checkout (roadmap D4).
|
|
158
|
+
- **One alias per deployment.** Two deployments on one Stripe account that
|
|
159
|
+
both leave `AILK_SITE_ID` unset both stamp `"default"` and treat each
|
|
160
|
+
other's sessions as their own — give each a distinct `AILK_SITE_ID`. The
|
|
161
|
+
alias is a deployment-wide constant: every instance and environment on the
|
|
162
|
+
account must agree on it, and change it only while no Checkout session is
|
|
163
|
+
open (a session lives up to 24 hours). A session whose stamp no longer
|
|
164
|
+
matches is acked as foreign and never retried, so its hub side effects are
|
|
165
|
+
lost; the `stamp` / `ownSite` pair in the log line is how to spot that drift.
|
|
166
|
+
- **Not filtered.** `customer.*`, `invoice.*` and subscription events carry no
|
|
167
|
+
session metadata and are handled as before. Filtering them needs a stamp on
|
|
168
|
+
those objects, which the spoke-checkout package writes (spoke pattern
|
|
169
|
+
Phase 3).
|
|
170
|
+
- **A label, not a boundary.** The stamp is written by whoever holds the
|
|
171
|
+
account's secret key. It separates cooperating sites on one account, not
|
|
172
|
+
tenants.
|
|
173
|
+
|
|
135
174
|
## Dunning (#6034)
|
|
136
175
|
|
|
137
176
|
Two arms record a per-subscriber lifecycle state so a failed renewal is no
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/webhooks/stripe — the hub's site filter (#7868).
|
|
3
|
+
*
|
|
4
|
+
* A Checkout session stamped `metadata.site` for ANOTHER site (a spoke on the
|
|
5
|
+
* shared Stripe account) produces no hub side effect on
|
|
6
|
+
* `checkout.session.completed` or `.expired`, and answers 200. An unstamped
|
|
7
|
+
* session, an empty stamp, and the hub's own stamp are handled exactly as
|
|
8
|
+
* today (roadmap D4). Spec D3/D7, AC-2 … AC-4.
|
|
9
|
+
*
|
|
10
|
+
* The stripe-flow-checkout.test.ts harness: the commerce claim mocked present,
|
|
11
|
+
* the prisma manual mock, `verifyWebhookSignature` mocked at the package
|
|
12
|
+
* boundary, the idempotency store reset per test. The seven existing
|
|
13
|
+
* `stripe*.test.ts` suites are the regression floor and are not edited.
|
|
14
|
+
*/
|
|
15
|
+
import type { FastifyInstance } from "fastify";
|
|
16
|
+
import Fastify from "fastify";
|
|
17
|
+
|
|
18
|
+
const mockTrack = jest.fn().mockResolvedValue(undefined);
|
|
19
|
+
jest.mock("@working-theory/license", () => ({
|
|
20
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
21
|
+
tier: "pro",
|
|
22
|
+
plan: "pro-monthly",
|
|
23
|
+
features: ["commerce"],
|
|
24
|
+
expiresAt: null,
|
|
25
|
+
revoked: false,
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock("@working-theory/analytics/server", () => ({
|
|
30
|
+
track: (...args: unknown[]) => mockTrack(...args),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
34
|
+
jest.mock("../../../lib/prisma.js");
|
|
35
|
+
|
|
36
|
+
const mockVerifyWebhookSignature = jest.fn();
|
|
37
|
+
class MockStripeWebhookSignatureError extends Error {
|
|
38
|
+
constructor(message: string) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "StripeWebhookSignatureError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
jest.mock("@working-theory/stripe-client", () => ({
|
|
44
|
+
verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args),
|
|
45
|
+
StripeWebhookSignatureError: MockStripeWebhookSignatureError,
|
|
46
|
+
assertSandboxKey: jest.fn(),
|
|
47
|
+
createCheckoutSession: jest.fn(),
|
|
48
|
+
getStripeClient: jest.fn(),
|
|
49
|
+
STRIPE_API_VERSION: "2024-06-20",
|
|
50
|
+
StripeConfigError: class StripeConfigError extends Error {},
|
|
51
|
+
UnknownProductCodeError: class UnknownProductCodeError extends Error {},
|
|
52
|
+
WEBHOOK_TOLERANCE_SECONDS: 300,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
import * as configModule from "../../../config/index.js";
|
|
56
|
+
import { stripeWebhookHandler } from "../stripe.js";
|
|
57
|
+
import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
|
|
58
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
59
|
+
|
|
60
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
const SESSION_ID = "cs_test_session_12345678901234567890";
|
|
63
|
+
const CUSTOMER_ID = "cus_test_12345678901234567890";
|
|
64
|
+
const EMAIL = "spoke-buyer@example.com";
|
|
65
|
+
const AMOUNT_TOTAL = 24917;
|
|
66
|
+
const USER_ID = "usr_buyer_7868";
|
|
67
|
+
const ORG_ID = "org_buyer_7868";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A session that reaches every one of the four `completed` tasks if it is
|
|
71
|
+
* dispatched: telemetry, the billing bind (customer + `ailk_user_id`), the
|
|
72
|
+
* recommender stamp (`flowCheckoutId`) and the request-paid stamp
|
|
73
|
+
* (`payment_link`, paid).
|
|
74
|
+
*/
|
|
75
|
+
function sessionEvent(
|
|
76
|
+
type: "checkout.session.completed" | "checkout.session.expired",
|
|
77
|
+
metadata: Record<string, unknown> = {},
|
|
78
|
+
) {
|
|
79
|
+
return {
|
|
80
|
+
id: "evt_test_12345678abcdef",
|
|
81
|
+
type,
|
|
82
|
+
data: {
|
|
83
|
+
object: {
|
|
84
|
+
id: SESSION_ID,
|
|
85
|
+
payment_status: "paid",
|
|
86
|
+
payment_link: "plink_test_1234567890",
|
|
87
|
+
amount_total: AMOUNT_TOTAL,
|
|
88
|
+
currency: "usd",
|
|
89
|
+
customer: CUSTOMER_ID,
|
|
90
|
+
customer_details: { email: EMAIL },
|
|
91
|
+
metadata: {
|
|
92
|
+
flowCheckoutId: "flchk_test_1",
|
|
93
|
+
flowSlug: "ailk-reco",
|
|
94
|
+
offerKey: "pro",
|
|
95
|
+
ailk_user_id: USER_ID,
|
|
96
|
+
...metadata,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type LogCall = [unknown, string?];
|
|
104
|
+
const logCalls: LogCall[] = [];
|
|
105
|
+
|
|
106
|
+
async function buildTestServer(): Promise<FastifyInstance> {
|
|
107
|
+
const server = Fastify({ logger: false });
|
|
108
|
+
await server.register(async (webhooksScope) => {
|
|
109
|
+
webhooksScope.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => {
|
|
110
|
+
done(null, body);
|
|
111
|
+
});
|
|
112
|
+
webhooksScope.addHook("onRequest", async (request) => {
|
|
113
|
+
for (const level of ["info", "warn", "error"] as const) {
|
|
114
|
+
const original = request.log[level].bind(request.log);
|
|
115
|
+
request.log[level] = ((...args: unknown[]) => {
|
|
116
|
+
logCalls.push(args as LogCall);
|
|
117
|
+
return (original as (...a: unknown[]) => void)(...args);
|
|
118
|
+
}) as typeof request.log.info;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
|
|
122
|
+
});
|
|
123
|
+
await server.ready();
|
|
124
|
+
return server;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
|
|
128
|
+
const HEADERS = { "content-type": "application/json", "stripe-signature": "t=1234,v1=abc" };
|
|
129
|
+
const ORIGINAL_AILK_SITE_ID = process.env.AILK_SITE_ID;
|
|
130
|
+
|
|
131
|
+
let server: FastifyInstance;
|
|
132
|
+
|
|
133
|
+
beforeEach(async () => {
|
|
134
|
+
jest.clearAllMocks();
|
|
135
|
+
logCalls.length = 0;
|
|
136
|
+
__resetIdempotencyStore();
|
|
137
|
+
delete process.env.AILK_SITE_ID;
|
|
138
|
+
jest.replaceProperty(configModule, "config", {
|
|
139
|
+
...configModule.config,
|
|
140
|
+
STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
|
|
141
|
+
});
|
|
142
|
+
(prisma.flowCheckout.findUnique as jest.Mock).mockResolvedValue({ amountMinor: AMOUNT_TOTAL });
|
|
143
|
+
(prisma.flowCheckout.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
144
|
+
(prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
145
|
+
(prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
|
|
146
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
|
147
|
+
(prisma.organization.findFirst as jest.Mock).mockResolvedValue(null);
|
|
148
|
+
(prisma.organization.update as jest.Mock).mockResolvedValue({});
|
|
149
|
+
server = await buildTestServer();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(async () => {
|
|
153
|
+
await server.close();
|
|
154
|
+
jest.restoreAllMocks();
|
|
155
|
+
if (ORIGINAL_AILK_SITE_ID === undefined) delete process.env.AILK_SITE_ID;
|
|
156
|
+
else process.env.AILK_SITE_ID = ORIGINAL_AILK_SITE_ID;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
async function post() {
|
|
160
|
+
return server.inject({ method: "POST", url: "/v1/webhooks/stripe", payload: VALID_BODY, headers: HEADERS });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function foreignSiteLogs() {
|
|
164
|
+
return logCalls.filter(
|
|
165
|
+
([obj]) => typeof obj === "object" && obj !== null && (obj as Record<string, unknown>)["outcome"] === "foreign_site",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function expectNoHubSideEffect() {
|
|
170
|
+
expect(mockTrack).not.toHaveBeenCalled();
|
|
171
|
+
expect(prisma.flowCheckout.findUnique).not.toHaveBeenCalled();
|
|
172
|
+
expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
|
|
173
|
+
expect(prisma.callback.updateMany).not.toHaveBeenCalled();
|
|
174
|
+
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
|
175
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
176
|
+
expect(prisma.user.update).not.toHaveBeenCalled();
|
|
177
|
+
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
|
178
|
+
expect(prisma.organization.findFirst).not.toHaveBeenCalled();
|
|
179
|
+
expect(prisma.organization.update).not.toHaveBeenCalled();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function expectHandledAsToday() {
|
|
183
|
+
expect(mockTrack).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(mockTrack).toHaveBeenCalledWith(
|
|
185
|
+
"purchase-completed",
|
|
186
|
+
expect.objectContaining({ amount: AMOUNT_TOTAL, eventType: "checkout.session.completed" }),
|
|
187
|
+
);
|
|
188
|
+
expect(prisma.flowCheckout.updateMany).toHaveBeenCalledTimes(1);
|
|
189
|
+
expect(prisma.callback.updateMany).toHaveBeenCalledTimes(1);
|
|
190
|
+
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
|
191
|
+
where: { billingProvider: "stripe", billingCustomerId: CUSTOMER_ID },
|
|
192
|
+
});
|
|
193
|
+
expect(foreignSiteLogs()).toHaveLength(0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Case 1 — a foreign `completed` produces no hub side effect (AC-2) ───────
|
|
197
|
+
|
|
198
|
+
describe("case 1 — a completed session stamped for another site", () => {
|
|
199
|
+
it("answers 200 and runs none of the four tasks", async () => {
|
|
200
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
201
|
+
sessionEvent("checkout.session.completed", { site: "samhenry" }),
|
|
202
|
+
);
|
|
203
|
+
const res = await post();
|
|
204
|
+
|
|
205
|
+
expect(res.statusCode).toBe(200);
|
|
206
|
+
expect(JSON.parse(res.body)).toEqual({ received: true });
|
|
207
|
+
expectNoHubSideEffect();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("logs one foreign_site line carrying the stamp and the own site, and no buyer field or amount", async () => {
|
|
211
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
212
|
+
sessionEvent("checkout.session.completed", { site: "samhenry" }),
|
|
213
|
+
);
|
|
214
|
+
await post();
|
|
215
|
+
|
|
216
|
+
const lines = foreignSiteLogs();
|
|
217
|
+
expect(lines).toHaveLength(1);
|
|
218
|
+
expect(lines[0]![0]).toEqual({
|
|
219
|
+
eventType: "checkout.session.completed",
|
|
220
|
+
eventIdPrefix: "evt_test",
|
|
221
|
+
outcome: "foreign_site",
|
|
222
|
+
stamp: "samhenry",
|
|
223
|
+
ownSite: "default",
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const logged = JSON.stringify(logCalls);
|
|
227
|
+
expect(logged).not.toContain(EMAIL);
|
|
228
|
+
expect(logged).not.toContain(String(AMOUNT_TOTAL));
|
|
229
|
+
expect(logged).not.toContain(USER_ID);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("logs exactly the first 64 characters of a long foreign stamp", async () => {
|
|
233
|
+
const longStamp = "s".repeat(36) + "t".repeat(64);
|
|
234
|
+
expect(longStamp).toHaveLength(100);
|
|
235
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
236
|
+
sessionEvent("checkout.session.completed", { site: longStamp }),
|
|
237
|
+
);
|
|
238
|
+
const res = await post();
|
|
239
|
+
|
|
240
|
+
expect(res.statusCode).toBe(200);
|
|
241
|
+
expectNoHubSideEffect();
|
|
242
|
+
const lines = foreignSiteLogs();
|
|
243
|
+
expect(lines).toHaveLength(1);
|
|
244
|
+
expect((lines[0]![0] as Record<string, unknown>)["stamp"]).toBe(longStamp.slice(0, 64));
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── Case 2 — a foreign session never reaches the Organization write ─────────
|
|
249
|
+
|
|
250
|
+
describe("case 2 — a foreign session carrying metadata.organizationId", () => {
|
|
251
|
+
it("writes no Organization", async () => {
|
|
252
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
253
|
+
sessionEvent("checkout.session.completed", { site: "samhenry", organizationId: ORG_ID }),
|
|
254
|
+
);
|
|
255
|
+
const res = await post();
|
|
256
|
+
|
|
257
|
+
expect(res.statusCode).toBe(200);
|
|
258
|
+
expectNoHubSideEffect();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── Case 3 — a foreign `expired` stamps no FlowCheckout (AC-3) ──────────────
|
|
263
|
+
|
|
264
|
+
describe("case 3 — an expired session stamped for another site", () => {
|
|
265
|
+
it("answers 200 and never expires a hub FlowCheckout", async () => {
|
|
266
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
267
|
+
sessionEvent("checkout.session.expired", { site: "samhenry" }),
|
|
268
|
+
);
|
|
269
|
+
const res = await post();
|
|
270
|
+
|
|
271
|
+
expect(res.statusCode).toBe(200);
|
|
272
|
+
expect(JSON.parse(res.body)).toEqual({ received: true });
|
|
273
|
+
expect(prisma.flowCheckout.updateMany).not.toHaveBeenCalled();
|
|
274
|
+
const lines = foreignSiteLogs();
|
|
275
|
+
expect(lines).toHaveLength(1);
|
|
276
|
+
expect(lines[0]![0]).toMatchObject({
|
|
277
|
+
eventType: "checkout.session.expired",
|
|
278
|
+
stamp: "samhenry",
|
|
279
|
+
ownSite: "default",
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// ── Cases 4–7 — own and unstamped sessions are handled as today (AC-4) ──────
|
|
285
|
+
|
|
286
|
+
describe("cases 4–7 — an unstamped, own-stamped or empty-stamped session is the hub's own", () => {
|
|
287
|
+
it("case 4 — an unstamped session runs every task, as today", async () => {
|
|
288
|
+
mockVerifyWebhookSignature.mockReturnValue(sessionEvent("checkout.session.completed"));
|
|
289
|
+
const res = await post();
|
|
290
|
+
|
|
291
|
+
expect(res.statusCode).toBe(200);
|
|
292
|
+
expectHandledAsToday();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("case 5 — a session stamped with the default alias, AILK_SITE_ID unset, runs every task", async () => {
|
|
296
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
297
|
+
sessionEvent("checkout.session.completed", { site: "default" }),
|
|
298
|
+
);
|
|
299
|
+
const res = await post();
|
|
300
|
+
|
|
301
|
+
expect(res.statusCode).toBe(200);
|
|
302
|
+
expectHandledAsToday();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("case 6 — under AILK_SITE_ID=wksp_hub, a wksp_hub stamp is own", async () => {
|
|
306
|
+
process.env.AILK_SITE_ID = "wksp_hub";
|
|
307
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
308
|
+
sessionEvent("checkout.session.completed", { site: "wksp_hub" }),
|
|
309
|
+
);
|
|
310
|
+
const res = await post();
|
|
311
|
+
|
|
312
|
+
expect(res.statusCode).toBe(200);
|
|
313
|
+
expectHandledAsToday();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("case 6 — under AILK_SITE_ID=wksp_hub, a default stamp is foreign", async () => {
|
|
317
|
+
process.env.AILK_SITE_ID = "wksp_hub";
|
|
318
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
319
|
+
sessionEvent("checkout.session.completed", { site: "default" }),
|
|
320
|
+
);
|
|
321
|
+
const res = await post();
|
|
322
|
+
|
|
323
|
+
expect(res.statusCode).toBe(200);
|
|
324
|
+
expectNoHubSideEffect();
|
|
325
|
+
const lines = foreignSiteLogs();
|
|
326
|
+
expect(lines).toHaveLength(1);
|
|
327
|
+
expect(lines[0]![0]).toMatchObject({ stamp: "default", ownSite: "wksp_hub" });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("case 7 — an empty stamp is unstamped, and runs every task", async () => {
|
|
331
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
332
|
+
sessionEvent("checkout.session.completed", { site: "" }),
|
|
333
|
+
);
|
|
334
|
+
const res = await post();
|
|
335
|
+
|
|
336
|
+
expect(res.statusCode).toBe(200);
|
|
337
|
+
expectHandledAsToday();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ── Case 8 — the event-id dedup is unchanged ────────────────────────────────
|
|
342
|
+
|
|
343
|
+
describe("case 8 — a foreign event delivered twice", () => {
|
|
344
|
+
it("is marked seen, so the redelivery takes the duplicate short-circuit", async () => {
|
|
345
|
+
mockVerifyWebhookSignature.mockReturnValue(
|
|
346
|
+
sessionEvent("checkout.session.completed", { site: "samhenry" }),
|
|
347
|
+
);
|
|
348
|
+
const first = await post();
|
|
349
|
+
expect(first.statusCode).toBe(200);
|
|
350
|
+
expect(foreignSiteLogs()).toHaveLength(1);
|
|
351
|
+
|
|
352
|
+
// Each delivery's lines are read on their own: the harness re-wraps the
|
|
353
|
+
// shared request logger per request, so a second request logs each line
|
|
354
|
+
// more than once.
|
|
355
|
+
logCalls.length = 0;
|
|
356
|
+
const second = await post();
|
|
357
|
+
|
|
358
|
+
expect(second.statusCode).toBe(200);
|
|
359
|
+
expect(JSON.parse(second.body)).toEqual({ received: true });
|
|
360
|
+
expect(foreignSiteLogs()).toHaveLength(0);
|
|
361
|
+
expect(logCalls.map(([, msg]) => msg)).toContain(
|
|
362
|
+
"duplicate stripe webhook event — idempotent short-circuit",
|
|
363
|
+
);
|
|
364
|
+
expectNoHubSideEffect();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -31,6 +31,15 @@
|
|
|
31
31
|
* Successful dispatch always 200s — including unknown event types.
|
|
32
32
|
* Telemetry failures are caught, logged, and do NOT change the response.
|
|
33
33
|
*
|
|
34
|
+
* Site filter (#7868):
|
|
35
|
+
* The Stripe account is shared with spoke sites, so this endpoint receives
|
|
36
|
+
* their Checkout sessions too. A `checkout.session.completed` or `.expired`
|
|
37
|
+
* whose `metadata.site` is a non-empty string naming another site
|
|
38
|
+
* (`isForeignSiteStamp`, lib/site.ts) takes no action: it logs
|
|
39
|
+
* `outcome: "foreign_site"` and answers 200. An unstamped session is the
|
|
40
|
+
* hub's own (roadmap D4) and is handled as before. The hub stamps its own
|
|
41
|
+
* sessions through `siteStamp()`. See README.md § Site filter (#7868).
|
|
42
|
+
*
|
|
34
43
|
* Billing upsert (customer.created / checkout.session.completed):
|
|
35
44
|
* Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
|
|
36
45
|
* carrying metadata.organizationId links the Organization (multi-tenant
|
|
@@ -83,7 +92,11 @@ import {
|
|
|
83
92
|
LICENSE_UNDETERMINED_RETRY_AFTER_SECONDS,
|
|
84
93
|
LICENSE_UNDETERMINED_STATUS,
|
|
85
94
|
} from "../../lib/require-commerce-claim.js";
|
|
86
|
-
import {
|
|
95
|
+
import {
|
|
96
|
+
currentSiteId,
|
|
97
|
+
isForeignSiteStamp,
|
|
98
|
+
SITE_STAMP_KEY,
|
|
99
|
+
} from "../../lib/site.js";
|
|
87
100
|
import {
|
|
88
101
|
StripeWebhookSignatureError,
|
|
89
102
|
toSubscriptionLifecycleStatus,
|
|
@@ -299,6 +312,14 @@ export const stripeWebhookHandler = async (
|
|
|
299
312
|
}
|
|
300
313
|
case "checkout.session.completed": {
|
|
301
314
|
const session = event.data.object as CheckoutSessionObject;
|
|
315
|
+
if (isForeignSiteStamp(session.metadata)) {
|
|
316
|
+
logForeignSiteSkip(request, {
|
|
317
|
+
stamp: session.metadata?.[SITE_STAMP_KEY],
|
|
318
|
+
eventType: event.type,
|
|
319
|
+
eventIdPrefix,
|
|
320
|
+
});
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
302
323
|
const props = {
|
|
303
324
|
amount: session.amount_total ?? undefined,
|
|
304
325
|
currency: session.currency ?? undefined,
|
|
@@ -363,6 +384,14 @@ export const stripeWebhookHandler = async (
|
|
|
363
384
|
}
|
|
364
385
|
case "checkout.session.expired": {
|
|
365
386
|
const session = event.data.object as CheckoutSessionObject;
|
|
387
|
+
if (isForeignSiteStamp(session.metadata)) {
|
|
388
|
+
logForeignSiteSkip(request, {
|
|
389
|
+
stamp: session.metadata?.[SITE_STAMP_KEY],
|
|
390
|
+
eventType: event.type,
|
|
391
|
+
eventIdPrefix,
|
|
392
|
+
});
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
366
395
|
if (!session.metadata?.["flowCheckoutId"]) {
|
|
367
396
|
// Not a recommender session — the same low-noise line the default
|
|
368
397
|
// arm emitted for this event before #4838 handled it.
|
|
@@ -432,6 +461,37 @@ type CheckoutSessionObject = {
|
|
|
432
461
|
metadata?: Record<string, string> | null;
|
|
433
462
|
};
|
|
434
463
|
|
|
464
|
+
/**
|
|
465
|
+
* The one log line for a session another site stamped (#7868 D3). Carries the
|
|
466
|
+
* foreign stamp (first 64 characters) and this site's own alias, so a spoke
|
|
467
|
+
* event correctly skipped can be told from a hub event whose alias drifted —
|
|
468
|
+
* site labels only: it takes the stamp, never the session, so no buyer field
|
|
469
|
+
* or amount can reach it.
|
|
470
|
+
*/
|
|
471
|
+
function logForeignSiteSkip(
|
|
472
|
+
request: FastifyRequest,
|
|
473
|
+
{
|
|
474
|
+
stamp,
|
|
475
|
+
eventType,
|
|
476
|
+
eventIdPrefix,
|
|
477
|
+
}: {
|
|
478
|
+
stamp: string | undefined;
|
|
479
|
+
eventType: string;
|
|
480
|
+
eventIdPrefix: string;
|
|
481
|
+
},
|
|
482
|
+
): void {
|
|
483
|
+
request.log.info(
|
|
484
|
+
{
|
|
485
|
+
eventType,
|
|
486
|
+
eventIdPrefix,
|
|
487
|
+
outcome: "foreign_site",
|
|
488
|
+
stamp: stamp?.slice(0, 64),
|
|
489
|
+
ownSite: currentSiteId(),
|
|
490
|
+
},
|
|
491
|
+
"checkout session stamped for another site — skipped",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
435
495
|
/**
|
|
436
496
|
* Minimal projected shape of a Stripe Invoice object for the dunning arms
|
|
437
497
|
* (#6034 FR-1). `subscription` and `customer` are EXPANDABLE Stripe fields —
|