@cosmicdrift/kumiko-framework 0.287.0 → 0.289.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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/changes.json +57 -0
  3. package/src/compliance/__tests__/sub-processors.test.ts +10 -0
  4. package/src/compliance/sub-processors.ts +14 -1
  5. package/src/crypto/__tests__/kek-source.test.ts +298 -0
  6. package/src/crypto/index.ts +3 -0
  7. package/src/crypto/kek-source.ts +189 -0
  8. package/src/crypto/kms-wiring.ts +20 -0
  9. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +191 -0
  10. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
  11. package/src/db/event-store-executor-context.ts +79 -0
  12. package/src/db/event-store-executor-write.ts +42 -5
  13. package/src/db/index.ts +1 -0
  14. package/src/db/tenant-db.ts +8 -1
  15. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
  16. package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
  17. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
  18. package/src/engine/boot-validator/entity-handler.ts +10 -4
  19. package/src/engine/extensions/storage-provider.ts +17 -2
  20. package/src/engine/extensions/tenant-data.ts +9 -0
  21. package/src/engine/factories.ts +2 -0
  22. package/src/engine/screen-helpers.ts +17 -0
  23. package/src/errors/classes.ts +24 -0
  24. package/src/errors/index.ts +3 -0
  25. package/src/errors/member-resolution.ts +12 -0
  26. package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
  27. package/src/event-store/provenance-append.ts +9 -1
  28. package/src/files/__tests__/file-handle.test.ts +28 -1
  29. package/src/files/file-handle.ts +26 -7
  30. package/src/files/index.ts +1 -1
  31. package/src/i18n/required-surface-keys.ts +24 -12
  32. package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
  33. package/src/pipeline/dispatch-shared.ts +14 -13
  34. package/src/pipeline/dispatch-stream.ts +8 -3
  35. package/src/pipeline/dispatch-write.ts +3 -2
  36. package/src/pipeline/event-dispatcher.ts +18 -5
  37. package/src/pipeline/member-read-only-transaction.ts +2 -6
  38. package/src/stack/table-helpers.ts +6 -0
@@ -1,8 +1,10 @@
1
1
  import { isExplicitDotFormKey } from "../engine/i18n-key";
2
+ import type { FieldsOrGroupsSection } from "../engine/screen-helpers";
2
3
  import {
3
4
  isExtensionEditSection,
4
5
  isWriteFormEditSection,
5
6
  normalizeListColumn,
7
+ sectionFieldSpecs,
6
8
  } from "../engine/screen-helpers";
7
9
  import type {
8
10
  ActionFormScreenDefinition,
@@ -82,6 +84,16 @@ function editFieldName(f: string | { readonly field: string }): string {
82
84
  return typeof f === "string" ? f : f.field;
83
85
  }
84
86
 
87
+ // Group titles are translated exactly like the section title (computeEditViewModel).
88
+ function pushSectionTitles(
89
+ out: Set<string>,
90
+ section: { readonly title?: string } & FieldsOrGroupsSection,
91
+ treatAsKey = false,
92
+ ): void {
93
+ pushKey(out, section.title, treatAsKey);
94
+ for (const group of section.groups ?? []) pushKey(out, group.title, treatAsKey);
95
+ }
96
+
85
97
  function pushRowActionKeys(out: Set<string>, action: RowAction): void {
86
98
  pushKey(out, action.label);
87
99
  if (action.kind === "writeHandler" || action.kind === undefined) {
@@ -182,8 +194,8 @@ export function requiredKeysFromScreen(
182
194
  continue;
183
195
  }
184
196
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
185
- pushKey(out, section.title);
186
- for (const f of section.fields) {
197
+ pushSectionTitles(out, section);
198
+ for (const f of sectionFieldSpecs(section)) {
187
199
  const fieldName = editFieldName(f);
188
200
  const override = edit.fieldLabels?.[fieldName];
189
201
  if (override !== undefined) pushKey(out, override);
@@ -206,8 +218,8 @@ export function requiredKeysFromScreen(
206
218
  continue;
207
219
  }
208
220
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
209
- pushKey(out, section.title);
210
- for (const f of section.fields) {
221
+ pushSectionTitles(out, section);
222
+ for (const f of sectionFieldSpecs(section)) {
211
223
  const fieldName = editFieldName(f);
212
224
  const override = form.fieldLabels?.[fieldName];
213
225
  if (override !== undefined) pushKey(out, override);
@@ -230,8 +242,8 @@ export function requiredKeysFromScreen(
230
242
  continue;
231
243
  }
232
244
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
233
- pushKey(out, section.title);
234
- for (const f of section.fields) {
245
+ pushSectionTitles(out, section);
246
+ for (const f of sectionFieldSpecs(section)) {
235
247
  const fieldName = editFieldName(f);
236
248
  out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
237
249
  }
@@ -254,8 +266,8 @@ export function requiredKeysFromScreen(
254
266
  continue;
255
267
  }
256
268
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
257
- pushKey(out, section.title);
258
- for (const f of section.fields) {
269
+ pushSectionTitles(out, section);
270
+ for (const f of sectionFieldSpecs(section)) {
259
271
  const fieldName = editFieldName(f);
260
272
  out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
261
273
  }
@@ -277,8 +289,8 @@ export function requiredKeysFromScreen(
277
289
  continue;
278
290
  }
279
291
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
280
- pushKey(out, section.title, treatDotFormAsKey);
281
- for (const f of section.fields) {
292
+ pushSectionTitles(out, section, treatDotFormAsKey);
293
+ for (const f of sectionFieldSpecs(section)) {
282
294
  const fieldName = editFieldName(f);
283
295
  const override = config.fieldLabels?.[fieldName];
284
296
  if (override !== undefined) pushKey(out, override, treatDotFormAsKey);
@@ -322,8 +334,8 @@ export function requiredKeysFromScreen(
322
334
  }
323
335
  continue;
324
336
  }
325
- pushKey(out, section.title);
326
- for (const f of section.fields) {
337
+ pushSectionTitles(out, section);
338
+ for (const f of sectionFieldSpecs(section)) {
327
339
  const fieldName = editFieldName(f);
328
340
  const override = detail.fieldLabels?.[fieldName];
329
341
  if (override !== undefined) pushKey(out, override);
@@ -0,0 +1,79 @@
1
+ // createUncheckedSystemDb's grantedUnsafeRawRunner (db/tenant-db.ts) hands out a
2
+ // raw runner without a grant check. Under a resolved member principal that door
3
+ // is closed only because applyMemberResolutionReadOnly drops systemDb and
4
+ // dbOutsideTransaction — this pins that, plus the write surfaces it neutralizes.
5
+ import { describe, expect, test } from "bun:test";
6
+ import type { HandlerContext } from "../../engine/types";
7
+ import { AccessDeniedError, FrameworkReasons } from "../../errors";
8
+ import { applyMemberResolutionReadOnly } from "../dispatch-shared";
9
+
10
+ const DROPPED_SURFACES = ["systemDb", "dbOutsideTransaction", "runPreSave", "files", "derivatives"];
11
+
12
+ const DENIED_CALLS = [
13
+ "write",
14
+ "writeAs",
15
+ "queryAs",
16
+ "appendEvent",
17
+ "unsafeAppendEvent",
18
+ "tryAppendEvent",
19
+ "fetchForWriting",
20
+ "archiveStream",
21
+ "restoreStream",
22
+ "snapshotAggregate",
23
+ "queryAsMember",
24
+ "resolveActiveMembership",
25
+ "notify",
26
+ ];
27
+
28
+ function readOnlyContext(): Record<string, unknown> {
29
+ const wired = Object.fromEntries(
30
+ [...DROPPED_SURFACES, ...DENIED_CALLS, "scheduleAfterCommit"].map((key) => [
31
+ key,
32
+ () => "escalated",
33
+ ]),
34
+ );
35
+ // @cast-boundary test stub — applyMemberResolutionReadOnly only spreads and overwrites.
36
+ const ctx = { ...wired, jobRunner: { handleEvent: () => "ran" } } as unknown as HandlerContext;
37
+ return applyMemberResolutionReadOnly(ctx) as unknown as Record<string, unknown>;
38
+ }
39
+
40
+ function surface(readOnly: Record<string, unknown>, key: string): unknown {
41
+ return readOnly[key];
42
+ }
43
+
44
+ describe("applyMemberResolutionReadOnly", () => {
45
+ test("drops every surface that could reach an unchecked system-scope db", () => {
46
+ const readOnly = readOnlyContext();
47
+
48
+ for (const key of DROPPED_SURFACES) {
49
+ expect(surface(readOnly, key)).toBeUndefined();
50
+ }
51
+ });
52
+
53
+ test.each(DENIED_CALLS)("%s rejects with the member-resolution reason", async (call) => {
54
+ // @cast-boundary test stub — every denied surface is a call returning a promise.
55
+ const denied = surface(readOnlyContext(), call) as () => Promise<unknown>;
56
+
57
+ const error = await denied().then(
58
+ () => undefined,
59
+ (e: unknown) => e,
60
+ );
61
+
62
+ expect(error).toBeInstanceOf(AccessDeniedError);
63
+ // @cast-boundary narrowed by the assertion above.
64
+ expect((error as AccessDeniedError).details).toMatchObject({
65
+ reason: FrameworkReasons.memberResolutionReadOnly,
66
+ });
67
+ });
68
+
69
+ test("scheduleAfterCommit and the jobRunner proxy throw synchronously", () => {
70
+ const readOnly = readOnlyContext();
71
+ // @cast-boundary test stub — sync surfaces, not promise-returning.
72
+ const scheduleAfterCommit = surface(readOnly, "scheduleAfterCommit") as () => void;
73
+ // @cast-boundary test stub — denyingJobRunnerProxy throws on any property read.
74
+ const jobRunner = surface(readOnly, "jobRunner") as { handleEvent: unknown };
75
+
76
+ expect(() => scheduleAfterCommit()).toThrow(AccessDeniedError);
77
+ expect(() => jobRunner.handleEvent).toThrow(AccessDeniedError);
78
+ });
79
+ });
@@ -31,10 +31,9 @@ import type {
31
31
  import { isRateLimitDisabled } from "../engine/types";
32
32
  import type { TenantId } from "../engine/types/identifiers";
33
33
  import {
34
- AccessDeniedError,
35
34
  FeatureDisabledError,
36
- FrameworkReasons,
37
35
  InternalError,
36
+ memberResolutionReadOnlyDenied,
38
37
  VersionConflictError,
39
38
  type WriteErrorInfo,
40
39
  } from "../errors";
@@ -176,7 +175,7 @@ async function appendDomainEvent(
176
175
  callerFeature: string | undefined,
177
176
  ): Promise<void> {
178
177
  // Sink behind every append surface, so a resolved member stays read-only even via fetchForWriting handles.
179
- if (user.origin === "member-resolution") throw memberResolutionReadOnlyDenied();
178
+ if (isMemberResolutionPrincipal(user)) throw memberResolutionReadOnlyDenied();
180
179
  const { registry } = ctx;
181
180
  const dbSource = resolveDbSource(ctx, tx);
182
181
  if (!dbSource) {
@@ -222,12 +221,8 @@ function createSystemScopedDbGuard(
222
221
  });
223
222
  }
224
223
 
225
- export function memberResolutionReadOnlyDenied(cause?: unknown): AccessDeniedError {
226
- return new AccessDeniedError({
227
- message: "a resolved member principal (ctx.queryAsMember) cannot write — read-only",
228
- details: { reason: FrameworkReasons.memberResolutionReadOnly },
229
- ...(cause instanceof Error && { cause }),
230
- });
224
+ export function isMemberResolutionPrincipal(user: SessionUser): boolean {
225
+ return user.origin === "member-resolution";
231
226
  }
232
227
 
233
228
  async function denyMemberResolutionWrite(): Promise<never> {
@@ -246,10 +241,11 @@ function denyingJobRunnerProxy(): JobRunnerRef {
246
241
 
247
242
  // Every write/side-effect surface throws, so "no writeAsMember" holds even
248
243
  // if a handler tries ctx.write directly on a resolved member principal.
249
- function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
244
+ export function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
250
245
  return {
251
246
  ...handlerContext,
252
- // `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY transaction.
247
+ // `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
248
+ // transaction, and its `memberReadOnly` grant denies every raw-runner handout.
253
249
  dbOutsideTransaction: undefined,
254
250
  write: denyMemberResolutionWrite,
255
251
  writeAs: denyMemberResolutionWrite,
@@ -319,7 +315,12 @@ export async function buildHandlerContext(
319
315
  context.tracer,
320
316
  context.meter,
321
317
  signal,
322
- { globalWrites: writeEscapeHatch, unsafeRaw: handlerEscapeHatch, report: reportEscapeHatch },
318
+ {
319
+ globalWrites: writeEscapeHatch,
320
+ unsafeRaw: handlerEscapeHatch,
321
+ report: reportEscapeHatch,
322
+ memberReadOnly: isMemberResolutionPrincipal(user),
323
+ },
323
324
  );
324
325
  // Propagate the request's AbortSignal so every TenantDb query throws when
325
326
  // the client has disconnected — handlers with many sequential queries skip
@@ -865,7 +866,7 @@ export async function buildHandlerContext(
865
866
  } as HandlerContext; // @cast-boundary engine-bridge
866
867
 
867
868
  // A resolved member principal is read-only by construction.
868
- return user.origin === "member-resolution"
869
+ return isMemberResolutionPrincipal(user)
869
870
  ? applyMemberResolutionReadOnly(handlerContext)
870
871
  : handlerContext;
871
872
  }
@@ -1,13 +1,18 @@
1
1
  import { hasAccess } from "../engine/access";
2
2
  import type { SessionUser } from "../engine/types";
3
- import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
3
+ import {
4
+ AccessDeniedError,
5
+ memberResolutionReadOnlyDenied,
6
+ NotFoundError,
7
+ validationErrorFromZod,
8
+ } from "../errors";
4
9
  import { assertNoSecretLeak } from "../secrets";
5
10
  import {
6
11
  buildHandlerContext,
7
12
  type DispatchContext,
8
13
  enforceRateLimit,
9
14
  ensureFeatureEnabled,
10
- memberResolutionReadOnlyDenied,
15
+ isMemberResolutionPrincipal,
11
16
  runStreamInstrumented,
12
17
  } from "./dispatch-shared";
13
18
 
@@ -38,7 +43,7 @@ async function* executeStreamInner(
38
43
 
39
44
  // A resolved member principal (ctx.queryAsMember) is read-only — streams
40
45
  // are excluded the same way executeWriteInner excludes writes.
41
- if (user.origin === "member-resolution") {
46
+ if (isMemberResolutionPrincipal(user)) {
42
47
  throw memberResolutionReadOnlyDenied();
43
48
  }
44
49
 
@@ -15,6 +15,7 @@ import {
15
15
  FrameworkReasons,
16
16
  InternalError,
17
17
  isKumikoError,
18
+ memberResolutionReadOnlyDenied,
18
19
  NotFoundError,
19
20
  ValidationError,
20
21
  validationErrorFromZod,
@@ -28,7 +29,7 @@ import {
28
29
  CONFIG_WRITE_SET_TYPE,
29
30
  checkFeatureEnabled,
30
31
  enforceRateLimit,
31
- memberResolutionReadOnlyDenied,
32
+ isMemberResolutionPrincipal,
32
33
  resolveDbSource,
33
34
  runHandlerInstrumented,
34
35
  TENANT_TIMEZONE_CONFIG_KEY,
@@ -371,7 +372,7 @@ async function executeWriteInner(
371
372
 
372
373
  // Defense in depth — covers runBatch and a spread-copied user that kept
373
374
  // `origin`, beyond buildHandlerContext's own ctx.write replacement.
374
- if (user.origin === "member-resolution") {
375
+ if (isMemberResolutionPrincipal(user)) {
375
376
  return writeFailure(memberResolutionReadOnlyDenied());
376
377
  }
377
378
 
@@ -127,6 +127,13 @@ export type EventDispatcher = {
127
127
  // a full stop/start cycle. Production never needs this — start() runs
128
128
  // it once on boot and the rows survive dispatcher lifetime.
129
129
  ensureRegistered(): Promise<void>;
130
+ // Waits out a pass already in flight, without touching the timer or the
131
+ // LISTEN subscription — unlike stop(), the dispatcher keeps running
132
+ // afterwards. Test-teardown surface: a beforeEach/afterEach that resets
133
+ // the events table (TRUNCATE) must not race a pass this same dispatcher
134
+ // started (timer tick or NOTIFY wake-up) that is still writing into the
135
+ // tables the reset is about to wipe. No-op when nothing is in flight.
136
+ drain(): Promise<void>;
130
137
  // Read-only view of the consumers this dispatcher is wired with. Exists
131
138
  // for lane-filter assertions (Welle 2.6.b split-deploy tests) and for
132
139
  // the boot-validator (Welle 2.6.c coverage check: every registered MSP
@@ -253,6 +260,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
253
260
  // pattern so behaviour under races stays predictable.
254
261
  let passInFlight: Promise<DispatcherPassResult> | null = null;
255
262
 
263
+ async function drainPassInFlight(): Promise<void> {
264
+ if (passInFlight) {
265
+ await passInFlight.catch(() => {
266
+ // skip: errors already recorded per-consumer inside the pass
267
+ });
268
+ }
269
+ }
270
+
256
271
  async function runOnce(): Promise<DispatcherPassResult> {
257
272
  if (!preRegistered) {
258
273
  throw new Error(
@@ -529,11 +544,7 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
529
544
  }
530
545
 
531
546
  // Drain any in-flight pass so shutdown observes consistent state.
532
- if (passInFlight) {
533
- await passInFlight.catch(() => {
534
- // skip: errors already recorded per-consumer inside the pass
535
- });
536
- }
547
+ await drainPassInFlight();
537
548
  // preRegistered stays true — the rows survive stop(). runOnce()
538
549
  // after a stop() still works (tests stop the timer and then drain
539
550
  // deterministically).
@@ -544,6 +555,8 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
544
555
  preRegistered = true;
545
556
  },
546
557
 
558
+ drain: drainPassInFlight,
559
+
547
560
  runOnce,
548
561
  };
549
562
  }
@@ -1,12 +1,8 @@
1
1
  import type { DbTx } from "../db/connection";
2
2
  import { extractPgError } from "../db/pg-error";
3
3
  import { asRawClient, runInSavepoint, transaction } from "../db/query";
4
- import { AccessDeniedError, InternalError } from "../errors";
5
- import {
6
- type DispatchContext,
7
- memberResolutionReadOnlyDenied,
8
- resolveDbSource,
9
- } from "./dispatch-shared";
4
+ import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
5
+ import { type DispatchContext, resolveDbSource } from "./dispatch-shared";
10
6
 
11
7
  const PG_READ_ONLY_SQLSTATE = "25006";
12
8
 
@@ -146,6 +146,12 @@ export async function resetEventStore(
146
146
  stack: { db: unknown; eventDispatcher?: EventDispatcher },
147
147
  extraTables: readonly (unknown | string)[] = [],
148
148
  ): Promise<void> {
149
+ // A pass the dispatcher's own timer/LISTEN already started (from a
150
+ // preceding test) may still be writing into the tables truncated below —
151
+ // drain it first so the TRUNCATE never races a write it didn't cause.
152
+ if (stack.eventDispatcher) {
153
+ await stack.eventDispatcher.drain();
154
+ }
149
155
  const frameworkTables = [
150
156
  "kumiko_events",
151
157
  "kumiko_event_consumers",