create-ailk 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -0
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -34
|
@@ -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
|
-
|
|
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
|
-
|
|
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,12 +54,18 @@ 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,
|
|
60
65
|
leadCallbackRoute,
|
|
61
66
|
} from "../index";
|
|
62
67
|
import {
|
|
68
|
+
MOCK_PERSON_ID,
|
|
63
69
|
mockNoWorkspace,
|
|
64
70
|
mockSentinelWorkspaceExists,
|
|
65
71
|
} from "../../../lib/__mocks__/prisma.js";
|
|
@@ -303,6 +309,8 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
303
309
|
source: CONTACT_FORM_LEAD_BODY.source,
|
|
304
310
|
email: CONTACT_FORM_LEAD_BODY.email,
|
|
305
311
|
payload: CONTACT_FORM_LEAD_BODY.payload,
|
|
312
|
+
// #852 D6 — the Person the shared mock's `person.findFirst` resolves.
|
|
313
|
+
personId: MOCK_PERSON_ID,
|
|
306
314
|
},
|
|
307
315
|
select: { id: true },
|
|
308
316
|
});
|
|
@@ -622,6 +630,8 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
622
630
|
source: "step-flow",
|
|
623
631
|
email: "envelope@example.com",
|
|
624
632
|
payload: envelopePayload,
|
|
633
|
+
// #852 D6 — the Person the shared mock's `person.findFirst` resolves.
|
|
634
|
+
personId: MOCK_PERSON_ID,
|
|
625
635
|
},
|
|
626
636
|
select: { id: true },
|
|
627
637
|
});
|
|
@@ -726,6 +736,8 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
726
736
|
source: "step-flow",
|
|
727
737
|
email: "top-level@example.com",
|
|
728
738
|
payload: { email: "shadowed-payload@example.com", message: "hi" },
|
|
739
|
+
// #852 D6 — the Person the shared mock's `person.findFirst` resolves.
|
|
740
|
+
personId: MOCK_PERSON_ID,
|
|
729
741
|
},
|
|
730
742
|
select: { id: true },
|
|
731
743
|
});
|
|
@@ -1336,3 +1348,273 @@ describe("POST /v1/leads/submit — #6730 delivery modes", () => {
|
|
|
1336
1348
|
await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
|
|
1337
1349
|
});
|
|
1338
1350
|
});
|
|
1351
|
+
|
|
1352
|
+
// ── #7869 — Idempotency-Key on submit (spec D1, D3–D7, D10 cases 1–8) ─────────
|
|
1353
|
+
//
|
|
1354
|
+
// `inbox` delivery (DATABASE_URL set), the sentinel workspace resolvable. The
|
|
1355
|
+
// app logs into `logLines` so case 8 can prove the key reaches no log line.
|
|
1356
|
+
describe("POST /v1/leads/submit — Idempotency-Key (#7869)", () => {
|
|
1357
|
+
let app: FastifyInstance;
|
|
1358
|
+
let logLines: string[];
|
|
1359
|
+
const KEY = "fixture-retry-0000-0000-0002";
|
|
1360
|
+
const STORED_ID = "lead_00000000-0000-7000-8000-00000000a7a7";
|
|
1361
|
+
const NEW_ID = "lead_00000000-0000-7000-8000-00000000b8b8";
|
|
1362
|
+
const sendLeadNotification = jest.requireMock("@working-theory/email")
|
|
1363
|
+
.sendLeadNotification as jest.Mock;
|
|
1364
|
+
const track = jest.requireMock("@working-theory/analytics/server")
|
|
1365
|
+
.track as jest.Mock;
|
|
1366
|
+
|
|
1367
|
+
const body = {
|
|
1368
|
+
kind: "submit",
|
|
1369
|
+
source: "contact",
|
|
1370
|
+
email: "visitor@example.com",
|
|
1371
|
+
payload: { message: "hello", name: "Jamie" },
|
|
1372
|
+
};
|
|
1373
|
+
const send = (
|
|
1374
|
+
headers: Record<string, string> = {},
|
|
1375
|
+
url = "/v1/leads/submit",
|
|
1376
|
+
payload: Record<string, unknown> = body,
|
|
1377
|
+
) => app.inject({ method: "POST", url, headers, payload });
|
|
1378
|
+
const keyLookups = () =>
|
|
1379
|
+
jest
|
|
1380
|
+
.mocked(prisma.lead.findUnique)
|
|
1381
|
+
.mock.calls.filter(
|
|
1382
|
+
(c) => "workspaceId_idempotencyKey" in (c[0]?.where ?? {}),
|
|
1383
|
+
);
|
|
1384
|
+
const keyIsNowhere = (resBody: string) => {
|
|
1385
|
+
expect(resBody).not.toContain(KEY);
|
|
1386
|
+
for (const line of logLines) expect(line).not.toContain(KEY);
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
beforeEach(async () => {
|
|
1390
|
+
jest.clearAllMocks();
|
|
1391
|
+
stateSentinelWorkspaceExists();
|
|
1392
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
1393
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1394
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1395
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: NEW_ID });
|
|
1396
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue(null);
|
|
1397
|
+
|
|
1398
|
+
logLines = [];
|
|
1399
|
+
const fastify = require("fastify")({
|
|
1400
|
+
logger: {
|
|
1401
|
+
level: "info",
|
|
1402
|
+
stream: { write: (msg: string) => logLines.push(msg) },
|
|
1403
|
+
},
|
|
1404
|
+
});
|
|
1405
|
+
await fastify.register(leadSubmitRoute);
|
|
1406
|
+
await fastify.register(leadSubscribeRoute);
|
|
1407
|
+
await fastify.register(leadCallbackRoute);
|
|
1408
|
+
app = fastify;
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
afterEach(async () => {
|
|
1412
|
+
await app.close();
|
|
1413
|
+
delete process.env.DATABASE_URL;
|
|
1414
|
+
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
1415
|
+
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
it("case 1 — no header: one create with no idempotencyKey, no key lookup, 201", async () => {
|
|
1419
|
+
const res = await send();
|
|
1420
|
+
|
|
1421
|
+
expect(res.statusCode).toBe(201);
|
|
1422
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
1423
|
+
expect(
|
|
1424
|
+
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
|
|
1425
|
+
).not.toHaveProperty("idempotencyKey");
|
|
1426
|
+
expect(keyLookups()).toHaveLength(0);
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it("case 2 — a key with no stored row: scoped lookup, the create carries the key, 201", async () => {
|
|
1430
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1431
|
+
|
|
1432
|
+
expect(res.statusCode).toBe(201);
|
|
1433
|
+
expect(res.json()).toEqual({
|
|
1434
|
+
id: NEW_ID,
|
|
1435
|
+
persisted: "db",
|
|
1436
|
+
email: { ok: true, messageId: "msg-test" },
|
|
1437
|
+
});
|
|
1438
|
+
expect(keyLookups()[0]?.[0]).toEqual({
|
|
1439
|
+
where: {
|
|
1440
|
+
workspaceId_idempotencyKey: {
|
|
1441
|
+
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
1442
|
+
idempotencyKey: KEY,
|
|
1443
|
+
},
|
|
1444
|
+
},
|
|
1445
|
+
select: { id: true, email: true, source: true, payload: true },
|
|
1446
|
+
});
|
|
1447
|
+
expect(
|
|
1448
|
+
jest.mocked(prisma.lead.create).mock.calls[0]?.[0].data,
|
|
1449
|
+
).toMatchObject({
|
|
1450
|
+
idempotencyKey: KEY,
|
|
1451
|
+
});
|
|
1452
|
+
keyIsNowhere(res.body);
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
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 () => {
|
|
1456
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1457
|
+
id: STORED_ID,
|
|
1458
|
+
email: body.email,
|
|
1459
|
+
source: body.source,
|
|
1460
|
+
payload: { name: "Jamie", message: "hello" },
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1464
|
+
|
|
1465
|
+
expect(res.statusCode).toBe(201);
|
|
1466
|
+
expect(res.json()).toEqual({
|
|
1467
|
+
id: STORED_ID,
|
|
1468
|
+
persisted: "db",
|
|
1469
|
+
email: { skipped: true, reason: "idempotent_replay" },
|
|
1470
|
+
});
|
|
1471
|
+
expect(Object.keys(res.json()).sort()).toEqual([
|
|
1472
|
+
"email",
|
|
1473
|
+
"id",
|
|
1474
|
+
"persisted",
|
|
1475
|
+
]);
|
|
1476
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1477
|
+
expect(sendLeadNotification).not.toHaveBeenCalled();
|
|
1478
|
+
expect(track).not.toHaveBeenCalled();
|
|
1479
|
+
await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
|
|
1480
|
+
const routed = logLines.find((l) => l.includes("lead routed"));
|
|
1481
|
+
expect(JSON.parse(routed!)).toMatchObject({ replayed: true });
|
|
1482
|
+
keyIsNowhere(res.body);
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it.each([
|
|
1486
|
+
["email", { email: "other@example.com" }],
|
|
1487
|
+
["source", { source: "footer" }],
|
|
1488
|
+
["payload", { payload: { message: "a different message", name: "Jamie" } }],
|
|
1489
|
+
])(
|
|
1490
|
+
"case 4 — a stored row with a different %s is 422 idempotency_key_reused, no create, one warn log",
|
|
1491
|
+
async (_field, override) => {
|
|
1492
|
+
(prisma.lead.findUnique as jest.Mock).mockResolvedValue({
|
|
1493
|
+
id: STORED_ID,
|
|
1494
|
+
email: body.email,
|
|
1495
|
+
source: body.source,
|
|
1496
|
+
payload: body.payload,
|
|
1497
|
+
...override,
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1501
|
+
|
|
1502
|
+
expect(res.statusCode).toBe(422);
|
|
1503
|
+
expect(res.json()).toEqual({
|
|
1504
|
+
error: "idempotency_key_reused",
|
|
1505
|
+
message: "This Idempotency-Key was used for a different lead.",
|
|
1506
|
+
});
|
|
1507
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1508
|
+
const warns = logLines
|
|
1509
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
1510
|
+
.filter((l) => l["outcome"] === "idempotency_conflict");
|
|
1511
|
+
expect(warns).toHaveLength(1);
|
|
1512
|
+
expect(warns[0]).toMatchObject({
|
|
1513
|
+
level: 40,
|
|
1514
|
+
kind: "submit",
|
|
1515
|
+
siteId: RESOLVED_WORKSPACE_ID,
|
|
1516
|
+
});
|
|
1517
|
+
keyIsNowhere(res.body);
|
|
1518
|
+
},
|
|
1519
|
+
);
|
|
1520
|
+
|
|
1521
|
+
it.each([
|
|
1522
|
+
["empty", ""],
|
|
1523
|
+
["15 characters", "a".repeat(15)],
|
|
1524
|
+
["256 characters", "a".repeat(256)],
|
|
1525
|
+
["a space", "abcdefghij klmnopqrs"],
|
|
1526
|
+
["a non-ASCII character", "abcdefghijéklmnopqrs"],
|
|
1527
|
+
])(
|
|
1528
|
+
"case 5 — a malformed key (%s) is 400 invalid_input with no lead read or write",
|
|
1529
|
+
async (_label, key) => {
|
|
1530
|
+
const res = await app.inject({
|
|
1531
|
+
method: "POST",
|
|
1532
|
+
url: "/v1/leads/submit",
|
|
1533
|
+
headers: { "idempotency-key": key },
|
|
1534
|
+
payload: body,
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
expect(res.statusCode).toBe(400);
|
|
1538
|
+
expect(res.json()).toEqual({
|
|
1539
|
+
error: "invalid_input",
|
|
1540
|
+
message: "Idempotency-Key must be 16 to 255 visible ASCII characters.",
|
|
1541
|
+
});
|
|
1542
|
+
expect(prisma.lead.findUnique).not.toHaveBeenCalled();
|
|
1543
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
1544
|
+
},
|
|
1545
|
+
);
|
|
1546
|
+
|
|
1547
|
+
it("case 6 — the same key stored only in another workspace is a miss here: create, 201 with the new id", async () => {
|
|
1548
|
+
jest.mocked(prisma.lead.findUnique).mockImplementation((async (args: {
|
|
1549
|
+
where: { workspaceId_idempotencyKey?: { workspaceId: string } };
|
|
1550
|
+
}) =>
|
|
1551
|
+
args.where.workspaceId_idempotencyKey?.workspaceId ===
|
|
1552
|
+
FOREIGN_WORKSPACE_ID
|
|
1553
|
+
? {
|
|
1554
|
+
id: STORED_ID,
|
|
1555
|
+
email: body.email,
|
|
1556
|
+
source: body.source,
|
|
1557
|
+
payload: body.payload,
|
|
1558
|
+
}
|
|
1559
|
+
: null) as never);
|
|
1560
|
+
|
|
1561
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1562
|
+
|
|
1563
|
+
expect(res.statusCode).toBe(201);
|
|
1564
|
+
expect(res.json().id).toBe(NEW_ID);
|
|
1565
|
+
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
it("case 8 — the key reaches no response body and no log line on a fresh write, a replay or a conflict", async () => {
|
|
1569
|
+
const stored = {
|
|
1570
|
+
id: STORED_ID,
|
|
1571
|
+
email: body.email,
|
|
1572
|
+
source: body.source,
|
|
1573
|
+
payload: body.payload,
|
|
1574
|
+
};
|
|
1575
|
+
(prisma.lead.findUnique as jest.Mock)
|
|
1576
|
+
.mockResolvedValueOnce(null) // fresh write
|
|
1577
|
+
.mockResolvedValueOnce(stored) // replay
|
|
1578
|
+
.mockResolvedValueOnce({ ...stored, email: "other@example.com" }); // conflict
|
|
1579
|
+
|
|
1580
|
+
const statuses: number[] = [];
|
|
1581
|
+
for (let i = 0; i < 3; i++) {
|
|
1582
|
+
const res = await send({ "Idempotency-Key": KEY });
|
|
1583
|
+
statuses.push(res.statusCode);
|
|
1584
|
+
keyIsNowhere(res.body);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
expect(statuses).toEqual([201, 201, 422]);
|
|
1588
|
+
expect(logLines.length).toBeGreaterThan(0);
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
it("case 7 — subscribe and callback ignore the header", async () => {
|
|
1592
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
1593
|
+
id: "subscr_1",
|
|
1594
|
+
});
|
|
1595
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({ id: "callbk_1" });
|
|
1596
|
+
|
|
1597
|
+
const sub = await send(
|
|
1598
|
+
{ "Idempotency-Key": "bad" },
|
|
1599
|
+
"/v1/leads/subscribe",
|
|
1600
|
+
{
|
|
1601
|
+
kind: "subscribe",
|
|
1602
|
+
email: "sub@example.com",
|
|
1603
|
+
source: "footer",
|
|
1604
|
+
},
|
|
1605
|
+
);
|
|
1606
|
+
const cb = await send({ "Idempotency-Key": KEY }, "/v1/leads/callback", {
|
|
1607
|
+
kind: "callback",
|
|
1608
|
+
name: "Alice",
|
|
1609
|
+
phone: "+15551234567",
|
|
1610
|
+
payload: {},
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
expect(sub.statusCode).toBe(201);
|
|
1614
|
+
expect(cb.statusCode).toBe(201);
|
|
1615
|
+
expect(keyLookups()).toHaveLength(0);
|
|
1616
|
+
expect(
|
|
1617
|
+
jest.mocked(prisma.callback.create).mock.calls[0]?.[0].data,
|
|
1618
|
+
).not.toHaveProperty("idempotencyKey");
|
|
1619
|
+
});
|
|
1620
|
+
});
|
|
@@ -42,6 +42,28 @@
|
|
|
42
42
|
* unchanged. So does a keyless request whose hub-status read fails before the
|
|
43
43
|
* process has any answer: lead capture is never lost (#7633, conductor
|
|
44
44
|
* amendment to NFR3). The body's `siteId` stays ignored on every branch.
|
|
45
|
+
*
|
|
46
|
+
* IDEMPOTENCY KEY (#7869, roadmap D5). `POST /v1/leads/submit` accepts an
|
|
47
|
+
* optional `Idempotency-Key` header: 16 to 255 visible ASCII characters, and
|
|
48
|
+
* it must be UNGUESSABLE (a UUID, a Stripe Checkout Session id) — a key is
|
|
49
|
+
* first-write-wins inside its workspace, so a short or predictable one could
|
|
50
|
+
* be claimed by someone else first (SEC-7869-1). It is checked after the
|
|
51
|
+
* tenant gate and before `routeLead`; a malformed key is `400 invalid_input`
|
|
52
|
+
* with no read or write. A repeat of a stored key in the same workspace with
|
|
53
|
+
* the same `email`, `source` and `payload` answers the first lead's 201 and
|
|
54
|
+
* writes nothing; with anything different it is `422
|
|
55
|
+
* idempotency_key_reused`. The key dedupes only where a `Lead` row exists —
|
|
56
|
+
* `inbox` delivery — so a first write that fell back to email or a local
|
|
57
|
+
* file leaves nothing to find, and its retry writes again. `subscribe` and
|
|
58
|
+
* `callback` never read the header. The key is never logged or echoed.
|
|
59
|
+
*
|
|
60
|
+
* API↔MCP PARITY for the header (#7869 D9, roadmap §12 Q5): NO guard entry.
|
|
61
|
+
* The guard binds a route's arguments to the validation export its handler
|
|
62
|
+
* `.safeParse`s on `request.params|query|body`; the header check below is a
|
|
63
|
+
* regex test, not such a parse, so this route still binds as
|
|
64
|
+
* `api-args-unbound` and the `capture_lead` row is unchanged. The 201 body's
|
|
65
|
+
* field set is unchanged too, and `capture_lead` sends no header. If the
|
|
66
|
+
* guard ever models headers, this becomes an API-side `arg-only-in`.
|
|
45
67
|
*/
|
|
46
68
|
|
|
47
69
|
import { track } from "@working-theory/analytics/server";
|
|
@@ -58,6 +80,9 @@ import {
|
|
|
58
80
|
import { resolveCallerSite } from "../../services/public-site-resolver.js";
|
|
59
81
|
import { sendRequestConfirmLink } from "../../services/request-confirm.js";
|
|
60
82
|
|
|
83
|
+
/** #7869 D1 — 16 to 255 visible ASCII characters; no spaces, no list. */
|
|
84
|
+
const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7E]{16,255}$/;
|
|
85
|
+
|
|
61
86
|
/**
|
|
62
87
|
* Read a named string field from the body, falling back to the same-named
|
|
63
88
|
* field inside `body.payload` when the top-level value is absent — the
|
|
@@ -199,12 +224,46 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
199
224
|
workspaceId = resolved;
|
|
200
225
|
}
|
|
201
226
|
|
|
227
|
+
// #7869 D1 — after the tenant gate, before any read or write. Node
|
|
228
|
+
// lower-cases header names, and joins a duplicated header with ", ",
|
|
229
|
+
// which the pattern refuses.
|
|
230
|
+
let idempotencyKey: string | undefined;
|
|
231
|
+
if (kind === "submit") {
|
|
232
|
+
const rawKey = request.headers["idempotency-key"];
|
|
233
|
+
if (rawKey !== undefined) {
|
|
234
|
+
if (
|
|
235
|
+
typeof rawKey !== "string" ||
|
|
236
|
+
!IDEMPOTENCY_KEY_PATTERN.test(rawKey)
|
|
237
|
+
) {
|
|
238
|
+
return reply.status(400).send({
|
|
239
|
+
error: "invalid_input",
|
|
240
|
+
message:
|
|
241
|
+
"Idempotency-Key must be 16 to 255 visible ASCII characters.",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
idempotencyKey = rawKey;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
202
248
|
const result = await routeLead(
|
|
203
249
|
buildInput(kind, body, workspaceId),
|
|
204
250
|
sourceMeta,
|
|
251
|
+
idempotencyKey === undefined ? {} : { idempotencyKey },
|
|
205
252
|
);
|
|
206
253
|
|
|
207
254
|
if (!result.ok) {
|
|
255
|
+
if (result.error.kind === "idempotency_conflict") {
|
|
256
|
+
// #7869 D6 — the operator's signal that a keyed lead was refused.
|
|
257
|
+
// Never the key, the email or the payload.
|
|
258
|
+
request.log.warn(
|
|
259
|
+
{ kind, siteId: workspaceId, outcome: "idempotency_conflict" },
|
|
260
|
+
"lead idempotency_conflict",
|
|
261
|
+
);
|
|
262
|
+
return reply.status(422).send({
|
|
263
|
+
error: "idempotency_key_reused",
|
|
264
|
+
message: "This Idempotency-Key was used for a different lead.",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
208
267
|
if (result.error.kind === "invalid_input") {
|
|
209
268
|
return reply
|
|
210
269
|
.status(400)
|
|
@@ -252,6 +311,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
252
311
|
ip: sourceMeta.ip,
|
|
253
312
|
persisted: result.persisted,
|
|
254
313
|
emailKind,
|
|
314
|
+
replayed: result.replayed === true,
|
|
255
315
|
},
|
|
256
316
|
"lead routed",
|
|
257
317
|
);
|
|
@@ -292,12 +352,15 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
292
352
|
}
|
|
293
353
|
|
|
294
354
|
// Fire-and-forget: track() never throws; no need to hold the response.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
"
|
|
299
|
-
|
|
300
|
-
|
|
355
|
+
// #7869 D6 — a replay records nothing new: the first write counted it.
|
|
356
|
+
if (!result.replayed) {
|
|
357
|
+
void track("lead-captured", {
|
|
358
|
+
source: body["source"] as string | undefined,
|
|
359
|
+
locale: (body["payload"] as Record<string, unknown> | undefined)?.[
|
|
360
|
+
"locale"
|
|
361
|
+
] as string | undefined,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
301
364
|
|
|
302
365
|
return reply.status(201).send({
|
|
303
366
|
id: result.id,
|
|
@@ -132,6 +132,45 @@ and the row is still stamped — the quote is never overwritten. The stamp
|
|
|
132
132
|
runs beside the telemetry + billing tasks, never instead of them, and an
|
|
133
133
|
event without the key never touches `flowCheckout`. Always 200.
|
|
134
134
|
|
|
135
|
+
## Site filter (#7868)
|
|
136
|
+
|
|
137
|
+
The hub's Stripe account is shared with its spoke sites, so this endpoint
|
|
138
|
+
receives every site's events. A Checkout session says which site opened it
|
|
139
|
+
through `metadata.site`.
|
|
140
|
+
|
|
141
|
+
- **The stamp.** Every session the hub creates — `POST /v1/checkout/sessions`
|
|
142
|
+
and the recommender's quoted checkout (`POST /v1/flows/:slug/checkout`) —
|
|
143
|
+
carries `metadata.site` = `currentSiteId()`: `AILK_SITE_ID`, else
|
|
144
|
+
`"default"`. The key, the stamp and the foreign test are defined once in
|
|
145
|
+
`lib/site.ts` (`SITE_STAMP_KEY`, `siteStamp()`, `isForeignSiteStamp()`).
|
|
146
|
+
No request field can set or override it.
|
|
147
|
+
- **The filter.** A `checkout.session.completed` or `checkout.session.expired`
|
|
148
|
+
whose `metadata.site` is a non-empty string other than `currentSiteId()` is
|
|
149
|
+
another site's. The handler takes no action — no telemetry, billing bind,
|
|
150
|
+
recommender stamp or request-paid stamp — logs `outcome: "foreign_site"`
|
|
151
|
+
with the foreign `stamp` (first 64 characters) and the hub's `ownSite`, and
|
|
152
|
+
answers 200. The comparison is exact and case-sensitive. The event is still
|
|
153
|
+
marked seen, so a redelivery is the usual duplicate short-circuit.
|
|
154
|
+
- **Unstamped is own.** A session with no stamp, or an empty one, is the hub's
|
|
155
|
+
and is handled as before: hub Payment Links, hosted invoices, portal events
|
|
156
|
+
and sessions opened before the stamp shipped carry none, so strict equality
|
|
157
|
+
would stop hub checkout (roadmap D4).
|
|
158
|
+
- **One alias per deployment.** Two deployments on one Stripe account that
|
|
159
|
+
both leave `AILK_SITE_ID` unset both stamp `"default"` and treat each
|
|
160
|
+
other's sessions as their own — give each a distinct `AILK_SITE_ID`. The
|
|
161
|
+
alias is a deployment-wide constant: every instance and environment on the
|
|
162
|
+
account must agree on it, and change it only while no Checkout session is
|
|
163
|
+
open (a session lives up to 24 hours). A session whose stamp no longer
|
|
164
|
+
matches is acked as foreign and never retried, so its hub side effects are
|
|
165
|
+
lost; the `stamp` / `ownSite` pair in the log line is how to spot that drift.
|
|
166
|
+
- **Not filtered.** `customer.*`, `invoice.*` and subscription events carry no
|
|
167
|
+
session metadata and are handled as before. Filtering them needs a stamp on
|
|
168
|
+
those objects, which the spoke-checkout package writes (spoke pattern
|
|
169
|
+
Phase 3).
|
|
170
|
+
- **A label, not a boundary.** The stamp is written by whoever holds the
|
|
171
|
+
account's secret key. It separates cooperating sites on one account, not
|
|
172
|
+
tenants.
|
|
173
|
+
|
|
135
174
|
## Dunning (#6034)
|
|
136
175
|
|
|
137
176
|
Two arms record a per-subscriber lifecycle state so a failed renewal is no
|