create-ailk 0.14.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 (95) hide show
  1. package/component-catalog.json +3 -3
  2. package/dist/add-module.d.ts +7 -0
  3. package/dist/add-module.js +34 -2
  4. package/dist/add-route.js +18 -2
  5. package/dist/apply-design.d.ts +12 -1
  6. package/dist/apply-design.js +36 -4
  7. package/dist/cli.js +23 -3
  8. package/dist/derive-architecture.d.ts +15 -2
  9. package/dist/derive-architecture.js +34 -6
  10. package/dist/design-package.js +3 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/chrome-readers.d.ts +21 -0
  14. package/dist/lib/chrome-readers.js +100 -0
  15. package/dist/lib/extract-module-bundle.d.ts +11 -0
  16. package/dist/lib/extract-module-bundle.js +36 -6
  17. package/dist/lib/license-key-files.d.ts +6 -1
  18. package/dist/lib/license-key-files.js +9 -4
  19. package/dist/module-architecture.d.ts +9 -0
  20. package/dist/module-architecture.js +6 -0
  21. package/dist/parse-args.d.ts +22 -0
  22. package/dist/parse-args.js +73 -0
  23. package/dist/programmatic.d.ts +20 -0
  24. package/dist/programmatic.js +110 -9
  25. package/dist/site-chrome.d.ts +4 -0
  26. package/dist/site-chrome.js +34 -3
  27. package/dist/site-manifest.d.ts +12 -5
  28. package/dist/site-manifest.js +25 -11
  29. package/dist/sync-routes.js +11 -2
  30. package/dist/topology.d.ts +131 -0
  31. package/dist/topology.js +533 -0
  32. package/package.json +3 -3
  33. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  34. package/templates/apps/api/CLAUDE.md +4 -2
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  38. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  39. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  40. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  41. package/templates/apps/api/src/lib/site.ts +37 -0
  42. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  43. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  44. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  45. package/templates/apps/api/src/middleware/auth.ts +3 -0
  46. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  47. package/templates/apps/api/src/openapi/spec.ts +38 -2
  48. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  49. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  50. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  51. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  52. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  53. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  54. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  55. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  56. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  57. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  58. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  59. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  60. package/templates/apps/mcp/src/index.ts +6 -4
  61. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  62. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  63. package/templates/apps/web/CLAUDE.md +1 -0
  64. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  67. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  68. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  69. package/templates/apps/web/app/[locale]/layout.tsx +71 -19
  70. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  71. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  72. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  74. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  75. package/templates/apps/web/instrumentation.ts +7 -4
  76. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  77. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  78. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  79. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  80. package/templates/apps/web/lib/relay.ts +206 -2
  81. package/templates/apps/web/lib/site-brand.tsx +22 -1
  82. package/templates/apps/web/lib/site-chrome.ts +3 -0
  83. package/templates/apps/web/lib/topology.generated.ts +23 -0
  84. package/templates/apps/web/package.json +1 -1
  85. package/templates/database/CHANGELOG.md +25 -0
  86. package/templates/database/__tests__/agents.db.test.ts +65 -0
  87. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  88. package/templates/database/auth/schema.prisma +23 -0
  89. package/templates/database/inbox/schema.prisma +13 -0
  90. package/templates/database/jest.config.cjs +7 -11
  91. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  92. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  93. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  94. package/templates/database/package.json +1 -1
  95. package/versions.json +34 -33
@@ -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 —
@@ -747,3 +747,202 @@ describe("#6730 — routeLead delivery modes", () => {
747
747
  });
748
748
  });
749
749
  });
750
+
751
+ // ── #7869 — the Idempotency-Key on submit (spec D3–D5, D10 cases 9–14) ──────
752
+ //
753
+ // Cases 9–13 run on `inbox` delivery (DATABASE_URL set), where the key is
754
+ // honoured; case 14 drops DATABASE_URL to prove a non-inbox delivery ignores
755
+ // it. A keyed create that loses the race throws P2002 and is answered from a
756
+ // re-read of the winner.
757
+ describe("#7869 — routeLead with an idempotency key", () => {
758
+ const KEY = "fixture-retry-0000-0000-0001";
759
+ const WINNER_ID = "lead_00000000-0000-7000-8000-00000000f001";
760
+ const ENV_KEYS = [
761
+ "DATABASE_URL",
762
+ "NODE_ENV",
763
+ "AILK_INBOX_FALLBACK_DIR",
764
+ "LEAD_CAPTURE_FILE",
765
+ "AILK_LEAD_NOTIFICATION_TO",
766
+ "AILK_LEAD_NOTIFICATION_FROM",
767
+ ] as const;
768
+ let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
769
+ let tmpDir: string;
770
+
771
+ const p2002 = () =>
772
+ Object.assign(
773
+ new Error(
774
+ "Unique constraint failed on the fields: (`workspace_id`,`idempotency_key`)",
775
+ ),
776
+ { code: "P2002" },
777
+ );
778
+ const keyWhere = (workspaceId: string) => ({
779
+ where: { workspaceId_idempotencyKey: { workspaceId, idempotencyKey: KEY } },
780
+ select: { id: true, email: true, source: true, payload: true },
781
+ });
782
+ const createData = () =>
783
+ jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data as Record<
784
+ string,
785
+ unknown
786
+ >;
787
+
788
+ beforeEach(async () => {
789
+ jest.clearAllMocks();
790
+ saved = {};
791
+ for (const k of ENV_KEYS) saved[k] = process.env[k];
792
+ for (const k of ENV_KEYS) delete process.env[k];
793
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-idem-test-"));
794
+ process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
795
+ process.env.DATABASE_URL = "postgres://stub";
796
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
797
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
798
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_new" });
799
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
800
+ mockSendLeadNotification.mockResolvedValue({
801
+ ok: true,
802
+ messageId: "msg-1",
803
+ });
804
+ });
805
+
806
+ afterEach(async () => {
807
+ for (const k of ENV_KEYS) {
808
+ if (saved[k] === undefined) delete process.env[k];
809
+ else process.env[k] = saved[k];
810
+ }
811
+ await fs.rm(tmpDir, { recursive: true, force: true });
812
+ });
813
+
814
+ it("case 9 — a P2002 race whose winner matches answers the winner's id: no fallback, no persist_failed, no notification, no capture record", async () => {
815
+ // Positive control first: a keyed create with no race writes exactly one
816
+ // line to the capture file, so the negative assertion below is load-bearing.
817
+ const controlFile = path.join(tmpDir, "control.jsonl");
818
+ process.env.LEAD_CAPTURE_FILE = controlFile;
819
+ const control = await routeLead(leadInput, sourceMeta, {
820
+ idempotencyKey: KEY,
821
+ });
822
+ expect(control).toMatchObject({
823
+ ok: true,
824
+ id: "lead_db_new",
825
+ persisted: "db",
826
+ });
827
+ expect(
828
+ (await fs.readFile(controlFile, "utf8")).trim().split("\n"),
829
+ ).toHaveLength(1);
830
+ expect(createData()).toMatchObject({ idempotencyKey: KEY });
831
+
832
+ jest.clearAllMocks();
833
+ const raceFile = path.join(tmpDir, "race.jsonl");
834
+ process.env.LEAD_CAPTURE_FILE = raceFile;
835
+ (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
836
+ (prisma.lead.findUnique as jest.Mock)
837
+ .mockResolvedValueOnce(null) // the pre-write lookup misses
838
+ .mockResolvedValueOnce({
839
+ id: WINNER_ID,
840
+ email: leadInput.email,
841
+ source: leadInput.source,
842
+ payload: { ...leadInput.payload },
843
+ });
844
+ const before = await fs.readdir(tmpDir);
845
+
846
+ const result = await routeLead(leadInput, sourceMeta, {
847
+ idempotencyKey: KEY,
848
+ });
849
+
850
+ expect(result).toEqual({
851
+ ok: true,
852
+ replayed: true,
853
+ id: WINNER_ID,
854
+ persisted: "db",
855
+ email: { skipped: true, reason: "idempotent_replay" },
856
+ });
857
+ expect(prisma.lead.findUnique).toHaveBeenNthCalledWith(
858
+ 2,
859
+ keyWhere(leadInput.siteId),
860
+ );
861
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
862
+ await expect(fs.access(raceFile)).rejects.toThrow();
863
+ expect(await fs.readdir(tmpDir)).toEqual(before); // no file fallback
864
+ });
865
+
866
+ it("case 10 — a P2002 race whose winner carries a different email is idempotency_conflict", async () => {
867
+ (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
868
+ (prisma.lead.findUnique as jest.Mock)
869
+ .mockResolvedValueOnce(null)
870
+ .mockResolvedValueOnce({
871
+ id: WINNER_ID,
872
+ email: "someone-else@example.com",
873
+ source: leadInput.source,
874
+ payload: leadInput.payload,
875
+ });
876
+
877
+ const result = await routeLead(leadInput, sourceMeta, {
878
+ idempotencyKey: KEY,
879
+ });
880
+
881
+ expect(result).toEqual({
882
+ ok: false,
883
+ error: {
884
+ kind: "idempotency_conflict",
885
+ message: "idempotency key reused",
886
+ },
887
+ });
888
+ expect(mockSendLeadNotification).not.toHaveBeenCalled();
889
+ });
890
+
891
+ it("case 11 — a P2002 race whose re-read finds nothing takes the existing persist_failed path", async () => {
892
+ (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
893
+
894
+ const result = (await routeLead(leadInput, sourceMeta, {
895
+ idempotencyKey: KEY,
896
+ })) as Extract<RouteLeadResult, { ok: false }>;
897
+
898
+ expect(result.ok).toBe(false);
899
+ expect(result.error.kind).toBe("persist_failed");
900
+ expect(prisma.lead.findUnique).toHaveBeenCalledTimes(2);
901
+ });
902
+
903
+ it("case 12 — a P2002 with no key passed is persist_failed, as today, with no key lookup", async () => {
904
+ (prisma.lead.create as jest.Mock).mockRejectedValue(p2002());
905
+
906
+ const result = (await routeLead(leadInput, sourceMeta)) as Extract<
907
+ RouteLeadResult,
908
+ { ok: false }
909
+ >;
910
+
911
+ expect(result.ok).toBe(false);
912
+ expect(result.error.kind).toBe("persist_failed");
913
+ expect(prisma.lead.findUnique).not.toHaveBeenCalled();
914
+ expect(createData()).not.toHaveProperty("idempotencyKey");
915
+ });
916
+
917
+ it("case 13 — a lookup that throws still reaches the create (the existing path)", async () => {
918
+ (prisma.lead.findUnique as jest.Mock).mockRejectedValue(
919
+ new Error("connection reset"),
920
+ );
921
+
922
+ const result = await routeLead(leadInput, sourceMeta, {
923
+ idempotencyKey: KEY,
924
+ });
925
+
926
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
927
+ expect(result).toMatchObject({
928
+ ok: true,
929
+ id: "lead_db_new",
930
+ persisted: "db",
931
+ });
932
+ });
933
+
934
+ it("case 14 — a non-inbox delivery ignores the key: no lookup, no key on the create, the existing file path", async () => {
935
+ delete process.env.DATABASE_URL; // `local` delivery outside production
936
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
937
+ new Error("connection refused"),
938
+ );
939
+
940
+ const result = await routeLead(leadInput, sourceMeta, {
941
+ idempotencyKey: KEY,
942
+ });
943
+
944
+ expect(prisma.lead.findUnique).not.toHaveBeenCalled();
945
+ expect(createData()).not.toHaveProperty("idempotencyKey");
946
+ expect(result).toMatchObject({ ok: true, persisted: "file" });
947
+ });
948
+ });