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.
Files changed (188) hide show
  1. package/README.md +12 -4
  2. package/component-catalog.json +250 -36
  3. package/component-catalog.md +10 -6
  4. package/dist/add-module.d.ts +7 -0
  5. package/dist/add-module.js +34 -2
  6. package/dist/add-route.js +18 -2
  7. package/dist/apply-design.d.ts +32 -2
  8. package/dist/apply-design.js +155 -22
  9. package/dist/cli.js +24 -3
  10. package/dist/derive-architecture.d.ts +15 -2
  11. package/dist/derive-architecture.js +34 -6
  12. package/dist/design-compile.d.ts +70 -2
  13. package/dist/design-compile.js +97 -1
  14. package/dist/design-home.d.ts +74 -0
  15. package/dist/design-home.js +256 -0
  16. package/dist/design-package.d.ts +39 -2
  17. package/dist/design-package.js +224 -9
  18. package/dist/design-presets.d.ts +17 -1
  19. package/dist/design-presets.js +17 -5
  20. package/dist/design-scaffold.d.ts +6 -25
  21. package/dist/design-scaffold.js +26 -74
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +1 -1
  24. package/dist/lib/chrome-readers.d.ts +21 -0
  25. package/dist/lib/chrome-readers.js +100 -0
  26. package/dist/lib/design-checks.d.ts +12 -1
  27. package/dist/lib/design-checks.js +38 -2
  28. package/dist/lib/extract-module-bundle.d.ts +11 -0
  29. package/dist/lib/extract-module-bundle.js +36 -6
  30. package/dist/lib/license-key-files.d.ts +6 -1
  31. package/dist/lib/license-key-files.js +9 -4
  32. package/dist/lib/module-license-gate.d.ts +9 -0
  33. package/dist/lib/module-license-gate.js +18 -0
  34. package/dist/module-architecture.d.ts +9 -0
  35. package/dist/module-architecture.js +6 -0
  36. package/dist/parse-args.d.ts +22 -0
  37. package/dist/parse-args.js +73 -0
  38. package/dist/programmatic.d.ts +20 -0
  39. package/dist/programmatic.js +180 -35
  40. package/dist/site-chrome.d.ts +42 -0
  41. package/dist/site-chrome.js +140 -2
  42. package/dist/site-fonts.d.ts +21 -0
  43. package/dist/site-fonts.js +54 -0
  44. package/dist/site-manifest.d.ts +12 -5
  45. package/dist/site-manifest.js +25 -11
  46. package/dist/site-roster.d.ts +16 -0
  47. package/dist/site-roster.js +81 -0
  48. package/dist/starter-grid.js +15 -0
  49. package/dist/surfaces.js +12 -0
  50. package/dist/sync-routes.js +11 -2
  51. package/dist/team-profiles.d.ts +29 -0
  52. package/dist/team-profiles.js +43 -0
  53. package/dist/topology.d.ts +131 -0
  54. package/dist/topology.js +533 -0
  55. package/package.json +3 -3
  56. package/release-scope.json +1 -1
  57. package/site-templates/_base/content/en/TeamList/team.mdx +68 -0
  58. package/site-templates/_base/content/en/TeamMember/first-member.mdx +22 -0
  59. package/site-templates/_base/content/en/TeamMember/second-member.mdx +22 -0
  60. package/site-templates/_base/content/en/TeamMember/third-member.mdx +22 -0
  61. package/site-templates/designs/minimalism/DESIGN.md +166 -36
  62. package/site-templates/designs/minimalism/home/local-business-service.mdx +119 -0
  63. package/site-templates/designs/minimalism/home/organization-product.mdx +132 -0
  64. package/site-templates/designs/minimalism/home/organization-service.mdx +131 -0
  65. package/site-templates/designs/minimalism/home/person-service.mdx +101 -0
  66. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +34 -1
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +20 -0
  68. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +34 -1
  69. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +22 -1
  70. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  71. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +34 -1
  72. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -7
  73. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  74. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +34 -1
  75. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -7
  76. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  77. package/templates/README.md +2 -0
  78. package/templates/apps/api/CLAUDE.md +6 -1
  79. package/templates/apps/api/package.json +1 -0
  80. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  81. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  82. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  83. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  84. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  85. package/templates/apps/api/src/lib/site.ts +37 -0
  86. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  87. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  88. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  89. package/templates/apps/api/src/middleware/auth.ts +3 -0
  90. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  91. package/templates/apps/api/src/openapi/spec.ts +38 -2
  92. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  93. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  94. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  95. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  96. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  97. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  98. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  99. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  100. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  101. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  102. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  103. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  104. package/templates/apps/mcp/package.json +1 -1
  105. package/templates/apps/mcp/src/index.ts +6 -4
  106. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  107. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  108. package/templates/apps/web/CLAUDE.md +12 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +10 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/__tests__/theme-modes.test.ts +42 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +8 -0
  112. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  113. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  115. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  116. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +14 -1
  117. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  118. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +163 -0
  119. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +68 -0
  120. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +4 -4
  121. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +15 -3
  122. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +17 -6
  123. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +14 -1
  124. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +2 -2
  125. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +4 -6
  126. package/templates/apps/web/app/[locale]/layout.tsx +212 -57
  127. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  128. package/templates/apps/web/app/[locale]/page.tsx +37 -0
  129. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +4 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  132. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +15 -2
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +43 -6
  134. package/templates/apps/web/app/[locale]/services/page.tsx +14 -2
  135. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/team/[slug]/page.tsx +45 -0
  137. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +155 -0
  138. package/templates/apps/web/app/[locale]/team/page.tsx +49 -0
  139. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +2 -2
  140. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +14 -1
  141. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  142. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +2 -2
  143. package/templates/apps/web/app/not-found.tsx +47 -0
  144. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  145. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  146. package/templates/apps/web/instrumentation.ts +7 -4
  147. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  148. package/templates/apps/web/lib/__tests__/profile-back-link.test.ts +95 -0
  149. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  150. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  151. package/templates/apps/web/lib/__tests__/site-theme-head.test.ts +70 -0
  152. package/templates/apps/web/lib/__tests__/site-theme.test.ts +64 -1
  153. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  154. package/templates/apps/web/lib/profile-back-link.ts +51 -0
  155. package/templates/apps/web/lib/relay.ts +206 -2
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +15 -0
  158. package/templates/apps/web/lib/site-fonts.ts +15 -0
  159. package/templates/apps/web/lib/site-logo.tsx +39 -1
  160. package/templates/apps/web/lib/site-theme-head.ts +33 -0
  161. package/templates/apps/web/lib/site-theme.ts +13 -1
  162. package/templates/apps/web/lib/topology.generated.ts +23 -0
  163. package/templates/apps/web/messages/en.json +7 -1
  164. package/templates/apps/web/package.json +1 -1
  165. package/templates/database/CHANGELOG.md +32 -0
  166. package/templates/database/__tests__/agents.db.test.ts +65 -0
  167. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  168. package/templates/database/auth/schema.prisma +23 -0
  169. package/templates/database/inbox/schema.prisma +13 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/package.json +1 -1
  175. package/versions.json +35 -34
  176. package/site-templates/designs/minimalism/sample/README.md +0 -48
  177. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +0 -244
  178. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-1.svg +0 -7
  179. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-2.svg +0 -7
  180. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-3.svg +0 -7
  181. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-4.svg +0 -7
  182. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-5.svg +0 -7
  183. package/site-templates/designs/minimalism/sample/public/avatar-placeholder-6.svg +0 -7
  184. package/site-templates/designs/minimalism/sample/public/hero-backdrop.svg +0 -17
  185. package/site-templates/designs/minimalism/sample/public/logo-placeholder-1.svg +0 -6
  186. package/site-templates/designs/minimalism/sample/public/logo-placeholder-2.svg +0 -6
  187. package/site-templates/designs/minimalism/sample/public/logo-placeholder-3.svg +0 -6
  188. 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
- void track("lead-captured", {
296
- source: body["source"] as string | undefined,
297
- locale: (body["payload"] as Record<string, unknown> | undefined)?.[
298
- "locale"
299
- ] as string | undefined,
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 { currentSiteId } from "../../lib/site.js";
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 —