@cosmicdrift/kumiko-bundled-features 0.125.1 → 0.126.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.
@@ -0,0 +1,176 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { configuredPiiSubjectKms, type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import {
4
+ createEventStoreExecutor,
5
+ createTenantDb,
6
+ type DbRunner,
7
+ deleteMany,
8
+ type EntityTableMeta,
9
+ } from "@cosmicdrift/kumiko-framework/db";
10
+ import {
11
+ createSystemUser,
12
+ EXT_EXTERNAL_RESOURCE,
13
+ EXT_INFRA_RESOURCE,
14
+ EXT_SEARCH_ADAPTER,
15
+ EXT_STORAGE_PROVIDER,
16
+ EXT_TENANT_DATA,
17
+ type Registry,
18
+ type TenantId,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
21
+ import { tenantEntity, tenantMembershipsTable, tenantTable } from "../tenant";
22
+ import type { TenantDestructionStageName } from "./constants";
23
+
24
+ export type DestructionStageCtx = {
25
+ readonly db: DbRunner;
26
+ readonly registry: Registry;
27
+ readonly tenantId: TenantId;
28
+ readonly log?: (message: string) => void;
29
+ };
30
+
31
+ export type DestructionStage = {
32
+ readonly name: TenantDestructionStageName;
33
+ readonly maxAttempts: number;
34
+ readonly run: (ctx: DestructionStageCtx) => Promise<void>;
35
+ };
36
+
37
+ const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
38
+
39
+ async function runExtensionDestroyHooks(
40
+ registry: Registry,
41
+ extensionName: string,
42
+ hookKey: "destroyTenant",
43
+ ctx: DestructionStageCtx,
44
+ ): Promise<void> {
45
+ const usages = registry.getExtensionUsages(extensionName);
46
+ for (const usage of usages) {
47
+ const hook = usage.options?.[hookKey];
48
+ if (typeof hook !== "function") continue;
49
+ await (hook as (tenantId: TenantId, hookCtx: DestructionStageCtx) => Promise<void>)(
50
+ ctx.tenantId,
51
+ ctx,
52
+ );
53
+ }
54
+ }
55
+
56
+ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
57
+ const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
58
+ for (const usage of usages) {
59
+ const destroy = usage.options?.["destroy"] as
60
+ | ((ctx: DestructionStageCtx) => Promise<void>)
61
+ | undefined;
62
+ if (!destroy) continue;
63
+ await destroy(ctx);
64
+ }
65
+ }
66
+
67
+ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
68
+ const kms = configuredPiiSubjectKms();
69
+ if (!kms) {
70
+ ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
71
+ // skip: KMS optional — apps without crypto-shredding still run other destroy stages
72
+ return;
73
+ }
74
+ const memberships = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {
75
+ tenantId: ctx.tenantId,
76
+ });
77
+ const subjects: SubjectId[] = [
78
+ { kind: "tenant", tenantId: ctx.tenantId },
79
+ ...memberships.map((m) => ({ kind: "user" as const, userId: m.userId })),
80
+ ];
81
+ for (const subject of subjects) {
82
+ await kms.eraseKey(subject, {
83
+ requestId: `tenant-lifecycle:destroy:${ctx.tenantId}`,
84
+ eraseReason: "tenant-destroy stage subject-keys",
85
+ });
86
+ }
87
+ }
88
+
89
+ async function purgeTenantCache(ctx: DestructionStageCtx): Promise<void> {
90
+ // ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
91
+ // then this stage is a documented no-op (no cache layer in test stack).
92
+ ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
93
+ }
94
+
95
+ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
96
+ const now = getTemporal().Now.instant();
97
+ const user = createSystemUser(ctx.tenantId);
98
+ const db = createTenantDb(ctx.db, ctx.tenantId, "system");
99
+ await tenantCrud.update(
100
+ {
101
+ id: ctx.tenantId,
102
+ changes: {
103
+ status: "destroyed",
104
+ destroyedAt: now,
105
+ isEnabled: false,
106
+ },
107
+ },
108
+ user,
109
+ db,
110
+ { skipOptimisticLock: true },
111
+ );
112
+ await deleteMany(ctx.db, tenantMembershipsTable as EntityTableMeta, {
113
+ tenantId: ctx.tenantId,
114
+ });
115
+ }
116
+
117
+ export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
118
+ {
119
+ name: "external-resources",
120
+ maxAttempts: 3,
121
+ run: (ctx) =>
122
+ runExtensionDestroyHooks(ctx.registry, EXT_EXTERNAL_RESOURCE, "destroyTenant", ctx),
123
+ },
124
+ {
125
+ name: "search-indices",
126
+ maxAttempts: 3,
127
+ run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_SEARCH_ADAPTER, "destroyTenant", ctx),
128
+ },
129
+ {
130
+ name: "cache",
131
+ maxAttempts: 1,
132
+ run: purgeTenantCache,
133
+ },
134
+ {
135
+ name: "app-data",
136
+ maxAttempts: 3,
137
+ run: runTenantDataHooks,
138
+ },
139
+ {
140
+ name: "subject-keys",
141
+ maxAttempts: 3,
142
+ run: eraseSubjectKeys,
143
+ },
144
+ {
145
+ name: "files",
146
+ maxAttempts: 3,
147
+ run: (ctx) =>
148
+ runExtensionDestroyHooks(ctx.registry, EXT_STORAGE_PROVIDER, "destroyTenant", ctx),
149
+ },
150
+ {
151
+ name: "infra-resources",
152
+ maxAttempts: 3,
153
+ run: (ctx) => runExtensionDestroyHooks(ctx.registry, EXT_INFRA_RESOURCE, "destroyTenant", ctx),
154
+ },
155
+ {
156
+ name: "tenant-row",
157
+ maxAttempts: 1,
158
+ run: tombstoneTenantRow,
159
+ },
160
+ ];
161
+
162
+ export function pickNextStage(
163
+ completedStages: ReadonlySet<string>,
164
+ abandonedStages: ReadonlySet<string>,
165
+ ): DestructionStage | null {
166
+ if (abandonedStages.size > 0) return null;
167
+ for (const stage of DESTRUCTION_STAGES) {
168
+ if (completedStages.has(stage.name)) continue;
169
+ return stage;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ export function isDestructionPipelineComplete(completedStages: ReadonlySet<string>): boolean {
175
+ return DESTRUCTION_STAGES.every((stage) => completedStages.has(stage.name));
176
+ }
@@ -1,10 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
3
4
  import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
4
5
  import { createConfigFeature } from "../../config/feature";
5
6
  import { createDataRetentionFeature } from "../../data-retention/feature";
6
7
  import { createSessionsFeature } from "../../sessions/feature";
7
8
  import { createUserFeature } from "../../user/feature";
9
+ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
8
10
  import { createUserDataRightsFeature } from "../feature";
9
11
 
10
12
  // Read-only GDPR inspector screens live IN user-data-rights (the boot-validator
@@ -16,13 +18,19 @@ import { createUserDataRightsFeature } from "../feature";
16
18
  // so an entityList binding is rebuild-safe (unlike jobs/sessions read-models).
17
19
 
18
20
  describe("user-data-rights read-only inspector screens", () => {
21
+ // A realistic assembly: mounting user-data-rights without its default hooks
22
+ // now fails the V3 boot gate (the framework `user` entity holds PII with no
23
+ // erase path). Real apps mount the defaults (which hard-require `files`)
24
+ // alongside — so this set mirrors that.
19
25
  const features = [
20
26
  createConfigFeature(),
21
27
  createUserFeature(),
22
28
  createSessionsFeature(),
23
29
  createDataRetentionFeature(),
24
30
  createComplianceProfilesFeature(),
31
+ createFilesFeature(),
25
32
  createUserDataRightsFeature(),
33
+ createUserDataRightsDefaultsFeature(),
26
34
  ];
27
35
 
28
36
  test("the assembled feature set boot-validates", () => {
@@ -791,6 +791,10 @@ describe("runExportJobs :: Atom 3c file-binaries", () => {
791
791
  },
792
792
  ],
793
793
  }),
794
+ // no-op: this fixture exercises only the export/file path; the erase
795
+ // side is covered by forget-*.integration.test.ts. Required so the
796
+ // V2 boot gate (export-without-erase) does not reject the stack.
797
+ delete: async () => {},
794
798
  });
795
799
  });
796
800