@cosmicdrift/kumiko-bundled-features 0.305.0 → 0.307.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 (76) hide show
  1. package/package.json +9 -9
  2. package/src/__tests__/extension-points-typed.test.ts +135 -0
  3. package/src/__tests__/use-extension-options.test.ts +278 -0
  4. package/src/auth-email-password/changes.json +5 -0
  5. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -1
  6. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  7. package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
  8. package/src/auth-foundation/__tests__/tenant-providers.test.ts +1 -0
  9. package/src/auth-foundation/__tests__/token-verifier.test.ts +2 -0
  10. package/src/auth-foundation/types.ts +14 -4
  11. package/src/auth-mfa/__tests__/session-scoped-sse-revoke.integration.test.ts +270 -0
  12. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -1
  13. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  14. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +4 -63
  15. package/src/billing-foundation/__tests__/tenant-destroy.integration.test.ts +199 -0
  16. package/src/billing-foundation/changes.json +6 -0
  17. package/src/billing-foundation/feature.ts +8 -1
  18. package/src/billing-foundation/tenant-destroy-hook.ts +20 -20
  19. package/src/billing-foundation/types.ts +13 -1
  20. package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -0
  21. package/src/cap-counter/changes.json +5 -0
  22. package/src/cap-counter/enforce-cap.ts +8 -1
  23. package/src/channel-email/feature.ts +2 -1
  24. package/src/channel-in-app/feature.ts +2 -1
  25. package/src/channel-push/feature.ts +2 -1
  26. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +14 -3
  27. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +11 -3
  28. package/src/config/__tests__/reencrypt-job-tenant-checks.integration.test.ts +7 -3
  29. package/src/config/__tests__/require-system-db.test.ts +3 -3
  30. package/src/config/handlers/reencrypt.job.ts +4 -8
  31. package/src/custom-fields/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +25 -24
  32. package/src/delivery/__tests__/delivery-screens.boot.test.ts +20 -2
  33. package/src/delivery/__tests__/delivery.integration.test.ts +140 -3
  34. package/src/delivery/constants.ts +4 -0
  35. package/src/delivery/delivery-service.ts +45 -29
  36. package/src/delivery/feature.ts +2 -1
  37. package/src/delivery/index.ts +14 -11
  38. package/src/delivery/types.ts +24 -0
  39. package/src/document-ingest-foundation/__tests__/pages-crypto-shredding.integration.test.ts +0 -27
  40. package/src/document-ingest-foundation/__tests__/tenant-destroy.integration.test.ts +166 -0
  41. package/src/document-ingest-foundation/changes.json +5 -0
  42. package/src/document-ingest-foundation/tenant-destroy-hook.ts +6 -17
  43. package/src/file-derivatives/handlers/public-variant.query.ts +7 -0
  44. package/src/files/__tests__/files.integration.test.ts +2 -2
  45. package/src/form-draft/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +24 -21
  46. package/src/inbound-mail-foundation/__tests__/tenant-destroy.integration.test.ts +173 -0
  47. package/src/inbound-mail-foundation/changes.json +6 -0
  48. package/src/inbound-mail-foundation/feature.ts +4 -0
  49. package/src/inbound-mail-foundation/tenant-destroy-hook.ts +15 -20
  50. package/src/inbound-mail-foundation/types.ts +10 -3
  51. package/src/jobs/__tests__/job-system-user.integration.test.ts +3 -3
  52. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +15 -6
  53. package/src/mail-foundation/feature.ts +15 -4
  54. package/src/mail-foundation/index.ts +1 -0
  55. package/src/mail-transport-inmemory/feature.ts +5 -4
  56. package/src/mail-transport-smtp/feature.ts +5 -4
  57. package/src/renderer-foundation/__tests__/collect-plugins.integration.test.ts +24 -2
  58. package/src/renderer-foundation/changes.json +8 -1
  59. package/src/renderer-foundation/constants.ts +4 -0
  60. package/src/renderer-foundation/feature.ts +10 -13
  61. package/src/renderer-foundation/index.ts +3 -0
  62. package/src/renderer-foundation/types.ts +23 -1
  63. package/src/renderer-simple/feature.ts +2 -1
  64. package/src/sessions/handlers/revoke-all-others.write.ts +1 -0
  65. package/src/sessions/session-callbacks.ts +1 -0
  66. package/src/sessions/session-revoked-event.ts +3 -0
  67. package/src/tenant/__tests__/multi-tenant.integration.test.ts +2 -2
  68. package/src/tenant-lifecycle/__tests__/stages.test.ts +51 -0
  69. package/src/tenant-lifecycle/stages.ts +20 -18
  70. package/src/user/db/queries/__tests__/unsafe-read-retrying.integration.test.ts +54 -0
  71. package/src/user-data-rights/__tests__/download.integration.test.ts +3 -0
  72. package/src/user-data-rights/lib/default-mailers.ts +2 -1
  73. package/src/workflow-runner/__tests__/event-wakeup.integration.test.ts +6 -2
  74. package/src/workflow-runner/__tests__/resume-loop.integration.test.ts +6 -2
  75. package/src/workflow-runner/__tests__/workflow-runner.integration.test.ts +5 -0
  76. package/src/user/db/queries/__tests__/unsafe-read-retrying.test.ts +0 -52
@@ -0,0 +1,270 @@
1
+ // Session-scoped SSE revocation (kumiko-framework "session-scoped-revoke").
2
+ //
3
+ // enable-confirm (and disable/regenerate-recovery) call
4
+ // sessionRevokeAllOthers, which appends sessions:event:session-revoked with
5
+ // an optional `keptSessionId` set to the caller's own sid — the ONE sid this
6
+ // write deliberately does NOT revoke. Before this fix,
7
+ // createAccessInvalidationEventConsumer ignored that and called
8
+ // sseBroker.publishAccessInvalidation USERWIDE, which closed the caller's
9
+ // own just-opened SSE stream along with every other session's — the exact
10
+ // bug this test proves fixed: studio's live MFA-status panel never saw the
11
+ // state flip because its own stream died with everyone else's.
12
+ //
13
+ // This is a KEEP-list (spare exactly keptSessionId), not a kill-list
14
+ // (scope to the revoked sessionIds) — the first test below proves why: a
15
+ // third session (C) that was already logged out through the real
16
+ // /api/auth/logout endpoint BEFORE enable-confirm runs never appears in any
17
+ // session-revoked event's sessionIds (logout revokes via the raw
18
+ // sessionRevoker callback with no event at all). A kill-list keyed on
19
+ // sessionIds would silently leave C's still-open stream running; the
20
+ // keep-list closes it like every other non-kept stream, exactly as the
21
+ // pre-scoping userwide invalidation always did.
22
+ //
23
+ // Real stack throughout: setupTestStack + real HTTP (createLiveDispatcher for
24
+ // the SSE streams, stack.http for the writes), never createTestDispatcher.
25
+
26
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
27
+ import { randomBytes } from "node:crypto";
28
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
29
+ import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
30
+ import type { SessionCreator } from "@cosmicdrift/kumiko-framework/api";
31
+ import { generateToken } from "@cosmicdrift/kumiko-framework/api";
32
+ import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
33
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
34
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
35
+ import {
36
+ setupTestStack,
37
+ type TestStack,
38
+ TestUsers,
39
+ testTenantId,
40
+ unsafeCreateEntityTable,
41
+ unsafePushTables,
42
+ } from "@cosmicdrift/kumiko-framework/stack";
43
+ import {
44
+ createLateBoundHolder,
45
+ createTestEnvelopeCipher,
46
+ resetTestTables,
47
+ } from "@cosmicdrift/kumiko-framework/testing";
48
+ import { z } from "zod";
49
+ import { AuthHandlers } from "../../auth-email-password/constants";
50
+ import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
51
+ import { createConfigFeature } from "../../config";
52
+ import { createConfigResolver } from "../../config/resolver";
53
+ import { configValuesTable } from "../../config/table";
54
+ import { makeSessionHelpers } from "../../sessions/__tests__/test-helpers";
55
+ import { createSessionsFeature } from "../../sessions/feature";
56
+ import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
57
+ import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
58
+ import { sessionCallbacksFromLateBound, withMintedSession } from "../../sessions/testing";
59
+ import { createTenantFeature } from "../../tenant";
60
+ import { TenantHandlers } from "../../tenant/constants";
61
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
62
+ import { tenantEntity } from "../../tenant/schema/tenant";
63
+ import { createUserFeature } from "../../user/feature";
64
+ import { userEntity, userTable } from "../../user/schema/user";
65
+ import { base32Decode } from "../base32";
66
+ import { AuthMfaHandlers } from "../constants";
67
+ import { bindMfaRevokeAllOtherSessionsFromFeature, createAuthMfaFeature } from "../feature";
68
+ import { userMfaEntity } from "../schema/user-mfa";
69
+ import { currentTotpCode } from "../totp";
70
+
71
+ let stack: TestStack;
72
+ let h: ReturnType<typeof makeSessionHelpers>;
73
+ let sessionCreator: SessionCreator;
74
+ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
75
+
76
+ const encryptionKey = randomBytes(32).toString("base64");
77
+ const SETUP_TOKEN_SECRET = "test-mfa-setup-token-secret-at-least-32-bytes!!";
78
+ const TENANT = testTenantId(1);
79
+
80
+ const chunkGates = new Map<string, { promise: Promise<void>; release: () => void }>();
81
+
82
+ function createGate(gateId: string): () => void {
83
+ let release!: () => void;
84
+ const promise = new Promise<void>((resolve) => {
85
+ release = resolve;
86
+ });
87
+ chunkGates.set(gateId, { promise, release });
88
+ return release;
89
+ }
90
+
91
+ const streamProbeFeature = defineFeature("stream-probe", (r) => {
92
+ r.streamHandler(
93
+ "probe:tail",
94
+ z.object({ gateId: z.string().min(1) }),
95
+ async function* (event) {
96
+ yield { phase: "open" as const };
97
+ const gate = chunkGates.get(event.payload.gateId);
98
+ if (!gate) throw new Error(`missing gate ${event.payload.gateId}`);
99
+ await gate.promise;
100
+ yield { phase: "survived" as const };
101
+ },
102
+ { access: { roles: ["User"] } },
103
+ );
104
+ });
105
+
106
+ function buildLiveFetch(authJwt: string): {
107
+ readonly fetch: typeof fetch;
108
+ readonly csrfToken: string;
109
+ } {
110
+ const csrfToken = generateToken();
111
+ const cookieHeader = `kumiko_auth=${authJwt}; kumiko_csrf=${csrfToken}`;
112
+ const fetchImpl = (async (url: unknown, init: RequestInit | undefined) => {
113
+ const reqInit: RequestInit = {
114
+ ...(init ?? {}),
115
+ headers: {
116
+ ...(init?.headers ?? {}),
117
+ Cookie: cookieHeader,
118
+ },
119
+ };
120
+ return stack.app.request(String(url), reqInit);
121
+ }) as unknown as typeof fetch;
122
+ return { fetch: fetchImpl, csrfToken };
123
+ }
124
+
125
+ async function openProbeStream(token: string): Promise<{
126
+ iter: AsyncIterator<{ phase: string }>;
127
+ release: () => void;
128
+ }> {
129
+ const gateId = generateToken();
130
+ const release = createGate(gateId);
131
+ const { fetch, csrfToken } = buildLiveFetch(token);
132
+ const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
133
+ const iter = dispatcher
134
+ .stream<{ phase: string }>("stream-probe:stream:probe:tail", { gateId })
135
+ [Symbol.asyncIterator]();
136
+ const first = await iter.next();
137
+ expect(first.done).toBe(false);
138
+ expect(first.value).toEqual({ phase: "open" });
139
+ return { iter, release };
140
+ }
141
+
142
+ beforeAll(async () => {
143
+ const encryption = createTestEnvelopeCipher(encryptionKey);
144
+ configureEntityFieldEncryption(encryption);
145
+ const resolver = createConfigResolver({ cipher: encryption });
146
+ const bound = sessionCallbacksFromLateBound(callbacks);
147
+ const mfaFeature = createAuthMfaFeature({
148
+ setupTokenSecret: SETUP_TOKEN_SECRET,
149
+ issuer: "Kumiko Test",
150
+ challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
151
+ });
152
+
153
+ stack = await setupTestStack({
154
+ features: [
155
+ createConfigFeature(),
156
+ createUserFeature(),
157
+ createTenantFeature(),
158
+ createAuthEmailPasswordFeature(),
159
+ authFoundationFeature,
160
+ createSessionsFeature({ autoRevokeOnPasswordChange: bound.asMassRevoker() }),
161
+ mfaFeature,
162
+ streamProbeFeature,
163
+ ],
164
+ extraContext: { configResolver: resolver, configEncryption: encryption },
165
+ authConfig: {
166
+ ...bound.asAuthConfig(),
167
+ membershipQuery: "tenant:query:memberships",
168
+ loginHandler: AuthHandlers.login,
169
+ },
170
+ });
171
+ callbacks.set(createSessionCallbacks({ db: stack.db }));
172
+ bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(callbacks.get().sessionRevokeAllOthers);
173
+
174
+ h = makeSessionHelpers(stack, TENANT, bound.asAuthConfig().sessionCreator);
175
+ const boundSessionCreator = bound.asAuthConfig().sessionCreator;
176
+ if (!boundSessionCreator) throw new Error("sessionCreator not bound — check setup order");
177
+ sessionCreator = boundSessionCreator;
178
+
179
+ await unsafeCreateEntityTable(stack.db, userEntity);
180
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
181
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
182
+ await unsafeCreateEntityTable(stack.db, userMfaEntity);
183
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
184
+ });
185
+
186
+ afterAll(async () => {
187
+ await stack.cleanup();
188
+ });
189
+
190
+ beforeEach(async () => {
191
+ await resetTestTables(stack.db, [
192
+ userTable,
193
+ tenantMembershipsTable,
194
+ userSessionTable,
195
+ eventsTable,
196
+ ]);
197
+ chunkGates.clear();
198
+ });
199
+
200
+ describe("session-scoped SSE revoke (enable-confirm keeps the caller's own stream open)", () => {
201
+ test("enable-confirm from session A keeps A's stream alive, closes B's, and also closes C's even though C was already logged out through the eventless /api/auth/logout path", async () => {
202
+ await h.seedUser("mfa-scope-a@example.com", "pw-long-enough");
203
+ const sessionA = await h.login("mfa-scope-a@example.com", "pw-long-enough");
204
+ const sessionB = await h.login("mfa-scope-a@example.com", "pw-long-enough");
205
+ const sessionC = await h.login("mfa-scope-a@example.com", "pw-long-enough");
206
+
207
+ const streamA = await openProbeStream(sessionA.token);
208
+ const streamB = await openProbeStream(sessionB.token);
209
+ const streamC = await openProbeStream(sessionC.token);
210
+
211
+ // C logs out through the REAL endpoint — sessionRevoker (session-
212
+ // callbacks.ts) revokes the row directly, no session-revoked event at
213
+ // all. C's sid therefore never appears in any later event's sessionIds.
214
+ const logoutRes = await h.authedPost("/api/auth/logout", sessionC.token);
215
+ expect(logoutRes.status).toBe(200);
216
+
217
+ const start = await h.authedPost("/api/write", sessionA.token, {
218
+ type: AuthMfaHandlers.enableStart,
219
+ payload: { accountLabel: "mfa-scope-a@example.com" },
220
+ });
221
+ expect(start.status).toBe(200);
222
+ const startBody = (await start.json()) as {
223
+ data: { setupToken: string; otpauthUri: string };
224
+ };
225
+ const secretParam =
226
+ new URLSearchParams(startBody.data.otpauthUri.split("?")[1]).get("secret") ?? "";
227
+ const secret = base32Decode(secretParam);
228
+
229
+ const confirm = await h.authedPost("/api/write", sessionA.token, {
230
+ type: AuthMfaHandlers.enableConfirm,
231
+ payload: { setupToken: startBody.data.setupToken, code: currentTotpCode(secret) },
232
+ });
233
+ expect(confirm.status).toBe(200);
234
+
235
+ await stack.eventDispatcher?.runOnce();
236
+
237
+ // Assert B and C first, without releasing A's gate: their rejections are
238
+ // proof the publish already reached every listener (they fire from the
239
+ // same publish loop), so a still-pending A cannot pass "by being slow".
240
+ await expect(streamB.iter.next()).rejects.toMatchObject({ code: "access_denied" });
241
+ await expect(streamC.iter.next()).rejects.toMatchObject({ code: "access_denied" });
242
+
243
+ streamA.release();
244
+ const survivedChunk = await streamA.iter.next();
245
+ expect(survivedChunk.done).toBe(false);
246
+ expect(survivedChunk.value).toEqual({ phase: "survived" });
247
+ });
248
+
249
+ test("an invalidation reason with no keptSessionId (role change) still closes every session's stream, unscoped", async () => {
250
+ const { userId } = await h.seedUser("mfa-scope-b@example.com", "pw-long-enough");
251
+ const sessionA = await h.login("mfa-scope-b@example.com", "pw-long-enough");
252
+ const sessionB = await h.login("mfa-scope-b@example.com", "pw-long-enough");
253
+
254
+ const streamA = await openProbeStream(sessionA.token);
255
+ const streamB = await openProbeStream(sessionB.token);
256
+
257
+ const admin = await withMintedSession(sessionCreator, TestUsers.systemAdmin);
258
+ const result = await stack.http.writeOk(
259
+ TenantHandlers.updateMemberRoles,
260
+ { userId, tenantId: TENANT, roles: ["Admin"] },
261
+ admin,
262
+ );
263
+ expect(result).toBeTruthy();
264
+
265
+ await stack.eventDispatcher?.runOnce();
266
+
267
+ await expect(streamA.iter.next()).rejects.toMatchObject({ code: "access_denied" });
268
+ await expect(streamB.iter.next()).rejects.toMatchObject({ code: "access_denied" });
269
+ });
270
+ });
@@ -63,7 +63,7 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
63
63
  setupToken: z.string().min(1),
64
64
  code: z.string().length(6),
65
65
  }),
66
- access: { roles: ["anonymous"] },
66
+ access: { roles: ["anonymous"], personalData: "public-intake" },
67
67
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
68
68
  escapeHatch: {
69
69
  reason:
@@ -45,7 +45,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
45
45
  return defineWriteHandler({
46
46
  name: "verify",
47
47
  schema: z.object({ challengeToken: z.string().min(1), code: z.string().min(6).max(9) }),
48
- access: { roles: ["anonymous"] },
48
+ access: { roles: ["anonymous"], personalData: "public-intake" },
49
49
  rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
50
50
  escapeHatch: {
51
51
  reason:
@@ -23,7 +23,7 @@ import {
23
23
  } from "@cosmicdrift/kumiko-framework/crypto";
24
24
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
25
25
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
26
- import { isStreamArchived, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
26
+ import { loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
27
27
  import {
28
28
  createTestUser,
29
29
  setupTestStack,
@@ -33,7 +33,6 @@ import {
33
33
  } from "@cosmicdrift/kumiko-framework/stack";
34
34
  import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
35
35
  import {
36
- ComplianceProfileHandlers,
37
36
  createComplianceProfilesFeature,
38
37
  tenantComplianceProfileEntity,
39
38
  } from "../../compliance-profiles";
@@ -50,7 +49,6 @@ import {
50
49
  } from "../constants";
51
50
  import { billingFoundationFeature } from "../feature";
52
51
  import { paymentsProjectionTable, subscriptionsProjectionTable } from "../projection";
53
- import { subscriptionTenantDestroyHook } from "../tenant-destroy-hook";
54
52
  import type { PaymentEvent, SubscriptionProviderPlugin } from "../types";
55
53
  import { createSubscriptionWebhookRoute } from "../webhook-handler";
56
54
 
@@ -669,66 +667,9 @@ describe("scenario 8: cancel-event setzt status auf canceled, behält subscripti
669
667
  });
670
668
  });
671
669
 
672
- describe("scenario 9: subscriptionTenantDestroyHook (#800 tenant-destroy PII erasure)", () => {
673
- test("default profile → row hard-deleted, stream archived", async () => {
674
- const admin = adminFor(3009);
675
- await stack.http.writeOk(
676
- SubscriptionFoundationHandlers.processEvent,
677
- buildEvent({
678
- providerEventId: "evt_3009_create",
679
- providerCustomerId: "cus_3009",
680
- providerSubscriptionId: "sub_3009",
681
- }),
682
- admin,
683
- );
684
-
685
- await subscriptionTenantDestroyHook({ db, tenantId: admin.tenantId });
686
-
687
- const rows = await selectMany(db, subscriptionsProjectionTable, {
688
- id: subscriptionAggregateId(admin.tenantId),
689
- });
690
- expect(rows).toHaveLength(0);
691
- expect(
692
- await isStreamArchived(db, admin.tenantId, subscriptionAggregateId(admin.tenantId)),
693
- ).toBe(true);
694
- });
695
-
696
- test("HGB profile → PII redacted, accounting fields + row survive, stream archived", async () => {
697
- const admin = adminFor(3010);
698
- await stack.http.writeOk(
699
- ComplianceProfileHandlers.setProfile,
700
- { profileKey: "de-hr-dsgvo-hgb" },
701
- admin,
702
- );
703
- await stack.http.writeOk(
704
- SubscriptionFoundationHandlers.processEvent,
705
- buildEvent({
706
- providerEventId: "evt_3010_create",
707
- providerCustomerId: "cus_3010",
708
- providerSubscriptionId: "sub_3010",
709
- tier: "pro",
710
- }),
711
- admin,
712
- );
713
-
714
- await subscriptionTenantDestroyHook({ db, tenantId: admin.tenantId });
715
-
716
- const rows = await selectMany<{
717
- providerCustomerId: string;
718
- providerSubscriptionId: string;
719
- tier: string;
720
- status: string;
721
- }>(db, subscriptionsProjectionTable, { id: subscriptionAggregateId(admin.tenantId) });
722
- expect(rows).toHaveLength(1);
723
- expect(rows[0]?.providerCustomerId).toBe("[erased]");
724
- expect(rows[0]?.providerSubscriptionId).toBe("[erased]");
725
- // accounting facts survive — that's the point of the HGB retention branch
726
- expect(rows[0]?.tier).toBe("pro");
727
- expect(
728
- await isStreamArchived(db, admin.tenantId, subscriptionAggregateId(admin.tenantId)),
729
- ).toBe(true);
730
- });
731
- });
670
+ // scenario 9 (subscriptionTenantDestroyHook direct-call, default+HGB branches) removed:
671
+ // covered end-to-end by billing-foundation/__tests__/tenant-destroy.integration.test.ts,
672
+ // which drives the real tenant-lifecycle sweep instead of hand-feeding the hook.
732
673
 
733
674
  describe("scenario 10: PII is encrypted at rest, not just erasable on destroy", () => {
734
675
  test("raw event-log payload and raw projection row both hold ciphertext, not the plaintext provider id", async () => {
@@ -0,0 +1,199 @@
1
+ // Drives the real tenant-lifecycle destruction sweep: a direct hook call with a hand-built
2
+ // TenantDb hides the escapeHatch grant the "app-data" stage actually resolves.
3
+
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
6
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
7
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { append, isStreamArchived, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
10
+ import {
11
+ createTestUser,
12
+ setupTestStack,
13
+ type TestStack,
14
+ TestUsers,
15
+ testTenantId,
16
+ unsafeCreateEntityTable,
17
+ } from "@cosmicdrift/kumiko-framework/stack";
18
+ import {
19
+ resetPiiSubjectKmsForTests,
20
+ resetTestTables,
21
+ updateRows,
22
+ } from "@cosmicdrift/kumiko-framework/testing";
23
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
24
+ import {
25
+ createComplianceProfilesFeature,
26
+ tenantComplianceProfileEntity,
27
+ tenantComplianceProfileTable,
28
+ } from "../../compliance-profiles";
29
+ import { createConfigFeature } from "../../config";
30
+ import { tenantMembershipEntity } from "../../tenant";
31
+ import { TenantHandlers } from "../../tenant/constants";
32
+ import { createTenantFeature } from "../../tenant/feature";
33
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
34
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
35
+ import {
36
+ TENANT_AGGREGATE_TYPE,
37
+ TENANT_DESTRUCTION_STARTED_EVENT_QN,
38
+ } from "../../tenant-lifecycle/constants";
39
+ import { runTenantDestructionSweep } from "../../tenant-lifecycle/run-tenant-destroy";
40
+ import { subscriptionAggregateId } from "../aggregate-id";
41
+ import { SubscriptionEventTypes, SubscriptionFoundationHandlers } from "../constants";
42
+ import { billingFoundationFeature } from "../feature";
43
+ import { subscriptionsProjectionTable } from "../projection";
44
+
45
+ const SET_PROFILE = "compliance-profiles:write:set-profile";
46
+
47
+ let stack: TestStack;
48
+ let db: DbConnection;
49
+
50
+ function adminFor(tenantNumber: number) {
51
+ return createTestUser({
52
+ id: tenantNumber,
53
+ tenantId: testTenantId(tenantNumber),
54
+ roles: ["TenantAdmin", "SystemAdmin"],
55
+ });
56
+ }
57
+
58
+ const tenantA = adminFor(9001);
59
+ const tenantB = adminFor(9002);
60
+
61
+ beforeAll(async () => {
62
+ stack = await setupTestStack({
63
+ features: [
64
+ createConfigFeature(),
65
+ createTenantFeature(),
66
+ createComplianceProfilesFeature(),
67
+ createTenantLifecycleFeature(),
68
+ billingFoundationFeature,
69
+ ],
70
+ });
71
+ db = stack.db;
72
+ await unsafeCreateEntityTable(db, tenantEntity);
73
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
74
+ await unsafeCreateEntityTable(db, tenantMembershipEntity);
75
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
76
+ });
77
+
78
+ afterAll(async () => {
79
+ await stack.cleanup();
80
+ resetPiiSubjectKmsForTests();
81
+ });
82
+
83
+ beforeEach(async () => {
84
+ stack.events.reset();
85
+ await resetTestTables(db, [tenantTable, tenantComplianceProfileTable]);
86
+ await stack.db.unsafe?.(`TRUNCATE kumiko_events, read_subscriptions RESTART IDENTITY CASCADE`);
87
+ });
88
+
89
+ async function seedTenant(user: typeof tenantA, profileKey = "eu-dsgvo"): Promise<void> {
90
+ await stack.http.writeOk(
91
+ TenantHandlers.create,
92
+ { id: user.tenantId, key: `t-${user.tenantId}`, name: "Tenant" },
93
+ TestUsers.systemAdmin,
94
+ );
95
+ await stack.http.writeOk(SET_PROFILE, { profileKey }, user);
96
+ }
97
+
98
+ async function seedSubscription(user: typeof tenantA, eventIdSuffix: string): Promise<void> {
99
+ await stack.http.writeOk(
100
+ SubscriptionFoundationHandlers.processEvent,
101
+ {
102
+ providerEventId: `evt_${eventIdSuffix}`,
103
+ providerName: "stripe",
104
+ type: SubscriptionEventTypes.created,
105
+ status: "active",
106
+ tier: "pro",
107
+ providerCustomerId: `cus_${eventIdSuffix}`,
108
+ providerSubscriptionId: `sub_${eventIdSuffix}`,
109
+ currentPeriodEndIso: "2030-01-01T00:00:00Z",
110
+ rawPayload: "{}",
111
+ },
112
+ user,
113
+ );
114
+ }
115
+
116
+ async function seedDestroyingTenant(tenantId: TenantId): Promise<void> {
117
+ const now = getTemporal().Now.instant();
118
+ await updateRows(
119
+ db,
120
+ tenantTable,
121
+ { status: "destroying", destroyStartedAt: now },
122
+ { id: tenantId },
123
+ );
124
+ await append(db, {
125
+ aggregateId: tenantId,
126
+ aggregateType: TENANT_AGGREGATE_TYPE,
127
+ tenantId,
128
+ expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
129
+ type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
130
+ payload: { startedAt: now.toString() },
131
+ metadata: { userId: "system", requestId: "test:destruction-started" },
132
+ });
133
+ }
134
+
135
+ async function driveDestructionToCompletion(tenantId: TenantId): Promise<string> {
136
+ const farFuture = getTemporal()
137
+ .Now.instant()
138
+ .add({ hours: 24 * 3650 });
139
+ let status = "";
140
+ for (let i = 0; i < 20; i++) {
141
+ await runTenantDestructionSweep({ db: stack.db, registry: stack.registry, now: farFuture });
142
+ const rows = await selectMany(db, tenantTable, { id: tenantId });
143
+ status = String(rows[0]?.["status"]);
144
+ if (status === "destroyed" || status === "destroyFailed") break;
145
+ }
146
+ return status;
147
+ }
148
+
149
+ describe("billing-foundation :: tenant destroy (#3196)", () => {
150
+ test("deletes the subscription row for the destroyed tenant, archives its stream, leaves another tenant's row untouched", async () => {
151
+ await seedTenant(tenantA);
152
+ await seedTenant(tenantB);
153
+
154
+ await seedSubscription(tenantA, "a");
155
+ await seedSubscription(tenantB, "b");
156
+
157
+ await seedDestroyingTenant(tenantA.tenantId);
158
+
159
+ const finalStatus = await driveDestructionToCompletion(tenantA.tenantId);
160
+ expect(finalStatus).toBe("destroyed");
161
+
162
+ const rowsA = await selectMany(db, subscriptionsProjectionTable, {
163
+ id: subscriptionAggregateId(tenantA.tenantId),
164
+ });
165
+ expect(rowsA).toHaveLength(0);
166
+
167
+ const rowsB = await selectMany(db, subscriptionsProjectionTable, {
168
+ id: subscriptionAggregateId(tenantB.tenantId),
169
+ });
170
+ expect(rowsB).toHaveLength(1);
171
+
172
+ expect(
173
+ await isStreamArchived(db, tenantA.tenantId, subscriptionAggregateId(tenantA.tenantId)),
174
+ ).toBe(true);
175
+ });
176
+
177
+ test("HGB profile: redacts PII fields but keeps the row and archives its stream", async () => {
178
+ const tenantHgb = adminFor(9003);
179
+ await seedTenant(tenantHgb, "de-hr-dsgvo-hgb");
180
+
181
+ await seedSubscription(tenantHgb, "c");
182
+
183
+ await seedDestroyingTenant(tenantHgb.tenantId);
184
+
185
+ const finalStatus = await driveDestructionToCompletion(tenantHgb.tenantId);
186
+ expect(finalStatus).toBe("destroyed");
187
+
188
+ const rowsA = await selectMany(db, subscriptionsProjectionTable, {
189
+ id: subscriptionAggregateId(tenantHgb.tenantId),
190
+ });
191
+ expect(rowsA).toHaveLength(1);
192
+ expect(rowsA[0]?.["providerCustomerId"]).toBe("[erased]");
193
+ expect(rowsA[0]?.["providerSubscriptionId"]).toBe("[erased]");
194
+
195
+ expect(
196
+ await isStreamArchived(db, tenantHgb.tenantId, subscriptionAggregateId(tenantHgb.tenantId)),
197
+ ).toBe(true);
198
+ });
199
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.306.0",
4
+ "type": "fix",
5
+ "title": "Tenant destroy no longer fails on subscription and payment rows",
6
+ "detail": "The subscription and payment tenant-destroy hooks passed the tenant-scoped TenantDb they receive into raw DbRunner helpers, so the app-data stage failed and the tenant ended in destroyFailed. They now use ctx.db methods and declare an escapeHatch for the compliance-profile lookup and the event-stream archive."
7
+ },
2
8
  {
3
9
  "version": "0.301.0",
4
10
  "type": "breaking",
@@ -81,7 +81,12 @@ import {
81
81
  paymentsProjectionTable,
82
82
  subscriptionsProjectionTable,
83
83
  } from "./projection";
84
- import { paymentTenantDestroyHook, subscriptionTenantDestroyHook } from "./tenant-destroy-hook";
84
+ import {
85
+ PAYMENT_TENANT_DESTROY_ARCHIVE_REASON,
86
+ paymentTenantDestroyHook,
87
+ SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON,
88
+ subscriptionTenantDestroyHook,
89
+ } from "./tenant-destroy-hook";
85
90
 
86
91
  export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
87
92
  r.describe(
@@ -169,8 +174,10 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
169
174
 
170
175
  r.useExtension(EXT_TENANT_DATA, "subscription", {
171
176
  destroy: subscriptionTenantDestroyHook,
177
+ escapeHatch: { reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON },
172
178
  });
173
179
  r.useExtension(EXT_TENANT_DATA, "payment", {
174
180
  destroy: paymentTenantDestroyHook,
181
+ escapeHatch: { reason: PAYMENT_TENANT_DESTROY_ARCHIVE_REASON },
175
182
  });
176
183
  });