@cosmicdrift/kumiko-framework 0.304.0 → 0.306.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 (92) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  3. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  4. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  5. package/src/api/__tests__/server-error-logging.test.ts +104 -0
  6. package/src/api/api-constants.ts +13 -0
  7. package/src/api/extra-route.ts +33 -4
  8. package/src/api/index.ts +1 -0
  9. package/src/api/request-context.ts +5 -4
  10. package/src/api/routes.ts +26 -1
  11. package/src/api/server.ts +8 -2
  12. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  13. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  14. package/src/bun-db/query.ts +42 -18
  15. package/src/changes.json +108 -0
  16. package/src/db/__tests__/pg-error.test.ts +14 -0
  17. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  18. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  19. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  20. package/src/db/event-store-executor-write.ts +7 -0
  21. package/src/db/index.ts +1 -1
  22. package/src/db/pg-error.ts +13 -0
  23. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  24. package/src/db/tenant-db.ts +140 -16
  25. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  26. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  27. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  28. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  29. package/src/engine/boot-validator/access-declarations.ts +5 -66
  30. package/src/engine/extension-names.ts +55 -25
  31. package/src/engine/extensions/storage-provider.ts +14 -41
  32. package/src/engine/extensions/tenant-data.ts +4 -0
  33. package/src/engine/extensions/tenant-resource.ts +40 -0
  34. package/src/engine/extensions/user-data.ts +8 -7
  35. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  36. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  37. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  38. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  39. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  40. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  41. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  42. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  43. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  44. package/src/engine/feature-ast/index.ts +11 -1
  45. package/src/engine/feature-ast/patch.ts +338 -5
  46. package/src/engine/feature-ast/patcher.ts +2 -2
  47. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  48. package/src/engine/feature-ast/patterns.ts +22 -15
  49. package/src/engine/feature-ast/render.ts +1 -0
  50. package/src/engine/feature-ui-extensions.ts +8 -7
  51. package/src/engine/index.ts +23 -5
  52. package/src/engine/personal-data-fields.ts +66 -0
  53. package/src/engine/registry-validate.ts +15 -0
  54. package/src/engine/registry.ts +2 -0
  55. package/src/engine/types/extension-options-map.ts +1 -0
  56. package/src/engine/types/index.ts +8 -0
  57. package/src/env/__tests__/dry-run.test.ts +43 -3
  58. package/src/env/dry-run.ts +28 -15
  59. package/src/errors/__tests__/write-failures.test.ts +47 -4
  60. package/src/errors/i18n/de.yaml +12 -0
  61. package/src/errors/i18n/en.yaml +12 -0
  62. package/src/errors/reasons.ts +4 -0
  63. package/src/errors/write-error-info.ts +12 -3
  64. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  65. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  66. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  67. package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
  68. package/src/jobs/job-runner.ts +170 -19
  69. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  70. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  71. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  72. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  73. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  74. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  75. package/src/pipeline/active-membership.ts +5 -1
  76. package/src/pipeline/dispatch-batch.ts +59 -13
  77. package/src/pipeline/dispatch-query.ts +16 -5
  78. package/src/pipeline/dispatch-shared.ts +12 -5
  79. package/src/pipeline/dispatch-stream.ts +7 -2
  80. package/src/pipeline/dispatch-write.ts +22 -5
  81. package/src/pipeline/dispatcher.ts +9 -2
  82. package/src/pipeline/idempotency.ts +16 -0
  83. package/src/pipeline/member-reader.ts +3 -1
  84. package/src/pipeline/system-identity-switch.ts +22 -4
  85. package/src/pipeline/write-origin.ts +107 -0
  86. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  87. package/src/rate-limit/middleware.ts +3 -0
  88. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  89. package/src/stack/test-stack.ts +5 -0
  90. package/src/testing/closed-connection-error.ts +62 -0
  91. package/src/testing/index.ts +1 -0
  92. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -1,16 +1,10 @@
1
- // #2323: event-store.ts's and projection-rebuild.ts's plain SELECT helpers
2
- // read via asRawClient(db).unsafe() directly, bypassing the #1163
3
- // closed-connection retry that only covered bun-db/query.ts's own
4
- // selectMany/countWhere. Routed the non-locking read call sites through
5
- // unsafeReadRetrying instead — this test mirrors
6
- // bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
7
- // the retry now fires. Writes (insertSubsequentEventRow, upsertSnapshot,
8
- // markProjectionRebuilding, ...) stay unretried per #1358, and the
9
- // FOR UPDATE / FOR UPDATE SKIP LOCKED reads in event-consumer.ts are always
10
- // called inside transaction() (verified against their only call sites) — the
11
- // retry guard there is a no-op, so those are left as asRawClient calls too.
1
+ // event-store.ts's and projection-rebuild.ts's SELECT helpers run through
2
+ // unsafeReadRetrying; this proves the retry fires using a real captured
3
+ // driver error. Writes and the FOR UPDATE reads inside transaction() stay
4
+ // out of scope — unretried by design.
12
5
 
13
- import { describe, expect, test } from "bun:test";
6
+ import { beforeAll, describe, expect, test } from "bun:test";
7
+ import { captureClosedConnectionError } from "../../../testing/closed-connection-error";
14
8
  import {
15
9
  selectAggregateMaxVersion,
16
10
  selectEventsHighWaterMark,
@@ -22,24 +16,28 @@ import {
22
16
  selectEventsForProjectionRebuildBatch,
23
17
  } from "../projection-rebuild";
24
18
 
25
- function closedConnectionError(): Error {
26
- return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
27
- }
19
+ let closedConnectionError: unknown;
20
+
21
+ beforeAll(async () => {
22
+ closedConnectionError = await captureClosedConnectionError();
23
+ });
28
24
 
29
25
  type RecordedCall = { readonly sql: string; readonly params: readonly unknown[] | undefined };
30
26
 
31
27
  type FakeClient = {
32
28
  unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
33
29
  begin: () => never;
30
+ options: { max: number };
34
31
  calls: number;
35
32
  recordedCalls: RecordedCall[];
36
33
  };
37
34
 
38
- function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient {
35
+ function fakeClient(failures: unknown[], row: Record<string, unknown>): FakeClient {
39
36
  const remaining = [...failures];
40
37
  const client: FakeClient = {
41
38
  calls: 0,
42
39
  recordedCalls: [],
40
+ options: { max: 1 },
43
41
  unsafe: async (sql, params) => {
44
42
  client.calls++;
45
43
  client.recordedCalls.push({ sql, params });
@@ -54,9 +52,9 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
54
52
  return client;
55
53
  }
56
54
 
57
- describe("framework db/queries — closed-connection retry (#2323)", () => {
55
+ describe("framework db/queries — closed-connection retry", () => {
58
56
  test("selectStreamMaxVersion retries once and returns the version", async () => {
59
- const db = fakeClient([closedConnectionError()], { v: 5 });
57
+ const db = fakeClient([closedConnectionError], { v: 5 });
60
58
  const result = await selectStreamMaxVersion(db as never, "agg1", "t1");
61
59
  expect(result).toBe(5);
62
60
  expect(db.calls).toBe(2);
@@ -65,28 +63,28 @@ describe("framework db/queries — closed-connection retry (#2323)", () => {
65
63
  });
66
64
 
67
65
  test("selectAggregateMaxVersion retries once and returns the version", async () => {
68
- const db = fakeClient([closedConnectionError()], { v: 7 });
66
+ const db = fakeClient([closedConnectionError], { v: 7 });
69
67
  const result = await selectAggregateMaxVersion(db as never, "agg1");
70
68
  expect(result).toBe(7);
71
69
  expect(db.calls).toBe(2);
72
70
  });
73
71
 
74
72
  test("selectEventsHighWaterMark retries once and returns the max id", async () => {
75
- const db = fakeClient([closedConnectionError()], { max: 42n });
73
+ const db = fakeClient([closedConnectionError], { max: 42n });
76
74
  const result = await selectEventsHighWaterMark(db as never);
77
75
  expect(result).toBe(42n);
78
76
  expect(db.calls).toBe(2);
79
77
  });
80
78
 
81
79
  test("selectNextEventIdAfter retries once and returns the next id", async () => {
82
- const db = fakeClient([closedConnectionError()], { id: 43n });
80
+ const db = fakeClient([closedConnectionError], { id: 43n });
83
81
  const result = await selectNextEventIdAfter(db as never, 42n);
84
82
  expect(result).toBe(43n);
85
83
  expect(db.calls).toBe(2);
86
84
  });
87
85
 
88
86
  test("selectEventsForProjectionRebuildBatch retries once and returns rows", async () => {
89
- const db = fakeClient([closedConnectionError()], { id: "1", type: "created" });
87
+ const db = fakeClient([closedConnectionError], { id: "1", type: "created" });
90
88
  const rows = await selectEventsForProjectionRebuildBatch(
91
89
  db as never,
92
90
  ["user"],
@@ -99,17 +97,19 @@ describe("framework db/queries — closed-connection retry (#2323)", () => {
99
97
  });
100
98
 
101
99
  test("countSubscribedEvents retries once and returns the count", async () => {
102
- const db = fakeClient([closedConnectionError()], { n: 12n });
100
+ const db = fakeClient([closedConnectionError], { n: 12n });
103
101
  const result = await countSubscribedEvents(db as never, ["user"], ["user:created"]);
104
102
  expect(result).toBe(12n);
105
103
  expect(db.calls).toBe(2);
106
104
  });
107
105
 
108
- test("gives up after the single retry when the connection stays closed", async () => {
109
- const db = fakeClient([closedConnectionError(), closedConnectionError()], { v: 5 });
110
- await expect(selectStreamMaxVersion(db as never, "agg1", "t1")).rejects.toThrow(
111
- "connection was closed",
106
+ test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
107
+ const db = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError], {
108
+ v: 5,
109
+ });
110
+ await expect(selectStreamMaxVersion(db as never, "agg1", "t1")).rejects.toBe(
111
+ closedConnectionError,
112
112
  );
113
- expect(db.calls).toBe(2);
113
+ expect(db.calls).toBe(3);
114
114
  });
115
115
  });
@@ -24,6 +24,7 @@ import {
24
24
  type SelectOptions,
25
25
  type WhereObject,
26
26
  } from "../db/query";
27
+ import type { EntityDefinition } from "../engine/types/fields";
27
28
  import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
28
29
  import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
29
30
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
@@ -45,6 +46,21 @@ const declaredUnsafeRawRunners = new WeakMap<
45
46
  (reason: string) => DbRunner
46
47
  >();
47
48
 
49
+ // The CRUD executor writes through tenantDbRunner, not insertOne, so it asks the
50
+ // TenantDb it was handed for its gate. Bound inside createTenantDb so rebound instances
51
+ // (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
52
+ const personalDataGates = new WeakMap<TenantDb, PersonalDataGate>();
53
+
54
+ // The executor passes its entity so the check does not depend on the table-name lookup.
55
+ export function assertPersonalDataWrite(
56
+ db: TenantDb,
57
+ tableName: string,
58
+ keys: readonly string[],
59
+ entity: EntityDefinition,
60
+ ): void {
61
+ personalDataGates.get(db)?.(tableName, keys, entity);
62
+ }
63
+
48
64
  // Framework-private (not re-exported from db/index.ts): same grant check + audit as unsafeRaw, for engine forwarding.
49
65
  export function unsafeRawForDeclaredStep(
50
66
  holder: TenantDb | UncheckedSystemDb,
@@ -57,25 +73,49 @@ export function unsafeRawForDeclaredStep(
57
73
  if (!runner) {
58
74
  throw new InternalError({
59
75
  message:
60
- "unsafeRawForDeclaredStep received a holder not built by createTenantDb or " +
61
- "createUncheckedSystemDb — no declared unsafeRaw runner bound.",
76
+ "unsafeRawForDeclaredStep received a holder not built by createTenantDb, " +
77
+ "createUncheckedSystemDb, or createSystemDbView — no declared unsafeRaw runner bound.",
62
78
  });
63
79
  }
64
80
  return runner(reason);
65
81
  }
66
82
 
67
- // buildHandlerContext (pipeline/dispatch-shared.ts) always builds "system"
68
- // mode from the caller's own tenantId, never a foreign one.
69
- //
70
- // dbOutsideTransaction is optional so every existing single-arg call site
71
- // (jobs, tests, delivery-service.ts) keeps compiling — those callers have no
72
- // outside-tx source to hand in and never needed one. Only
73
- // buildHandlerContext passes it, which is also the only place `.outsideTransaction`
74
- // is reachable through `ctx.systemDb`.
75
- export function createUncheckedSystemDb(
83
+ const systemDbRebinders = new WeakMap<
84
+ UncheckedSystemDb,
85
+ (grant: EscapeHatchDeclaration | undefined, deniedCallerLabel: string) => UncheckedSystemDb
86
+ >();
87
+
88
+ // Rebinds a hook's own escapeHatch onto ctx.systemDb, mirroring withUnsafeRawGrant: always
89
+ // rebuilt from the original db/dbOutsideTransaction/report, never stacked onto a prior rebind.
90
+ // Inputs not built by createUncheckedSystemDb pass through unchanged.
91
+ export function withSystemDbUnsafeRawGrant(
92
+ systemDb: UncheckedSystemDb,
93
+ grant: EscapeHatchDeclaration | undefined,
94
+ deniedCallerLabel: string,
95
+ ): UncheckedSystemDb {
96
+ const rebind = systemDbRebinders.get(systemDb);
97
+ return rebind ? rebind(grant, deniedCallerLabel) : systemDb;
98
+ }
99
+
100
+ // Ungated when `gate` is absent (the handler's own ctx.systemDb — systemScope() is
101
+ // itself the grant there). Gated by a hook's own escapeHatch when `gate.kind` is
102
+ // "hook-grant": unsafeRaw then denies without `hasGrant(gate.grant)`, same error shape
103
+ // as ctx.db.unsafeRaw's denial in createTenantDb below. Gated by the source TenantDb's
104
+ // own escapeHatch when `gate.kind` is "source-tenant-db" (createSystemDbView): unsafeRaw
105
+ // defers entirely to db's own declared runner (reason/memberReadOnly/grant check and
106
+ // report) — the view's own `report` is never called for unsafe-raw in that mode, and a
107
+ // source not built by createTenantDb fails closed.
108
+ function buildUncheckedSystemDb(
76
109
  db: TenantDb,
77
- dbOutsideTransaction?: TenantDb,
78
- report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
110
+ dbOutsideTransaction: TenantDb | undefined,
111
+ report: EscapeHatchReporter,
112
+ gate?:
113
+ | {
114
+ readonly kind: "hook-grant";
115
+ readonly grant: EscapeHatchDeclaration | undefined;
116
+ readonly deniedCallerLabel: string;
117
+ }
118
+ | { readonly kind: "source-tenant-db" },
79
119
  ): UncheckedSystemDb {
80
120
  const allowedTenantIds: readonly TenantId[] = [db.tenantId, SYSTEM_TENANT_ID];
81
121
 
@@ -97,9 +137,19 @@ export function createUncheckedSystemDb(
97
137
  }
98
138
 
99
139
  function grantedUnsafeRawRunner(reason: string): DbRunner {
140
+ if (gate?.kind === "source-tenant-db") {
141
+ return unsafeRawForDeclaredStep(db, reason);
142
+ }
100
143
  if (reason.trim().length === 0) {
101
144
  throw new Error("unsafeRaw requires a non-empty reason");
102
145
  }
146
+ if (gate && !hasGrant(gate.grant)) {
147
+ throw new AccessDeniedError({
148
+ message:
149
+ 'ctx.systemDb.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
150
+ `${gate.deniedCallerLabel} to allow unsafeRaw.`,
151
+ });
152
+ }
103
153
  report("unsafe-raw", reason);
104
154
  return tenantDbRunner(db);
105
155
  }
@@ -162,15 +212,58 @@ export function createUncheckedSystemDb(
162
212
  },
163
213
  };
164
214
  declaredUnsafeRawRunners.set(uncheckedSystemDb, grantedUnsafeRawRunner);
215
+ if (gate?.kind !== "source-tenant-db") {
216
+ systemDbRebinders.set(uncheckedSystemDb, (grant, deniedCallerLabel) =>
217
+ buildUncheckedSystemDb(
218
+ withUnsafeRawGrant(db, grant),
219
+ dbOutsideTransaction && withUnsafeRawGrant(dbOutsideTransaction, grant),
220
+ report,
221
+ { kind: "hook-grant", grant, deniedCallerLabel },
222
+ ),
223
+ );
224
+ }
165
225
  return uncheckedSystemDb;
166
226
  }
167
227
 
228
+ // Framework-private (not re-exported from db/index.ts): buildHandlerContext
229
+ // (pipeline/dispatch-shared.ts) always builds "system" mode from the caller's
230
+ // own tenantId, never a foreign one.
231
+ //
232
+ // dbOutsideTransaction is optional so every existing single-arg call site
233
+ // (jobs/job-runner.ts, tests) keeps compiling — those callers have no
234
+ // outside-tx source to hand in and never needed one. Only
235
+ // buildHandlerContext passes it, which is also the only place `.outsideTransaction`
236
+ // is reachable through `ctx.systemDb`.
237
+ //
238
+ // Ungated here (the handler's own systemScope() is the grant); a hook's own
239
+ // escapeHatch is layered on afterwards via withSystemDbUnsafeRawGrant. Public
240
+ // callers use createSystemDbView instead, whose unsafeRaw follows the source
241
+ // TenantDb's own escapeHatch gate.
242
+ export function createUncheckedSystemDb(
243
+ db: TenantDb,
244
+ dbOutsideTransaction?: TenantDb,
245
+ report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
246
+ ): UncheckedSystemDb {
247
+ return buildUncheckedSystemDb(db, dbOutsideTransaction, report);
248
+ }
249
+
250
+ // Public: must never grant more raw access than the source TenantDb — unlike
251
+ // createUncheckedSystemDb (framework-private; r.systemScope()/a job IS the
252
+ // declaration), this view's unsafeRaw defers entirely to db's own escapeHatch gate.
253
+ export function createSystemDbView(
254
+ db: TenantDb,
255
+ dbOutsideTransaction?: TenantDb,
256
+ report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
257
+ ): UncheckedSystemDb {
258
+ return buildUncheckedSystemDb(db, dbOutsideTransaction, report, { kind: "source-tenant-db" });
259
+ }
260
+
168
261
  // @cast-boundary tenant-db-row
169
262
  export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
170
263
  return rows as unknown as readonly T[];
171
264
  }
172
265
 
173
- function tableNameOf(table: Table | EntityTableMeta): string {
266
+ export function tableNameOf(table: Table | EntityTableMeta): string {
174
267
  const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
175
268
  if (typeof sym === "string") return sym;
176
269
  return asEntityTableMeta(table)?.tableName ?? "<unknown>";
@@ -205,8 +298,16 @@ export type TenantDbGrants = {
205
298
  // Set for a resolved member principal (ctx.queryAsMember): no raw DbRunner leaves
206
299
  // this TenantDb, so no handler can COMMIT/RELEASE SAVEPOINT out of the READ ONLY scope.
207
300
  readonly memberReadOnly?: boolean;
301
+ // Set only for an anonymous root without personalData: "public-intake" (write-origin.ts).
302
+ readonly personalDataGate?: PersonalDataGate;
208
303
  };
209
304
 
305
+ export type PersonalDataGate = (
306
+ tableName: string,
307
+ keys: readonly string[],
308
+ entity?: EntityDefinition,
309
+ ) => void;
310
+
210
311
  const unsafeRawRebinders = new WeakMap<
211
312
  TenantDb,
212
313
  (grant: EscapeHatchDeclaration | undefined) => TenantDb
@@ -347,6 +448,20 @@ export function createTenantDb(
347
448
  return grants?.globalWrites?.reason ?? "";
348
449
  }
349
450
 
451
+ function personalDataDenied(
452
+ table: Table | EntityTableMeta,
453
+ keys: readonly string[],
454
+ ): AccessDeniedError | undefined {
455
+ if (!grants?.personalDataGate) return undefined;
456
+ try {
457
+ grants.personalDataGate(tableNameOf(table), keys);
458
+ return undefined;
459
+ } catch (e) {
460
+ if (e instanceof AccessDeniedError) return e;
461
+ throw e;
462
+ }
463
+ }
464
+
350
465
  function foreignTenantOnGlobalWrite(
351
466
  table: Table | EntityTableMeta,
352
467
  tenantIdValue: unknown,
@@ -379,7 +494,9 @@ export function createTenantDb(
379
494
  values: Record<string, unknown>,
380
495
  ): Promise<T | undefined> {
381
496
  const denied =
382
- missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, values["tenantId"]);
497
+ missingEscapeHatch(table) ??
498
+ foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
499
+ personalDataDenied(table, Object.keys(values));
383
500
  if (denied) return Promise.reject(denied);
384
501
  report("global-write", globalWriteReason());
385
502
  return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
@@ -389,7 +506,9 @@ export function createTenantDb(
389
506
  where: WhereObject,
390
507
  ): Promise<readonly T[]> {
391
508
  const denied =
392
- missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, set["tenantId"]);
509
+ missingEscapeHatch(table) ??
510
+ foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
511
+ personalDataDenied(table, Object.keys(set));
393
512
  if (denied) return Promise.reject(denied);
394
513
  if (!where || Object.keys(where).length === 0) {
395
514
  return Promise.reject(
@@ -483,6 +602,8 @@ export function createTenantDb(
483
602
  );
484
603
  if (denied) return Promise.reject(denied);
485
604
  }
605
+ const personalDenied = personalDataDenied(table, Object.keys(values));
606
+ if (personalDenied) return Promise.reject(personalDenied);
486
607
  const data = insertValues(table, values);
487
608
  return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, data));
488
609
  },
@@ -499,6 +620,8 @@ export function createTenantDb(
499
620
  ),
500
621
  );
501
622
  }
623
+ const personalDenied = personalDataDenied(table, Object.keys(set));
624
+ if (personalDenied) return Promise.reject(personalDenied);
502
625
  const filter = writeWhere(table, where);
503
626
  return withDbSpan("update", table, async () => bunUpdateMany<T>(db, table, set, filter));
504
627
  },
@@ -525,6 +648,7 @@ export function createTenantDb(
525
648
  return createTenantDb(db, tenantId, "system", tracer, meter, signal, grants);
526
649
  });
527
650
  bindTenantDbRunner(tenantDb, db);
651
+ if (grants?.personalDataGate) personalDataGates.set(tenantDb, grants.personalDataGate);
528
652
  return tenantDb;
529
653
  }
530
654
 
@@ -15,7 +15,12 @@ import { defineFeature } from "../define-feature";
15
15
  const udr = () => defineFeature("user-data-rights", () => {});
16
16
  const fileProvider = (name: string) =>
17
17
  defineFeature(`file-provider-${name}`, (r) => {
18
- r.useExtension("fileProvider", name);
18
+ // build is never invoked here — the boot check only cares that a provider is mounted.
19
+ r.useExtension("fileProvider", name, {
20
+ build: async () => {
21
+ throw new Error("test stub — never invoked");
22
+ },
23
+ });
19
24
  });
20
25
 
21
26
  const S3_ENV = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
@@ -104,8 +104,8 @@ describe("S0 Integration — full surface stack", () => {
104
104
  access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
105
105
  });
106
106
 
107
- r.useExtension(EXT_USER_DATA, "user", {});
108
- r.useExtension(EXT_USER_DATA, "comment", {});
107
+ r.useExtension(EXT_USER_DATA, "user", { export: async () => null });
108
+ r.useExtension(EXT_USER_DATA, "comment", { export: async () => null });
109
109
 
110
110
  r.writeHandler({
111
111
  name: "user:rename",
@@ -358,7 +358,7 @@ describe("boot-validator", () => {
358
358
  const self = defineFeature("tier-stub", (r) => {
359
359
  r.extendsRegistrar("tenantTierResolver", { onRegister: () => {} });
360
360
  r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
361
- r.useExtension("tenantTierResolver", "dummy");
361
+ r.useExtension("tenantTierResolver", "dummy", { build: async () => () => new Set() });
362
362
  });
363
363
  expect(() => validateBoot([self])).not.toThrow();
364
364
  });
@@ -15,7 +15,7 @@ function tierResolverFeature(name: string) {
15
15
  return defineFeature(name, (r) => {
16
16
  r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
17
17
  r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
18
- r.useExtension(TENANT_TIER_RESOLVER_EXT, "dummy");
18
+ r.useExtension(TENANT_TIER_RESOLVER_EXT, "dummy", { build: async () => () => new Set() });
19
19
  });
20
20
  }
21
21
 
@@ -1,68 +1,20 @@
1
+ import {
2
+ accessAllowsAnonymous,
3
+ declaredPersonalData,
4
+ personalFieldNames,
5
+ } from "../personal-data-fields";
1
6
  import { ANONYMOUS_ROLE } from "../system-user";
2
7
  import type {
3
8
  AccessRule,
4
9
  FeatureDefinition,
5
- OwnershipMap,
6
- OwnershipRule,
7
10
  QueryHandlerDef,
8
11
  StreamHandlerDef,
9
12
  WriteHandlerDef,
10
13
  } from "../types";
11
- import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
12
14
  import { collectZodObjectKeys } from "./zod-shape";
13
15
 
14
16
  type HandlerKind = "write" | "query" | "stream";
15
17
 
16
- // Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
17
- // minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
18
- // sense the openToAll personal-data check is guarding against.
19
- function isPersonalDataField(field: unknown): boolean {
20
- const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
21
- return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
22
- }
23
-
24
- function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
25
- if (rule === "all" || rule.kind !== "from") return false;
26
- return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
27
- }
28
-
29
- // The executor checks access.write against every created/updated row; one "all" role
30
- // or an empty map (= public) lets a caller write rows owned by someone else.
31
- function writeMapBindsRowsToCaller(
32
- writeMap: OwnershipMap | undefined,
33
- ownerColumn: string,
34
- ): boolean {
35
- const rules = Object.values(writeMap ?? {});
36
- return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
37
- }
38
-
39
- const ROW_ID_COLUMN = "id";
40
-
41
- // A self/record-owned field's subject is the row itself, so only from("user:id", "id")
42
- // makes that row the caller — on any other entity "self" names a third party.
43
- function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
44
- if (annot.userOwned) return annot.userOwned.ownerField;
45
- if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
46
- return undefined;
47
- }
48
-
49
- function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
50
- const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
51
- return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
52
- }
53
-
54
- function personalFieldNames(
55
- entity: EntityDefinition,
56
- honorOwnerBinding: boolean,
57
- ): ReadonlySet<string> {
58
- const names = new Set<string>();
59
- for (const [fieldName, field] of Object.entries(entity.fields)) {
60
- const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
61
- if (isPersonalDataField(field) && !exempt) names.add(fieldName);
62
- }
63
- return names;
64
- }
65
-
66
18
  // escapeHatch and r.systemScope() can write around the entity's write map
67
19
  // (db.global(), systemDb, SYSTEM identity), so the map no longer vouches for the row.
68
20
  function canWriteAroundExecutor(feature: FeatureDefinition, handler: WriteHandlerDef): boolean {
@@ -104,19 +56,6 @@ function hasOpenToAll(access: AccessRule): boolean {
104
56
  return "openToAll" in access;
105
57
  }
106
58
 
107
- // Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
108
- function declaredPersonalData(access: AccessRule): unknown {
109
- if (!("openToAll" in access)) return access.personalData;
110
- const openToAll: unknown = access.openToAll;
111
- if (typeof openToAll !== "object" || openToAll === null) return undefined;
112
- return "personalData" in openToAll ? openToAll.personalData : undefined;
113
- }
114
-
115
- function accessAllowsAnonymous(access: AccessRule): boolean {
116
- if ("openToAll" in access) return false;
117
- return Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE);
118
- }
119
-
120
59
  function declaresTenantMembersPersonalData(access: AccessRule): boolean {
121
60
  return declaredPersonalData(access) === "tenant-members";
122
61
  }
@@ -1,3 +1,12 @@
1
+ import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
2
+ import type { OverlayResolverPlugin } from "../derivatives/derivatives-context";
3
+ import type { FileProviderPlugin } from "../files/provider-resolver";
4
+ import type { PrincipalStatusPlugin, TenantLifecycleStatusPlugin } from "./active-membership";
5
+ import type { TenantDataExtensionHooks } from "./extensions/tenant-data";
6
+ import type { TenantResourceExtensionHooks } from "./extensions/tenant-resource";
7
+ import type { UserDataExtensionOptions } from "./extensions/user-data";
8
+ import { TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin } from "./tier-resolver-extension";
9
+
1
10
  // Standardisierte Extension-Namen fuer Datenschutz-Hook-Achsen.
2
11
  //
3
12
  // Features registrieren Extensions via:
@@ -55,14 +64,12 @@ export const EXT_USER_DATA_ORDER = {
55
64
  export const EXT_TENANT_DATA = "tenantData" as const;
56
65
 
57
66
  /**
58
- * `storageProvider` — File-Storage-Plugin-Hooks (Crypto-Shredding fuer Files).
67
+ * `storageProvider` — file-storage-plugin tenant-destroy hook.
59
68
  *
60
- * Erwartete Hook-Methoden:
61
- * - `destroyTenant(tenantId, ctx) => Promise<void>`
62
- * - `destroySubject(subject, ctx) => Promise<{ deleted: number }>`
69
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
63
70
  *
64
- * Registriert von: `storage-encryption` (Sprint 4).
65
- * Genutzt von: pluggable Provider (Local, MinIO, S3, R2).
71
+ * Registered by: `files-tenant-data`.
72
+ * Consumed by: pluggable providers (Local, MinIO, S3, R2).
66
73
  */
67
74
  export const EXT_STORAGE_PROVIDER = "storageProvider" as const;
68
75
 
@@ -148,37 +155,28 @@ export const EXT_DERIVATIVE_PUBLIC_PREDICATE = "derivativePublicPredicate" as co
148
155
  export const EXT_DERIVATIVE_OVERLAY_RESOLVER = "derivativeOverlayResolver" as const;
149
156
 
150
157
  /**
151
- * `searchAdapter` — Search-Adapter-Forget-Hooks (Meilisearch-Index-Cleanup
152
- * bei User-Forget oder Tenant-Destroy).
158
+ * `searchAdapter` — search-index tenant-destroy hook (Meilisearch index
159
+ * cleanup on tenant-destroy).
153
160
  *
154
- * Erwartete Hook-Methoden:
155
- * - `destroyTenant(tenantId, ctx) => Promise<void>`
156
- * - `eraseSubject(subject, ctx) => Promise<void>`
161
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
157
162
  *
158
- * Registriert von: `tenant-lifecycle` (Sprint 5).
159
- * Genutzt von: Meilisearch- und andere Search-Adapter-Implementierungen.
163
+ * Consumed by: Meilisearch and other search-adapter implementations.
160
164
  */
161
165
  export const EXT_SEARCH_ADAPTER = "searchAdapter" as const;
162
166
 
163
167
  /**
164
- * `externalResource` — External-Service-Tenant-Cleanup
165
- * (Webhook-Subscriptions, Brevo-Empfaenger-Listen, Stripe-Customer-Account).
168
+ * `externalResource` — external-service tenant-destroy hook (webhook
169
+ * subscriptions, Brevo recipient lists, provider customer accounts).
166
170
  *
167
- * Erwartete Hook-Methoden:
168
- * - `destroyTenant(tenantId, ctx) => Promise<void>`
169
- *
170
- * Registriert von: `tenant-lifecycle` (Sprint 5).
171
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
171
172
  */
172
173
  export const EXT_EXTERNAL_RESOURCE = "externalResource" as const;
173
174
 
174
175
  /**
175
- * `infraResource` — Pulumi-managed Resources pro Tenant
176
- * (Custom-Domain, Cert-Manager-Issuer, dedicated Pod/Volume).
176
+ * `infraResource` — Pulumi-managed per-tenant resource tenant-destroy hook
177
+ * (custom domain, cert-manager issuer, dedicated pod/volume).
177
178
  *
178
- * Erwartete Hook-Methoden:
179
- * - `destroyTenant(tenantId, ctx) => Promise<void>`
180
- *
181
- * Registriert von: `tenant-lifecycle` (Sprint 5).
179
+ * Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
182
180
  */
183
181
  export const EXT_INFRA_RESOURCE = "infraResource" as const;
184
182
 
@@ -206,7 +204,39 @@ export const TENANT_MEMBERSHIPS_QUERY = "tenant:query:memberships" as const;
206
204
  export type KumikoExtensionName =
207
205
  | typeof EXT_USER_DATA
208
206
  | typeof EXT_TENANT_DATA
207
+ | typeof EXT_STORAGE_PROVIDER
208
+ | typeof EXT_SEARCH_ADAPTER
209
+ | typeof EXT_EXTERNAL_RESOURCE
210
+ | typeof EXT_INFRA_RESOURCE
211
+ | typeof EXT_FILE_PROVIDER
212
+ | typeof EXT_DERIVATIVE_RENDERER
213
+ | typeof EXT_DERIVATIVE_PUBLIC_PREDICATE
214
+ | typeof EXT_DERIVATIVE_OVERLAY_RESOLVER
215
+ | typeof EXT_PRINCIPAL_STATUS
216
+ | typeof EXT_TENANT_LIFECYCLE_STATUS;
217
+
218
+ /** The four `destroyTenant(tenantId, ctx)`-only resource-cleanup extension points. */
219
+ export type TenantResourceExtensionName =
209
220
  | typeof EXT_STORAGE_PROVIDER
210
221
  | typeof EXT_SEARCH_ADAPTER
211
222
  | typeof EXT_EXTERNAL_RESOURCE
212
223
  | typeof EXT_INFRA_RESOURCE;
224
+
225
+ // r.useExtension options-shape for framework-owned extension points; app/bundled-features-owned
226
+ // points augment co-located with their owner, since the framework never imports upward.
227
+ declare module "@cosmicdrift/kumiko-framework/engine" {
228
+ interface KumikoExtensionOptionsMap {
229
+ [EXT_TENANT_DATA]: TenantDataExtensionHooks;
230
+ [EXT_USER_DATA]: UserDataExtensionOptions;
231
+ [EXT_FILE_PROVIDER]: FileProviderPlugin;
232
+ [EXT_DERIVATIVE_RENDERER]: DerivativeRendererPlugin;
233
+ [EXT_DERIVATIVE_OVERLAY_RESOLVER]: OverlayResolverPlugin;
234
+ [EXT_PRINCIPAL_STATUS]: PrincipalStatusPlugin;
235
+ [EXT_TENANT_LIFECYCLE_STATUS]: TenantLifecycleStatusPlugin;
236
+ [EXT_STORAGE_PROVIDER]: TenantResourceExtensionHooks;
237
+ [EXT_SEARCH_ADAPTER]: TenantResourceExtensionHooks;
238
+ [EXT_EXTERNAL_RESOURCE]: TenantResourceExtensionHooks;
239
+ [EXT_INFRA_RESOURCE]: TenantResourceExtensionHooks;
240
+ [TENANT_TIER_RESOLVER_EXT]: TierResolverPlugin;
241
+ }
242
+ }