@cosmicdrift/kumiko-framework 0.173.0 → 0.174.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.
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import type { SchemaTable } from "../../db/dialect";
4
4
  import { table, text } from "../../db/dialect";
@@ -590,6 +590,31 @@ describe("boot-validator", () => {
590
590
  expect(() => validateBoot(features)).not.toThrow();
591
591
  });
592
592
 
593
+ test("warns when a role is used by exactly one handler, reached through the real validateBoot wiring", () => {
594
+ const warnSpy = spyOn(console, "warn");
595
+ try {
596
+ const features = [
597
+ defineFeature("a", (r) => {
598
+ r.queryHandler("list", z.object({}), async () => [], {
599
+ access: { roles: ["OnlyHereRole"] },
600
+ });
601
+ }),
602
+ ];
603
+ validateBoot(features);
604
+ // Not toHaveBeenCalledTimes(1): this file's tests share the process-global
605
+ // console.warn and run with the default concurrency (bunfig.toml) — other
606
+ // concurrently-running tests' own "role used by one handler" warnings can
607
+ // land on this spy too. Assert the wiring fired at least once instead.
608
+ expect(
609
+ warnSpy.mock.calls.some((call) =>
610
+ (call[0] as string | undefined)?.includes("OnlyHereRole"),
611
+ ),
612
+ ).toBe(true);
613
+ } finally {
614
+ warnSpy.mockRestore();
615
+ }
616
+ });
617
+
593
618
  test("throws when a stream handler has no access rule", () => {
594
619
  const features = [
595
620
  defineFeature("a", (r) => {
@@ -2953,6 +2978,67 @@ describe("boot-validator — config key backing × scope", () => {
2953
2978
  );
2954
2979
  });
2955
2980
 
2981
+ // framework#1708: the same params-vs-update-mode check as entityList,
2982
+ // extended to projectionList rowActions (#1680 covered entityList only).
2983
+ test("projectionList rowAction: navigate with params to an entityEdit target + explicit entityId → Throw", () => {
2984
+ const feature = defineFeature("shop", (r) => {
2985
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2986
+ r.screen({
2987
+ id: "product-projection",
2988
+ type: "projectionList",
2989
+ query: "shop:query:products",
2990
+ columns: ["name"],
2991
+ rowActions: [
2992
+ {
2993
+ kind: "navigate",
2994
+ id: "open",
2995
+ label: "actions.open",
2996
+ screen: "product-edit",
2997
+ entityId: "name",
2998
+ params: { pick: ["name"] },
2999
+ },
3000
+ ],
3001
+ });
3002
+ r.screen({
3003
+ id: "product-edit",
3004
+ type: "entityEdit",
3005
+ entity: "product",
3006
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
3007
+ });
3008
+ });
3009
+ expect(() => validateBoot([feature])).toThrow(
3010
+ /\(projectionList\) rowAction "open".*resolves to UPDATE mode \(explicit entityId "name"\)/,
3011
+ );
3012
+ });
3013
+
3014
+ test("projectionList rowAction: navigate with params to an entityEdit-create target (no entityId) → no throw", () => {
3015
+ const feature = defineFeature("shop", (r) => {
3016
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3017
+ r.screen({
3018
+ id: "product-projection",
3019
+ type: "projectionList",
3020
+ query: "shop:query:products",
3021
+ columns: ["name"],
3022
+ rowActions: [
3023
+ {
3024
+ kind: "navigate",
3025
+ id: "open",
3026
+ label: "actions.open",
3027
+ screen: "product-edit",
3028
+ params: { pick: ["name"] },
3029
+ },
3030
+ ],
3031
+ });
3032
+ r.screen({
3033
+ id: "product-edit",
3034
+ type: "entityEdit",
3035
+ entity: "product",
3036
+ layout: { sections: [{ columns: 1, fields: ["name"] }] },
3037
+ });
3038
+ });
3039
+ expect(() => validateBoot([feature])).not.toThrow();
3040
+ });
3041
+
2956
3042
  test("navigate with params targeting a cross-entity entityEdit screen (no explicit entityId) → no throw", () => {
2957
3043
  // The issue's actual use case: a unit row navigates to "create contract"
2958
3044
  // pre-filled with unitId. Different entity + no explicit entityId → the
@@ -8,6 +8,7 @@ import { asRawClient } from "../../db/query";
8
8
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
9
9
  import { defineFeature } from "../define-feature";
10
10
  import { createEntity, createTextField } from "../factories";
11
+ import { from } from "../ownership";
11
12
 
12
13
  const contactEntity = createEntity({
13
14
  table: "presave_wiring_contacts",
@@ -15,6 +16,12 @@ const contactEntity = createEntity({
15
16
  firstName: createTextField({ required: true }),
16
17
  lastName: createTextField({ required: true }),
17
18
  displayName: createTextField(),
19
+ // authorId is never set by the client — only deriveAuthorId (a preSave
20
+ // hook) writes it. secretNote's ownership rule checks authorId, so
21
+ // create only succeeds if the hook ran BEFORE the field-ownership check
22
+ // (kumiko-framework#1672 — see also event-store-executor-write.ts).
23
+ authorId: createTextField(),
24
+ secretNote: createTextField({ access: { write: { User: from("user:id", "authorId") } } }),
18
25
  },
19
26
  });
20
27
 
@@ -30,6 +37,16 @@ const deriveDisplayName: import("../types").PreSaveHookFn = async (changes, ctx)
30
37
  return { ...changes, displayName: `${first ?? ""} ${last ?? ""}`.trim() };
31
38
  };
32
39
 
40
+ const deriveAuthorId: import("../types").PreSaveHookFn = async (changes) => ({
41
+ ...changes,
42
+ authorId: TestUsers.user.id,
43
+ });
44
+
45
+ const THROWING_HOOK_MESSAGE = "business rule violated";
46
+ const throwOnPreSave: import("../types").PreSaveHookFn = async () => {
47
+ throw new Error(THROWING_HOOK_MESSAGE);
48
+ };
49
+
33
50
  const contactFeature = defineFeature("presave-wiring", (r) => {
34
51
  r.crud("contact", contactEntity, {
35
52
  write: { access: { roles: ["User"] } },
@@ -41,17 +58,34 @@ const contactFeature = defineFeature("presave-wiring", (r) => {
41
58
  // handlers, so both need their own target.
42
59
  r.hook("preSave", "contact:create", deriveDisplayName);
43
60
  r.hook("preSave", "contact:update", deriveDisplayName);
61
+ r.hook("preSave", "contact:create", deriveAuthorId);
62
+ r.hook("preSave", "contact:update", deriveAuthorId);
63
+ });
64
+
65
+ const throwingEntity = createEntity({
66
+ table: "presave_wiring_throwing",
67
+ fields: { name: createTextField({ required: true }) },
68
+ });
69
+
70
+ const throwingFeature = defineFeature("presave-wiring-throw", (r) => {
71
+ r.crud("thing", throwingEntity, {
72
+ write: { access: { roles: ["User"] } },
73
+ read: { access: { openToAll: true } },
74
+ });
75
+ r.hook("preSave", "thing:create", throwOnPreSave);
44
76
  });
45
77
 
46
78
  const CREATE = "presave-wiring:write:contact:create";
47
79
  const UPDATE = "presave-wiring:write:contact:update";
80
+ const THROWING_CREATE = "presave-wiring-throw:write:thing:create";
48
81
 
49
82
  describe("preSave hooks — real dispatcher path (#1672)", () => {
50
83
  let stack: TestStack;
51
84
 
52
85
  beforeAll(async () => {
53
- stack = await setupTestStack({ features: [contactFeature] });
86
+ stack = await setupTestStack({ features: [contactFeature, throwingFeature] });
54
87
  await unsafeCreateEntityTable(stack.db, contactEntity);
88
+ await unsafeCreateEntityTable(stack.db, throwingEntity);
55
89
  });
56
90
 
57
91
  afterAll(async () => {
@@ -94,4 +128,22 @@ describe("preSave hooks — real dispatcher path (#1672)", () => {
94
128
  expect(updated.data.displayName).toBe("Marc Kumiko");
95
129
  expect(seenIsNew).toEqual([true, false]);
96
130
  });
131
+
132
+ test("preSave runs before ownership checks: field authz sees the hook-derived owner id", async () => {
133
+ const res = await stack.http.write(
134
+ CREATE,
135
+ { firstName: "Marc", lastName: "Ristone", secretNote: "psst" },
136
+ TestUsers.user,
137
+ );
138
+ expect(res.status).toBe(200);
139
+ const { data } = (await res.json()) as { data: { data: { secretNote: string } } };
140
+ expect(data.data.secretNote).toBe("psst");
141
+ });
142
+
143
+ test("a throwing preSave hook maps to a clean writeFailure, not a 500", async () => {
144
+ const res = await stack.http.write(THROWING_CREATE, { name: "x" }, TestUsers.user);
145
+ expect(res.status).toBe(422);
146
+ const body = (await res.json()) as { error?: { details?: { message?: string } } };
147
+ expect(body.error?.details?.message).toBe(THROWING_HOOK_MESSAGE);
148
+ });
97
149
  });
@@ -131,6 +131,18 @@ describe("buildInsertSchema", () => {
131
131
  valid: { age: 5 },
132
132
  invalid: { age: 11 },
133
133
  },
134
+ {
135
+ name: "integer field rejects a value outside Postgres int4 range (must 400, not crash the DB write)",
136
+ fields: { attempt: createNumberField({ integer: true }) },
137
+ valid: { attempt: 2147483647 },
138
+ invalid: { attempt: 2147483648 },
139
+ },
140
+ {
141
+ name: "integer field with explicit max narrower than int4 still enforces the explicit bound",
142
+ fields: { displayOrder: createNumberField({ integer: true, max: 100 }) },
143
+ valid: { displayOrder: 100 },
144
+ invalid: { displayOrder: 101 },
145
+ },
134
146
  {
135
147
  name: "date field",
136
148
  fields: { born: createDateField() },
@@ -449,6 +461,23 @@ describe("buildInsertSchema", () => {
449
461
  expect(schema.safeParse({ locale: "en" }).success).toBe(true);
450
462
  expect(schema.safeParse({ locale: "xx" }).success).toBe(false);
451
463
  });
464
+
465
+ // #1712: pin omitted-key + explicit undefined — ZodPipe/optin path for defaults
466
+ test("optional select with default applies when key omitted or undefined", () => {
467
+ const entity = createEntity({
468
+ table: "Test",
469
+ fields: {
470
+ locale: createSelectField({ options: ["de", "en", "fr"] as const, default: "de" }),
471
+ },
472
+ });
473
+ const schema = buildInsertSchema(entity);
474
+ const omitted = schema.safeParse({});
475
+ expect(omitted.success).toBe(true);
476
+ if (omitted.success) expect(omitted.data["locale"]).toBe("de");
477
+ const undef = schema.safeParse({ locale: undefined });
478
+ expect(undef.success).toBe(true);
479
+ if (undef.success) expect(undef.data["locale"]).toBe("de");
480
+ });
452
481
  });
453
482
 
454
483
  // --- Update schema (all partial) ---
@@ -84,6 +84,21 @@ describe("r.storeTable — declaration", () => {
84
84
  ).toThrow(/the "read_" prefix is reserved/);
85
85
  });
86
86
 
87
+ // #1598: registration guard — plain literal meta bypasses defineUnmanagedTable
88
+ test("r.storeTable rejects a plain literal meta with reserved read_ prefix", () => {
89
+ const literalMeta = {
90
+ tableName: "read_rt_probe",
91
+ columns: [{ name: "id", pgType: "text" as const, notNull: true, primaryKey: true }],
92
+ source: "unmanaged" as const,
93
+ indexes: [],
94
+ };
95
+ expect(() =>
96
+ defineFeature("probe", (r) => {
97
+ r.storeTable(literalMeta, { reason: "test" });
98
+ }),
99
+ ).toThrow(/the "read_" prefix is reserved/);
100
+ });
101
+
87
102
  test("accepts valid registration and stores meta + reason", () => {
88
103
  const feature = defineFeature("probe", (r) => {
89
104
  r.storeTable(probeMeta, {
@@ -22,6 +22,47 @@ import type {
22
22
  ToolbarAction,
23
23
  } from "../types/screen";
24
24
 
25
+ // Tier 2.7e navigate rowAction → target-screen params validity. Shared by
26
+ // entityList and projectionList (framework#1708) — projectionList has no
27
+ // `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
28
+ // entityEdit target without an explicit entityId reaches create there.
29
+ function validateRowActionNavigateParams(
30
+ featureName: string,
31
+ screenId: string,
32
+ screenType: "entityList" | "projectionList",
33
+ screenEntity: string | undefined,
34
+ action: RowAction,
35
+ target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
36
+ ): void {
37
+ // skip: not a navigate-with-params action — nothing to validate here.
38
+ if (action.kind !== "navigate" || action.params === undefined) return;
39
+ // skip: unresolvable/custom target already reported (or exempt) elsewhere.
40
+ if (target === undefined || target.screen.type === "custom") return;
41
+
42
+ const isEntityEditUpdate =
43
+ target.screen.type === "entityEdit" &&
44
+ (action.entityId !== undefined ||
45
+ (screenEntity !== undefined && target.screen.entity === screenEntity));
46
+ if (
47
+ (target.screen.type !== "actionForm" && target.screen.type !== "entityEdit") ||
48
+ isEntityEditUpdate
49
+ ) {
50
+ const reason = isEntityEditUpdate
51
+ ? `resolves to UPDATE mode (${
52
+ action.entityId !== undefined
53
+ ? `explicit entityId "${action.entityId}"`
54
+ : `same entity "${screenEntity}" auto-fills row["id"]`
55
+ })`
56
+ : `screen type "${target.screen.type}"`;
57
+ throw new Error(
58
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" ` +
59
+ `sets params on navigate-target "${action.screen}" which ${reason} — only actionForm ` +
60
+ `and entityEdit-create targets read URL search params as initial values. Remove the ` +
61
+ `params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
62
+ );
63
+ }
64
+ }
65
+
25
66
  // --- Screen validation ---
26
67
  //
27
68
  // For every r.screen() declaration check what's locally knowable at boot:
@@ -169,6 +210,28 @@ export function validateScreens(
169
210
  for (const col of screen.columns) {
170
211
  validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
171
212
  }
213
+ if (screen.rowActions !== undefined) {
214
+ for (const action of screen.rowActions) {
215
+ if (action.kind === "navigate") {
216
+ const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
217
+ if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
218
+ throw new Error(
219
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList) rowAction "${action.id}" ` +
220
+ `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
221
+ );
222
+ }
223
+ const target = screensByShortId.get(action.screen)?.[0];
224
+ validateRowActionNavigateParams(
225
+ feature.name,
226
+ screenId,
227
+ "projectionList",
228
+ undefined,
229
+ action,
230
+ target,
231
+ );
232
+ }
233
+ }
234
+ }
172
235
  continue;
173
236
  }
174
237
 
@@ -590,33 +653,14 @@ export function validateScreens(
590
653
  // explicit entityId) a same-entity target gets row["id"] auto-
591
654
  // injected — only a cross-entity target with no explicit
592
655
  // entityId reaches create.
593
- if (
594
- action.params !== undefined &&
595
- target !== undefined &&
596
- target.screen.type !== "custom"
597
- ) {
598
- const isEntityEditUpdate =
599
- target.screen.type === "entityEdit" &&
600
- (action.entityId !== undefined || target.screen.entity === screen.entity);
601
- if (
602
- (target.screen.type !== "actionForm" && target.screen.type !== "entityEdit") ||
603
- isEntityEditUpdate
604
- ) {
605
- const reason = isEntityEditUpdate
606
- ? `resolves to UPDATE mode (${
607
- action.entityId !== undefined
608
- ? `explicit entityId "${action.entityId}"`
609
- : `same entity "${screen.entity}" auto-fills row["id"]`
610
- })`
611
- : `screen type "${target.screen.type}"`;
612
- throw new Error(
613
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
614
- `sets params on navigate-target "${action.screen}" which ${reason} — only actionForm ` +
615
- `and entityEdit-create targets read URL search params as initial values. Remove the ` +
616
- `params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
617
- );
618
- }
619
- }
656
+ validateRowActionNavigateParams(
657
+ feature.name,
658
+ screenId,
659
+ "entityList",
660
+ screen.entity,
661
+ action,
662
+ target,
663
+ );
620
664
  } else {
621
665
  if (!allWriteHandlerQns.has(action.handler)) {
622
666
  throw new Error(
@@ -98,15 +98,29 @@ export function checkWriteFieldRoles(
98
98
  // row. For creates, pass oldRow = undefined; the check degenerates to a
99
99
  // newRow-only evaluation.
100
100
  //
101
+ // `submittedChanges` and `rowContext` are deliberately separate (fw#1685):
102
+ // - `submittedChanges` drives WHICH fields get checked — only what the user
103
+ // actually wrote in the request. A preSave-hook-derived field the user
104
+ // never touched (e.g. a system hook setting `assignedTo`) must not be
105
+ // field-ownership-checked against the user at all.
106
+ // - `rowContext` drives what a checked field's rule is evaluated AGAINST —
107
+ // this needs the full post-hook row, because an ownership rule can
108
+ // reference a DIFFERENT column that only a hook populates (kumiko-
109
+ // framework#1672: a hook derives `authorId`, a user-submitted `secretNote`
110
+ // field's rule is `from("user:id", "authorId")` — the check needs the
111
+ // hook-derived `authorId` in scope even though the user only wrote
112
+ // `secretNote`). Defaults to `submittedChanges` when omitted.
113
+ //
101
114
  // Returns the denied field name for the caller to wrap into an
102
115
  // `ownership_denied` error with scope: "field", or null if all fields pass.
103
116
  export function checkWriteFieldOwnership(
104
117
  entity: EntityDefinition,
105
- changes: Readonly<Record<string, unknown>>,
118
+ submittedChanges: Readonly<Record<string, unknown>>,
106
119
  user: SessionUser,
107
120
  oldRow?: Readonly<Record<string, unknown>>,
121
+ rowContext: Readonly<Record<string, unknown>> = submittedChanges,
108
122
  ): string | null {
109
- for (const key of Object.keys(changes)) {
123
+ for (const key of Object.keys(submittedChanges)) {
110
124
  const field = entity.fields[key];
111
125
  if (!field) continue;
112
126
 
@@ -120,7 +134,7 @@ export function checkWriteFieldOwnership(
120
134
  const hasOwnershipRule = Object.values(accessMap).some((r) => r !== "all");
121
135
  if (!hasOwnershipRule) continue;
122
136
 
123
- const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...changes };
137
+ const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...rowContext };
124
138
  const effectiveOld = oldRow ?? newRow; // create: compare against newRow
125
139
 
126
140
  if (!userCanWriteFieldRow(user, accessMap, effectiveOld, newRow)) {
@@ -109,7 +109,10 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
109
109
  }
110
110
  case "number": {
111
111
  let schema = z.number();
112
- if (field.integer) schema = schema.int();
112
+ // `integer: true` maps to a Postgres int4 column (entity-table-meta.ts)
113
+ // — bound it here so an out-of-range write fails loud (400) at the
114
+ // schema boundary instead of dying in Postgres (22003 → 500).
115
+ if (field.integer) schema = schema.int().min(-2147483648).max(2147483647);
113
116
  if (field.min !== undefined) schema = schema.min(field.min);
114
117
  if (field.max !== undefined) schema = schema.max(field.max);
115
118
  return field.default !== undefined ? schema.default(field.default) : schema;
@@ -32,6 +32,11 @@ const splitFeature = defineFeature("split", (r) => {
32
32
  });
33
33
  });
34
34
 
35
+ // Consumed by the worker's OWN eventDispatcher after worker.start() — proves
36
+ // the write→afterCommit→MSP chain runs end-to-end inside the worker lane,
37
+ // not just that the write itself lands in the event store (framework#1720).
38
+ const consumedNotes: string[] = [];
39
+
35
40
  const workerWriteFeature = defineFeature("workerWrite", (r) => {
36
41
  const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
37
42
  r.writeHandler(
@@ -48,8 +53,24 @@ const workerWriteFeature = defineFeature("workerWrite", (r) => {
48
53
  },
49
54
  { access: { openToAll: true } },
50
55
  );
56
+ r.multiStreamProjection({
57
+ name: "consume-notes",
58
+ apply: {
59
+ [noted.name]: async (event) => {
60
+ consumedNotes.push((event.payload as { note: string }).note);
61
+ },
62
+ },
63
+ });
51
64
  });
52
65
 
66
+ async function waitForCondition(check: () => boolean, timeoutMs = 5000): Promise<void> {
67
+ const deadline = Date.now() + timeoutMs;
68
+ while (!check()) {
69
+ if (Date.now() > deadline) throw new Error("waitForCondition: timed out");
70
+ await new Promise((resolve) => setTimeout(resolve, 25));
71
+ }
72
+ }
73
+
53
74
  const JWT = "split-deploy-test-secret-must-be-32-chars!!";
54
75
 
55
76
  // Per-test queue-name with a random suffix. Date.now() alone collided
@@ -132,6 +153,8 @@ describe("entrypoint factories", () => {
132
153
  queueNamePrefix: uniquePrefix("split-dispatch"),
133
154
  });
134
155
 
156
+ consumedNotes.length = 0;
157
+ await worker.start();
135
158
  try {
136
159
  const result = await worker.dispatcher.write(
137
160
  "worker-write:write:note",
@@ -147,6 +170,11 @@ describe("entrypoint factories", () => {
147
170
  expect((rows[0] as { payload: { note: string } }).payload.note).toBe(
148
171
  "written from the worker",
149
172
  );
173
+
174
+ // The worker's own eventDispatcher (started above) picks the event
175
+ // back up and runs the MSP — proves the afterCommit/MSP chain works
176
+ // inside the worker lane, not just that the write itself succeeded.
177
+ await waitForCondition(() => consumedNotes.includes("written from the worker"));
150
178
  } finally {
151
179
  await worker.stop();
152
180
  }
@@ -9,7 +9,7 @@ describe("schedulerIdForJobName", () => {
9
9
  const id = schedulerIdForJobName("publicstatus:job:uptime-probe");
10
10
  expect(id).toBe("scheduler-publicstatus-job-uptime-probe");
11
11
  expect(id.includes(":")).toBe(false);
12
- expect(`repeat:${id}:1784992080000`.split(":").length).toBeLessThan(5);
12
+ expect(`repeat:${id}:1784992080000`.split(":").length).toBe(3);
13
13
  });
14
14
 
15
15
  test("still collapses dotted QNs", () => {
@@ -60,7 +60,7 @@ import {
60
60
  observabilityContext,
61
61
  } from "../observability";
62
62
  import { buildBucketKey } from "../rate-limit";
63
- import { createTzContext } from "../time";
63
+ import { createTzContext, isValidIanaTimeZone } from "../time";
64
64
  import { appendDomainEventCore } from "./append-event-core";
65
65
  import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
66
66
  import { executeQuery } from "./dispatch-query";
@@ -523,10 +523,18 @@ export async function buildHandlerContext(
523
523
  // tenant (createTzContext's own default). An app-injected GeoTzProvider
524
524
  // (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
525
525
  const tenantTz = config !== undefined ? await config("tenant:config:timezone") : undefined;
526
+ // Guarded against garbage: an unvalidated string here (free-form config
527
+ // key, legacy JWT claim predating validation) blows up every ctx.tz call
528
+ // for the whole tenant with a RangeError. Fall back to UTC/tenant instead
529
+ // of trusting the raw value.
530
+ const safeTenantTz =
531
+ typeof tenantTz === "string" && isValidIanaTimeZone(tenantTz) ? tenantTz : "UTC";
532
+ const safeUserTz =
533
+ user.timezone !== undefined && isValidIanaTimeZone(user.timezone) ? user.timezone : undefined;
526
534
  const tz = createTzContext({
527
535
  ...(context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {}),
528
- tenant: typeof tenantTz === "string" ? tenantTz : "UTC",
529
- ...(user.timezone !== undefined && { user: user.timezone }),
536
+ tenant: safeTenantTz,
537
+ ...(safeUserTz !== undefined && { user: safeUserTz }),
530
538
  });
531
539
 
532
540
  return {
@@ -59,12 +59,9 @@ async function* executeStreamInner(
59
59
  const invalidated = new Promise<void>((resolve) => {
60
60
  resolveInvalidated = resolve;
61
61
  });
62
- const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation?.(
63
- user.id,
64
- () => {
65
- resolveInvalidated?.();
66
- },
67
- );
62
+ const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
63
+ resolveInvalidated?.();
64
+ });
68
65
 
69
66
  let iterator: AsyncIterator<unknown> | undefined;
70
67
  // When access is revoked mid-pull, `iterator.next()` is still in flight.
@@ -94,7 +94,7 @@ export function createSearchEventConsumer(
94
94
  // #1610 — subject-annotated searchable fields are ciphertext in the event
95
95
  // payload; decrypt into the derived index only. No KMS → omit ciphertext
96
96
  // values rather than indexing blobs.
97
- async function decryptSearchableSubjectFields(
97
+ export async function decryptSearchableSubjectFields(
98
98
  entityName: string,
99
99
  state: Record<string, unknown>,
100
100
  registry: Registry,
@@ -116,7 +116,7 @@ async function decryptSearchableSubjectFields(
116
116
  });
117
117
  }
118
118
 
119
- function hasErasedSearchableSubjectField(
119
+ export function hasErasedSearchableSubjectField(
120
120
  entityName: string,
121
121
  state: Record<string, unknown>,
122
122
  registry: Registry,
@@ -416,7 +416,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
416
416
  // poison would otherwise permanently stop access-invalidation for
417
417
  // every user behind one bad row).
418
418
  if (typeof userId !== "string" || userId.length === 0) return;
419
- sseBroker.publishAccessInvalidation?.(userId);
419
+ sseBroker.publishAccessInvalidation(userId);
420
420
  }
421
421
 
422
422
  if (
@@ -427,7 +427,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
427
427
  // skip: previous snapshot missing/malformed userId — same fail-open
428
428
  // reasoning as above.
429
429
  if (userId === undefined) return;
430
- sseBroker.publishAccessInvalidation?.(userId);
430
+ sseBroker.publishAccessInvalidation(userId);
431
431
  }
432
432
  },
433
433
  };