@cosmicdrift/kumiko-bundled-features 0.126.0 → 0.128.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 (61) hide show
  1. package/package.json +6 -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 +195 -2
  6. package/src/billing-foundation/entities.ts +28 -2
  7. package/src/billing-foundation/events.ts +6 -2
  8. package/src/billing-foundation/feature.ts +2 -0
  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 +30 -7
  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-stripe/feature.ts +2 -0
  33. package/src/tags/feature.ts +2 -0
  34. package/src/tags/i18n.ts +5 -0
  35. package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
  36. package/src/tenant/feature.ts +3 -0
  37. package/src/tenant/handlers/members.query.ts +19 -4
  38. package/src/tenant/i18n.ts +28 -0
  39. package/src/tenant/index.ts +1 -1
  40. package/src/tenant/schema/tenant.ts +2 -2
  41. package/src/tenant/screens.ts +3 -2
  42. package/src/tenant/web/members-screen.tsx +8 -3
  43. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +161 -1
  44. package/src/tenant-lifecycle/feature.ts +1 -0
  45. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -0
  46. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +2 -0
  47. package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
  48. package/src/tenant-lifecycle/run-tenant-destroy.ts +50 -39
  49. package/src/tenant-lifecycle/stages.ts +36 -6
  50. package/src/tier-engine/feature.ts +2 -0
  51. package/src/tier-engine/i18n.ts +8 -0
  52. package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
  53. package/src/user/feature.ts +3 -0
  54. package/src/user/i18n.ts +20 -0
  55. package/src/user/schema/user.ts +1 -0
  56. package/src/user/screens.ts +3 -15
  57. package/src/user-data-rights/feature.ts +17 -0
  58. package/src/user-data-rights/i18n.ts +32 -33
  59. package/src/user-data-rights/schema/export-job.ts +2 -0
  60. package/src/user-data-rights/screens.ts +2 -1
  61. package/src/user-data-rights/web/i18n.ts +9 -0
@@ -16,7 +16,7 @@ export const tenantListScreen: EntityListScreenDefinition = {
16
16
  id: "tenant-list",
17
17
  type: "entityList",
18
18
  entity: "tenant",
19
- columns: ["key", "name", "isEnabled"],
19
+ columns: ["key", "name", "status", "isEnabled"],
20
20
  rowActions: [
21
21
  {
22
22
  kind: "navigate",
@@ -26,7 +26,8 @@ export const tenantListScreen: EntityListScreenDefinition = {
26
26
  entityId: "id",
27
27
  },
28
28
  ],
29
- searchable: false,
29
+ defaultSort: { field: "name", dir: "asc" },
30
+ searchable: true,
30
31
  access: { roles: ["SystemAdmin"] },
31
32
  };
32
33
 
@@ -12,6 +12,8 @@ type MemberRow = {
12
12
  readonly id: string;
13
13
  readonly userId: string;
14
14
  readonly tenantId: string;
15
+ readonly email: string | null;
16
+ readonly displayName: string | null;
15
17
  readonly roles: readonly string[];
16
18
  };
17
19
 
@@ -131,8 +133,8 @@ export function MembersScreen({
131
133
  testId="members-active-table"
132
134
  columns={[
133
135
  {
134
- field: "userId",
135
- label: t("tenant.members.col.userId"),
136
+ field: "email",
137
+ label: t("tenant.members.col.email"),
136
138
  type: "string",
137
139
  sortable: false,
138
140
  },
@@ -145,7 +147,10 @@ export function MembersScreen({
145
147
  ]}
146
148
  rows={state.members.map((m) => ({
147
149
  id: m.id,
148
- values: { userId: m.userId, roles: m.roles.join(", ") },
150
+ values: {
151
+ email: m.email ?? `${m.userId.slice(0, 8)}…`,
152
+ roles: m.roles.join(", "),
153
+ },
149
154
  }))}
150
155
  />
151
156
  </Card>
@@ -4,6 +4,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
4
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
5
5
  import {
6
6
  defineFeature,
7
+ EXT_EXTERNAL_RESOURCE,
7
8
  EXT_TENANT_DATA,
8
9
  type TenantId,
9
10
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -18,6 +19,7 @@ import {
18
19
  setupTestStack,
19
20
  type TestStack,
20
21
  TestUsers,
22
+ testTenantId,
21
23
  unsafeCreateEntityTable,
22
24
  unsafePushTables,
23
25
  } from "@cosmicdrift/kumiko-framework/stack";
@@ -26,6 +28,7 @@ import {
26
28
  resetTestTables,
27
29
  updateRows,
28
30
  } from "@cosmicdrift/kumiko-framework/testing";
31
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
29
32
  import {
30
33
  createComplianceProfilesFeature,
31
34
  tenantComplianceProfileEntity,
@@ -36,9 +39,11 @@ import { createConfigResolver } from "../../config/resolver";
36
39
  import { configValuesTable } from "../../config/table";
37
40
  import { createSessionsFeature } from "../../sessions";
38
41
  import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
42
+ import { tenantMembershipEntity, tenantMembershipsTable } from "../../tenant";
39
43
  import { TenantHandlers, TenantQueries } from "../../tenant/constants";
40
44
  import { createTenantFeature } from "../../tenant/feature";
41
45
  import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
46
+ import { seedTenantMembership } from "../../tenant/testing";
42
47
  import { createUserFeature } from "../../user/feature";
43
48
  import {
44
49
  TENANT_AGGREGATE_TYPE,
@@ -50,7 +55,8 @@ import {
50
55
  resolveTenantLifecycleGate,
51
56
  TenantLifecycleHandlers,
52
57
  } from "../index";
53
- import { runNextDestructionStage } from "../run-tenant-destroy";
58
+ import { resetTenantLifecycleGateCacheForTests } from "../lifecycle-gate";
59
+ import { runNextDestructionStage, runTenantDestructionSweep } from "../run-tenant-destroy";
54
60
 
55
61
  const REQUEST = TenantLifecycleHandlers.requestDestruction;
56
62
  const CANCEL = TenantLifecycleHandlers.cancelDestruction;
@@ -86,6 +92,7 @@ beforeAll(async () => {
86
92
  await unsafeCreateEntityTable(db, tenantEntity);
87
93
  await unsafeCreateEntityTable(db, userSessionEntity);
88
94
  await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
95
+ await unsafeCreateEntityTable(db, tenantMembershipEntity);
89
96
  await createEventsTable(db);
90
97
  await unsafePushTables(db, { configValuesTable });
91
98
  });
@@ -96,10 +103,15 @@ afterAll(async () => {
96
103
 
97
104
  beforeEach(async () => {
98
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();
99
110
  await resetTestTables(db, [
100
111
  tenantTable,
101
112
  tenantComplianceProfileTable,
102
113
  userSessionTable,
114
+ tenantMembershipsTable,
103
115
  eventsTable,
104
116
  ]);
105
117
  });
@@ -306,4 +318,152 @@ describe("tenant-lifecycle :: batch cancel exemption", () => {
306
318
  const rows = await selectMany(db, tenantTable, { id: tenantAdmin.tenantId });
307
319
  expect(rows[0]?.["status"]).toBe("active");
308
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
+ });
309
469
  });
@@ -89,6 +89,7 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
89
89
  db: ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection,
90
90
  registry: ctx.registry,
91
91
  now: T.Now.instant(),
92
+ log: (message) => ctx.log?.warn(message),
92
93
  });
93
94
  },
94
95
  );
@@ -6,6 +6,7 @@ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
6
6
  import { z } from "zod";
7
7
  import { type TenantLifecycleStatus, tenantEntity, tenantTable } from "../../tenant";
8
8
  import { DESTRUCTION_CANCELLED_EVENT_QN } from "../constants";
9
+ import { invalidateTenantLifecycleGate } from "../lifecycle-gate";
9
10
 
10
11
  const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
11
12
 
@@ -59,6 +60,7 @@ export const cancelDestructionWrite = defineWriteHandler({
59
60
  { skipOptimisticLock: true },
60
61
  );
61
62
  if (!update.isSuccess) return update;
63
+ invalidateTenantLifecycleGate(tenantId);
62
64
 
63
65
  await ctx.unsafeAppendEvent({
64
66
  aggregateId: tenantId,
@@ -9,6 +9,7 @@ import { resolveProfileForTenant } from "../../compliance-profiles";
9
9
  import { type TenantLifecycleStatus, tenantEntity, tenantTable } from "../../tenant";
10
10
  import { DESTRUCTION_REQUESTED_EVENT_QN } from "../constants";
11
11
  import { revokeTenantSessions } from "../lib/revoke-tenant-sessions";
12
+ import { invalidateTenantLifecycleGate } from "../lifecycle-gate";
12
13
 
13
14
  const crud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
14
15
 
@@ -54,6 +55,7 @@ export const requestDestructionWrite = defineWriteHandler({
54
55
  { skipOptimisticLock: true },
55
56
  );
56
57
  if (!update.isSuccess) return update;
58
+ invalidateTenantLifecycleGate(tenantId);
57
59
 
58
60
  await ctx.unsafeAppendEvent({
59
61
  aggregateId: tenantId,
@@ -0,0 +1,58 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { tenantTable } from "../tenant";
5
+
6
+ export type TenantLifecycleGate = {
7
+ readonly status: string;
8
+ readonly gracePeriodEnd: string | null;
9
+ };
10
+
11
+ // Every authenticated + anonymous request consults this (via auth-middleware's
12
+ // resolveTenantLifecycleStatus) — a per-request DB round-trip otherwise. No
13
+ // TTL: status must be observable the instant it changes (a stale "active"
14
+ // read after tombstoning would let a request slip through the 410 gate), so
15
+ // this is invalidated explicitly by every status-changing write —
16
+ // request/cancel-destruction, the sweep, and tombstoneTenantRow all call
17
+ // invalidateTenantLifecycleGate(tenantId) right after their update succeeds.
18
+ // Split into its own module (not run-tenant-destroy.ts) so stages.ts can
19
+ // invalidate too without a stages.ts <-> run-tenant-destroy.ts import cycle.
20
+ // ponytail: unbounded Map — fine while tenant counts stay in the thousands (a
21
+ // few bytes/entry); swap for a bounded LRU if that ever changes.
22
+ const gateCache = new Map<TenantId, TenantLifecycleGate | null>();
23
+
24
+ export function invalidateTenantLifecycleGate(tenantId: TenantId): void {
25
+ gateCache.delete(tenantId);
26
+ }
27
+
28
+ /** @internal test-only — resetTestTables/resetTestTable wipe rows directly
29
+ * via SQL, bypassing the write handlers that invalidate normally. Tests
30
+ * that reset tenantTable (and reuse a fixed tenantId across cases) must
31
+ * call this in beforeEach or a later test can read an earlier test's
32
+ * cached status for the same tenantId. */
33
+ export function resetTenantLifecycleGateCacheForTests(): void {
34
+ gateCache.clear();
35
+ }
36
+
37
+ export async function resolveTenantLifecycleGate(
38
+ db: DbRunner,
39
+ tenantId: TenantId,
40
+ ): Promise<TenantLifecycleGate | null> {
41
+ if (gateCache.has(tenantId)) return gateCache.get(tenantId) ?? null;
42
+ const rows = await selectMany<{ status: string; gracePeriodEnd: Temporal.Instant | null }>(
43
+ db,
44
+ tenantTable,
45
+ { id: tenantId },
46
+ );
47
+ const row = rows[0];
48
+ if (!row) {
49
+ gateCache.set(tenantId, null);
50
+ return null;
51
+ }
52
+ const gate: TenantLifecycleGate = {
53
+ status: row.status,
54
+ gracePeriodEnd: row.gracePeriodEnd?.toString() ?? null,
55
+ };
56
+ gateCache.set(tenantId, gate);
57
+ return gate;
58
+ }
@@ -23,6 +23,7 @@ import {
23
23
  TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
24
24
  TENANT_DESTRUCTION_STARTED_EVENT_SHORT,
25
25
  } from "./constants";
26
+ import { invalidateTenantLifecycleGate } from "./lifecycle-gate";
26
27
  import { type DestructionStageCtx, isDestructionPipelineComplete, pickNextStage } from "./stages";
27
28
 
28
29
  const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
@@ -148,6 +149,7 @@ async function markTenantDestroyFailed(db: DbRunner, tenantId: TenantId): Promis
148
149
  scopedDb,
149
150
  { skipOptimisticLock: true },
150
151
  );
152
+ invalidateTenantLifecycleGate(tenantId);
151
153
  }
152
154
 
153
155
  async function haltPipelineOnAbandon(args: {
@@ -156,16 +158,20 @@ async function haltPipelineOnAbandon(args: {
156
158
  readonly stage: string;
157
159
  readonly attempts: number;
158
160
  readonly error: string;
159
- readonly expectedVersion: number;
160
161
  }): Promise<void> {
161
162
  const T = getTemporal();
162
163
  const failedAt = T.Now.instant();
164
+ // Reload fresh instead of trusting a caller-supplied version: the failed
165
+ // stage's own run() may have appended to this same aggregate (e.g.
166
+ // tombstoneTenantRow's tenantCrud.update) before throwing, so any version
167
+ // captured earlier in the pipeline is stale and would VersionConflict here.
168
+ const eventsBeforeAbandon = await loadAggregate(args.db, args.tenantId, args.tenantId);
163
169
  await appendTenantStageEvent(
164
170
  args.db,
165
171
  args.tenantId,
166
172
  qualified(TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT),
167
173
  { stage: args.stage, attempts: args.attempts, error: args.error },
168
- args.expectedVersion,
174
+ lastEventVersion(eventsBeforeAbandon),
169
175
  );
170
176
  const eventsAfterAbandon = await loadAggregate(args.db, args.tenantId, args.tenantId);
171
177
  const versionAfterAbandon = lastEventVersion(eventsAfterAbandon);
@@ -243,14 +249,19 @@ export async function runNextDestructionStage(args: {
243
249
 
244
250
  try {
245
251
  await next.run(ctx);
252
+ // next.run() may itself have appended events to this aggregate (e.g.
253
+ // tombstoneTenantRow updates the tenant row on the same stream) — reload
254
+ // the version fresh instead of trusting the pre-run `version`, or this
255
+ // append throws VersionConflictError on every stage whose run() writes.
256
+ const eventsAfterRun = await loadAggregate(args.db, args.tenantId, args.tenantId);
246
257
  version = await appendTenantStageEventIdempotent(
247
258
  args.db,
248
259
  args.tenantId,
249
260
  qualified(TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT),
250
261
  { stage: next.name, attempts: attempt },
251
- version,
262
+ lastEventVersion(eventsAfterRun),
252
263
  stageOutcomeRecorded(
253
- await loadAggregate(args.db, args.tenantId, args.tenantId),
264
+ eventsAfterRun,
254
265
  next.name,
255
266
  TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
256
267
  ),
@@ -269,7 +280,6 @@ export async function runNextDestructionStage(args: {
269
280
  stage: next.name,
270
281
  attempts: attempt,
271
282
  error: message,
272
- expectedVersion: version,
273
283
  });
274
284
  return { done: false, error: message, halted: true };
275
285
  }
@@ -289,6 +299,7 @@ export async function runTenantDestructionSweep(args: {
289
299
  readonly db: DbRunner;
290
300
  readonly registry: Registry;
291
301
  readonly now?: Temporal.Instant;
302
+ readonly log?: (message: string) => void;
292
303
  }): Promise<{ readonly triggered: number; readonly advanced: number }> {
293
304
  const T = getTemporal();
294
305
  const now = args.now ?? T.Now.instant();
@@ -302,23 +313,26 @@ export async function runTenantDestructionSweep(args: {
302
313
  const tenantId = row.id as TenantId;
303
314
  const events = await loadAggregate(args.db, tenantId, tenantId);
304
315
  const version = lastEventVersion(events);
316
+ const user = createSystemUser(tenantId);
317
+ const scopedDb = createTenantDb(args.db, tenantId, "system");
318
+ // Version-guarded (no skipOptimisticLock): a concurrent cancel-destruction
319
+ // between the `due` select above and this write also appends to the same
320
+ // tenant stream, so an unguarded overwrite here would silently revert the
321
+ // cancellation. A version conflict means we lost that race — skip, the
322
+ // tenant is no longer due and the next tick re-evaluates it.
323
+ const result = await tenantCrud.update(
324
+ { id: tenantId, version, changes: { status: "destroying", destroyStartedAt: now } },
325
+ user,
326
+ scopedDb,
327
+ );
328
+ if (!result.isSuccess) continue;
329
+ invalidateTenantLifecycleGate(tenantId);
305
330
  await appendTenantStageEvent(
306
331
  args.db,
307
332
  tenantId,
308
333
  qualified(TENANT_DESTRUCTION_STARTED_EVENT_SHORT),
309
334
  { startedAt: now.toString() },
310
- version,
311
- );
312
- const user = createSystemUser(tenantId);
313
- const scopedDb = createTenantDb(args.db, tenantId, "system");
314
- await tenantCrud.update(
315
- {
316
- id: tenantId,
317
- changes: { status: "destroying", destroyStartedAt: now },
318
- },
319
- user,
320
- scopedDb,
321
- { skipOptimisticLock: true },
335
+ version + 1,
322
336
  );
323
337
  triggered++;
324
338
  }
@@ -328,30 +342,27 @@ export async function runTenantDestructionSweep(args: {
328
342
  status: "destroying",
329
343
  });
330
344
  for (const row of destroying) {
331
- const result = await runNextDestructionStage({
332
- db: args.db,
333
- registry: args.registry,
334
- tenantId: row.id as TenantId,
335
- });
336
- if (!result.error && !result.halted) advanced++;
345
+ try {
346
+ const result = await runNextDestructionStage({
347
+ db: args.db,
348
+ registry: args.registry,
349
+ tenantId: row.id as TenantId,
350
+ log: args.log,
351
+ });
352
+ if (!result.error && !result.halted) advanced++;
353
+ } catch (err) {
354
+ // Isolate the failure to this tenant — an uncaught throw here must not
355
+ // abort the whole sweep tick and starve every other "destroying" tenant
356
+ // of progress. The tenant stays "destroying" and the next tick retries.
357
+ args.log?.(
358
+ `[tenant-lifecycle] sweep: unexpected error advancing tenant ${row.id}: ${
359
+ err instanceof Error ? err.message : String(err)
360
+ }`,
361
+ );
362
+ }
337
363
  }
338
364
 
339
365
  return { triggered, advanced };
340
366
  }
341
367
 
342
- export async function resolveTenantLifecycleGate(
343
- db: DbRunner,
344
- tenantId: TenantId,
345
- ): Promise<{ status: string; gracePeriodEnd: string | null } | null> {
346
- const rows = await selectMany<{ status: string; gracePeriodEnd: Temporal.Instant | null }>(
347
- db,
348
- tenantTable,
349
- { id: tenantId },
350
- );
351
- const row = rows[0];
352
- if (!row) return null;
353
- return {
354
- status: row.status,
355
- gracePeriodEnd: row.gracePeriodEnd?.toString() ?? null,
356
- };
357
- }
368
+ export { invalidateTenantLifecycleGate, resolveTenantLifecycleGate } from "./lifecycle-gate";
@@ -4,8 +4,6 @@ import {
4
4
  createEventStoreExecutor,
5
5
  createTenantDb,
6
6
  type DbRunner,
7
- deleteMany,
8
- type EntityTableMeta,
9
7
  } from "@cosmicdrift/kumiko-framework/db";
10
8
  import {
11
9
  createSystemUser,
@@ -18,8 +16,14 @@ import {
18
16
  type TenantId,
19
17
  } from "@cosmicdrift/kumiko-framework/engine";
20
18
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
21
- import { tenantEntity, tenantMembershipsTable, tenantTable } from "../tenant";
19
+ import {
20
+ tenantEntity,
21
+ tenantMembershipEntity,
22
+ tenantMembershipsTable,
23
+ tenantTable,
24
+ } from "../tenant";
22
25
  import type { TenantDestructionStageName } from "./constants";
26
+ import { invalidateTenantLifecycleGate } from "./lifecycle-gate";
23
27
 
24
28
  export type DestructionStageCtx = {
25
29
  readonly db: DbRunner;
@@ -35,6 +39,13 @@ export type DestructionStage = {
35
39
  };
36
40
 
37
41
  const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
42
+ const tenantMembershipCrud = createEventStoreExecutor(
43
+ tenantMembershipsTable,
44
+ tenantMembershipEntity,
45
+ {
46
+ entityName: "tenant-membership",
47
+ },
48
+ );
38
49
 
39
50
  async function runExtensionDestroyHooks(
40
51
  registry: Registry,
@@ -96,6 +107,27 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
96
107
  const now = getTemporal().Now.instant();
97
108
  const user = createSystemUser(ctx.tenantId);
98
109
  const db = createTenantDb(ctx.db, ctx.tenantId, "system");
110
+ // Per-row forget() through the executor, not a bulk deleteMany: memberships
111
+ // are an ES-managed projection (add/remove/update-roles all go through
112
+ // tenantMembershipCrud), so a raw table write here is eventless — a future
113
+ // projection rebuild would replay the historical add-member events and
114
+ // resurrect rows this stage removed. forget() (Art.17 hard-purge) keeps the
115
+ // erasure rebuild-safe and gives each membership its own audit event.
116
+ const memberships = await selectMany<{ id: string }>(ctx.db, tenantMembershipsTable, {
117
+ tenantId: ctx.tenantId,
118
+ });
119
+ for (const membership of memberships) {
120
+ const result = await tenantMembershipCrud.forget({ id: membership.id }, user, db);
121
+ // executor writes return {isSuccess:false} on failure, they don't throw —
122
+ // a silently-discarded result here would report this stage "succeeded"
123
+ // while membership PII survives. Throw so the pipeline's retry/abandon
124
+ // handling (runNextDestructionStage) sees it instead.
125
+ if (!result.isSuccess) {
126
+ throw new Error(
127
+ `tenant-lifecycle: failed to forget membership ${membership.id} for tenant ${ctx.tenantId}: ${result.error.message}`,
128
+ );
129
+ }
130
+ }
99
131
  await tenantCrud.update(
100
132
  {
101
133
  id: ctx.tenantId,
@@ -109,9 +141,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
109
141
  db,
110
142
  { skipOptimisticLock: true },
111
143
  );
112
- await deleteMany(ctx.db, tenantMembershipsTable as EntityTableMeta, {
113
- tenantId: ctx.tenantId,
114
- });
144
+ invalidateTenantLifecycleGate(ctx.tenantId);
115
145
  }
116
146
 
117
147
  export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
@@ -73,6 +73,7 @@ import { tierAssignmentEntity } from "./entity";
73
73
  import { getActiveTierQuery } from "./handlers/active-tier.query";
74
74
  import { getTenantTierQuery } from "./handlers/get-tenant-tier.query";
75
75
  import { createSetTenantTierWrite } from "./handlers/set-tenant-tier.write";
76
+ import { TIER_ENGINE_I18N } from "./i18n";
76
77
  import { isTrialActive, type TrialPolicy } from "./trial";
77
78
 
78
79
  // Drizzle-table for the tier-assignment-entity. Built once at module-load
@@ -234,6 +235,7 @@ export function createTierEngineFeature<
234
235
  // verdrahtet (Platform-Admin-Hoheit, nicht App-konfigurierbar). App
235
236
  // platziert ihn nur via r.nav("tier-engine:screen:tier-admin"); die
236
237
  // Komponente liefert tierEngineClient() aus dem ./web-subpath.
238
+ r.translations({ keys: TIER_ENGINE_I18N });
237
239
  r.screen({
238
240
  id: TIER_ADMIN_SCREEN_ID,
239
241
  type: "custom",
@@ -5,8 +5,15 @@
5
5
 
6
6
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
7
7
 
8
+ type LocalizedString = { readonly de: string; readonly en: string };
9
+
10
+ export const TIER_ENGINE_I18N: Readonly<Record<string, LocalizedString>> = {
11
+ "screen:tier-admin.title": { de: "Tier manuell zuweisen", en: "Assign tier manually" },
12
+ };
13
+
8
14
  export const defaultTranslations: TranslationsByLocale = {
9
15
  de: {
16
+ "screen:tier-admin.title": "Tier manuell zuweisen",
10
17
  "tier-admin.title": "Tier manuell zuweisen",
11
18
  "tier-admin.explainer":
12
19
  "Weise einem Tenant ein Tier ohne Kauf zu. Der Grant wird als „manuell“ markiert und von einem späteren Billing-Sync nicht überschrieben.",
@@ -22,6 +29,7 @@ export const defaultTranslations: TranslationsByLocale = {
22
29
  "Diese App hat keine TierMap konfiguriert — es gibt keine zuweisbaren Tiers.",
23
30
  },
24
31
  en: {
32
+ "screen:tier-admin.title": "Assign tier manually",
25
33
  "tier-admin.title": "Assign tier manually",
26
34
  "tier-admin.explainer":
27
35
  "Grant a tenant a tier without a purchase. The grant is marked as “manual” and a later billing sync won't overwrite it.",