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
@@ -751,7 +751,25 @@ export function buildOpenApiSpec(
751
751
  "only), or, on a deployment with no database, the site operator's inbox " +
752
752
  "by email (`persisted: \"email\"`); with a database, the response's " +
753
753
  "`email` field reports, best-effort, whether the operator was notified. " +
754
- "A lead is never reported captured when nothing stored or delivered it.",
754
+ "A lead is never reported captured when nothing stored or delivered it. " +
755
+ "An optional `Idempotency-Key` header makes a retry safe: with a " +
756
+ "database, a repeat of a stored key in the same workspace with the same " +
757
+ "`email`, `source` and `payload` writes nothing and answers the first " +
758
+ "lead's id with a 201; with anything different it is a 422. Keys are " +
759
+ "scoped per workspace, are first-write-wins, and should be unguessable " +
760
+ "(a UUID, a Stripe Checkout Session id). Without a database there is no " +
761
+ "stored lead to match, so the key has no effect.",
762
+ parameters: [
763
+ {
764
+ name: "Idempotency-Key",
765
+ in: "header",
766
+ required: false,
767
+ description:
768
+ "An unguessable value identifying this lead write, so a retry " +
769
+ "answers the first lead instead of writing a second (#7869).",
770
+ schema: { type: "string", pattern: "^[\\x21-\\x7E]{16,255}$" },
771
+ },
772
+ ],
755
773
  requestBody: {
756
774
  required: true,
757
775
  content: {
@@ -770,13 +788,31 @@ export function buildOpenApiSpec(
770
788
  },
771
789
  },
772
790
  "400": {
773
- description: "Validation error — invalid request body",
791
+ description:
792
+ "Validation error — invalid request body, or a malformed Idempotency-Key",
774
793
  content: {
775
794
  "application/json": {
776
795
  schema: { $ref: "#/components/schemas/InvalidInputResponse" },
777
796
  },
778
797
  },
779
798
  },
799
+ "422": {
800
+ description:
801
+ "The Idempotency-Key was already used in this workspace for a lead " +
802
+ "with a different email, source or payload; nothing was written",
803
+ content: {
804
+ "application/json": {
805
+ schema: {
806
+ type: "object",
807
+ required: ["error", "message"],
808
+ properties: {
809
+ error: { type: "string", enum: ["idempotency_key_reused"] },
810
+ message: { type: "string" },
811
+ },
812
+ },
813
+ },
814
+ },
815
+ },
780
816
  "503": {
781
817
  description:
782
818
  "Lead capture unavailable — persistence failed, delivery failed, or " +
@@ -1080,7 +1080,8 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
1080
1080
  expect(res.statusCode).toBe(201);
1081
1081
  const arg = mockCreateCheckoutSession.mock.calls[0][0];
1082
1082
  expect(arg).not.toHaveProperty("buyer");
1083
- expect(arg).not.toHaveProperty("metadata");
1083
+ // #7868 D4 — the only metadata is the hub's own site stamp.
1084
+ expect(arg.metadata).toEqual({ site: "default" });
1084
1085
  expect(arg).not.toHaveProperty("ailk_user_id");
1085
1086
  });
1086
1087
 
@@ -1093,7 +1094,8 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
1093
1094
 
1094
1095
  const arg = mockCreateCheckoutSession.mock.calls[0][0];
1095
1096
  expect(arg.buyer.userId).toBe("usr_buyer");
1096
- expect(arg).not.toHaveProperty("metadata");
1097
+ // #7868 D4 — the only metadata is the hub's own site stamp.
1098
+ expect(arg.metadata).toEqual({ site: "default" });
1097
1099
  });
1098
1100
 
1099
1101
  it("#6866 R5: with no cookie, the call is exactly today's argument object", async () => {
@@ -1109,6 +1111,8 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
1109
1111
  successUrl: VALID_BODY.successUrl,
1110
1112
  cancelUrl: VALID_BODY.cancelUrl,
1111
1113
  quantity: 1,
1114
+ // #7868 D4 — the hub's own site stamp (AILK_SITE_ID unset).
1115
+ metadata: { site: "default" },
1112
1116
  });
1113
1117
  expect(prisma.session.findUnique).not.toHaveBeenCalled();
1114
1118
  expect(prisma.user.findUnique).not.toHaveBeenCalled();
@@ -1116,6 +1120,50 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
1116
1120
  expect(createdLog()).not.toHaveProperty("identity");
1117
1121
  });
1118
1122
 
1123
+ it("#7868 D4: the site stamp follows AILK_SITE_ID", async () => {
1124
+ const original = process.env.AILK_SITE_ID;
1125
+ process.env.AILK_SITE_ID = "wksp_hub";
1126
+ try {
1127
+ const res = await post({});
1128
+
1129
+ expect(res.statusCode).toBe(201);
1130
+ expect(mockCreateCheckoutSession.mock.calls[0][0].metadata).toEqual({
1131
+ site: "wksp_hub",
1132
+ });
1133
+ } finally {
1134
+ if (original === undefined) delete process.env.AILK_SITE_ID;
1135
+ else process.env.AILK_SITE_ID = original;
1136
+ }
1137
+ });
1138
+
1139
+ it("#7868 D4: the anonymous retry after a stale customer carries the site stamp too", async () => {
1140
+ givenUser({ billingProvider: "stripe", billingCustomerId: "cus_bound" });
1141
+ mockCreateCheckoutSession.mockRejectedValueOnce(new UnknownCustomerError());
1142
+
1143
+ const res = await signedIn();
1144
+
1145
+ expect(res.statusCode).toBe(201);
1146
+ expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(2);
1147
+ expect(mockCreateCheckoutSession.mock.calls[0][0].metadata).toEqual({
1148
+ site: "default",
1149
+ });
1150
+ expect(mockCreateCheckoutSession.mock.calls[1][0].metadata).toEqual({
1151
+ site: "default",
1152
+ });
1153
+ });
1154
+
1155
+ it("#7868 D4: a signed-in call carries both the buyer and the site stamp", async () => {
1156
+ const res = await signedIn();
1157
+
1158
+ expect(res.statusCode).toBe(201);
1159
+ const arg = mockCreateCheckoutSession.mock.calls[0][0];
1160
+ expect(arg.buyer).toEqual({
1161
+ userId: "usr_buyer",
1162
+ email: "buyer@example.com",
1163
+ });
1164
+ expect(arg.metadata).toEqual({ site: "default" });
1165
+ });
1166
+
1119
1167
  it.each([
1120
1168
  [
1121
1169
  "an unknown session",
@@ -22,6 +22,11 @@
22
22
  * identifies a buyer. Any lookup failure, and a pinned customer Stripe
23
23
  * does not know, degrades to today's anonymous Checkout — never an error.
24
24
  * The stamped id is never echoed or logged.
25
+ * - Site stamp (#7868): every session carries `metadata.site` from
26
+ * `siteStamp()` (lib/site.ts) — on the signed-in try and the anonymous
27
+ * retry alike — so the Stripe webhook can tell this site's sessions from
28
+ * a spoke's on a shared account. The body schema has no metadata field,
29
+ * so no caller can set or override the stamp.
25
30
  * - X-Internal-Auth gate: when OSS_API_INTERNAL_SECRET env var is set,
26
31
  * the X-Internal-Auth header must match; requests without a matching header
27
32
  * are rejected with 401. When the env var is unset, no gate is applied
@@ -55,6 +60,7 @@ import type { FastifyReply, FastifyRequest } from "fastify";
55
60
  import { z } from "zod";
56
61
 
57
62
  import { config } from "../../config/index.js";
63
+ import { siteStamp } from "../../lib/site.js";
58
64
  import {
59
65
  assertSandboxKey,
60
66
  createCheckoutSession,
@@ -306,6 +312,7 @@ export async function checkoutSessionsHandler(
306
312
  // Omitted when the caller says nothing — Stripe's default (no promo-code
307
313
  // field) is unchanged for every existing caller.
308
314
  ...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
315
+ metadata: siteStamp(),
309
316
  };
310
317
  // Built only from the session cookie — never from `parsed.data` (#6866 D5).
311
318
  let { buyer, identity } = await resolveBuyer(request);
@@ -54,6 +54,11 @@ jest.mock("@working-theory/license", () => ({
54
54
  }),
55
55
  }));
56
56
 
57
+ // #7869 — the Idempotency-Key suite asserts a replay records no new capture
58
+ // event; `track` is fire-and-forget and never throws, so mocking it changes
59
+ // nothing for the cases above.
60
+ jest.mock("@working-theory/analytics/server", () => ({ track: jest.fn() }));
61
+
57
62
  import {
58
63
  leadSubmitRoute,
59
64
  leadSubscribeRoute,
@@ -1336,3 +1341,273 @@ describe("POST /v1/leads/submit — #6730 delivery modes", () => {
1336
1341
  await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
1337
1342
  });
1338
1343
  });
1344
+
1345
+ // ── #7869 — Idempotency-Key on submit (spec D1, D3–D7, D10 cases 1–8) ─────────
1346
+ //
1347
+ // `inbox` delivery (DATABASE_URL set), the sentinel workspace resolvable. The
1348
+ // app logs into `logLines` so case 8 can prove the key reaches no log line.
1349
+ describe("POST /v1/leads/submit — Idempotency-Key (#7869)", () => {
1350
+ let app: FastifyInstance;
1351
+ let logLines: string[];
1352
+ const KEY = "fixture-retry-0000-0000-0002";
1353
+ const STORED_ID = "lead_00000000-0000-7000-8000-00000000a7a7";
1354
+ const NEW_ID = "lead_00000000-0000-7000-8000-00000000b8b8";
1355
+ const sendLeadNotification = jest.requireMock("@working-theory/email")
1356
+ .sendLeadNotification as jest.Mock;
1357
+ const track = jest.requireMock("@working-theory/analytics/server")
1358
+ .track as jest.Mock;
1359
+
1360
+ const body = {
1361
+ kind: "submit",
1362
+ source: "contact",
1363
+ email: "visitor@example.com",
1364
+ payload: { message: "hello", name: "Jamie" },
1365
+ };
1366
+ const send = (
1367
+ headers: Record<string, string> = {},
1368
+ url = "/v1/leads/submit",
1369
+ payload: Record<string, unknown> = body,
1370
+ ) => app.inject({ method: "POST", url, headers, payload });
1371
+ const keyLookups = () =>
1372
+ jest
1373
+ .mocked(prisma.lead.findUnique)
1374
+ .mock.calls.filter(
1375
+ (c) => "workspaceId_idempotencyKey" in (c[0]?.where ?? {}),
1376
+ );
1377
+ const keyIsNowhere = (resBody: string) => {
1378
+ expect(resBody).not.toContain(KEY);
1379
+ for (const line of logLines) expect(line).not.toContain(KEY);
1380
+ };
1381
+
1382
+ beforeEach(async () => {
1383
+ jest.clearAllMocks();
1384
+ stateSentinelWorkspaceExists();
1385
+ process.env.DATABASE_URL = "postgres://stub";
1386
+ process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
1387
+ process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
1388
+ (prisma.lead.create as jest.Mock).mockResolvedValue({ id: NEW_ID });
1389
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
1390
+
1391
+ logLines = [];
1392
+ const fastify = require("fastify")({
1393
+ logger: {
1394
+ level: "info",
1395
+ stream: { write: (msg: string) => logLines.push(msg) },
1396
+ },
1397
+ });
1398
+ await fastify.register(leadSubmitRoute);
1399
+ await fastify.register(leadSubscribeRoute);
1400
+ await fastify.register(leadCallbackRoute);
1401
+ app = fastify;
1402
+ });
1403
+
1404
+ afterEach(async () => {
1405
+ await app.close();
1406
+ delete process.env.DATABASE_URL;
1407
+ delete process.env.AILK_LEAD_NOTIFICATION_TO;
1408
+ delete process.env.AILK_LEAD_NOTIFICATION_FROM;
1409
+ });
1410
+
1411
+ it("case 1 — no header: one create with no idempotencyKey, no key lookup, 201", async () => {
1412
+ const res = await send();
1413
+
1414
+ expect(res.statusCode).toBe(201);
1415
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1416
+ expect(
1417
+ jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
1418
+ ).not.toHaveProperty("idempotencyKey");
1419
+ expect(keyLookups()).toHaveLength(0);
1420
+ });
1421
+
1422
+ it("case 2 — a key with no stored row: scoped lookup, the create carries the key, 201", async () => {
1423
+ const res = await send({ "Idempotency-Key": KEY });
1424
+
1425
+ expect(res.statusCode).toBe(201);
1426
+ expect(res.json()).toEqual({
1427
+ id: NEW_ID,
1428
+ persisted: "db",
1429
+ email: { ok: true, messageId: "msg-test" },
1430
+ });
1431
+ expect(keyLookups()[0]?.[0]).toEqual({
1432
+ where: {
1433
+ workspaceId_idempotencyKey: {
1434
+ workspaceId: RESOLVED_WORKSPACE_ID,
1435
+ idempotencyKey: KEY,
1436
+ },
1437
+ },
1438
+ select: { id: true, email: true, source: true, payload: true },
1439
+ });
1440
+ expect(
1441
+ jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
1442
+ ).toMatchObject({
1443
+ idempotencyKey: KEY,
1444
+ });
1445
+ keyIsNowhere(res.body);
1446
+ });
1447
+
1448
+ it("case 3 — a stored row with the same email, source and payload (keys reordered) replays: 201, the stored id, no create, no notification, no track, no capture line", async () => {
1449
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1450
+ id: STORED_ID,
1451
+ email: body.email,
1452
+ source: body.source,
1453
+ payload: { name: "Jamie", message: "hello" },
1454
+ });
1455
+
1456
+ const res = await send({ "Idempotency-Key": KEY });
1457
+
1458
+ expect(res.statusCode).toBe(201);
1459
+ expect(res.json()).toEqual({
1460
+ id: STORED_ID,
1461
+ persisted: "db",
1462
+ email: { skipped: true, reason: "idempotent_replay" },
1463
+ });
1464
+ expect(Object.keys(res.json()).sort()).toEqual([
1465
+ "email",
1466
+ "id",
1467
+ "persisted",
1468
+ ]);
1469
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1470
+ expect(sendLeadNotification).not.toHaveBeenCalled();
1471
+ expect(track).not.toHaveBeenCalled();
1472
+ await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
1473
+ const routed = logLines.find((l) => l.includes("lead routed"));
1474
+ expect(JSON.parse(routed!)).toMatchObject({ replayed: true });
1475
+ keyIsNowhere(res.body);
1476
+ });
1477
+
1478
+ it.each([
1479
+ ["email", { email: "other@example.com" }],
1480
+ ["source", { source: "footer" }],
1481
+ ["payload", { payload: { message: "a different message", name: "Jamie" } }],
1482
+ ])(
1483
+ "case 4 — a stored row with a different %s is 422 idempotency_key_reused, no create, one warn log",
1484
+ async (_field, override) => {
1485
+ (prisma.lead.findUnique as jest.Mock).mockResolvedValue({
1486
+ id: STORED_ID,
1487
+ email: body.email,
1488
+ source: body.source,
1489
+ payload: body.payload,
1490
+ ...override,
1491
+ });
1492
+
1493
+ const res = await send({ "Idempotency-Key": KEY });
1494
+
1495
+ expect(res.statusCode).toBe(422);
1496
+ expect(res.json()).toEqual({
1497
+ error: "idempotency_key_reused",
1498
+ message: "This Idempotency-Key was used for a different lead.",
1499
+ });
1500
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1501
+ const warns = logLines
1502
+ .map((l) => JSON.parse(l) as Record<string, unknown>)
1503
+ .filter((l) => l["outcome"] === "idempotency_conflict");
1504
+ expect(warns).toHaveLength(1);
1505
+ expect(warns[0]).toMatchObject({
1506
+ level: 40,
1507
+ kind: "submit",
1508
+ siteId: RESOLVED_WORKSPACE_ID,
1509
+ });
1510
+ keyIsNowhere(res.body);
1511
+ },
1512
+ );
1513
+
1514
+ it.each([
1515
+ ["empty", ""],
1516
+ ["15 characters", "a".repeat(15)],
1517
+ ["256 characters", "a".repeat(256)],
1518
+ ["a space", "abcdefghij klmnopqrs"],
1519
+ ["a non-ASCII character", "abcdefghijéklmnopqrs"],
1520
+ ])(
1521
+ "case 5 — a malformed key (%s) is 400 invalid_input with no lead read or write",
1522
+ async (_label, key) => {
1523
+ const res = await app.inject({
1524
+ method: "POST",
1525
+ url: "/v1/leads/submit",
1526
+ headers: { "idempotency-key": key },
1527
+ payload: body,
1528
+ });
1529
+
1530
+ expect(res.statusCode).toBe(400);
1531
+ expect(res.json()).toEqual({
1532
+ error: "invalid_input",
1533
+ message: "Idempotency-Key must be 16 to 255 visible ASCII characters.",
1534
+ });
1535
+ expect(prisma.lead.findUnique).not.toHaveBeenCalled();
1536
+ expect(prisma.lead.create).not.toHaveBeenCalled();
1537
+ },
1538
+ );
1539
+
1540
+ it("case 6 — the same key stored only in another workspace is a miss here: create, 201 with the new id", async () => {
1541
+ jest.mocked(prisma.lead.findUnique).mockImplementation((async (args: {
1542
+ where: { workspaceId_idempotencyKey?: { workspaceId: string } };
1543
+ }) =>
1544
+ args.where.workspaceId_idempotencyKey?.workspaceId ===
1545
+ FOREIGN_WORKSPACE_ID
1546
+ ? {
1547
+ id: STORED_ID,
1548
+ email: body.email,
1549
+ source: body.source,
1550
+ payload: body.payload,
1551
+ }
1552
+ : null) as never);
1553
+
1554
+ const res = await send({ "Idempotency-Key": KEY });
1555
+
1556
+ expect(res.statusCode).toBe(201);
1557
+ expect(res.json().id).toBe(NEW_ID);
1558
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
1559
+ });
1560
+
1561
+ it("case 8 — the key reaches no response body and no log line on a fresh write, a replay or a conflict", async () => {
1562
+ const stored = {
1563
+ id: STORED_ID,
1564
+ email: body.email,
1565
+ source: body.source,
1566
+ payload: body.payload,
1567
+ };
1568
+ (prisma.lead.findUnique as jest.Mock)
1569
+ .mockResolvedValueOnce(null) // fresh write
1570
+ .mockResolvedValueOnce(stored) // replay
1571
+ .mockResolvedValueOnce({ ...stored, email: "other@example.com" }); // conflict
1572
+
1573
+ const statuses: number[] = [];
1574
+ for (let i = 0; i < 3; i++) {
1575
+ const res = await send({ "Idempotency-Key": KEY });
1576
+ statuses.push(res.statusCode);
1577
+ keyIsNowhere(res.body);
1578
+ }
1579
+
1580
+ expect(statuses).toEqual([201, 201, 422]);
1581
+ expect(logLines.length).toBeGreaterThan(0);
1582
+ });
1583
+
1584
+ it("case 7 — subscribe and callback ignore the header", async () => {
1585
+ (prisma.subscriber.create as jest.Mock).mockResolvedValue({
1586
+ id: "subscr_1",
1587
+ });
1588
+ (prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_1" });
1589
+
1590
+ const sub = await send(
1591
+ { "Idempotency-Key": "bad" },
1592
+ "/v1/leads/subscribe",
1593
+ {
1594
+ kind: "subscribe",
1595
+ email: "sub@example.com",
1596
+ source: "footer",
1597
+ },
1598
+ );
1599
+ const cb = await send({ "Idempotency-Key": KEY }, "/v1/leads/callback", {
1600
+ kind: "callback",
1601
+ name: "Alice",
1602
+ phone: "+15551234567",
1603
+ payload: {},
1604
+ });
1605
+
1606
+ expect(sub.statusCode).toBe(201);
1607
+ expect(cb.statusCode).toBe(201);
1608
+ expect(keyLookups()).toHaveLength(0);
1609
+ expect(
1610
+ jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data,
1611
+ ).not.toHaveProperty("idempotencyKey");
1612
+ });
1613
+ });
@@ -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