@cosmicdrift/kumiko-framework 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.125.1",
3
+ "version": "0.126.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -189,7 +189,7 @@
189
189
  "zod": "^4.4.3"
190
190
  },
191
191
  "devDependencies": {
192
- "@cosmicdrift/kumiko-dispatcher-live": "0.125.1",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.126.0",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -55,6 +55,10 @@ export type AuthSessionChecker = (
55
55
  // and short-circuits the JWT path on a hit.
56
56
  export type PatResolver = (rawToken: string) => Promise<SessionUser | null>;
57
57
 
58
+ export type TenantLifecycleStatusResolver = (
59
+ tenantId: TenantId,
60
+ ) => Promise<{ readonly status: string } | null>;
61
+
58
62
  export type AuthMiddlewareOptions = {
59
63
  // Called after JWT-verify when the token carries a sid. If the checker
60
64
  // reports anything other than "live", the request is rejected with 401.
@@ -74,6 +78,10 @@ export type AuthMiddlewareOptions = {
74
78
  // a SessionUser with id="anonymous" and roles=["anonymous"], scoped to a
75
79
  // tenantId resolved through the chain documented on AnonymousAccessConfig.
76
80
  readonly anonymousAccess?: AnonymousAccessConfig;
81
+ // Consulted after tenantId is resolved (JWT/PAT/anonymous). Returns 410
82
+ // when the tenant is in teardown (destroyRequested/destroying/destroyed).
83
+ // cancel-destruction is exempt while status=destroyRequested.
84
+ readonly resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver;
77
85
  };
78
86
 
79
87
  // Resolves the tenant for an unauthenticated request. Returns null when no
@@ -127,14 +135,15 @@ type MiddlewareRejectCode =
127
135
  | "tenant_required"
128
136
  | "tenant_not_found"
129
137
  | "tenant_mismatch"
130
- | "invalid_tenant_format";
138
+ | "invalid_tenant_format"
139
+ | "tenant_unavailable";
131
140
 
132
141
  // @wrapper-known error-helper
133
142
  function middlewareReject(
134
143
  c: Context,
135
144
  opts: {
136
145
  code: MiddlewareRejectCode;
137
- status: 400 | 401 | 403 | 404;
146
+ status: 400 | 401 | 403 | 404 | 410;
138
147
  message: string;
139
148
  i18nKey: string;
140
149
  details?: Record<string, unknown>;
@@ -187,7 +196,13 @@ function extractToken(
187
196
  }
188
197
 
189
198
  export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions = {}) {
190
- const { sessionChecker, strictMode = false, anonymousAccess, patResolver } = options;
199
+ const {
200
+ sessionChecker,
201
+ strictMode = false,
202
+ anonymousAccess,
203
+ patResolver,
204
+ resolveTenantLifecycleStatus,
205
+ } = options;
191
206
 
192
207
  return async (c: Context, next: Next) => {
193
208
  const extracted = extractToken(c);
@@ -219,7 +234,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
219
234
  i18nKey: "auth.errors.missingToken",
220
235
  });
221
236
  }
222
- return await handleAnonymous(c, anonymousAccess, next);
237
+ return await handleAnonymous(c, anonymousAccess, next, resolveTenantLifecycleStatus);
223
238
  }
224
239
  return middlewareReject(c, {
225
240
  code: "missing_token",
@@ -234,7 +249,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
234
249
  // Personal Access Token, not a JWT. Short-circuit the JWT path entirely.
235
250
  // Cookie transport is never a PAT (the browser holds the JWT).
236
251
  if (patResolver && transport === "bearer" && token.startsWith(PAT_TOKEN_PREFIX)) {
237
- return await handlePat(c, patResolver, token, next);
252
+ return await handlePat(c, patResolver, token, next, resolveTenantLifecycleStatus);
238
253
  }
239
254
 
240
255
  let payload: Awaited<ReturnType<JwtHelper["verify"]>>;
@@ -286,6 +301,12 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
286
301
  ...(payload.claims ? { claims: payload.claims } : {}),
287
302
  ...(payload.jti ? { sid: payload.jti } : {}),
288
303
  };
304
+ const lifecycleReject = await rejectIfTenantTeardown(
305
+ c,
306
+ payload.tenantId,
307
+ resolveTenantLifecycleStatus,
308
+ );
309
+ if (lifecycleReject) return lifecycleReject;
289
310
  c.set(USER_KEY, user);
290
311
  c.set(AUTH_TRANSPORT_KEY, transport);
291
312
  await next();
@@ -311,6 +332,7 @@ async function handlePat(
311
332
  patResolver: PatResolver,
312
333
  token: string,
313
334
  next: Next,
335
+ resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
314
336
  ): Promise<Response | undefined> {
315
337
  const patUser = await patResolver(token);
316
338
  if (!patUser) {
@@ -335,6 +357,12 @@ async function handlePat(
335
357
  }
336
358
  c.set(USER_KEY, patUser);
337
359
  c.set(AUTH_TRANSPORT_KEY, "bearer");
360
+ const lifecycleReject = await rejectIfTenantTeardown(
361
+ c,
362
+ patUser.tenantId,
363
+ resolveTenantLifecycleStatus,
364
+ );
365
+ if (lifecycleReject) return lifecycleReject;
338
366
  await next();
339
367
  // skip: PAT path completed — next() ran; explicit return keeps the
340
368
  // Response|undefined union honest (same as handleAnonymous).
@@ -357,6 +385,7 @@ async function handleAnonymous(
357
385
  c: Context,
358
386
  config: AnonymousAccessConfig,
359
387
  next: Next,
388
+ resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
360
389
  ): Promise<Response | undefined> {
361
390
  // Step 1+2: parse client-supplied tenant. Reject malformed values before
362
391
  // they touch any downstream consumer (DB, cache, audit row).
@@ -394,6 +423,12 @@ async function handleAnonymous(
394
423
  }
395
424
 
396
425
  // Step 5: synthesise + continue.
426
+ const lifecycleReject = await rejectIfTenantTeardown(
427
+ c,
428
+ resolved.tenantId,
429
+ resolveTenantLifecycleStatus,
430
+ );
431
+ if (lifecycleReject) return lifecycleReject;
397
432
  c.set(USER_KEY, createAnonymousUser(resolved.tenantId));
398
433
  await next();
399
434
  // skip: anonymous path completed — Hono middleware contract returns void
@@ -479,8 +514,59 @@ async function resolveTenant(
479
514
 
480
515
  type RejectArgs = {
481
516
  code: MiddlewareRejectCode;
482
- status: 400 | 401 | 403 | 404;
517
+ status: 400 | 401 | 403 | 404 | 410;
483
518
  message: string;
484
519
  i18nKey: string;
485
520
  details?: Record<string, unknown>;
486
521
  };
522
+
523
+ const TENANT_LIFECYCLE_BLOCKED = new Set([
524
+ "destroyRequested",
525
+ "destroying",
526
+ "destroyFailed",
527
+ "destroyed",
528
+ ]);
529
+ const TENANT_LIFECYCLE_CANCEL_QN = "tenant-lifecycle:write:cancel-destruction";
530
+
531
+ async function requestsCancelDestruction(c: Context): Promise<boolean> {
532
+ if (c.req.method !== "POST") return false;
533
+ try {
534
+ const path = c.req.path;
535
+ const body = (await c.req.raw.clone().json()) as {
536
+ type?: string;
537
+ commands?: Array<{ type?: string }>;
538
+ };
539
+ if (path === "/api/write") {
540
+ return body.type === TENANT_LIFECYCLE_CANCEL_QN;
541
+ }
542
+ if (path === "/api/batch") {
543
+ return (
544
+ Array.isArray(body.commands) &&
545
+ body.commands.some((command) => command.type === TENANT_LIFECYCLE_CANCEL_QN)
546
+ );
547
+ }
548
+ } catch {
549
+ return false;
550
+ }
551
+ return false;
552
+ }
553
+
554
+ async function rejectIfTenantTeardown(
555
+ c: Context,
556
+ tenantId: TenantId,
557
+ resolveTenantLifecycleStatus: TenantLifecycleStatusResolver | undefined,
558
+ ): Promise<Response | undefined> {
559
+ if (!resolveTenantLifecycleStatus) return undefined;
560
+ const lifecycle = await resolveTenantLifecycleStatus(tenantId);
561
+ if (!lifecycle || !TENANT_LIFECYCLE_BLOCKED.has(lifecycle.status)) return undefined;
562
+ if (lifecycle.status === "destroyRequested" && (await requestsCancelDestruction(c))) {
563
+ return undefined;
564
+ }
565
+ return middlewareReject(c, {
566
+ code: "tenant_unavailable",
567
+ status: 410,
568
+ message: `tenant "${tenantId}" is unavailable (${lifecycle.status})`,
569
+ i18nKey: "auth.errors.tenantUnavailable",
570
+ details: { tenantId, status: lifecycle.status },
571
+ });
572
+ }
@@ -243,6 +243,8 @@ export type AuthRoutesConfig = {
243
243
  // Wired by run-prod-app when the PAT feature is mounted; unwired = no PAT
244
244
  // rate limiting.
245
245
  patRateLimiter?: LoginRateLimiter;
246
+ // Tenant-lifecycle 410 gate — wired by tenant-lifecycle / run-prod-app.
247
+ resolveTenantLifecycleStatus?: import("./auth-middleware").TenantLifecycleStatusResolver;
246
248
  // Password-reset flow. When wired, POST /auth/request-password-reset and
247
249
  // POST /auth/reset-password are mounted as public routes. The framework
248
250
  // dispatches to the feature-level handlers (authoring QNs typically come
package/src/api/index.ts CHANGED
@@ -7,6 +7,7 @@ export type {
7
7
  AuthSessionStatus,
8
8
  PatResolver,
9
9
  TenantExists,
10
+ TenantLifecycleStatusResolver,
10
11
  TenantResolver,
11
12
  } from "./auth-middleware";
12
13
  export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
package/src/api/server.ts CHANGED
@@ -567,6 +567,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
567
567
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
568
568
  ...(options.auth?.sessionStrictMode ? { strictMode: options.auth.sessionStrictMode } : {}),
569
569
  ...(options.auth?.patResolver ? { patResolver: options.auth.patResolver } : {}),
570
+ ...(options.auth?.resolveTenantLifecycleStatus
571
+ ? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
572
+ : {}),
570
573
  ...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
571
574
  });
572
575
  app.use("/api/*", async (c, next) => {
@@ -24,6 +24,17 @@ const fileProvider = (name: string) =>
24
24
 
25
25
  const S3_ENV = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
26
26
 
27
+ // V2/V3 are hard boot gates now — capture the thrown message so a single case
28
+ // can assert several substrings (feature, entity, article).
29
+ function catchMessage(fn: () => void): string {
30
+ try {
31
+ fn();
32
+ } catch (e) {
33
+ return e instanceof Error ? e.message : String(e);
34
+ }
35
+ throw new Error("expected function to throw, but it did not");
36
+ }
37
+
27
38
  describe("validateGdprStoragePersistence (V1)", () => {
28
39
  let warnSpy: ReturnType<typeof spyOn>;
29
40
  let savedEnv: Array<readonly [string, string | undefined]>;
@@ -84,79 +95,53 @@ describe("validateGdprStoragePersistence (V1)", () => {
84
95
  });
85
96
 
86
97
  describe("validateGdprHookCompleteness (V2)", () => {
87
- let warnSpy: ReturnType<typeof spyOn>;
88
-
89
- beforeEach(() => {
90
- warnSpy = spyOn(console, "warn").mockImplementation(() => {});
91
- });
92
-
93
- afterEach(() => {
94
- warnSpy.mockRestore();
95
- });
96
-
97
98
  const exportFn = async () => null;
98
99
  const deleteFn = async () => {};
99
100
 
100
- test("export + delete hooks → no warn", () => {
101
+ test("export + delete hooks → no throw", () => {
101
102
  const f = defineFeature("my-feature", (r) => {
102
103
  r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn, delete: deleteFn });
103
104
  });
104
- validateGdprHookCompleteness([f]);
105
- expect(warnSpy).not.toHaveBeenCalled();
105
+ expect(() => validateGdprHookCompleteness([f])).not.toThrow();
106
106
  });
107
107
 
108
- test("export hook without delete hook → Art.17 warn", () => {
108
+ test("export hook without delete hook → Art.17 throw", () => {
109
109
  const f = defineFeature("my-feature", (r) => {
110
110
  r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn });
111
111
  });
112
- validateGdprHookCompleteness([f]);
113
- expect(warnSpy).toHaveBeenCalledTimes(1);
114
- const msg = String(warnSpy.mock.calls[0]?.[0]);
112
+ const msg = catchMessage(() => validateGdprHookCompleteness([f]));
115
113
  expect(msg).toContain("my-feature");
116
114
  expect(msg).toContain("myEntity");
117
115
  expect(msg).toContain("Art.17");
118
116
  });
119
117
 
120
- test("delete hook only (no export) → no warn", () => {
118
+ test("delete hook only (no export) → no throw", () => {
121
119
  const f = defineFeature("my-feature", (r) => {
122
120
  r.useExtension(EXT_USER_DATA, "myEntity", { delete: deleteFn });
123
121
  });
124
- validateGdprHookCompleteness([f]);
125
- expect(warnSpy).not.toHaveBeenCalled();
122
+ expect(() => validateGdprHookCompleteness([f])).not.toThrow();
126
123
  });
127
124
 
128
- test("no EXT_USER_DATA hooks at all → no warn", () => {
125
+ test("no EXT_USER_DATA hooks at all → no throw", () => {
129
126
  const f = defineFeature("my-feature", (r) => {
130
127
  r.useExtension("fileProvider", "s3");
131
128
  });
132
- validateGdprHookCompleteness([f]);
133
- expect(warnSpy).not.toHaveBeenCalled();
129
+ expect(() => validateGdprHookCompleteness([f])).not.toThrow();
134
130
  });
135
131
 
136
- test("multiple features, one missing delete → one warn per missing hook", () => {
132
+ test("multiple features, one missing delete → throws naming the offender", () => {
137
133
  const good = defineFeature("good", (r) => {
138
134
  r.useExtension(EXT_USER_DATA, "entityA", { export: exportFn, delete: deleteFn });
139
135
  });
140
136
  const bad = defineFeature("bad", (r) => {
141
137
  r.useExtension(EXT_USER_DATA, "entityB", { export: exportFn });
142
138
  });
143
- validateGdprHookCompleteness([good, bad]);
144
- expect(warnSpy).toHaveBeenCalledTimes(1);
145
- expect(String(warnSpy.mock.calls[0]?.[0])).toContain("entityB");
139
+ const msg = catchMessage(() => validateGdprHookCompleteness([good, bad]));
140
+ expect(msg).toContain("entityB");
146
141
  });
147
142
  });
148
143
 
149
144
  describe("validateGdprPiiHookCoverage (V3)", () => {
150
- let warnSpy: ReturnType<typeof spyOn>;
151
-
152
- beforeEach(() => {
153
- warnSpy = spyOn(console, "warn").mockImplementation(() => {});
154
- });
155
-
156
- afterEach(() => {
157
- warnSpy.mockRestore();
158
- });
159
-
160
145
  const exportFn = async () => null;
161
146
  const deleteFn = async () => {};
162
147
 
@@ -174,30 +159,38 @@ describe("validateGdprPiiHookCoverage (V3)", () => {
174
159
  );
175
160
  });
176
161
 
177
- test("user-data-rights not mounted → no warn", () => {
178
- validateGdprPiiHookCoverage([piiFeature()]);
179
- expect(warnSpy).not.toHaveBeenCalled();
162
+ test("user-data-rights not mounted → no throw", () => {
163
+ expect(() => validateGdprPiiHookCoverage([piiFeature()])).not.toThrow();
180
164
  });
181
165
 
182
- test("pii entity without any EXT_USER_DATA hook → warn naming entity and fields", () => {
183
- validateGdprPiiHookCoverage([udr(), piiFeature()]);
184
- expect(warnSpy).toHaveBeenCalledTimes(1);
185
- const msg = String(warnSpy.mock.calls[0]?.[0]);
166
+ test("pii entity without any EXT_USER_DATA hook → throws naming entity and fields", () => {
167
+ const msg = catchMessage(() => validateGdprPiiHookCoverage([udr(), piiFeature()]));
186
168
  expect(msg).toContain('"contact"');
187
169
  expect(msg).toContain("email");
188
170
  expect(msg).toContain("note");
189
171
  expect(msg).toContain("Art.17");
190
172
  });
191
173
 
192
- test("pii entity with hook registered by another feature → no warn", () => {
174
+ test("pii entity with hook registered by another feature → no throw", () => {
193
175
  const hooks = defineFeature("crm-user-data", (r) => {
194
176
  r.useExtension(EXT_USER_DATA, "contact", { export: exportFn, delete: deleteFn });
195
177
  });
196
- validateGdprPiiHookCoverage([udr(), piiFeature(), hooks]);
197
- expect(warnSpy).not.toHaveBeenCalled();
178
+ expect(() => validateGdprPiiHookCoverage([udr(), piiFeature(), hooks])).not.toThrow();
198
179
  });
199
180
 
200
- test("entity without subject annotations → no warn", () => {
181
+ test("no-op hook is the intentional escape hatch → no throw", () => {
182
+ const hooks = defineFeature("crm-user-data", (r) => {
183
+ // Escape hatch: erasure handled elsewhere (crypto-shredding key-erase),
184
+ // so the pipeline hook is a deliberate no-op.
185
+ r.useExtension(EXT_USER_DATA, "contact", {
186
+ export: async () => null,
187
+ delete: async () => {},
188
+ });
189
+ });
190
+ expect(() => validateGdprPiiHookCoverage([udr(), piiFeature(), hooks])).not.toThrow();
191
+ });
192
+
193
+ test("entity without subject annotations → no throw", () => {
201
194
  const plain = defineFeature("catalog", (r) => {
202
195
  r.entity(
203
196
  "product",
@@ -206,11 +199,10 @@ describe("validateGdprPiiHookCoverage (V3)", () => {
206
199
  }),
207
200
  );
208
201
  });
209
- validateGdprPiiHookCoverage([udr(), plain]);
210
- expect(warnSpy).not.toHaveBeenCalled();
202
+ expect(() => validateGdprPiiHookCoverage([udr(), plain])).not.toThrow();
211
203
  });
212
204
 
213
- test("userOwned field alone counts as user-subject data → warn", () => {
205
+ test("userOwned field alone counts as user-subject data → throws", () => {
214
206
  const f = defineFeature("notes", (r) => {
215
207
  r.entity(
216
208
  "note",
@@ -222,8 +214,7 @@ describe("validateGdprPiiHookCoverage (V3)", () => {
222
214
  }),
223
215
  );
224
216
  });
225
- validateGdprPiiHookCoverage([udr(), f]);
226
- expect(warnSpy).toHaveBeenCalledTimes(1);
227
- expect(String(warnSpy.mock.calls[0]?.[0])).toContain('"note"');
217
+ const msg = catchMessage(() => validateGdprPiiHookCoverage([udr(), f]));
218
+ expect(msg).toContain('"note"');
228
219
  });
229
220
  });
@@ -1,4 +1,4 @@
1
- import { EXT_USER_DATA } from "../extension-names";
1
+ import { EXT_TENANT_DATA, EXT_USER_DATA } from "../extension-names";
2
2
  import type { FeatureDefinition } from "../types";
3
3
  import type { PiiAnnotations } from "../types/fields";
4
4
 
@@ -60,10 +60,12 @@ export function validateGdprStoragePersistence(features: readonly FeatureDefinit
60
60
  }
61
61
  }
62
62
 
63
- // V2: export-without-erase guard. A feature that registers an EXT_USER_DATA
63
+ // V2: export-without-erase gate. A feature that registers an EXT_USER_DATA
64
64
  // export hook without a matching delete hook exports data under Art.20 but
65
- // never erases it on forget — an Art.17 violation. Registry-level signal only;
66
- // runtime no-ops (a delete hook that silently skips) are not detectable here.
65
+ // never erases it on forget — an Art.17 violation. Hard boot failure: no app
66
+ // should ship a GDPR export path with no erase path. Registry-level signal
67
+ // only; runtime no-ops (a delete hook that silently skips) are not detectable
68
+ // here — those are covered by the export/forget integration tests.
67
69
  export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
68
70
  for (const feature of features) {
69
71
  for (const usage of feature.extensionUsages) {
@@ -71,21 +73,23 @@ export function validateGdprHookCompleteness(features: readonly FeatureDefinitio
71
73
  const hasExport = typeof usage.options?.["export"] === "function";
72
74
  const hasDelete = typeof usage.options?.["delete"] === "function";
73
75
  if (hasExport && !hasDelete) {
74
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
75
- console.warn(
76
- `[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 risk). Add a delete hook, or a no-op with a comment explaining why erasure is intentionally skipped.`,
76
+ throw new Error(
77
+ `[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
77
78
  );
78
79
  }
79
80
  }
80
81
  }
81
82
  }
82
83
 
83
- // V3: PII-entity-without-hook guard. V2 checks registered hooks for
84
+ // V3: PII-entity-without-hook gate. V2 checks registered hooks for
84
85
  // completeness; V3 catches the entity nobody registered at all — fields
85
86
  // annotated as user-subject data (pii / userOwned) yet invisible to the
86
- // Art.15/20 export and Art.17 forget pipeline. Matching is by entity name
87
- // across all features (usage.entityName is unqualified); a same-named entity
88
- // in another feature can mask a gap accepted for a WARN-level heuristic.
87
+ // Art.15/20 export and Art.17 forget pipeline. Hard boot failure once
88
+ // user-data-rights is mounted: a subject-data entity that skips the pipeline
89
+ // is exactly the "feature built past GDPR" leak this gate exists to stop.
90
+ // Matching is by entity name across all features (usage.entityName is
91
+ // unqualified); a same-named entity in another feature can mask a gap —
92
+ // accepted, as the common case is a distinct entity name.
89
93
  export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition[]): void {
90
94
  const featureNames = new Set(features.map((f) => f.name));
91
95
  if (!featureNames.has("user-data-rights")) {
@@ -110,9 +114,41 @@ export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition
110
114
  })
111
115
  .map(([name]) => name);
112
116
  if (subjectFields.length === 0) continue;
113
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
114
- console.warn(
115
- `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature.`,
117
+ throw new Error(
118
+ `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature. If this entity is intentionally out of the pipeline (e.g. crypto-shredding key-erase covers it), register a no-op hook { export: async () => null, delete: async () => {} } with a comment explaining why.`,
119
+ );
120
+ }
121
+ }
122
+ }
123
+
124
+ // V4: tenantOwned-entity-without-hook gate. Mirrors validateGdprPiiHookCoverage
125
+ // but for EXT_TENANT_DATA when tenant-lifecycle is mounted.
126
+ export function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void {
127
+ const featureNames = new Set(features.map((f) => f.name));
128
+ if (!featureNames.has("tenant-lifecycle")) {
129
+ // skip: this guard only applies to apps that mount tenant-lifecycle
130
+ return;
131
+ }
132
+
133
+ const hookedEntities = new Set<string>();
134
+ for (const f of features) {
135
+ for (const usage of f.extensionUsages) {
136
+ if (usage.extensionName === EXT_TENANT_DATA) hookedEntities.add(usage.entityName);
137
+ }
138
+ }
139
+
140
+ for (const feature of features) {
141
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
142
+ if (hookedEntities.has(entityName)) continue;
143
+ const tenantSubjectFields = Object.entries(entity.fields)
144
+ .filter(([, field]) => {
145
+ const annot = field as PiiAnnotations;
146
+ return Boolean(annot.tenantOwned);
147
+ })
148
+ .map(([name]) => name);
149
+ if (tenantSubjectFields.length === 0) continue;
150
+ throw new Error(
151
+ `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has tenant-subject fields (${tenantSubjectFields.join(", ")}) but no feature registers an EXT_TENANT_DATA destroy hook for it — tenant destroy never erases this data. Register r.useExtension(EXT_TENANT_DATA, "${entityName}", { destroy }) or a documented no-op if crypto-shredding covers it.`,
116
152
  );
117
153
  }
118
154
  }
@@ -31,6 +31,7 @@ import {
31
31
  validateGdprHookCompleteness,
32
32
  validateGdprPiiHookCoverage,
33
33
  validateGdprStoragePersistence,
34
+ validateTenantDataHookCoverage,
34
35
  } from "./gdpr-storage";
35
36
  import { validateI18nSurfaceKeys } from "./i18n-keys";
36
37
  import { validateOwnershipRules } from "./ownership";
@@ -184,6 +185,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
184
185
  validateGdprStoragePersistence(features);
185
186
  validateGdprHookCompleteness(features);
186
187
  validateGdprPiiHookCoverage(features);
188
+ validateTenantDataHookCoverage(features);
187
189
 
188
190
  if (hasEncryptedFields) {
189
191
  // Availability check, not env-presence: eagerly building the keyring
@@ -0,0 +1,19 @@
1
+ // Hook-Signatur-Types für EXT_TENANT_DATA (DSGVO tenant-scoped destroy).
2
+ //
3
+ // Mirror of engine/extensions/user-data.ts at tenant granularity.
4
+ // tenant-lifecycle orchestrates destroy via registry.getExtensionUsages(EXT_TENANT_DATA).
5
+
6
+ import type { DbRunner } from "../../db/connection";
7
+ import type { Registry, TenantId } from "../types";
8
+
9
+ export interface TenantDataHookCtx {
10
+ readonly db: DbRunner;
11
+ readonly registry: Registry;
12
+ readonly tenantId: TenantId;
13
+ }
14
+
15
+ export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<void>;
16
+
17
+ export interface TenantDataExtensionHooks {
18
+ readonly destroy: TenantDataDestroyHook;
19
+ }
@@ -81,6 +81,11 @@ export {
81
81
  EXT_USER_DATA_ORDER,
82
82
  FILE_PROVIDER_CONFIG_KEY,
83
83
  } from "./extension-names";
84
+ export type {
85
+ TenantDataDestroyHook,
86
+ TenantDataExtensionHooks,
87
+ TenantDataHookCtx,
88
+ } from "./extensions/tenant-data";
84
89
  export type {
85
90
  TenantUserModel,
86
91
  UserDataDeleteHook,