@cosmicdrift/kumiko-bundled-features 0.125.2 → 0.127.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 (69) hide show
  1. package/package.json +7 -6
  2. package/src/audit/__tests__/audit-security.integration.test.ts +50 -0
  3. package/src/audit/i18n.ts +9 -0
  4. package/src/audit/web/audit-log-screen.tsx +112 -2
  5. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +206 -2
  6. package/src/billing-foundation/entities.ts +28 -4
  7. package/src/billing-foundation/events.ts +6 -2
  8. package/src/billing-foundation/feature.ts +9 -1
  9. package/src/billing-foundation/get-subscription-for-tenant.ts +16 -5
  10. package/src/billing-foundation/handlers/create-portal-session.write.ts +17 -1
  11. package/src/billing-foundation/handlers/list-subscriptions.query.ts +15 -1
  12. package/src/billing-foundation/handlers/process-event.write.ts +39 -3
  13. package/src/billing-foundation/tenant-destroy-hook.ts +47 -0
  14. package/src/cap-counter/entity.ts +1 -1
  15. package/src/cap-counter/feature.ts +10 -2
  16. package/src/cap-counter/i18n.ts +12 -0
  17. package/src/compliance-profiles/feature.ts +3 -0
  18. package/src/compliance-profiles/i18n.ts +6 -0
  19. package/src/config/feature.ts +3 -0
  20. package/src/config/i18n.ts +9 -0
  21. package/src/delivery/feature.ts +3 -0
  22. package/src/delivery/i18n.ts +6 -0
  23. package/src/feature-toggles/feature.ts +3 -0
  24. package/src/feature-toggles/i18n.ts +6 -0
  25. package/src/jobs/i18n.ts +4 -0
  26. package/src/jobs/web/job-runs-screen.tsx +9 -9
  27. package/src/managed-pages/i18n.ts +5 -0
  28. package/src/managed-pages/screens/page-screens.ts +5 -1
  29. package/src/personal-access-tokens/feature.ts +2 -0
  30. package/src/personal-access-tokens/i18n.ts +5 -0
  31. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +7 -3
  32. package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +12 -1
  33. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +9 -0
  34. package/src/subscription-stripe/feature.ts +2 -0
  35. package/src/tags/feature.ts +2 -0
  36. package/src/tags/i18n.ts +5 -0
  37. package/src/tenant/__tests__/members-screens.boot.test.ts +7 -0
  38. package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
  39. package/src/tenant/feature.ts +4 -1
  40. package/src/tenant/handlers/members.query.ts +19 -4
  41. package/src/tenant/i18n.ts +28 -0
  42. package/src/tenant/index.ts +7 -2
  43. package/src/tenant/schema/tenant.ts +27 -2
  44. package/src/tenant/screens.ts +3 -2
  45. package/src/tenant/web/members-screen.tsx +8 -3
  46. package/src/tenant-lifecycle/__tests__/stages.test.ts +22 -0
  47. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +469 -0
  48. package/src/tenant-lifecycle/constants.ts +56 -0
  49. package/src/tenant-lifecycle/events.ts +46 -0
  50. package/src/tenant-lifecycle/feature.ts +105 -0
  51. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +81 -0
  52. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +83 -0
  53. package/src/tenant-lifecycle/index.ts +7 -0
  54. package/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +16 -0
  55. package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
  56. package/src/tenant-lifecycle/run-tenant-destroy.ts +368 -0
  57. package/src/tenant-lifecycle/stages.ts +206 -0
  58. package/src/tier-engine/feature.ts +2 -0
  59. package/src/tier-engine/i18n.ts +8 -0
  60. package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
  61. package/src/user/feature.ts +3 -0
  62. package/src/user/i18n.ts +20 -0
  63. package/src/user/schema/user.ts +1 -0
  64. package/src/user/screens.ts +3 -15
  65. package/src/user-data-rights/feature.ts +17 -0
  66. package/src/user-data-rights/i18n.ts +32 -33
  67. package/src/user-data-rights/schema/export-job.ts +2 -0
  68. package/src/user-data-rights/screens.ts +2 -1
  69. package/src/user-data-rights/web/i18n.ts +9 -0
@@ -0,0 +1,469 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
5
+ import {
6
+ defineFeature,
7
+ EXT_EXTERNAL_RESOURCE,
8
+ EXT_TENANT_DATA,
9
+ type TenantId,
10
+ } from "@cosmicdrift/kumiko-framework/engine";
11
+ import {
12
+ append,
13
+ createEventsTable,
14
+ eventsTable,
15
+ loadAggregate,
16
+ } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ createTestUser,
19
+ setupTestStack,
20
+ type TestStack,
21
+ TestUsers,
22
+ testTenantId,
23
+ unsafeCreateEntityTable,
24
+ unsafePushTables,
25
+ } from "@cosmicdrift/kumiko-framework/stack";
26
+ import {
27
+ createTestEnvelopeCipher,
28
+ resetTestTables,
29
+ updateRows,
30
+ } from "@cosmicdrift/kumiko-framework/testing";
31
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
32
+ import {
33
+ createComplianceProfilesFeature,
34
+ tenantComplianceProfileEntity,
35
+ tenantComplianceProfileTable,
36
+ } from "../../compliance-profiles";
37
+ import { createConfigFeature } from "../../config";
38
+ import { createConfigResolver } from "../../config/resolver";
39
+ import { configValuesTable } from "../../config/table";
40
+ import { createSessionsFeature } from "../../sessions";
41
+ import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
42
+ import { tenantMembershipEntity, tenantMembershipsTable } from "../../tenant";
43
+ import { TenantHandlers, TenantQueries } from "../../tenant/constants";
44
+ import { createTenantFeature } from "../../tenant/feature";
45
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
46
+ import { seedTenantMembership } from "../../tenant/testing";
47
+ import { createUserFeature } from "../../user/feature";
48
+ import {
49
+ TENANT_AGGREGATE_TYPE,
50
+ TENANT_DESTRUCTION_FAILED_EVENT_QN,
51
+ TENANT_DESTRUCTION_STARTED_EVENT_QN,
52
+ } from "../constants";
53
+ import {
54
+ createTenantLifecycleFeature,
55
+ resolveTenantLifecycleGate,
56
+ TenantLifecycleHandlers,
57
+ } from "../index";
58
+ import { resetTenantLifecycleGateCacheForTests } from "../lifecycle-gate";
59
+ import { runNextDestructionStage, runTenantDestructionSweep } from "../run-tenant-destroy";
60
+
61
+ const REQUEST = TenantLifecycleHandlers.requestDestruction;
62
+ const CANCEL = TenantLifecycleHandlers.cancelDestruction;
63
+ const SET_PROFILE = "compliance-profiles:write:set-profile";
64
+
65
+ let stack: TestStack;
66
+ let db: DbConnection;
67
+ const tenantAdmin = TestUsers.admin;
68
+
69
+ beforeAll(async () => {
70
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
71
+ const resolver = createConfigResolver({ cipher: encryption });
72
+
73
+ stack = await setupTestStack({
74
+ features: [
75
+ createConfigFeature(),
76
+ createUserFeature(),
77
+ createTenantFeature(),
78
+ createComplianceProfilesFeature(),
79
+ createSessionsFeature(),
80
+ createTenantLifecycleFeature(),
81
+ ],
82
+ extraContext: { configResolver: resolver, configEncryption: encryption },
83
+ authConfig: {
84
+ resolveTenantLifecycleStatus: async (tenantId: TenantId) => {
85
+ const gate = await resolveTenantLifecycleGate(stack.db, tenantId);
86
+ return gate ? { status: gate.status } : null;
87
+ },
88
+ } as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
89
+ });
90
+ db = stack.db;
91
+
92
+ await unsafeCreateEntityTable(db, tenantEntity);
93
+ await unsafeCreateEntityTable(db, userSessionEntity);
94
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
95
+ await unsafeCreateEntityTable(db, tenantMembershipEntity);
96
+ await createEventsTable(db);
97
+ await unsafePushTables(db, { configValuesTable });
98
+ });
99
+
100
+ afterAll(async () => {
101
+ await stack.cleanup();
102
+ });
103
+
104
+ beforeEach(async () => {
105
+ stack.events.reset();
106
+ // resetTestTables wipes rows via raw SQL, not through the write handlers
107
+ // that invalidate the lifecycle-gate cache — clear it too, or a later test
108
+ // reusing tenantAdmin.tenantId reads an earlier test's cached status.
109
+ resetTenantLifecycleGateCacheForTests();
110
+ await resetTestTables(db, [
111
+ tenantTable,
112
+ tenantComplianceProfileTable,
113
+ userSessionTable,
114
+ tenantMembershipsTable,
115
+ eventsTable,
116
+ ]);
117
+ });
118
+
119
+ async function seedTenant(): Promise<void> {
120
+ await stack.http.writeOk(
121
+ TenantHandlers.create,
122
+ { id: tenantAdmin.tenantId, key: "acme", name: "ACME Corp" },
123
+ TestUsers.systemAdmin,
124
+ );
125
+ await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
126
+ }
127
+
128
+ describe("tenant-lifecycle :: request / cancel / 410 gate", () => {
129
+ test("request destruction sets destroyRequested + grace", async () => {
130
+ await seedTenant();
131
+ const result = await stack.http.writeOk<{
132
+ status: string;
133
+ gracePeriodEnd: string;
134
+ }>(REQUEST, {}, tenantAdmin);
135
+ expect(result.status).toBe("destroyRequested");
136
+ expect(result.gracePeriodEnd).toBeTruthy();
137
+
138
+ const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
139
+ expect(rows[0]?.["status"]).toBe("destroyRequested");
140
+ });
141
+
142
+ test("API returns 410 after destruction requested", async () => {
143
+ await seedTenant();
144
+ await stack.http.writeOk(REQUEST, {}, tenantAdmin);
145
+
146
+ const res = await stack.http.query(TenantQueries.me, {}, tenantAdmin);
147
+ expect(res.status).toBe(410);
148
+ });
149
+
150
+ test("cancel within grace restores active tenant", async () => {
151
+ await seedTenant();
152
+ await stack.http.writeOk(REQUEST, {}, tenantAdmin);
153
+ const cancelled = await stack.http.writeOk<{ status: string }>(CANCEL, {}, tenantAdmin);
154
+ expect(cancelled.status).toBe("active");
155
+
156
+ const me = await stack.http.queryOk(TenantQueries.me, {}, tenantAdmin);
157
+ expect(me).not.toBeNull();
158
+ });
159
+
160
+ test("cancel after grace expired returns grace_period_expired", async () => {
161
+ await seedTenant();
162
+ await stack.http.writeOk(REQUEST, {}, tenantAdmin);
163
+ const past = (await import("@cosmicdrift/kumiko-framework/time"))
164
+ .getTemporal()
165
+ .Now.instant()
166
+ .subtract({
167
+ hours: 1,
168
+ });
169
+ await updateRows(db, tenantTable, { gracePeriodEnd: past }, { id: tenantAdmin.tenantId });
170
+
171
+ const err = await stack.http.writeErr(CANCEL, {}, tenantAdmin);
172
+ expect(err.httpStatus).toBe(422);
173
+ expect((err.details as { reason?: string } | undefined)?.reason).toBe("grace_period_expired");
174
+ });
175
+
176
+ test("non-admin cannot request destruction", async () => {
177
+ await seedTenant();
178
+ const member = createTestUser({ id: 99, roles: ["Member"] });
179
+ const err = await stack.http.writeErr(REQUEST, {}, member);
180
+ expect(err.httpStatus).toBe(403);
181
+ });
182
+ });
183
+
184
+ function createPoisonTenantDataFeature() {
185
+ return defineFeature("test-poison-tenant-data", (r) => {
186
+ r.requires("tenant-lifecycle");
187
+ r.useExtension(EXT_TENANT_DATA, "poison-pill", {
188
+ destroy: async () => {
189
+ throw new Error("poison-pill");
190
+ },
191
+ });
192
+ });
193
+ }
194
+
195
+ describe("tenant-lifecycle :: pipeline abandon / destroyFailed", () => {
196
+ let poisonStack: TestStack;
197
+
198
+ beforeAll(async () => {
199
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
200
+ const resolver = createConfigResolver({ cipher: encryption });
201
+ poisonStack = await setupTestStack({
202
+ features: [
203
+ createConfigFeature(),
204
+ createTenantFeature(),
205
+ createComplianceProfilesFeature(),
206
+ createTenantLifecycleFeature(),
207
+ createPoisonTenantDataFeature(),
208
+ ],
209
+ extraContext: { configResolver: resolver, configEncryption: encryption },
210
+ authConfig: {
211
+ resolveTenantLifecycleStatus: async (tenantId: TenantId) => {
212
+ const gate = await resolveTenantLifecycleGate(poisonStack.db, tenantId);
213
+ return gate ? { status: gate.status } : null;
214
+ },
215
+ } as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
216
+ });
217
+ await unsafeCreateEntityTable(poisonStack.db, tenantEntity);
218
+ await unsafeCreateEntityTable(poisonStack.db, tenantComplianceProfileEntity);
219
+ await createEventsTable(poisonStack.db);
220
+ await unsafePushTables(poisonStack.db, { configValuesTable });
221
+ });
222
+
223
+ afterAll(async () => {
224
+ await poisonStack.cleanup();
225
+ });
226
+
227
+ beforeEach(async () => {
228
+ poisonStack.events.reset();
229
+ await resetTestTables(poisonStack.db, [tenantTable, tenantComplianceProfileTable, eventsTable]);
230
+ });
231
+
232
+ async function seedDestroyingTenant(): Promise<void> {
233
+ await poisonStack.http.writeOk(
234
+ TenantHandlers.create,
235
+ { id: tenantAdmin.tenantId, key: "acme", name: "ACME Corp" },
236
+ TestUsers.systemAdmin,
237
+ );
238
+ await poisonStack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
239
+ const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
240
+ const now = T.Now.instant();
241
+ await updateRows(
242
+ poisonStack.db,
243
+ tenantTable,
244
+ { status: "destroying", destroyStartedAt: now },
245
+ { id: tenantAdmin.tenantId },
246
+ );
247
+ await append(poisonStack.db, {
248
+ aggregateId: tenantAdmin.tenantId,
249
+ aggregateType: TENANT_AGGREGATE_TYPE,
250
+ tenantId: tenantAdmin.tenantId,
251
+ expectedVersion:
252
+ (await loadAggregate(poisonStack.db, tenantAdmin.tenantId, tenantAdmin.tenantId)).at(-1)
253
+ ?.version ?? 0,
254
+ type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
255
+ payload: { startedAt: now.toString() },
256
+ metadata: { userId: "system", requestId: "test:destruction-started" },
257
+ });
258
+ }
259
+
260
+ test("abandoned app-data stage halts pipeline as destroyFailed without tombstone", async () => {
261
+ await seedDestroyingTenant();
262
+ let halted = false;
263
+ for (let i = 0; i < 12; i++) {
264
+ const result = await runNextDestructionStage({
265
+ db: poisonStack.db,
266
+ registry: poisonStack.registry,
267
+ tenantId: tenantAdmin.tenantId,
268
+ });
269
+ if (result.halted) {
270
+ halted = true;
271
+ break;
272
+ }
273
+ if (result.done) break;
274
+ }
275
+ expect(halted).toBe(true);
276
+
277
+ const rows = await selectMany(poisonStack.db, tenantTable, { id: tenantAdmin.tenantId });
278
+ expect(rows[0]?.["status"]).toBe("destroyFailed");
279
+ expect(rows[0]?.["destroyedAt"]).toBeNull();
280
+
281
+ const events = await selectMany(poisonStack.db, eventsTable, {
282
+ aggregateId: tenantAdmin.tenantId,
283
+ });
284
+ expect(events.some((e) => e["type"] === TENANT_DESTRUCTION_FAILED_EVENT_QN)).toBe(true);
285
+ expect(events.some((e) => String(e["type"]).includes("tenant-destruction-completed"))).toBe(
286
+ false,
287
+ );
288
+ });
289
+
290
+ test("destroyFailed tenant returns 410 on API", async () => {
291
+ await seedDestroyingTenant();
292
+ for (let i = 0; i < 12; i++) {
293
+ const result = await runNextDestructionStage({
294
+ db: poisonStack.db,
295
+ registry: poisonStack.registry,
296
+ tenantId: tenantAdmin.tenantId,
297
+ });
298
+ if (result.halted || result.done) break;
299
+ }
300
+ const res = await poisonStack.http.query(TenantQueries.me, {}, tenantAdmin);
301
+ expect(res.status).toBe(410);
302
+ });
303
+ });
304
+
305
+ describe("tenant-lifecycle :: batch cancel exemption", () => {
306
+ test("cancel via /api/batch during grace succeeds", async () => {
307
+ await seedTenant();
308
+ await stack.http.writeOk(REQUEST, {}, tenantAdmin);
309
+ const res = await stack.http.batch([{ type: CANCEL, payload: {} }], tenantAdmin);
310
+ expect(res.status).toBe(200);
311
+ const body = (await res.json()) as {
312
+ isSuccess: boolean;
313
+ results: Array<{ isSuccess: boolean }>;
314
+ };
315
+ expect(body.isSuccess).toBe(true);
316
+ expect(body.results[0]?.isSuccess).toBe(true);
317
+
318
+ const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
319
+ expect(rows[0]?.["status"]).toBe("active");
320
+ });
321
+
322
+ test("batch mixing cancel with another command is NOT exempt (410)", async () => {
323
+ await seedTenant();
324
+ await stack.http.writeOk(REQUEST, {}, tenantAdmin);
325
+ const res = await stack.http.batch(
326
+ [
327
+ { type: CANCEL, payload: {} },
328
+ { type: SET_PROFILE, payload: { profileKey: "de-hr-dsgvo-hgb" } },
329
+ ],
330
+ tenantAdmin,
331
+ );
332
+ expect(res.status).toBe(410);
333
+
334
+ // Neither command ran — the tenant is still destroyRequested, not
335
+ // cancelled back to active.
336
+ const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
337
+ expect(rows[0]?.["status"]).toBe("destroyRequested");
338
+ });
339
+ });
340
+
341
+ describe("tenant-lifecycle :: full sweep pipeline", () => {
342
+ test("runTenantDestructionSweep drives destroyRequested -> destroyed, memberships forgotten", async () => {
343
+ await seedTenant();
344
+ const membership = await seedTenantMembership(db, {
345
+ userId: TestUsers.user.id,
346
+ tenantId: tenantAdmin.tenantId,
347
+ roles: ["User"],
348
+ });
349
+ await stack.http.writeOk(REQUEST, {}, tenantAdmin);
350
+
351
+ // Comfortably past any compliance profile's grace period, so the sweep's
352
+ // first loop flips destroyRequested -> destroying on the first tick.
353
+ const farFuture = getTemporal()
354
+ .Now.instant()
355
+ .add({ hours: 24 * 3650 });
356
+
357
+ let status = "";
358
+ for (let i = 0; i < 20; i++) {
359
+ await runTenantDestructionSweep({ db: stack.db, registry: stack.registry, now: farFuture });
360
+ const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
361
+ status = String(rows[0]?.["status"]);
362
+ if (status === "destroyed" || status === "destroyFailed") break;
363
+ }
364
+
365
+ expect(status).toBe("destroyed");
366
+
367
+ const memberships = await selectMany(db, tenantMembershipsTable, {
368
+ tenantId: tenantAdmin.tenantId,
369
+ });
370
+ expect(memberships).toHaveLength(0);
371
+
372
+ // Discriminates the fix from a raw deleteMany bypass — both empty the
373
+ // projection table, but only forget() through the executor leaves an
374
+ // event on the membership's own aggregate stream (rebuild-safe erasure).
375
+ const membershipEvents = await loadAggregate(db, membership.id, tenantAdmin.tenantId);
376
+ expect(membershipEvents.some((e) => e.type === "tenant-membership.forgotten")).toBe(true);
377
+
378
+ // Gate resolves the fresh status too — proves the cache was invalidated
379
+ // through every status transition, not just the final one.
380
+ const gate = await resolveTenantLifecycleGate(stack.db, tenantAdmin.tenantId);
381
+ expect(gate?.status).toBe("destroyed");
382
+ });
383
+ });
384
+
385
+ // One tenantId is set here per-test right before the sweep call — the hook
386
+ // throws only for that tenant, letting a single stack exercise "one tenant's
387
+ // destroy-hook fails, does that stall every OTHER destroying tenant's sweep
388
+ // progress in the same tick?" without a second full stack.
389
+ let selectivePoisonTargetTenantId: TenantId | null = null;
390
+
391
+ function createSelectivePoisonTenantDataFeature() {
392
+ return defineFeature("test-selective-poison-tenant-data", (r) => {
393
+ r.requires("tenant-lifecycle");
394
+ // EXT_EXTERNAL_RESOURCE — the pipeline's FIRST stage — so the poison
395
+ // fires on the very first sweep tick instead of several stages in.
396
+ r.useExtension(EXT_EXTERNAL_RESOURCE, "selective-poison-pill", {
397
+ destroyTenant: async (tenantId: TenantId) => {
398
+ if (tenantId === selectivePoisonTargetTenantId) {
399
+ throw new Error("selective-poison");
400
+ }
401
+ },
402
+ });
403
+ });
404
+ }
405
+
406
+ describe("tenant-lifecycle :: sweep isolates one tenant's failure from another's progress", () => {
407
+ let isolationStack: TestStack;
408
+ const tenantA = tenantAdmin.tenantId; // poisoned
409
+ const tenantB = testTenantId(9002); // healthy
410
+
411
+ beforeAll(async () => {
412
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
413
+ const resolver = createConfigResolver({ cipher: encryption });
414
+ isolationStack = await setupTestStack({
415
+ features: [
416
+ createConfigFeature(),
417
+ createTenantFeature(),
418
+ createComplianceProfilesFeature(),
419
+ createTenantLifecycleFeature(),
420
+ createSelectivePoisonTenantDataFeature(),
421
+ ],
422
+ extraContext: { configResolver: resolver, configEncryption: encryption },
423
+ });
424
+ await unsafeCreateEntityTable(isolationStack.db, tenantEntity);
425
+ await unsafeCreateEntityTable(isolationStack.db, tenantComplianceProfileEntity);
426
+ await createEventsTable(isolationStack.db);
427
+ await unsafePushTables(isolationStack.db, { configValuesTable });
428
+ });
429
+
430
+ afterAll(async () => {
431
+ await isolationStack.cleanup();
432
+ });
433
+
434
+ async function seedDestroyingTenant(id: TenantId): Promise<void> {
435
+ await isolationStack.http.writeOk(
436
+ TenantHandlers.create,
437
+ { id, key: `acme-${id.slice(-4)}`, name: "ACME Corp" },
438
+ TestUsers.systemAdmin,
439
+ );
440
+ await isolationStack.http.writeOk(
441
+ SET_PROFILE,
442
+ { profileKey: "eu-dsgvo" },
443
+ { ...tenantAdmin, tenantId: id },
444
+ );
445
+ await updateRows(isolationStack.db, tenantTable, { status: "destroying" }, { id });
446
+ }
447
+
448
+ test("tenant A's poisoned destroy-hook does not block tenant B's sweep progress", async () => {
449
+ selectivePoisonTargetTenantId = tenantA;
450
+ await seedDestroyingTenant(tenantA);
451
+ await seedDestroyingTenant(tenantB);
452
+
453
+ const result = await runTenantDestructionSweep({
454
+ db: isolationStack.db,
455
+ registry: isolationStack.registry,
456
+ });
457
+
458
+ // A's first stage ("external-resources") throws every attempt — not
459
+ // counted as advanced. B has no poison, so it does advance. If A's
460
+ // failure aborted the sweep loop (the pre-fix behavior), B would never
461
+ // be reached and this would be 0.
462
+ expect(result.advanced).toBe(1);
463
+
464
+ const rowB = await selectMany(isolationStack.db, tenantTable, { id: tenantB });
465
+ expect(rowB[0]?.["status"]).toBe("destroying"); // still mid-pipeline, but progressed
466
+ const eventsB = await loadAggregate(isolationStack.db, tenantB, tenantB);
467
+ expect(eventsB.some((e) => e.type.endsWith("tenant-destruction-stage-succeeded"))).toBe(true);
468
+ });
469
+ });
@@ -0,0 +1,56 @@
1
+ export const TENANT_LIFECYCLE_FEATURE = "tenant-lifecycle" as const;
2
+
3
+ export const TENANT_AGGREGATE_TYPE = "tenant" as const;
4
+
5
+ const EVENT_PREFIX = `${TENANT_LIFECYCLE_FEATURE}:event:` as const;
6
+
7
+ export const DESTRUCTION_REQUESTED_EVENT_SHORT = "destruction-requested" as const;
8
+ export const DESTRUCTION_CANCELLED_EVENT_SHORT = "destruction-cancelled" as const;
9
+ export const TENANT_DESTRUCTION_STARTED_EVENT_SHORT = "tenant-destruction-started" as const;
10
+ export const TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT =
11
+ "tenant-destruction-stage-started" as const;
12
+ export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT =
13
+ "tenant-destruction-stage-succeeded" as const;
14
+ export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT =
15
+ "tenant-destruction-stage-failed" as const;
16
+ export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT =
17
+ "tenant-destruction-stage-abandoned" as const;
18
+ export const TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT = "tenant-destruction-completed" as const;
19
+ export const TENANT_DESTRUCTION_FAILED_EVENT_SHORT = "tenant-destruction-failed" as const;
20
+
21
+ export const DESTRUCTION_REQUESTED_EVENT_QN =
22
+ `${EVENT_PREFIX}${DESTRUCTION_REQUESTED_EVENT_SHORT}` as const;
23
+ export const DESTRUCTION_CANCELLED_EVENT_QN =
24
+ `${EVENT_PREFIX}${DESTRUCTION_CANCELLED_EVENT_SHORT}` as const;
25
+ export const TENANT_DESTRUCTION_STARTED_EVENT_QN =
26
+ `${EVENT_PREFIX}${TENANT_DESTRUCTION_STARTED_EVENT_SHORT}` as const;
27
+ export const TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_QN =
28
+ `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT}` as const;
29
+ export const TENANT_DESTRUCTION_STAGE_FAILED_EVENT_QN =
30
+ `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT}` as const;
31
+ export const TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_QN =
32
+ `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT}` as const;
33
+ export const TENANT_DESTRUCTION_COMPLETED_EVENT_QN =
34
+ `${EVENT_PREFIX}${TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT}` as const;
35
+ export const TENANT_DESTRUCTION_FAILED_EVENT_QN =
36
+ `${EVENT_PREFIX}${TENANT_DESTRUCTION_FAILED_EVENT_SHORT}` as const;
37
+ export const TENANT_DESTRUCTION_STAGE_STARTED_EVENT_QN =
38
+ `${EVENT_PREFIX}${TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT}` as const;
39
+
40
+ export const TenantLifecycleHandlers = {
41
+ requestDestruction: `${TENANT_LIFECYCLE_FEATURE}:write:request-destruction`,
42
+ cancelDestruction: `${TENANT_LIFECYCLE_FEATURE}:write:cancel-destruction`,
43
+ } as const;
44
+
45
+ export const TENANT_DESTRUCTION_STAGES = [
46
+ "external-resources",
47
+ "search-indices",
48
+ "cache",
49
+ "app-data",
50
+ "subject-keys",
51
+ "files",
52
+ "infra-resources",
53
+ "tenant-row",
54
+ ] as const;
55
+
56
+ export type TenantDestructionStageName = (typeof TENANT_DESTRUCTION_STAGES)[number];
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+
3
+ export const destructionRequestedSchema = z.object({
4
+ requestedBy: z.uuid(),
5
+ gracePeriodEnd: z.string().datetime(),
6
+ });
7
+
8
+ export const destructionCancelledSchema = z.object({
9
+ cancelledBy: z.uuid(),
10
+ });
11
+
12
+ export const tenantDestructionStartedSchema = z.object({
13
+ startedAt: z.string().datetime(),
14
+ });
15
+
16
+ export const tenantDestructionStageStartedSchema = z.object({
17
+ stage: z.string().min(1),
18
+ attempts: z.number().int().positive(),
19
+ });
20
+
21
+ export const tenantDestructionStageSucceededSchema = z.object({
22
+ stage: z.string().min(1),
23
+ attempts: z.number().int().positive(),
24
+ });
25
+
26
+ export const tenantDestructionStageFailedSchema = z.object({
27
+ stage: z.string().min(1),
28
+ attempts: z.number().int().positive(),
29
+ error: z.string().min(1),
30
+ });
31
+
32
+ export const tenantDestructionStageAbandonedSchema = z.object({
33
+ stage: z.string().min(1),
34
+ attempts: z.number().int().positive(),
35
+ error: z.string().min(1),
36
+ });
37
+
38
+ export const tenantDestructionCompletedSchema = z.object({
39
+ destroyedAt: z.string().datetime(),
40
+ });
41
+
42
+ export const tenantDestructionFailedSchema = z.object({
43
+ stage: z.string().min(1),
44
+ error: z.string().min(1),
45
+ failedAt: z.string().datetime(),
46
+ });
@@ -0,0 +1,105 @@
1
+ import {
2
+ defineFeature,
3
+ EXT_EXTERNAL_RESOURCE,
4
+ EXT_INFRA_RESOURCE,
5
+ EXT_SEARCH_ADAPTER,
6
+ EXT_STORAGE_PROVIDER,
7
+ EXT_TENANT_DATA,
8
+ type FeatureDefinition,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import {
11
+ DESTRUCTION_CANCELLED_EVENT_SHORT,
12
+ DESTRUCTION_REQUESTED_EVENT_SHORT,
13
+ TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT,
14
+ TENANT_DESTRUCTION_FAILED_EVENT_SHORT,
15
+ TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
16
+ TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT,
17
+ TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
18
+ TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
19
+ TENANT_DESTRUCTION_STARTED_EVENT_SHORT,
20
+ } from "./constants";
21
+ import {
22
+ destructionCancelledSchema,
23
+ destructionRequestedSchema,
24
+ tenantDestructionCompletedSchema,
25
+ tenantDestructionFailedSchema,
26
+ tenantDestructionStageAbandonedSchema,
27
+ tenantDestructionStageFailedSchema,
28
+ tenantDestructionStageStartedSchema,
29
+ tenantDestructionStageSucceededSchema,
30
+ tenantDestructionStartedSchema,
31
+ } from "./events";
32
+ import { cancelDestructionWrite } from "./handlers/cancel-destruction.write";
33
+ import { requestDestructionWrite } from "./handlers/request-destruction.write";
34
+ import { runTenantDestructionSweep } from "./run-tenant-destroy";
35
+
36
+ export function createTenantLifecycleFeature(): FeatureDefinition {
37
+ return defineFeature("tenant-lifecycle", (r) => {
38
+ r.describe(
39
+ "Tenant-destroy lifecycle: request/cancel destruction with compliance-profile grace, auth 410 gate for teardown states, cron trigger after grace, and staged destroy runner (extension fan-out, subject-key erase, tenant tombstone).",
40
+ );
41
+ r.uiHints({
42
+ displayLabel: "Tenant Lifecycle · Destroy",
43
+ category: "compliance",
44
+ recommended: false,
45
+ });
46
+ r.requires("tenant", "compliance-profiles");
47
+ r.optionalRequires("sessions");
48
+ r.usesApi("compliance.forTenant");
49
+
50
+ r.extendsRegistrar(EXT_TENANT_DATA, {});
51
+ r.extendsRegistrar(EXT_SEARCH_ADAPTER, {});
52
+ r.extendsRegistrar(EXT_EXTERNAL_RESOURCE, {});
53
+ r.extendsRegistrar(EXT_STORAGE_PROVIDER, {});
54
+ r.extendsRegistrar(EXT_INFRA_RESOURCE, {});
55
+
56
+ r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema);
57
+ r.defineEvent(DESTRUCTION_CANCELLED_EVENT_SHORT, destructionCancelledSchema);
58
+ r.defineEvent(TENANT_DESTRUCTION_STARTED_EVENT_SHORT, tenantDestructionStartedSchema);
59
+ r.defineEvent(
60
+ TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
61
+ tenantDestructionStageStartedSchema,
62
+ );
63
+ r.defineEvent(
64
+ TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
65
+ tenantDestructionStageSucceededSchema,
66
+ );
67
+ r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema);
68
+ r.defineEvent(
69
+ TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
70
+ tenantDestructionStageAbandonedSchema,
71
+ );
72
+ r.defineEvent(TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, tenantDestructionCompletedSchema);
73
+ r.defineEvent(TENANT_DESTRUCTION_FAILED_EVENT_SHORT, tenantDestructionFailedSchema);
74
+
75
+ r.writeHandler(requestDestructionWrite);
76
+ r.writeHandler(cancelDestructionWrite);
77
+
78
+ r.job(
79
+ "run-tenant-destruction",
80
+ { trigger: { cron: "0 * * * * *" }, concurrency: "skip" },
81
+ async (_payload, ctx) => {
82
+ if (!ctx.db || !ctx.registry) {
83
+ throw new Error(
84
+ "run-tenant-destruction: ctx.db + ctx.registry required (JobContext incomplete)",
85
+ );
86
+ }
87
+ const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
88
+ await runTenantDestructionSweep({
89
+ db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection,
90
+ registry: ctx.registry,
91
+ now: T.Now.instant(),
92
+ log: (message) => ctx.log?.warn(message),
93
+ });
94
+ },
95
+ );
96
+
97
+ r.exposesApi("tenantLifecycle.runDestroySweep");
98
+ });
99
+ }
100
+
101
+ export {
102
+ TENANT_LIFECYCLE_FEATURE,
103
+ TenantLifecycleHandlers,
104
+ } from "./constants";
105
+ export { resolveTenantLifecycleGate, runTenantDestructionSweep } from "./run-tenant-destroy";