@cosmicdrift/kumiko-framework 0.167.0 → 0.168.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.167.0",
3
+ "version": "0.168.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>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.167.0",
185
+ "@cosmicdrift/kumiko-types": "0.168.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.167.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.168.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -493,6 +493,22 @@ describe("full stack: auth + access + validation", () => {
493
493
  );
494
494
  expectErrorIncludes(error, "banned_domain");
495
495
  });
496
+
497
+ test("queryOk throws on a query error response instead of returning undefined", async () => {
498
+ await expect(
499
+ stack.http.queryOk("users:query:user:detail", { id: "not-a-uuid" }, adminUser),
500
+ ).rejects.toThrow(/validation_error/);
501
+ });
502
+
503
+ test("queryErr asserts + returns the structured error for a failing query", async () => {
504
+ const error = await stack.http.queryErr(
505
+ "users:query:user:detail",
506
+ { id: "not-a-uuid" },
507
+ adminUser,
508
+ );
509
+ expect(error.code).toBe("validation_error");
510
+ expect(error.httpStatus).toBe(400);
511
+ });
496
512
  });
497
513
 
498
514
  // =============================================================================
@@ -82,6 +82,18 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
82
82
  .sign(new TextEncoder().encode(SECRET));
83
83
  await expect(jwt.verify(token)).rejects.toThrow(/sub/);
84
84
  });
85
+
86
+ // fw#1636 — SessionUser.timezone must survive the sign/verify roundtrip
87
+ // (jwt.sign silently drops any field it doesn't explicitly copy).
88
+ it("round-trips the timezone claim when set", async () => {
89
+ const payload = await jwt.verify(await jwt.sign({ ...user, timezone: "Asia/Tokyo" }));
90
+ expect(payload.timezone).toBe("Asia/Tokyo");
91
+ });
92
+
93
+ it("omits the timezone claim when unset", async () => {
94
+ const payload = await jwt.verify(await jwt.sign(user));
95
+ expect(payload.timezone).toBeUndefined();
96
+ });
85
97
  });
86
98
 
87
99
  describe("createJwtHelper — keyring form", () => {
@@ -307,6 +307,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
307
307
  id: payload.sub,
308
308
  tenantId: payload.tenantId,
309
309
  roles: payload.roles,
310
+ ...(payload.timezone ? { timezone: payload.timezone } : {}),
310
311
  ...(payload.claims ? { claims: payload.claims } : {}),
311
312
  ...(payload.jti ? { sid: payload.jti } : {}),
312
313
  };
package/src/api/jwt.ts CHANGED
@@ -10,6 +10,9 @@ export type JwtPayload = {
10
10
  sub: string;
11
11
  tenantId: TenantId;
12
12
  roles: string[];
13
+ // IANA zone from user.timezone, set at login — see SessionUser.timezone
14
+ // (fw#1636). Absent → ctx.tz.user falls back to ctx.tz.tenant.
15
+ timezone?: string;
13
16
  // Optional — present when a feature has registered auth claims via the
14
17
  // `r.authClaims()` hook system. Absent for stateless-JWT deployments
15
18
  // without auth-claims wiring.
@@ -106,6 +109,7 @@ export function createJwtHelper(
106
109
  tenantId: user.tenantId,
107
110
  roles: [...user.roles],
108
111
  };
112
+ if (user.timezone) body.timezone = user.timezone;
109
113
  if (user.claims) body.claims = { ...user.claims };
110
114
 
111
115
  const header: jose.JWTHeaderParameters = keyring.signKid
@@ -155,6 +159,9 @@ export function createJwtHelper(
155
159
  tenantId,
156
160
  roles,
157
161
  };
162
+ if (typeof payload["timezone"] === "string") {
163
+ result.timezone = payload["timezone"];
164
+ }
158
165
  const claims = payload["claims"];
159
166
  if (claims && typeof claims === "object") {
160
167
  result.claims = claims as DbRow;
@@ -0,0 +1,40 @@
1
+ [
2
+ {
3
+ "version": "0.167.0",
4
+ "type": "breaking",
5
+ "title": "resetEntityFieldEncryptionCacheForTests / resetEventPiiCatalogForTests moved to /testing (fw#1631).",
6
+ "detail": "Test-only reset helpers with no owning feature: resetEntityFieldEncryptionCacheForTests left the /db barrel, resetEventPiiCatalogForTests left /crypto. The functions did not move, only their export path.",
7
+ "migration": "Import both from \"@cosmicdrift/kumiko-framework/testing\" instead of \"/db\" and \"/crypto\". Relative deep-imports of the defining module are unaffected."
8
+ },
9
+ {
10
+ "version": "0.167.0",
11
+ "type": "breaking",
12
+ "title": "Six identity-sensitive error classes moved from kumiko-types into kumiko-framework (fw#1616).",
13
+ "detail": "VersionConflictError, IdempotentAppendConflictError and ArchivedStreamError now live in /event-store, KeyErasedError, KeyNotFoundError and KeyAlreadyExistsError in /crypto — the public paths callers already import from. With no classes left in it, kumiko-types is a plain dependency again instead of a peerDependency, which closes the changesets cycle that escalated every minor release to a major.",
14
+ "migration": "Only affects direct imports from the removed @cosmicdrift/kumiko-types/event-store-errors subpath: import from @cosmicdrift/kumiko-framework/event-store or /crypto instead. Apps importing from the framework paths need no change."
15
+ },
16
+ {
17
+ "version": "0.167.0",
18
+ "type": "fix",
19
+ "title": "hono range raised to ^4.12.27 — security floor for the HTTP layer (fw#1634).",
20
+ "detail": "Carries the fixes for three advisories: cross-request data disclosure in hono/jsx (context not isolated per request), server-side XSS via a JSX escaping bypass in cx(), and a dropped repeated request header in the API-Gateway v1 adapter. The old ^4.12.18 allowed the patched versions, but the lockfile sat on 4.12.25 — the range now states the floor instead of relying on resolution luck."
21
+ },
22
+ {
23
+ "version": "0.165.2",
24
+ "type": "improvement",
25
+ "title": "buildEntityTableMeta renamed to deriveEntityTableMeta (fw#1208).",
26
+ "detail": "The old name read like the unmanaged escape hatch (defineUnmanagedTable). Unmanaged builders now reject the reserved read_ table-name prefix. The deprecated alias still works."
27
+ },
28
+ {
29
+ "version": "0.165.1",
30
+ "type": "fix",
31
+ "title": "isSafeHref decodes HTML character references before its scheme check (fw#1551).",
32
+ "detail": "javascript&colon;alert(1) and java&Tab;script:alert(1) slipped through because neither contains a literal colon for the pre-decode regex, while the browser decodes the entity back into an executable javascript: URL on click. Affects renderSafeMarkdown (page-render) and the renderer-web Link primitive."
33
+ },
34
+ {
35
+ "version": "0.165.1",
36
+ "type": "improvement",
37
+ "title": "createNumberField accepts max (fw#1573).",
38
+ "detail": "Mirrored from min; the schema-builder applies Zod .max() at the write boundary so integer CRUD rejects values that would overflow a Postgres integer instead of failing at insert time."
39
+ }
40
+ ]
@@ -3,16 +3,28 @@
3
3
  // werden (TenantDb filtert), sonst leakt eagerload fremde Rows nach _refs.
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
- import { seedRows } from "@cosmicdrift/kumiko-framework/testing";
6
+ import {
7
+ configurePiiSubjectKms,
8
+ encryptPiiFieldValues,
9
+ InMemoryKmsAdapter,
10
+ isPiiCiphertext,
11
+ } from "../../crypto";
7
12
  import { createEntity, createTextField } from "../../engine";
8
13
  import type { EntityDefinition } from "../../engine/types";
9
14
  import { setupTestStack, type TestStack, testTenantId, unsafeCreateEntityTable } from "../../stack";
15
+ import { createTestEnvelopeCipher, resetPiiSubjectKmsForTests, seedRows } from "../../testing";
10
16
  import {
11
17
  collectReferenceFields,
12
18
  type EagerloadedRow,
13
19
  enrichRowWithReferences,
14
20
  enrichWithReferences,
15
21
  } from "../eagerload";
22
+ import {
23
+ collectEncryptedFieldNames,
24
+ configureEntityFieldEncryption,
25
+ encryptEntityFieldValues,
26
+ resetEntityFieldEncryptionCacheForTests,
27
+ } from "../entity-field-encryption";
16
28
  import { buildEntityTable } from "../table-builder";
17
29
  import { createTenantDb } from "../tenant-db";
18
30
 
@@ -30,8 +42,31 @@ const postEntity = createEntity({
30
42
  });
31
43
  const authorTable = buildEntityTable("author", authorEntity);
32
44
 
33
- const resolve = (name: string): EntityDefinition | undefined =>
34
- name === "author" ? authorEntity : undefined;
45
+ // #1667: eager-loaded _refs bypassed PII/entity-field decryption entirely
46
+ // a raw selectMany read the referenced row straight from the table, so
47
+ // piiCiphertextResponseGuard 500'd on the leaked ciphertext.
48
+ const contactEntity = createEntity({
49
+ table: "el_contacts",
50
+ fields: {
51
+ name: createTextField({ required: true }),
52
+ email: createTextField({ required: true, tenantOwned: true }),
53
+ iban: createTextField({ required: true, encrypted: true }),
54
+ },
55
+ });
56
+ const leadEntity = createEntity({
57
+ table: "el_leads",
58
+ fields: {
59
+ title: createTextField({ required: true }),
60
+ contact: { type: "reference", entity: "contact" },
61
+ },
62
+ });
63
+ const contactTable = buildEntityTable("contact", contactEntity);
64
+
65
+ const resolve = (name: string): EntityDefinition | undefined => {
66
+ if (name === "author") return authorEntity;
67
+ if (name === "contact") return contactEntity;
68
+ return undefined;
69
+ };
35
70
 
36
71
  const tenantA = testTenantId(91);
37
72
  const tenantB = testTenantId(92);
@@ -47,12 +82,18 @@ const single = (r: EagerloadedRow | undefined, f: string) =>
47
82
  const many = (r: EagerloadedRow | undefined, f: string) =>
48
83
  r?._refs?.[f] as ReadonlyArray<Record<string, unknown>> | undefined;
49
84
 
85
+ const CX = "44444444-4444-4444-8444-444444444444";
86
+ const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
87
+ const cipher = createTestEnvelopeCipher(TEST_KEY);
88
+ const kms = new InMemoryKmsAdapter();
89
+
50
90
  let stack: TestStack;
51
91
  let dbA: ReturnType<typeof createTenantDb>;
52
92
 
53
93
  beforeAll(async () => {
54
94
  stack = await setupTestStack({ features: [] });
55
95
  await unsafeCreateEntityTable(stack.db, authorEntity);
96
+ await unsafeCreateEntityTable(stack.db, contactEntity);
56
97
  dbA = createTenantDb(stack.db, tenantA, "tenant");
57
98
 
58
99
  await seedRows(stack.db, authorTable, [
@@ -60,9 +101,30 @@ beforeAll(async () => {
60
101
  { id: A2, tenantId: tenantA, name: "Linus" },
61
102
  { id: BX, tenantId: tenantB, name: "Foreign" },
62
103
  ]);
104
+
105
+ configureEntityFieldEncryption(cipher);
106
+ configurePiiSubjectKms(kms);
107
+ const plainContact = {
108
+ id: CX,
109
+ tenantId: tenantA,
110
+ name: "Grace",
111
+ email: "grace@acme.test",
112
+ iban: "DE12345",
113
+ };
114
+ const piiEncrypted = await encryptPiiFieldValues(plainContact, contactEntity, ["email"], kms, {
115
+ requestId: "test",
116
+ });
117
+ const encrypted = await encryptEntityFieldValues(
118
+ piiEncrypted,
119
+ collectEncryptedFieldNames(contactEntity),
120
+ cipher,
121
+ );
122
+ await seedRows(stack.db, contactTable, [encrypted]);
63
123
  });
64
124
 
65
125
  afterAll(async () => {
126
+ resetEntityFieldEncryptionCacheForTests();
127
+ resetPiiSubjectKmsForTests();
66
128
  await stack.cleanup();
67
129
  });
68
130
 
@@ -161,4 +223,18 @@ describe("enrichWithReferences", () => {
161
223
  )) as EagerloadedRow;
162
224
  expect(single(row, "author")?.["name"]).toBe("Ada");
163
225
  });
226
+
227
+ test("#1667: referenzierte PII-/encrypted-Felder werden entschlüsselt, keine Ciphertext-Leaks", async () => {
228
+ const [row] = (await enrichWithReferences(
229
+ [{ id: "l1", contact: CX }],
230
+ leadEntity,
231
+ resolve,
232
+ dbA,
233
+ )) as EagerloadedRow[];
234
+ const contact = single(row, "contact");
235
+
236
+ expect(contact?.["email"]).toBe("grace@acme.test");
237
+ expect(contact?.["iban"]).toBe("DE12345");
238
+ expect(isPiiCiphertext(contact?.["email"])).toBe(false);
239
+ });
164
240
  });
@@ -23,8 +23,15 @@
23
23
  // keine framework-engine-Internals und kann auch von custom
24
24
  // query-handlern manuell aufgerufen werden.
25
25
 
26
+ import { requestContext } from "../api/request-context";
27
+ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues } from "../crypto";
26
28
  import { selectMany } from "../db/query";
27
29
  import type { EntityDefinition, FieldDefinition, ReferenceFieldDef } from "../engine/types";
30
+ import {
31
+ collectEncryptedFieldNames,
32
+ decryptEntityFieldValues,
33
+ resolveEntityFieldEncryption,
34
+ } from "./entity-field-encryption";
28
35
  import { buildEntityTable } from "./table-builder";
29
36
  import type { TenantDb } from "./tenant-db";
30
37
 
@@ -81,6 +88,32 @@ export function collectReferenceFields(entity: EntityDefinition): readonly Refer
81
88
  return out;
82
89
  }
83
90
 
91
+ // Referenced rows are read via a raw selectMany, not the referenced entity's
92
+ // own executor context (enrichWithReferences only gets an
93
+ // EagerLoadEntityResolver — routing through buildExecutorContext per ref
94
+ // would need table/searchAdapter/entityCache wiring for no reason). Mirrors
95
+ // event-store-executor-context's decryptForRead ordering: PII is the outer
96
+ // layer, peel it before the envelope-encrypted fields, or the envelope
97
+ // cipher chokes on a still-PII-wrapped string.
98
+ async function decryptReferencedRow(
99
+ row: Record<string, unknown>,
100
+ refEntity: EntityDefinition,
101
+ ): Promise<Record<string, unknown>> {
102
+ let out = row;
103
+ const piiFields = collectPiiSubjectFields(refEntity);
104
+ const kms = configuredPiiSubjectKms();
105
+ if (piiFields.length > 0 && kms) {
106
+ out = await decryptPiiFieldValues(out, piiFields, kms, {
107
+ requestId: requestContext.get()?.requestId ?? "eagerload",
108
+ });
109
+ }
110
+ const encryptedFields = collectEncryptedFieldNames(refEntity);
111
+ if (encryptedFields.size > 0) {
112
+ out = await decryptEntityFieldValues(out, encryptedFields, resolveEntityFieldEncryption());
113
+ }
114
+ return out;
115
+ }
116
+
84
117
  /** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
85
118
  * flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
86
119
  export async function enrichWithReferences(
@@ -123,9 +156,10 @@ export async function enrichWithReferences(
123
156
  }
124
157
  const refTable = buildEntityTable(rf.refEntityName, refEntity);
125
158
  const idArray = [...ids];
126
- const refRows = (await selectMany(db, refTable, { id: idArray })) as Array<
159
+ const rawRefRows = (await selectMany(db, refTable, { id: idArray })) as Array<
127
160
  Record<string, unknown>
128
161
  >;
162
+ const refRows = await Promise.all(rawRefRows.map((r) => decryptReferencedRow(r, refEntity)));
129
163
  const map = new Map<string, Record<string, unknown>>();
130
164
  for (const r of refRows) {
131
165
  const id = r["id"];
@@ -558,6 +558,7 @@ describe("validateBoot — retention", () => {
558
558
  createEntity({
559
559
  fields: {
560
560
  invoiceNumber: createTextField({ allowPlaintext: "is-business-data" }),
561
+ customerName: createTextField({ pii: true }),
561
562
  },
562
563
  retention: { keepFor: "10y", strategy: "blockDelete" },
563
564
  }),
@@ -570,6 +571,25 @@ describe("validateBoot — retention", () => {
570
571
  expect(matchingWarn).toBeDefined();
571
572
  });
572
573
 
574
+ test("blockDelete without any subject-annotated field stays silent (#1622)", () => {
575
+ const feature = defineFeature("test", (r) => {
576
+ r.entity(
577
+ "lease",
578
+ createEntity({
579
+ fields: {
580
+ reference: createTextField({ allowPlaintext: "is-business-data" }),
581
+ },
582
+ retention: { keepFor: "10y", strategy: "blockDelete" },
583
+ }),
584
+ );
585
+ });
586
+ validateBoot([feature]);
587
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
588
+ String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
589
+ );
590
+ expect(matchingWarn).toBeUndefined();
591
+ });
592
+
573
593
  test('retention.keepFor with invalid format "30days" warns', () => {
574
594
  const feature = defineFeature("test", (r) => {
575
595
  r.entity(
@@ -25,4 +25,43 @@ describe("r.crud", () => {
25
25
  ["task:detail", "task:list"].sort(),
26
26
  );
27
27
  });
28
+
29
+ test("without verbAccess, write handlers keep write.access (never fall back to the broader read.access)", () => {
30
+ const write = { access: { roles: ["Manager"] } } as const;
31
+ const read = { access: { openToAll: true } } as const;
32
+
33
+ const feature = defineFeature("via-crud-no-verb-access", (r) => {
34
+ r.crud("task", taskEntity, { write, read });
35
+ });
36
+
37
+ for (const verb of ["create", "update", "delete", "restore"] as const) {
38
+ expect(feature.writeHandlers?.[`task:${verb}`]?.access).toEqual(write.access);
39
+ }
40
+ for (const verb of ["list", "detail"] as const) {
41
+ expect(feature.queryHandlers?.[`task:${verb}`]?.access).toEqual(read.access);
42
+ }
43
+ });
44
+
45
+ test("verbAccess overrides access per verb, other verbs keep write/read.access", () => {
46
+ const write = { access: { roles: ["Manager"] } } as const;
47
+ const read = { access: { openToAll: true } } as const;
48
+ const deleteAccess = { roles: ["Operator"] } as const;
49
+ const restoreAccess = { roles: ["Operator"] } as const;
50
+ const listAccess = { roles: ["Auditor"] } as const;
51
+
52
+ const feature = defineFeature("via-crud-verb-access", (r) => {
53
+ r.crud("task", taskEntity, {
54
+ write,
55
+ read,
56
+ verbAccess: { delete: deleteAccess, restore: restoreAccess, list: listAccess },
57
+ });
58
+ });
59
+
60
+ expect(feature.writeHandlers?.["task:create"]?.access).toEqual(write.access);
61
+ expect(feature.writeHandlers?.["task:update"]?.access).toEqual(write.access);
62
+ expect(feature.writeHandlers?.["task:delete"]?.access).toEqual(deleteAccess);
63
+ expect(feature.writeHandlers?.["task:restore"]?.access).toEqual(restoreAccess);
64
+ expect(feature.queryHandlers?.["task:list"]?.access).toEqual(listAccess);
65
+ expect(feature.queryHandlers?.["task:detail"]?.access).toEqual(read.access);
66
+ });
28
67
  });
@@ -0,0 +1,83 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { asRawClient } from "../../db/query";
3
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
4
+ import { defineFeature } from "../define-feature";
5
+ import { createEntity, createTextField } from "../factories";
6
+
7
+ const propertyEntity = createEntity({
8
+ table: "crud_verb_access_properties",
9
+ fields: { title: createTextField({ required: true }) },
10
+ softDelete: true,
11
+ });
12
+
13
+ const propertyFeature = defineFeature("crud-verb-access", (r) => {
14
+ r.crud("property", propertyEntity, {
15
+ write: { access: { roles: ["User"] } },
16
+ read: { access: { openToAll: true } },
17
+ verbAccess: {
18
+ delete: { roles: ["Admin"] },
19
+ restore: { roles: ["Admin"] },
20
+ },
21
+ });
22
+ });
23
+
24
+ const CREATE = "crud-verb-access:write:property:create";
25
+ const DELETE = "crud-verb-access:write:property:delete";
26
+ const RESTORE = "crud-verb-access:write:property:restore";
27
+
28
+ describe("r.crud verbAccess — real dispatcher path", () => {
29
+ let stack: TestStack;
30
+
31
+ beforeAll(async () => {
32
+ stack = await setupTestStack({ features: [propertyFeature] });
33
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await stack.cleanup();
38
+ });
39
+
40
+ beforeEach(async () => {
41
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
42
+ await asRawClient(stack.db).unsafe('DELETE FROM "crud_verb_access_properties"');
43
+ });
44
+
45
+ test("create stays gated by write.access — User role passes", async () => {
46
+ const res = await stack.http.write(CREATE, { title: "verbAccess create" }, TestUsers.user);
47
+ expect(res.status).toBe(200);
48
+ });
49
+
50
+ test("delete overridden by verbAccess — User role (write.access) is rejected", async () => {
51
+ const created = await stack.http.write(CREATE, { title: "to be deleted" }, TestUsers.user);
52
+ const { data } = (await created.json()) as { data: { id: string } };
53
+
54
+ const res = await stack.http.write(DELETE, { id: data.id }, TestUsers.user);
55
+ expect(res.status).toBe(403);
56
+ const body = (await res.json()) as { isSuccess: false; error: { code: string } };
57
+ expect(body.error.code).toBe("access_denied");
58
+ });
59
+
60
+ test("delete overridden by verbAccess — Admin role (verbAccess.delete) passes", async () => {
61
+ const created = await stack.http.write(
62
+ CREATE,
63
+ { title: "to be deleted by admin" },
64
+ TestUsers.user,
65
+ );
66
+ const { data } = (await created.json()) as { data: { id: string } };
67
+
68
+ const res = await stack.http.write(DELETE, { id: data.id }, TestUsers.admin);
69
+ expect(res.status).toBe(200);
70
+ });
71
+
72
+ test("restore overridden by verbAccess — Admin role passes, User role rejected", async () => {
73
+ const created = await stack.http.write(CREATE, { title: "to be restored" }, TestUsers.user);
74
+ const { data } = (await created.json()) as { data: { id: string } };
75
+ await stack.http.write(DELETE, { id: data.id }, TestUsers.admin);
76
+
77
+ const deniedRestore = await stack.http.write(RESTORE, { id: data.id }, TestUsers.user);
78
+ expect(deniedRestore.status).toBe(403);
79
+
80
+ const allowedRestore = await stack.http.write(RESTORE, { id: data.id }, TestUsers.admin);
81
+ expect(allowedRestore.status).toBe(200);
82
+ });
83
+ });
@@ -234,11 +234,17 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
234
234
  }
235
235
 
236
236
  if (retention.strategy === "blockDelete") {
237
+ // blockDelete on an entity with no subject field is the correct
238
+ // "never auto-delete" choice; User-Forget never reaches those rows (#1622).
239
+ const hasSubjectField = Object.values(fieldsByName).some((f) => {
240
+ const a = f as PiiAnnotations; // @cast-boundary schema-walk
241
+ return Boolean(a.pii || a.userOwned || a.tenantOwned);
242
+ });
237
243
  const hasAnonymize = Object.values(fieldsByName).some((f) => {
238
244
  const a = f as PiiAnnotations; // @cast-boundary schema-walk
239
245
  return Boolean(a.anonymize);
240
246
  });
241
- if (!hasAnonymize) {
247
+ if (hasSubjectField && !hasAnonymize) {
242
248
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
243
249
  console.warn(
244
250
  `[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`,
@@ -479,23 +479,31 @@ export function registerEntityCrud(
479
479
  }
480
480
  const writeOpts = options?.write;
481
481
  const readOpts = options?.read;
482
+ const resolveWriteOpts = (verb: EntityCrudVerb): EntityHandlerOptions => ({
483
+ ...writeOpts,
484
+ access: options?.verbAccess?.[verb] ?? writeOpts?.access,
485
+ });
486
+ const resolveReadOpts = (verb: EntityCrudVerb): EntityQueryHandlerOptions => ({
487
+ ...readOpts,
488
+ access: options?.verbAccess?.[verb] ?? readOpts?.access,
489
+ });
482
490
 
483
491
  if (verbs.create) {
484
- r.writeHandler(defineEntityCreateHandler(entityName, entity, writeOpts));
492
+ r.writeHandler(defineEntityCreateHandler(entityName, entity, resolveWriteOpts("create")));
485
493
  }
486
494
  if (verbs.update) {
487
- r.writeHandler(defineEntityUpdateHandler(entityName, entity, writeOpts));
495
+ r.writeHandler(defineEntityUpdateHandler(entityName, entity, resolveWriteOpts("update")));
488
496
  }
489
497
  if (verbs.delete) {
490
- r.writeHandler(defineEntityDeleteHandler(entityName, entity, writeOpts));
498
+ r.writeHandler(defineEntityDeleteHandler(entityName, entity, resolveWriteOpts("delete")));
491
499
  }
492
500
  if (verbs.restore) {
493
- r.writeHandler(defineEntityRestoreHandler(entityName, entity, writeOpts));
501
+ r.writeHandler(defineEntityRestoreHandler(entityName, entity, resolveWriteOpts("restore")));
494
502
  }
495
503
  if (verbs.list) {
496
- r.queryHandler(defineEntityListHandler(entityName, entity, readOpts));
504
+ r.queryHandler(defineEntityListHandler(entityName, entity, resolveReadOpts("list")));
497
505
  }
498
506
  if (verbs.detail) {
499
- r.queryHandler(defineEntityDetailHandler(entityName, entity, readOpts));
507
+ r.queryHandler(defineEntityDetailHandler(entityName, entity, resolveReadOpts("detail")));
500
508
  }
501
509
  }
@@ -79,7 +79,7 @@ async function executeQueryInner(
79
79
  typeof parsed.data === "object" &&
80
80
  parsed.data !== null &&
81
81
  (parsed.data as Record<string, unknown>)["includeDeleted"] === true; // @cast-boundary validated-payload
82
- const handlerContext = buildHandlerContext(ctx, type, user, tx, undefined, includeDeleted);
82
+ const handlerContext = await buildHandlerContext(ctx, type, user, tx, undefined, includeDeleted);
83
83
  let result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
84
84
 
85
85
  // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
@@ -146,14 +146,14 @@ async function appendDomainEvent(
146
146
  );
147
147
  }
148
148
 
149
- export function buildHandlerContext(
149
+ export async function buildHandlerContext(
150
150
  ctx: DispatchContext,
151
151
  type: string,
152
152
  user: SessionUser,
153
153
  tx?: DbTx,
154
154
  afterCommitHooks?: AfterCommitHook[],
155
155
  includeDeleted?: boolean,
156
- ): HandlerContext {
156
+ ): Promise<HandlerContext> {
157
157
  const { registry, appContext: context, effectiveFeatures, jobRunner } = ctx;
158
158
  const isSystem = registry.isHandlerSystemScoped(type);
159
159
  // The outer dispatcher receives a DbConnection from the server/stack;
@@ -515,13 +515,19 @@ export function buildHandlerContext(
515
515
  // HandlerContext. The spread-then-assign order matters: anything in
516
516
  // `context` can be overridden, but we want the authoritative registry
517
517
  // from the dispatcher's own closure to win.
518
- // ctx.tz ist immer da. Tenant + User-Defaults kommen aus dem
519
- // SessionUser sobald die Felder existierenbis dahin "UTC". Ein
520
- // app-injizierter GeoTzProvider (context.geoTzProvider) speist
521
- // ctx.tz.fromCoordinates / fromAddress.
522
- const tz = createTzContext(
523
- context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {},
524
- );
518
+ // ctx.tz is always present. tenant reads tenant:config:timezone through
519
+ // the already-built `config` accessor (raw qualified-key no import of
520
+ // the tenant feature, framework/pipeline stays bundled-features-free);
521
+ // falls back to "UTC" when config is missing or the key is unset. user
522
+ // comes from SessionUser.timezone (set at login), else falls back to
523
+ // tenant (createTzContext's own default). An app-injected GeoTzProvider
524
+ // (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
525
+ const tenantTz = config !== undefined ? await config("tenant:config:timezone") : undefined;
526
+ const tz = createTzContext({
527
+ ...(context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {}),
528
+ tenant: typeof tenantTz === "string" ? tenantTz : "UTC",
529
+ ...(user.timezone !== undefined && { user: user.timezone }),
530
+ });
525
531
 
526
532
  return {
527
533
  ...context,
@@ -73,7 +73,7 @@ async function* executeStreamInner(
73
73
  // await; close is fire-and-forget instead (#1563).
74
74
  let abandonedForInvalidation = false;
75
75
  try {
76
- const handlerContext = buildHandlerContext(ctx, type, user);
76
+ const handlerContext = await buildHandlerContext(ctx, type, user);
77
77
  const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
78
78
  iterator = chunks[Symbol.asyncIterator]();
79
79
 
@@ -348,7 +348,7 @@ async function executeWriteInner(
348
348
  }
349
349
  }
350
350
 
351
- const handlerContext = buildHandlerContext(ctx, type, user, tx, afterCommitHooks);
351
+ const handlerContext = await buildHandlerContext(ctx, type, user, tx, afterCommitHooks);
352
352
 
353
353
  // Auto transition guard: if entity has transitions and handler doesn't skip it
354
354
  if (entityName && !handler.unsafeSkipTransitionGuard) {
@@ -14,7 +14,7 @@ type WireErrorBody = {
14
14
  };
15
15
  };
16
16
 
17
- function formatWriteFailure(type: string, body: unknown): string {
17
+ function formatFailure(kind: "write" | "query", type: string, body: unknown): string {
18
18
  const parsed = body as {
19
19
  isSuccess?: boolean;
20
20
  error?: WireErrorBody | string;
@@ -35,12 +35,12 @@ function formatWriteFailure(type: string, body: unknown): string {
35
35
  ? String((details as { causeName?: unknown }).causeName ?? "")
36
36
  : "";
37
37
  if (code === "internal_error" && (causeMessage || causeName)) {
38
- return `Expected write "${type}" to succeed but got error: ${code} (${causeName}: ${causeMessage})`;
38
+ return `Expected ${kind} "${type}" to succeed but got error: ${code} (${causeName}: ${causeMessage})`;
39
39
  }
40
40
  if (details !== undefined) {
41
- return `Expected write "${type}" to succeed but got error: ${code} — ${JSON.stringify(details)}`;
41
+ return `Expected ${kind} "${type}" to succeed but got error: ${code} — ${JSON.stringify(details)}`;
42
42
  }
43
- return `Expected write "${type}" to succeed but got error: ${code}`;
43
+ return `Expected ${kind} "${type}" to succeed but got error: ${code}`;
44
44
  }
45
45
 
46
46
  export type RequestHelper = {
@@ -80,6 +80,13 @@ export type RequestHelper = {
80
80
  ) => Promise<import("../errors").WriteErrorInfo>;
81
81
  /** query + json — returns data directly */
82
82
  queryOk: <T = unknown>(type: string, payload: unknown, user: SessionUser) => Promise<T>;
83
+ /** query + json + assert the response is an error — returns the structured
84
+ * WriteErrorInfo with `httpStatus` filled in from the HTTP response. */
85
+ queryErr: (
86
+ type: string,
87
+ payload: unknown,
88
+ user: SessionUser,
89
+ ) => Promise<import("../errors").WriteErrorInfo>;
83
90
 
84
91
  /** write + additional HTTP headers (e.g. X-Correlation-ID). Returns the
85
92
  * raw Response so callers can assert on status + headers + body as needed. */
@@ -205,7 +212,7 @@ export function createRequestHelper(
205
212
  // follow the error-contract shape { error: { code, i18nKey, ... } } with
206
213
  // a 4xx/5xx status — no isSuccess flag. Detect either.
207
214
  if (body.isSuccess !== true) {
208
- throw new Error(formatWriteFailure(type, body));
215
+ throw new Error(formatFailure("write", type, body));
209
216
  }
210
217
  return body.data as T; // @cast-boundary engine-bridge
211
218
  },
@@ -239,10 +246,45 @@ export function createRequestHelper(
239
246
 
240
247
  async queryOk<T = unknown>(type: string, payload: unknown, user: SessionUser): Promise<T> {
241
248
  const res = await queryRaw(type, payload, user);
242
- const body = (await res.json()) as { data: unknown }; // @cast-boundary engine-bridge
249
+ const rawBody = await res.json();
250
+ const body = rawBody as {
251
+ // @cast-boundary engine-bridge
252
+ data?: unknown;
253
+ error?: WireErrorBody | string;
254
+ };
255
+ // res.ok mirrors writeOk's isSuccess assertion — belt-and-suspenders
256
+ // in case a future non-dispatcher rejection skips the error-contract shape.
257
+ if (!res.ok || body.error !== undefined) {
258
+ throw new Error(`${formatFailure("query", type, body)} [HTTP ${res.status}]`);
259
+ }
243
260
  return body.data as T; // @cast-boundary engine-bridge
244
261
  },
245
262
 
263
+ async queryErr(
264
+ type: string,
265
+ payload: unknown,
266
+ user: SessionUser,
267
+ ): Promise<import("../errors").WriteErrorInfo> {
268
+ const res = await queryRaw(type, payload, user);
269
+ const rawErrorBody = await res.json();
270
+ const body = rawErrorBody as {
271
+ // @cast-boundary engine-bridge
272
+ error?: Omit<import("../errors").WriteErrorInfo, "httpStatus">;
273
+ };
274
+ if (res.ok) {
275
+ throw new Error(`Expected query "${type}" to fail but it succeeded`);
276
+ }
277
+ const wire = body.error;
278
+ if (!wire || typeof wire !== "object" || typeof wire.code !== "string") {
279
+ throw new Error(
280
+ `Expected error response for "${type}" but got unexpected shape: ${JSON.stringify(body)}`,
281
+ );
282
+ }
283
+ // Same rationale as writeErr: the wire body has no httpStatus (it would
284
+ // be redundant with the HTTP response status), so fill it in here.
285
+ return { ...wire, httpStatus: res.status };
286
+ },
287
+
246
288
  async writeWithHeaders(type, payload, user, extraHeaders) {
247
289
  const authHeaders = await authHeader(user);
248
290
  return req("POST", "/api/write", { type, payload }, { ...authHeaders, ...extraHeaders });
@@ -27,9 +27,20 @@ export const sharedWidgetTable = buildEntityTable("widget", sharedWidgetEntity);
27
27
  // realistic-looking user record.
28
28
  export const sharedUserEntity = createEntity({
29
29
  fields: {
30
- email: createTextField({ required: true, format: "email", searchable: true }),
31
- firstName: createTextField({ searchable: true }),
32
- lastName: createTextField({ searchable: true }),
30
+ email: createTextField({
31
+ required: true,
32
+ format: "email",
33
+ searchable: true,
34
+ allowPlaintext: "shared test fixture, not real user data",
35
+ }),
36
+ firstName: createTextField({
37
+ searchable: true,
38
+ allowPlaintext: "shared test fixture, not real user data",
39
+ }),
40
+ lastName: createTextField({
41
+ searchable: true,
42
+ allowPlaintext: "shared test fixture, not real user data",
43
+ }),
33
44
  isEnabled: createBooleanField({ default: true }),
34
45
  },
35
46
  softDelete: true,
@@ -10,9 +10,10 @@
10
10
  // Feature code should no longer call `new Date()` — the lint rule for that
11
11
  // lands in a later iteration, once all existing usages are migrated.
12
12
  //
13
- // `tenant` + `user` are the TZ defaults for the current request. Currently
14
- // both default to "UTC" — once tenant.timezone + user.timezone fields
15
- // exist, they get read from the request context here.
13
+ // `tenant` + `user` are the TZ defaults for the current request — resolved
14
+ // by buildHandlerContext (tenant:config:timezone, then SessionUser.timezone,
15
+ // falling back to "UTC" at each step) and passed in via TzContextOptions.
16
+ // This factory itself stays a pure default-filler.
16
17
  //
17
18
  // The pure type contracts (TzContext, TzContextOptions, LocatedTimestampJson)
18
19
  // live in @cosmicdrift/kumiko-types/tz-context — only the factories are here.