@cosmicdrift/kumiko-bundled-features 0.250.0 → 0.252.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/package.json +9 -9
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +222 -4
- package/src/billing-foundation/__tests__/feature.test.ts +9 -1
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +8 -2
- package/src/billing-foundation/aggregate-id.ts +42 -0
- package/src/billing-foundation/constants.ts +16 -0
- package/src/billing-foundation/db/queries/payment-projection.ts +19 -0
- package/src/billing-foundation/entities.ts +29 -0
- package/src/billing-foundation/events.ts +32 -0
- package/src/billing-foundation/feature.ts +35 -3
- package/src/billing-foundation/handlers/process-payment-event.write.ts +117 -0
- package/src/billing-foundation/index.ts +12 -3
- package/src/billing-foundation/projection.ts +40 -2
- package/src/billing-foundation/tenant-destroy-hook.ts +38 -3
- package/src/billing-foundation/types.ts +63 -17
- package/src/billing-foundation/webhook-handler.ts +49 -20
- package/src/crypto-shredding/__tests__/forget-subject-record.integration.test.ts +291 -0
- package/src/crypto-shredding/constants.ts +2 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +72 -4
- package/src/subscription-stripe/__tests__/feature.test.ts +2 -0
- package/src/subscription-stripe/__tests__/payment-checkout.test.ts +167 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +37 -12
- package/src/subscription-stripe/constants.ts +7 -0
- package/src/subscription-stripe/verify-webhook.ts +110 -4
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// crypto-shredding forget-subject over a "record" subject (kumiko-framework#2786):
|
|
2
|
+
//
|
|
3
|
+
// - a row-scoped field (personal: { of: "id" } → recordOwned) shreds via
|
|
4
|
+
// forget-subject with { subject: { kind: "record", entity, id } }
|
|
5
|
+
// - the raw kumiko_events ciphertext stays permanently under the
|
|
6
|
+
// record:<entity>:<id> prefix — decrypting it separately proves the
|
|
7
|
+
// erase, not just the projection read path
|
|
8
|
+
// - the record namespace never collides with a "user" subject that
|
|
9
|
+
// happens to share the same uuid
|
|
10
|
+
|
|
11
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
+
import {
|
|
14
|
+
configurePiiSubjectKms,
|
|
15
|
+
decryptPiiFieldValues,
|
|
16
|
+
InMemoryKmsAdapter,
|
|
17
|
+
isPiiCiphertext,
|
|
18
|
+
PII_CIPHERTEXT_PREFIX,
|
|
19
|
+
PII_ERASED_SENTINEL,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
21
|
+
import {
|
|
22
|
+
buildEntityTable,
|
|
23
|
+
createEventStoreExecutor,
|
|
24
|
+
createTenantDb,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
+
import {
|
|
27
|
+
createEntity,
|
|
28
|
+
createTextField,
|
|
29
|
+
defineFeature,
|
|
30
|
+
type TenantId,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
33
|
+
import {
|
|
34
|
+
setupTestStack,
|
|
35
|
+
type TestStack,
|
|
36
|
+
testTenantId,
|
|
37
|
+
unsafeCreateEntityTable,
|
|
38
|
+
unsafePushTables,
|
|
39
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
40
|
+
import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
41
|
+
import { createConfigFeature } from "../../config";
|
|
42
|
+
import { createTenantFeature } from "../../tenant";
|
|
43
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
44
|
+
import { RECORD_ENTITY_NOT_REGISTERED, TARGET_RECORD_NOT_ADMIN_TENANT } from "../constants";
|
|
45
|
+
import { createCryptoShreddingFeature } from "../feature";
|
|
46
|
+
|
|
47
|
+
const FORGET = "crypto-shredding:write:forget-subject";
|
|
48
|
+
const RECORD_PROBE_ENTITY_NAME = "recordProbe";
|
|
49
|
+
const REASON = "authority request #2786 (Art. 17 row-subject)";
|
|
50
|
+
|
|
51
|
+
const recordProbeEntity = createEntity({
|
|
52
|
+
table: "read_forget_subject_record_probe",
|
|
53
|
+
fields: {
|
|
54
|
+
body: createTextField({
|
|
55
|
+
required: true,
|
|
56
|
+
maxLength: 200,
|
|
57
|
+
personal: { of: "id" },
|
|
58
|
+
find: "none",
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const recordProbeTable = buildEntityTable("forgetSubjectRecordProbe", recordProbeEntity);
|
|
63
|
+
const recordProbeFeature = defineFeature("forget-subject-record-probe", (r) => {
|
|
64
|
+
r.entity(RECORD_PROBE_ENTITY_NAME, recordProbeEntity);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function recordProbeExecutor() {
|
|
68
|
+
return createEventStoreExecutor(recordProbeTable, recordProbeEntity, {
|
|
69
|
+
entityName: RECORD_PROBE_ENTITY_NAME,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const TENANT: TenantId = testTenantId(21);
|
|
74
|
+
|
|
75
|
+
const dpoUser = {
|
|
76
|
+
id: "cccccccc-cccc-4ccc-8ccc-000000000001",
|
|
77
|
+
tenantId: TENANT,
|
|
78
|
+
roles: ["DataProtectionOfficer"],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let stack: TestStack;
|
|
82
|
+
let kms: InMemoryKmsAdapter;
|
|
83
|
+
|
|
84
|
+
beforeAll(async () => {
|
|
85
|
+
stack = await setupTestStack({
|
|
86
|
+
features: [createCryptoShreddingFeature(), recordProbeFeature],
|
|
87
|
+
});
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, recordProbeEntity, RECORD_PROBE_ENTITY_NAME);
|
|
89
|
+
await createEventsTable(stack.db);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterAll(async () => {
|
|
93
|
+
await stack.cleanup();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
await resetTestTables(stack.db, [recordProbeTable, eventsTable]);
|
|
98
|
+
kms = new InMemoryKmsAdapter();
|
|
99
|
+
configurePiiSubjectKms(kms);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
resetPiiSubjectKmsForTests();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("crypto-shredding :: forget-subject (record subject, #2786)", () => {
|
|
107
|
+
test("erases the row's ciphertext under the record namespace, proven via decrypt; a same-uuid user-forget never touches it", async () => {
|
|
108
|
+
const tenantDb = createTenantDb(stack.db, TENANT, "system");
|
|
109
|
+
const plaintext = "confidential support note";
|
|
110
|
+
const created = await recordProbeExecutor().create({ body: plaintext }, dpoUser, tenantDb);
|
|
111
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
112
|
+
const rowId = String(created.data.id);
|
|
113
|
+
|
|
114
|
+
const beforeShred = await recordProbeExecutor().detail({ id: rowId }, dpoUser, tenantDb);
|
|
115
|
+
expect(beforeShred?.["body"]).toBe(plaintext);
|
|
116
|
+
|
|
117
|
+
// Namespace separation, checked BEFORE the record subject is touched: a
|
|
118
|
+
// user-forget for the row's own uuid must leave the record row readable
|
|
119
|
+
// — record:<entity>:<id> and user:<id> are different DEKs even when the
|
|
120
|
+
// uuid is identical.
|
|
121
|
+
await stack.http.writeOk(
|
|
122
|
+
FORGET,
|
|
123
|
+
{ subject: { kind: "user", userId: rowId }, reason: REASON },
|
|
124
|
+
dpoUser,
|
|
125
|
+
);
|
|
126
|
+
const afterBystanderForget = await recordProbeExecutor().detail(
|
|
127
|
+
{ id: rowId },
|
|
128
|
+
dpoUser,
|
|
129
|
+
tenantDb,
|
|
130
|
+
);
|
|
131
|
+
expect(afterBystanderForget?.["body"]).toBe(plaintext);
|
|
132
|
+
|
|
133
|
+
const result = await stack.http.writeOk<{ subjectKey: string }>(
|
|
134
|
+
FORGET,
|
|
135
|
+
{
|
|
136
|
+
subject: { kind: "record", entity: RECORD_PROBE_ENTITY_NAME, id: rowId },
|
|
137
|
+
reason: REASON,
|
|
138
|
+
},
|
|
139
|
+
dpoUser,
|
|
140
|
+
);
|
|
141
|
+
expect(result.subjectKey).toBe(`record:${RECORD_PROBE_ENTITY_NAME}:${rowId}`);
|
|
142
|
+
|
|
143
|
+
const afterShred = await recordProbeExecutor().detail({ id: rowId }, dpoUser, tenantDb);
|
|
144
|
+
expect(afterShred?.["body"]).toBe(PII_ERASED_SENTINEL);
|
|
145
|
+
|
|
146
|
+
const createdEvents = (await selectMany(stack.db, eventsTable, {
|
|
147
|
+
aggregateId: rowId,
|
|
148
|
+
type: `${RECORD_PROBE_ENTITY_NAME}.created`,
|
|
149
|
+
})) as Array<{ payload: Record<string, unknown> }>;
|
|
150
|
+
expect(createdEvents).toHaveLength(1);
|
|
151
|
+
const rawBody = createdEvents[0]?.payload["body"];
|
|
152
|
+
expect(typeof rawBody).toBe("string");
|
|
153
|
+
expect(isPiiCiphertext(rawBody)).toBe(true);
|
|
154
|
+
expect(String(rawBody)).toStartWith(
|
|
155
|
+
`${PII_CIPHERTEXT_PREFIX}record:${RECORD_PROBE_ENTITY_NAME}:${rowId}:`,
|
|
156
|
+
);
|
|
157
|
+
expect(String(rawBody)).not.toContain(plaintext);
|
|
158
|
+
|
|
159
|
+
const decrypted = await decryptPiiFieldValues({ body: rawBody }, ["body"], kms, {
|
|
160
|
+
requestId: "test",
|
|
161
|
+
});
|
|
162
|
+
expect(decrypted["body"]).toBe(PII_ERASED_SENTINEL);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Empirical: update()'s previous-row re-encrypt (encryptForStorage(previous))
|
|
166
|
+
// sees the erased sentinel and passes it through unchanged (pii-field-
|
|
167
|
+
// encryption.ts's isPiiCiphertext/PII_ERASED_SENTINEL short-circuit) — but
|
|
168
|
+
// the NEW value in `changes` is real plaintext, so it still needs a DEK.
|
|
169
|
+
// getOrCreateDek's getKey call on a tombstoned subject throws KeyErasedError,
|
|
170
|
+
// which propagates out of update() uncaught (only version-conflict errors
|
|
171
|
+
// are caught there). Not record-specific: the same getOrCreateDek path runs
|
|
172
|
+
// for a user/tenant PII field after that subject is forgotten.
|
|
173
|
+
test("update on an already-shredded row rejects instead of writing a fresh plaintext value", async () => {
|
|
174
|
+
const tenantDb = createTenantDb(stack.db, TENANT, "system");
|
|
175
|
+
const created = await recordProbeExecutor().create(
|
|
176
|
+
{ body: "pre-shred content" },
|
|
177
|
+
dpoUser,
|
|
178
|
+
tenantDb,
|
|
179
|
+
);
|
|
180
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
181
|
+
const rowId = String(created.data.id);
|
|
182
|
+
|
|
183
|
+
await stack.http.writeOk(
|
|
184
|
+
FORGET,
|
|
185
|
+
{ subject: { kind: "record", entity: RECORD_PROBE_ENTITY_NAME, id: rowId }, reason: REASON },
|
|
186
|
+
dpoUser,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
await expect(
|
|
190
|
+
recordProbeExecutor().update(
|
|
191
|
+
{ id: rowId, changes: { body: "post-shred content" } },
|
|
192
|
+
dpoUser,
|
|
193
|
+
tenantDb,
|
|
194
|
+
{ skipOptimisticLock: true },
|
|
195
|
+
),
|
|
196
|
+
).rejects.toThrow(`Subject key erased: record:${RECORD_PROBE_ENTITY_NAME}:${rowId}`);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// mh#349 / #2786: resolveTenantScopeDenial's record branch has its own
|
|
201
|
+
// `!features.has("tenant")` fail-open — a denial test against the top-of-file
|
|
202
|
+
// stack (crypto-shredding only) would pass without proving anything, since
|
|
203
|
+
// the tenant gate never even runs there. This describe mounts a separate
|
|
204
|
+
// stack with the tenant feature actually present so the two denial branches
|
|
205
|
+
// (RECORD_ENTITY_NOT_REGISTERED, TARGET_RECORD_NOT_ADMIN_TENANT) get real
|
|
206
|
+
// coverage.
|
|
207
|
+
describe("crypto-shredding :: forget-subject (record subject) tenant gate, #2786", () => {
|
|
208
|
+
let gateStack: TestStack;
|
|
209
|
+
let gateKms: InMemoryKmsAdapter;
|
|
210
|
+
|
|
211
|
+
const GATE_TENANT_A: TenantId = testTenantId(22);
|
|
212
|
+
const GATE_TENANT_B: TenantId = testTenantId(23);
|
|
213
|
+
|
|
214
|
+
const dpoTenantA = {
|
|
215
|
+
id: "cccccccc-cccc-4ccc-8ccc-000000000002",
|
|
216
|
+
tenantId: GATE_TENANT_A,
|
|
217
|
+
roles: ["DataProtectionOfficer"],
|
|
218
|
+
};
|
|
219
|
+
const dpoTenantB = {
|
|
220
|
+
id: "cccccccc-cccc-4ccc-8ccc-000000000003",
|
|
221
|
+
tenantId: GATE_TENANT_B,
|
|
222
|
+
roles: ["DataProtectionOfficer"],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
beforeAll(async () => {
|
|
226
|
+
gateStack = await setupTestStack({
|
|
227
|
+
features: [
|
|
228
|
+
createCryptoShreddingFeature(),
|
|
229
|
+
createConfigFeature(),
|
|
230
|
+
createTenantFeature(),
|
|
231
|
+
recordProbeFeature,
|
|
232
|
+
],
|
|
233
|
+
});
|
|
234
|
+
await unsafeCreateEntityTable(gateStack.db, recordProbeEntity, RECORD_PROBE_ENTITY_NAME);
|
|
235
|
+
await unsafePushTables(gateStack.db, { tenantMembershipsTable });
|
|
236
|
+
await createEventsTable(gateStack.db);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
afterAll(async () => {
|
|
240
|
+
await gateStack.cleanup();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
beforeEach(async () => {
|
|
244
|
+
await resetTestTables(gateStack.db, [recordProbeTable, eventsTable]);
|
|
245
|
+
gateKms = new InMemoryKmsAdapter();
|
|
246
|
+
configurePiiSubjectKms(gateKms);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
afterEach(() => {
|
|
250
|
+
resetPiiSubjectKmsForTests();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("DPO from another tenant cannot forget a record subject owned by a foreign tenant → denied, key survives", async () => {
|
|
254
|
+
const tenantADb = createTenantDb(gateStack.db, GATE_TENANT_A, "system");
|
|
255
|
+
const plaintext = "tenant A confidential note";
|
|
256
|
+
const created = await recordProbeExecutor().create({ body: plaintext }, dpoTenantA, tenantADb);
|
|
257
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
258
|
+
const rowId = String(created.data.id);
|
|
259
|
+
|
|
260
|
+
const err = await gateStack.http.writeErr(
|
|
261
|
+
FORGET,
|
|
262
|
+
{
|
|
263
|
+
subject: { kind: "record", entity: RECORD_PROBE_ENTITY_NAME, id: rowId },
|
|
264
|
+
reason: REASON,
|
|
265
|
+
},
|
|
266
|
+
dpoTenantB,
|
|
267
|
+
);
|
|
268
|
+
expect(err.httpStatus).toBe(403);
|
|
269
|
+
expect((err.details as { reason?: string } | undefined)?.reason).toBe(
|
|
270
|
+
TARGET_RECORD_NOT_ADMIN_TENANT,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const stillReadable = await recordProbeExecutor().detail({ id: rowId }, dpoTenantA, tenantADb);
|
|
274
|
+
expect(stillReadable?.["body"]).toBe(plaintext);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("record subject naming an unregistered entity is denied, not resolved into raw SQL", async () => {
|
|
278
|
+
const err = await gateStack.http.writeErr(
|
|
279
|
+
FORGET,
|
|
280
|
+
{
|
|
281
|
+
subject: { kind: "record", entity: "notARegisteredEntity", id: crypto.randomUUID() },
|
|
282
|
+
reason: REASON,
|
|
283
|
+
},
|
|
284
|
+
dpoTenantA,
|
|
285
|
+
);
|
|
286
|
+
expect(err.httpStatus).toBe(403);
|
|
287
|
+
expect((err.details as { reason?: string } | undefined)?.reason).toBe(
|
|
288
|
+
RECORD_ENTITY_NOT_REGISTERED,
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -6,3 +6,5 @@ export const SUBJECT_FORGET_DENIED_EVENT_NAME = "crypto-shredding:event:forget-d
|
|
|
6
6
|
export const CRYPTO_SHREDDING_AGGREGATE_TYPE = "crypto-shredding-subject";
|
|
7
7
|
|
|
8
8
|
export const TARGET_TENANT_NOT_ADMIN_TENANT = "target_tenant_not_admin_tenant" as const;
|
|
9
|
+
export const RECORD_ENTITY_NOT_REGISTERED = "record_entity_not_registered" as const;
|
|
10
|
+
export const TARGET_RECORD_NOT_ADMIN_TENANT = "target_record_not_admin_tenant" as const;
|
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
import {
|
|
10
10
|
type DbRunner,
|
|
11
11
|
nullBlindIndexesForSubject,
|
|
12
|
+
recordRowExistsInTenant,
|
|
12
13
|
subjectRowExistsInTenant,
|
|
13
14
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
14
15
|
import {
|
|
15
16
|
defineWriteHandler,
|
|
17
|
+
type EntityDefinition,
|
|
16
18
|
type FeatureDefinition,
|
|
17
19
|
type HandlerContext,
|
|
18
20
|
type SessionUser,
|
|
@@ -38,14 +40,24 @@ import {
|
|
|
38
40
|
} from "../../user-data-rights";
|
|
39
41
|
import {
|
|
40
42
|
CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
43
|
+
RECORD_ENTITY_NOT_REGISTERED,
|
|
41
44
|
SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
42
45
|
SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
46
|
+
TARGET_RECORD_NOT_ADMIN_TENANT,
|
|
43
47
|
TARGET_TENANT_NOT_ADMIN_TENANT,
|
|
44
48
|
} from "../constants";
|
|
45
49
|
|
|
50
|
+
// Registry entity names are identifier-shaped; ":" would break the subject-key round-trip.
|
|
51
|
+
const RECORD_ENTITY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
52
|
+
|
|
46
53
|
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
47
54
|
z.object({ kind: z.literal("user"), userId: z.uuid() }),
|
|
48
55
|
z.object({ kind: z.literal("tenant"), tenantId: z.uuid() }),
|
|
56
|
+
z.object({
|
|
57
|
+
kind: z.literal("record"),
|
|
58
|
+
entity: z.string().regex(RECORD_ENTITY_PATTERN),
|
|
59
|
+
id: z.uuid(),
|
|
60
|
+
}),
|
|
49
61
|
]);
|
|
50
62
|
|
|
51
63
|
export const forgetSubjectSchema = z.object({
|
|
@@ -61,7 +73,7 @@ export const subjectForgottenSchema = z.object({
|
|
|
61
73
|
|
|
62
74
|
export const subjectForgetDeniedSchema = z.object({
|
|
63
75
|
subjectKeyDigest: z.string().min(1),
|
|
64
|
-
subjectKind: z.enum(["user", "tenant"]),
|
|
76
|
+
subjectKind: z.enum(["user", "tenant", "record"]),
|
|
65
77
|
reason: z.string().min(10),
|
|
66
78
|
forgottenBy: z.string().min(1),
|
|
67
79
|
actorTenantId: z.string().min(1),
|
|
@@ -70,6 +82,36 @@ export const subjectForgetDeniedSchema = z.object({
|
|
|
70
82
|
|
|
71
83
|
type SubjectIdInput = z.infer<typeof subjectIdSchema>;
|
|
72
84
|
|
|
85
|
+
// Exhaustive switch so a 4th subject kind fails to compile instead of
|
|
86
|
+
// silently aggregating under the wrong id.
|
|
87
|
+
function subjectAggregateId(raw: SubjectIdInput): string {
|
|
88
|
+
switch (raw.kind) {
|
|
89
|
+
case "user":
|
|
90
|
+
return raw.userId;
|
|
91
|
+
case "tenant":
|
|
92
|
+
return raw.tenantId;
|
|
93
|
+
case "record":
|
|
94
|
+
return raw.id;
|
|
95
|
+
default: {
|
|
96
|
+
const exhaustiveCheck: never = raw;
|
|
97
|
+
throw new Error(`Unhandled subject kind: ${JSON.stringify(exhaustiveCheck)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The record subject names its own entity — resolve it against the registry
|
|
103
|
+
// instead of trusting the payload straight into resolveTableName/SQL (#2786).
|
|
104
|
+
function findRegisteredEntity(
|
|
105
|
+
features: ReadonlyMap<string, FeatureDefinition>,
|
|
106
|
+
entityName: string,
|
|
107
|
+
): EntityDefinition | undefined {
|
|
108
|
+
for (const feature of features.values()) {
|
|
109
|
+
const entity = feature.entities?.[entityName];
|
|
110
|
+
if (entity) return entity;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
73
115
|
// Tenant-scope guard (mh#349): DataProtectionOfficer is a tenant-scoped role,
|
|
74
116
|
// but the handler otherwise erases ANY subject cross-tenant on a raw
|
|
75
117
|
// (un-scoped) client — a Tenant-A DPO who learns a Tenant-B subject id
|
|
@@ -90,6 +132,30 @@ async function resolveTenantScopeDenial(
|
|
|
90
132
|
);
|
|
91
133
|
}
|
|
92
134
|
|
|
135
|
+
if (raw.kind === "record") {
|
|
136
|
+
// Same fail-open rule as the user branch below — without the tenant
|
|
137
|
+
// feature there is no tenant concept to enforce.
|
|
138
|
+
if (!features.has("tenant")) return undefined;
|
|
139
|
+
const entity = findRegisteredEntity(features, raw.entity);
|
|
140
|
+
if (!entity) {
|
|
141
|
+
return writeFailure(
|
|
142
|
+
new AccessDeniedError({ details: { reason: RECORD_ENTITY_NOT_REGISTERED } }),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const ownedInTenant = await recordRowExistsInTenant(
|
|
146
|
+
db,
|
|
147
|
+
features,
|
|
148
|
+
raw.entity,
|
|
149
|
+
raw.id,
|
|
150
|
+
user.tenantId,
|
|
151
|
+
);
|
|
152
|
+
return ownedInTenant
|
|
153
|
+
? undefined
|
|
154
|
+
: writeFailure(
|
|
155
|
+
new AccessDeniedError({ details: { reason: TARGET_RECORD_NOT_ADMIN_TENANT } }),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
93
159
|
// Without the tenant feature there's no membership table to check against —
|
|
94
160
|
// no scoping concept exists to enforce (single/no-tenant apps only;
|
|
95
161
|
// ponytail: fail-open here, not a gap in multi-tenant apps).
|
|
@@ -188,7 +254,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
188
254
|
schema: forgetSubjectSchema,
|
|
189
255
|
access: { roles: [ROLES.DataProtectionOfficer, ROLES.SystemAdmin] },
|
|
190
256
|
description:
|
|
191
|
-
"Irreversibly crypto-shreds one user or
|
|
257
|
+
"Irreversibly crypto-shreds one user, tenant or record subject by erasing its encryption key, nulling its blind indexes, purging its search documents and closing the user's login, for supervisory-authority requests and operator recovery outside the automated Art. 17 cleanup pipeline.",
|
|
192
258
|
// Erasing the subject key is irreversible: there is no undo, so an agent must
|
|
193
259
|
// not be able to reach it at all.
|
|
194
260
|
agent: { expose: false },
|
|
@@ -208,7 +274,9 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
208
274
|
const subject: SubjectId =
|
|
209
275
|
raw.kind === "user"
|
|
210
276
|
? { kind: "user", userId: raw.userId }
|
|
211
|
-
:
|
|
277
|
+
: raw.kind === "tenant"
|
|
278
|
+
? { kind: "tenant", tenantId: raw.tenantId as TenantId } // @cast-boundary uuid-validated command payload → branded id
|
|
279
|
+
: { kind: "record", entity: raw.entity, id: raw.id };
|
|
212
280
|
const subjectKey = subjectIdToKey(subject);
|
|
213
281
|
|
|
214
282
|
const tenantScopeDenial = await resolveTenantScopeDenial(
|
|
@@ -288,7 +356,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
288
356
|
}
|
|
289
357
|
|
|
290
358
|
await ctx.unsafeAppendEvent({
|
|
291
|
-
aggregateId: raw
|
|
359
|
+
aggregateId: subjectAggregateId(raw),
|
|
292
360
|
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
293
361
|
type: SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
294
362
|
payload: {
|
|
@@ -109,6 +109,8 @@ describe("constants — Stripe-event-types die wir mappen", () => {
|
|
|
109
109
|
"customer.subscription.deleted",
|
|
110
110
|
"invoice.paid",
|
|
111
111
|
"invoice.payment_failed",
|
|
112
|
+
"checkout.session.completed",
|
|
113
|
+
"checkout.session.async_payment_succeeded",
|
|
112
114
|
]);
|
|
113
115
|
});
|
|
114
116
|
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Unit tests for the one-off-payment branch of verifyAndParseStripeWebhook
|
|
2
|
+
// (checkout.session.completed / .async_payment_succeeded). Split out of
|
|
3
|
+
// verify-webhook.test.ts — that file's fixtures stay subscription/invoice-
|
|
4
|
+
// only, this one owns every checkout.session.* fixture.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { BillingEventKinds } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
8
|
+
import Stripe from "stripe";
|
|
9
|
+
import type { StripeWebhookRuntime } from "../runtime";
|
|
10
|
+
import { verifyAndParseStripeWebhook } from "../verify-webhook";
|
|
11
|
+
|
|
12
|
+
const TEST_SECRET = "whsec_test_secret_12345";
|
|
13
|
+
const TEST_API_KEY = "sk_test_dummy_apikey";
|
|
14
|
+
|
|
15
|
+
const stripeForFixtures = new Stripe(TEST_API_KEY);
|
|
16
|
+
|
|
17
|
+
async function signEvent(payload: string, secret = TEST_SECRET): Promise<string> {
|
|
18
|
+
return stripeForFixtures.webhooks.generateTestHeaderStringAsync({ payload, secret });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildCheckoutSessionEvent(overrides: {
|
|
22
|
+
eventType?: string;
|
|
23
|
+
eventId?: string;
|
|
24
|
+
sessionId?: string;
|
|
25
|
+
mode?: string;
|
|
26
|
+
paymentStatus?: string;
|
|
27
|
+
}) {
|
|
28
|
+
return {
|
|
29
|
+
id: overrides.eventId ?? "evt_checkout_001",
|
|
30
|
+
object: "event",
|
|
31
|
+
api_version: "2026-04-22.dahlia",
|
|
32
|
+
created: 1_770_000_000,
|
|
33
|
+
type: overrides.eventType ?? "checkout.session.completed",
|
|
34
|
+
livemode: false,
|
|
35
|
+
pending_webhooks: 1,
|
|
36
|
+
request: { id: null, idempotency_key: null },
|
|
37
|
+
data: {
|
|
38
|
+
object: {
|
|
39
|
+
id: overrides.sessionId ?? "cs_test_001",
|
|
40
|
+
object: "checkout.session",
|
|
41
|
+
mode: overrides.mode ?? "payment",
|
|
42
|
+
payment_status: overrides.paymentStatus ?? "paid",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Stub-client: real Stripe.webhooks (sig-verify), fake checkout.sessions.retrieve
|
|
49
|
+
* (the lazy-fetch). No real network precedent for this call in the suite, so a
|
|
50
|
+
* stub result is the intended pattern (advisor-endorsed), not a mock-out of
|
|
51
|
+
* application logic under test. */
|
|
52
|
+
function webhookRuntimeWithRetrieve(
|
|
53
|
+
retrieve: () => Promise<unknown>,
|
|
54
|
+
webhookSecret = TEST_SECRET,
|
|
55
|
+
): StripeWebhookRuntime {
|
|
56
|
+
const stripe = {
|
|
57
|
+
webhooks: stripeForFixtures.webhooks,
|
|
58
|
+
checkout: { sessions: { retrieve } },
|
|
59
|
+
} as unknown as Stripe; // @cast-boundary engine-bridge
|
|
60
|
+
return { resolve: async () => ({ stripe, webhookSecret }) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function throwingRetrieve(): Promise<never> {
|
|
64
|
+
throw new Error("checkout.sessions.retrieve must not be called for a non-payment/unpaid session");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildExpandedSession(overrides: {
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
tenantId?: string;
|
|
70
|
+
priceId?: string;
|
|
71
|
+
customerId?: string;
|
|
72
|
+
}) {
|
|
73
|
+
return {
|
|
74
|
+
id: overrides.sessionId ?? "cs_test_001",
|
|
75
|
+
customer: overrides.customerId ?? "cus_test_checkout",
|
|
76
|
+
payment_intent: {
|
|
77
|
+
id: "pi_test_001",
|
|
78
|
+
metadata: { tenantId: overrides.tenantId ?? "tenant-test-1" },
|
|
79
|
+
customer: overrides.customerId ?? "cus_test_checkout",
|
|
80
|
+
},
|
|
81
|
+
line_items: {
|
|
82
|
+
object: "list",
|
|
83
|
+
data: [{ id: "li_test_001", price: { id: overrides.priceId ?? "price_topup_test" } }],
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("verifyAndParseStripeWebhook — one-off payment (checkout.session.*)", () => {
|
|
89
|
+
test("mode: 'subscription' → no PaymentEvent, lazy-fetch never called", async () => {
|
|
90
|
+
const verify = verifyAndParseStripeWebhook(webhookRuntimeWithRetrieve(throwingRetrieve), {
|
|
91
|
+
priceToTier: {},
|
|
92
|
+
});
|
|
93
|
+
const payload = JSON.stringify(
|
|
94
|
+
buildCheckoutSessionEvent({ mode: "subscription", paymentStatus: "paid" }),
|
|
95
|
+
);
|
|
96
|
+
const sig = await signEvent(payload);
|
|
97
|
+
|
|
98
|
+
const event = await verify(payload, { "stripe-signature": sig });
|
|
99
|
+
expect(event).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("checkout.session.completed with payment_status: 'unpaid' → no PaymentEvent", async () => {
|
|
103
|
+
const verify = verifyAndParseStripeWebhook(webhookRuntimeWithRetrieve(throwingRetrieve), {
|
|
104
|
+
priceToTier: {},
|
|
105
|
+
});
|
|
106
|
+
const payload = JSON.stringify(
|
|
107
|
+
buildCheckoutSessionEvent({
|
|
108
|
+
eventType: "checkout.session.completed",
|
|
109
|
+
mode: "payment",
|
|
110
|
+
paymentStatus: "unpaid",
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
const sig = await signEvent(payload);
|
|
114
|
+
|
|
115
|
+
const event = await verify(payload, { "stripe-signature": sig });
|
|
116
|
+
expect(event).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("checkout.session.async_payment_succeeded, mode: payment, paid → PaymentEvent", async () => {
|
|
120
|
+
const verify = verifyAndParseStripeWebhook(
|
|
121
|
+
webhookRuntimeWithRetrieve(async () => buildExpandedSession({})),
|
|
122
|
+
{ priceToTier: {} },
|
|
123
|
+
);
|
|
124
|
+
const payload = JSON.stringify(
|
|
125
|
+
buildCheckoutSessionEvent({
|
|
126
|
+
eventType: "checkout.session.async_payment_succeeded",
|
|
127
|
+
eventId: "evt_checkout_async_001",
|
|
128
|
+
mode: "payment",
|
|
129
|
+
paymentStatus: "paid",
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
const sig = await signEvent(payload);
|
|
133
|
+
|
|
134
|
+
const event = await verify(payload, { "stripe-signature": sig });
|
|
135
|
+
expect(event).not.toBeNull();
|
|
136
|
+
if (!event || event.kind !== BillingEventKinds.payment) {
|
|
137
|
+
throw new Error("expected a PaymentEvent");
|
|
138
|
+
}
|
|
139
|
+
expect(event.providerEventId).toBe("evt_checkout_async_001");
|
|
140
|
+
expect(event.providerName).toBe("stripe");
|
|
141
|
+
expect(event.tenantId).toBe("tenant-test-1");
|
|
142
|
+
expect(event.priceId).toBe("price_topup_test");
|
|
143
|
+
expect(event.providerCustomerId).toBe("cus_test_checkout");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("checkout.session.completed, mode: payment, paid, missing tenantId metadata → null", async () => {
|
|
147
|
+
const verify = verifyAndParseStripeWebhook(
|
|
148
|
+
webhookRuntimeWithRetrieve(async () => ({
|
|
149
|
+
...buildExpandedSession({}),
|
|
150
|
+
payment_intent: { id: "pi_test_002", metadata: {}, customer: "cus_test_checkout" },
|
|
151
|
+
})),
|
|
152
|
+
{ priceToTier: {} },
|
|
153
|
+
);
|
|
154
|
+
const payload = JSON.stringify(
|
|
155
|
+
buildCheckoutSessionEvent({
|
|
156
|
+
eventType: "checkout.session.completed",
|
|
157
|
+
eventId: "evt_checkout_no_tenant",
|
|
158
|
+
mode: "payment",
|
|
159
|
+
paymentStatus: "paid",
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
const sig = await signEvent(payload);
|
|
163
|
+
|
|
164
|
+
const event = await verify(payload, { "stripe-signature": sig });
|
|
165
|
+
expect(event).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
});
|