@cosmicdrift/kumiko-framework 0.265.0 → 0.268.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 (54) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
  6. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  7. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
  8. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
  9. package/src/api/__tests__/jwt.test.ts +8 -0
  10. package/src/api/jwt.ts +8 -0
  11. package/src/changes.json +35 -0
  12. package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
  13. package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
  14. package/src/db/__tests__/tenant-db-global.test.ts +95 -5
  15. package/src/db/entity-table-meta.ts +7 -0
  16. package/src/db/event-store-executor-context.ts +19 -0
  17. package/src/db/event-store-executor-write.ts +16 -0
  18. package/src/db/index.ts +3 -1
  19. package/src/db/table-builder.ts +27 -0
  20. package/src/db/tenant-db.ts +83 -10
  21. package/src/engine/active-membership.ts +15 -1
  22. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
  23. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
  24. package/src/engine/boot-validator/access-declarations.ts +101 -27
  25. package/src/engine/boot-validator/global-tenancy.ts +17 -0
  26. package/src/engine/boot-validator/index.ts +2 -0
  27. package/src/engine/boot-validator/zod-shape.ts +92 -1
  28. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
  29. package/src/engine/feature-ast/extractors/hooks.ts +8 -4
  30. package/src/engine/feature-ui-extensions.ts +2 -2
  31. package/src/engine/index.ts +4 -0
  32. package/src/engine/registry-validate.ts +6 -6
  33. package/src/engine/types/index.ts +3 -0
  34. package/src/entrypoint/index.ts +2 -13
  35. package/src/errors/i18n/de.yaml +20 -0
  36. package/src/errors/i18n/en.yaml +19 -0
  37. package/src/errors/reasons.ts +8 -0
  38. package/src/jobs/job-runner.ts +14 -0
  39. package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
  40. package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
  41. package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
  42. package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
  43. package/src/pipeline/active-membership.ts +3 -1
  44. package/src/pipeline/dispatch-shared.ts +89 -10
  45. package/src/pipeline/dispatch-stream.ts +9 -5
  46. package/src/pipeline/dispatch-write.ts +7 -0
  47. package/src/pipeline/dispatcher-utils.ts +1 -0
  48. package/src/pipeline/dispatcher.ts +18 -0
  49. package/src/pipeline/index.ts +1 -1
  50. package/src/pipeline/member-reader.ts +108 -0
  51. package/src/pipeline/system-identity-switch.ts +73 -13
  52. package/src/stack/test-stack.ts +10 -1
  53. package/src/testing/handler-context.ts +3 -0
  54. package/src/ui-types/index.ts +1 -0
@@ -51,6 +51,23 @@ describe("dispatcherSpanAttributes", () => {
51
51
  "kumiko.feature": "feat",
52
52
  });
53
53
  });
54
+
55
+ test("includes kumiko.user_origin when the user has one", () => {
56
+ const user = {
57
+ id: "u1",
58
+ tenantId: "tenant-1",
59
+ roles: [],
60
+ origin: "member-resolution" as const,
61
+ };
62
+ const attrs = dispatcherSpanAttributes("feat:query:task:list", "query", user, "feat");
63
+ expect(attrs["kumiko.user_origin"]).toBe("member-resolution");
64
+ });
65
+
66
+ test("omits kumiko.user_origin when the user has none", () => {
67
+ const user = createSystemUser("tenant-1");
68
+ const attrs = dispatcherSpanAttributes("feat:query:task:list", "query", user, "feat");
69
+ expect(attrs["kumiko.user_origin"]).toBeUndefined();
70
+ });
54
71
  });
55
72
 
56
73
  describe("prefixValidationPath", () => {
@@ -19,7 +19,6 @@ const globalStoreTable = defineUnmanagedTable({
19
19
  primaryKey: true,
20
20
  defaultSql: "gen_random_uuid()",
21
21
  },
22
- { name: "tenant_id", pgType: "uuid", notNull: true },
23
22
  { name: "note", pgType: "text", notNull: true },
24
23
  ],
25
24
  });
@@ -36,7 +35,6 @@ const escapeHatchFeature = defineFeature("escape-hatch-probe", (r) => {
36
35
  escapeHatch: { reason: "fw#2855 integration test — declared cross-tenant write" },
37
36
  handler: async (event, ctx) => {
38
37
  const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
39
- tenantId: event.user.tenantId,
40
38
  note: event.payload.note,
41
39
  });
42
40
  return { isSuccess: true as const, data: row };
@@ -49,7 +47,6 @@ const escapeHatchFeature = defineFeature("escape-hatch-probe", (r) => {
49
47
  access: { roles: ["User"] },
50
48
  handler: async (event, ctx) => {
51
49
  const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
52
- tenantId: event.user.tenantId,
53
50
  note: event.payload.note,
54
51
  });
55
52
  return { isSuccess: true as const, data: row };
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
+ import type { DbRunner } from "../../db/connection";
3
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
2
4
  import { createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID } from "../../engine";
3
5
  import type { SessionUser } from "../../engine/types";
4
6
  import type { TenantId } from "../../engine/types/identifiers";
@@ -8,7 +10,8 @@ import {
8
10
  type IdentitySwitch,
9
11
  isSystemIdentity,
10
12
  isSystemIdentitySwitchAllowed,
11
- withHookIdentitySwitchGrant,
13
+ type WriteAsFn,
14
+ withHookEscapeHatchGrant,
12
15
  } from "../system-identity-switch";
13
16
 
14
17
  const TENANT = "00000000-0000-4000-8000-00000000ab01" as TenantId;
@@ -120,18 +123,14 @@ describe("createGatedIdentitySwitch", () => {
120
123
  });
121
124
  });
122
125
 
123
- describe("withHookIdentitySwitchGrant", () => {
126
+ describe("withHookEscapeHatchGrant", () => {
124
127
  test("hook without escapeHatch does NOT inherit the handler's SYSTEM grant", async () => {
125
128
  const { ungated } = makeUngated();
126
129
  // Simulates dispatch-shared.ts's buildHandlerContext for a handler that
127
130
  // itself declared escapeHatch / is systemScope (allowSystemIdentity=true).
128
131
  const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', true, ungated) };
129
132
 
130
- const hookCtx = withHookIdentitySwitchGrant(
131
- handlerCtx,
132
- 'postSave hook of feature "f"',
133
- undefined,
134
- );
133
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, 'postSave hook of feature "f"', undefined);
135
134
 
136
135
  await expect(hookCtx.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(
137
136
  AccessDeniedError,
@@ -143,7 +142,7 @@ describe("withHookIdentitySwitchGrant", () => {
143
142
  // Handler itself has NO escapeHatch (allowSystemIdentity=false).
144
143
  const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
145
144
 
146
- const hookCtx = withHookIdentitySwitchGrant(handlerCtx, 'postSave hook of feature "f"', {
145
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, 'postSave hook of feature "f"', {
147
146
  reason: "test hook needs SYSTEM",
148
147
  });
149
148
 
@@ -155,7 +154,7 @@ describe("withHookIdentitySwitchGrant", () => {
155
154
  test("a non-system asUser is always allowed through the hook's own gate", async () => {
156
155
  const { ungated, queryAsMock } = makeUngated();
157
156
  const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', false, ungated) };
158
- const hookCtx = withHookIdentitySwitchGrant(handlerCtx, "hook", undefined);
157
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", undefined);
159
158
 
160
159
  await hookCtx.queryAs(normalUser, "q", {});
161
160
  expect(queryAsMock).toHaveBeenCalledWith(normalUser, "q", {});
@@ -166,30 +165,76 @@ describe("withHookIdentitySwitchGrant", () => {
166
165
  const handlerCtx = { ...createGatedIdentitySwitch('handler "outer"', true, ungated), extra: 1 };
167
166
  const spread = { ...handlerCtx };
168
167
 
169
- const hookCtx = withHookIdentitySwitchGrant(spread, "hook", { reason: "needs system" });
168
+ const hookCtx = withHookEscapeHatchGrant(spread, "hook", { reason: "needs system" });
170
169
  await hookCtx.queryAs(systemUserById, "q", {});
171
170
 
172
171
  expect(queryAsMock).toHaveBeenCalledWith(systemUserById, "q", {});
173
172
  });
174
173
 
175
- test("a context without queryAs/writeAs is returned unchanged", () => {
176
- const plainContext = { db: {} };
177
- const result = withHookIdentitySwitchGrant(plainContext, "hook", { reason: "x" });
174
+ test("a context without queryAs/writeAs/db/dbOutsideTransaction is returned unchanged", () => {
175
+ const plainContext = { unrelated: 1 };
176
+ const result = withHookEscapeHatchGrant(plainContext, "hook", { reason: "x" });
178
177
  expect(result).toBe(plainContext);
179
178
  });
180
179
 
180
+ test("a deny-stubbed writeAs is not revived through a sibling queryAs's registered ungated pair", async () => {
181
+ // Like a member-resolution ctx: registered gated queryAs next to an unregistered deny-stub writeAs.
182
+ const { ungated, writeAsMock } = makeUngated();
183
+ const gatedIdentitySwitch = createGatedIdentitySwitch('handler "outer"', true, ungated);
184
+ const denyStubWriteAs: WriteAsFn = async () => {
185
+ throw new AccessDeniedError({
186
+ message: "read-only",
187
+ details: { reason: "member_resolution_read_only" },
188
+ });
189
+ };
190
+ const handlerCtx = { queryAs: gatedIdentitySwitch.queryAs, writeAs: denyStubWriteAs };
191
+
192
+ const hookCtx = withHookEscapeHatchGrant(handlerCtx, "hook", {
193
+ reason: "test hook needs SYSTEM",
194
+ });
195
+
196
+ await expect(hookCtx.writeAs(systemUserById, "w", {})).rejects.toBeInstanceOf(
197
+ AccessDeniedError,
198
+ );
199
+ expect(writeAsMock).not.toHaveBeenCalled();
200
+ });
201
+
181
202
  test("an ungated context not built by the dispatcher bridge fails closed (unit-test stub)", async () => {
182
203
  const stubQueryAs = mock(async (_user: SessionUser, _qn: string, _payload: unknown) => "ok");
183
204
  const stubContext = { queryAs: stubQueryAs };
184
205
 
185
- const hookCtx = withHookIdentitySwitchGrant(stubContext, "hook", undefined);
206
+ const hookCtx = withHookEscapeHatchGrant(stubContext, "hook", undefined);
186
207
  await expect(hookCtx.queryAs(systemUserById, "q", {})).rejects.toBeInstanceOf(
187
208
  AccessDeniedError,
188
209
  );
189
210
  expect(stubQueryAs).not.toHaveBeenCalled();
190
211
 
191
- const allowedHookCtx = withHookIdentitySwitchGrant(stubContext, "hook", { reason: "x" });
212
+ const allowedHookCtx = withHookEscapeHatchGrant(stubContext, "hook", { reason: "x" });
192
213
  await allowedHookCtx.queryAs(systemUserById, "q", {});
193
214
  expect(stubQueryAs).toHaveBeenCalledWith(systemUserById, "q", {});
194
215
  });
216
+
217
+ test("hook re-gates ctx.db.unsafeRaw() independently of the handler's own grant", () => {
218
+ const rawDb: DbRunner = {
219
+ unsafe: async () => [],
220
+ begin: async () => {
221
+ throw new Error("begin not used in this test");
222
+ },
223
+ } as DbRunner;
224
+ // Simulates buildHandlerContext granting the HANDLER its own unsafeRaw escapeHatch.
225
+ const handlerDb = createTenantDb(rawDb, TENANT, "tenant", undefined, undefined, undefined, {
226
+ unsafeRaw: { reason: "handler's own grant" },
227
+ });
228
+ const handlerCtx = { db: handlerDb };
229
+
230
+ const hookCtxWithoutEscapeHatch = withHookEscapeHatchGrant(handlerCtx, "hook", undefined) as {
231
+ db: TenantDb;
232
+ };
233
+ expect(() => hookCtxWithoutEscapeHatch.db.unsafeRaw("test reason")).toThrow(AccessDeniedError);
234
+
235
+ const hookCtxWithEscapeHatch = withHookEscapeHatchGrant(handlerCtx, "hook", {
236
+ reason: "hook's own grant",
237
+ }) as { db: TenantDb };
238
+ expect(hookCtxWithEscapeHatch.db.unsafeRaw("test reason")).toBe(rawDb);
239
+ });
195
240
  });
@@ -0,0 +1,103 @@
1
+ // fw#2858 — a write/query handler's `escapeHatch` also gates ctx.db.unsafeRaw(reason):
2
+ // WITH it the raw query succeeds, WITHOUT it the same handler rejects with access_denied.
3
+ // Real HTTP calls + setupTestStack — never createTestDispatcher. Modelled exactly on
4
+ // escape-hatch.integration.test.ts (fw#2855).
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { z } from "zod";
8
+ import { executeRawQuery } from "../../db/queries/raw-sql";
9
+ import { defineFeature } from "../../engine";
10
+ import { createTestUser, setupTestStack, type TestStack } from "../../stack";
11
+
12
+ const unsafeRawFeature = defineFeature("unsafe-raw-probe", (r) => {
13
+ r.writeHandler({
14
+ name: "write-with-hatch",
15
+ schema: z.object({}),
16
+ access: { roles: ["User"] },
17
+ escapeHatch: { reason: "fw#2858 integration test — declared unsafeRaw write" },
18
+ handler: async (_event, ctx) => {
19
+ const runner = ctx.db.unsafeRaw("fw#2858 integration test — declared unsafeRaw write");
20
+ const rows = await executeRawQuery<{ one: number }>(runner, "SELECT 1 AS one");
21
+ return { isSuccess: true as const, data: { one: rows[0]?.one } };
22
+ },
23
+ });
24
+
25
+ r.writeHandler({
26
+ name: "write-without-hatch",
27
+ schema: z.object({}),
28
+ access: { roles: ["User"] },
29
+ handler: async (_event, ctx) => {
30
+ const runner = ctx.db.unsafeRaw("no escapeHatch declared — must throw before this runs");
31
+ const rows = await executeRawQuery<{ one: number }>(runner, "SELECT 1 AS one");
32
+ return { isSuccess: true as const, data: { one: rows[0]?.one } };
33
+ },
34
+ });
35
+
36
+ r.queryHandler({
37
+ name: "query-with-hatch",
38
+ schema: z.object({}),
39
+ access: { roles: ["User"] },
40
+ escapeHatch: { reason: "fw#2858 integration test — declared unsafeRaw query" },
41
+ handler: async (_query, ctx) => {
42
+ const runner = ctx.db.unsafeRaw("fw#2858 integration test — declared unsafeRaw query");
43
+ const rows = await executeRawQuery<{ one: number }>(runner, "SELECT 1 AS one");
44
+ return { one: rows[0]?.one };
45
+ },
46
+ });
47
+
48
+ r.queryHandler({
49
+ name: "query-without-hatch",
50
+ schema: z.object({}),
51
+ access: { roles: ["User"] },
52
+ handler: async (_query, ctx) => {
53
+ const runner = ctx.db.unsafeRaw("no escapeHatch declared — must throw before this runs");
54
+ const rows = await executeRawQuery<{ one: number }>(runner, "SELECT 1 AS one");
55
+ return { one: rows[0]?.one };
56
+ },
57
+ });
58
+ });
59
+
60
+ let stack: TestStack;
61
+ const user = createTestUser({
62
+ id: 92,
63
+ tenantId: "11111111-0000-4000-8000-000000000092",
64
+ roles: ["User"],
65
+ });
66
+
67
+ beforeAll(async () => {
68
+ stack = await setupTestStack({ features: [unsafeRawFeature] });
69
+ });
70
+
71
+ afterAll(async () => {
72
+ await stack.cleanup();
73
+ });
74
+
75
+ describe("write/query handler escapeHatch gates ctx.db.unsafeRaw()", () => {
76
+ test("write WITH escapeHatch: unsafeRaw succeeds", async () => {
77
+ const result = await stack.http.writeOk<{ one: number }>(
78
+ "unsafe-raw-probe:write:write-with-hatch",
79
+ {},
80
+ user,
81
+ );
82
+ expect(result.one).toBe(1);
83
+ });
84
+
85
+ test("write WITHOUT escapeHatch: unsafeRaw fails with access_denied", async () => {
86
+ const err = await stack.http.writeErr("unsafe-raw-probe:write:write-without-hatch", {}, user);
87
+ expect(err.code).toBe("access_denied");
88
+ });
89
+
90
+ test("query WITH escapeHatch: unsafeRaw succeeds", async () => {
91
+ const result = await stack.http.queryOk<{ one: number }>(
92
+ "unsafe-raw-probe:query:query-with-hatch",
93
+ {},
94
+ user,
95
+ );
96
+ expect(result.one).toBe(1);
97
+ });
98
+
99
+ test("query WITHOUT escapeHatch: unsafeRaw fails with access_denied", async () => {
100
+ const err = await stack.http.queryErr("unsafe-raw-probe:query:query-without-hatch", {}, user);
101
+ expect(err.code).toBe("access_denied");
102
+ });
103
+ });
@@ -76,7 +76,9 @@ async function findMembership(
76
76
  return rawMemberships.find((m) => m.tenantId === tenantId);
77
77
  }
78
78
 
79
- function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugin {
79
+ // Exported so member-reader.ts (ctx.queryAsMember) can reach the registered
80
+ // PrincipalStatusPlugin's resolveProfile without duplicating this lookup.
81
+ export function resolvePrincipalPlugin(registry: Registry): PrincipalStatusPlugin {
80
82
  const usages = registry.getExtensionUsages(EXT_PRINCIPAL_STATUS);
81
83
  if (usages.length !== 1) {
82
84
  throw new InternalError({
@@ -16,13 +16,16 @@ import type {
16
16
  FetchForWritingArgs,
17
17
  HandlerContext,
18
18
  JobRunnerRef,
19
+ MemberReader,
19
20
  Registry,
20
21
  SessionUser,
21
22
  WriteResult,
22
23
  } from "../engine/types";
23
24
  import type { TenantId } from "../engine/types/identifiers";
24
25
  import {
26
+ AccessDeniedError,
25
27
  FeatureDisabledError,
28
+ FrameworkReasons,
26
29
  InternalError,
27
30
  VersionConflictError,
28
31
  type WriteErrorInfo,
@@ -76,7 +79,12 @@ import {
76
79
  } from "./dispatcher-utils";
77
80
  import type { IdempotencyGuard } from "./idempotency";
78
81
  import type { LifecycleHooks } from "./lifecycle-pipeline";
79
- import { createGatedIdentitySwitch, systemIdentitySwitchDenied } from "./system-identity-switch";
82
+ import { createMemberReaderFn } from "./member-reader";
83
+ import {
84
+ createGatedIdentitySwitch,
85
+ createGatedMemberReader,
86
+ systemIdentitySwitchDenied,
87
+ } from "./system-identity-switch";
80
88
  import type { TenantTimezoneCache } from "./tenant-timezone-cache";
81
89
 
82
90
  // Framework/pipeline stays bundled-features-free, so this can't import the
@@ -151,6 +159,8 @@ async function appendDomainEvent(
151
159
  tx: DbTx | undefined,
152
160
  callerFeature: string | undefined,
153
161
  ): Promise<void> {
162
+ // Sink behind every append surface, so a resolved member stays read-only even via fetchForWriting handles.
163
+ if (user.origin === "member-resolution") throw memberResolutionReadOnlyDenied();
154
164
  const { registry } = ctx;
155
165
  const dbSource = resolveDbSource(ctx, tx);
156
166
  if (!dbSource) {
@@ -196,6 +206,59 @@ function createSystemScopedDbGuard(
196
206
  });
197
207
  }
198
208
 
209
+ export function memberResolutionReadOnlyDenied(): AccessDeniedError {
210
+ return new AccessDeniedError({
211
+ message: "a resolved member principal (ctx.queryAsMember) cannot write — read-only",
212
+ details: { reason: FrameworkReasons.memberResolutionReadOnly },
213
+ });
214
+ }
215
+
216
+ async function denyMemberResolutionWrite(): Promise<never> {
217
+ throw memberResolutionReadOnlyDenied();
218
+ }
219
+
220
+ // JobRunnerRef's declared type only carries handleEvent, but callers
221
+ // routinely bracket-access a fuller runner — a Proxy denies ANY property read.
222
+ function denyingJobRunnerProxy(): JobRunnerRef {
223
+ return new Proxy({} as JobRunnerRef, {
224
+ get() {
225
+ throw memberResolutionReadOnlyDenied();
226
+ },
227
+ });
228
+ }
229
+
230
+ // Every write/side-effect surface throws, so "no writeAsMember" holds even
231
+ // if a handler tries ctx.write directly on a resolved member principal.
232
+ function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext {
233
+ return {
234
+ ...handlerContext,
235
+ // `db` itself stays open — same access the member's own HTTP request has;
236
+ // raw ctx.db writes inside a queried handler are not blocked by this.
237
+ dbOutsideTransaction: undefined,
238
+ write: denyMemberResolutionWrite,
239
+ writeAs: denyMemberResolutionWrite,
240
+ appendEvent: denyMemberResolutionWrite as AppendEventFn, // @cast-boundary engine-bridge
241
+ unsafeAppendEvent: denyMemberResolutionWrite,
242
+ tryAppendEvent: denyMemberResolutionWrite,
243
+ fetchForWriting: denyMemberResolutionWrite,
244
+ archiveStream: denyMemberResolutionWrite,
245
+ restoreStream: denyMemberResolutionWrite,
246
+ snapshotAggregate: denyMemberResolutionWrite,
247
+ queryAsMember: denyMemberResolutionWrite,
248
+ resolveActiveMembership: denyMemberResolutionWrite,
249
+ scheduleAfterCommit: () => {
250
+ throw memberResolutionReadOnlyDenied();
251
+ },
252
+ // A read as a member gets no system-scope DB, preSave pipeline or file storage.
253
+ runPreSave: undefined,
254
+ systemDb: undefined,
255
+ files: undefined,
256
+ derivatives: undefined,
257
+ ...(handlerContext.jobRunner && { jobRunner: denyingJobRunnerProxy() }),
258
+ ...(handlerContext.notify && { notify: denyMemberResolutionWrite }),
259
+ };
260
+ }
261
+
199
262
  export async function buildHandlerContext(
200
263
  ctx: DispatchContext,
201
264
  type: string,
@@ -211,13 +274,10 @@ export async function buildHandlerContext(
211
274
  // but at this point we're the root of the pipeline — cast is safe.
212
275
  const dbSource = resolveDbSource(ctx, tx);
213
276
  const reqCtx = requestContext.get();
214
- // db.global()'s write-gate — undefined for query/stream handlers, which can't declare escapeHatch.
215
- const escapeHatch = registry.getWriteHandler(type)?.escapeHatch;
216
- // Broader than `escapeHatch` above — a query handler can't reach db.global() but can still opt in here.
217
- const allowSystemIdentity =
218
- isSystem ||
219
- (registry.getWriteHandler(type)?.escapeHatch ?? registry.getQueryHandler(type)?.escapeHatch) !==
220
- undefined;
277
+ // global() writes are write-handler-only; SYSTEM identity switch and unsafeRaw accept write or query escapeHatch.
278
+ const writeEscapeHatch = registry.getWriteHandler(type)?.escapeHatch;
279
+ const handlerEscapeHatch = writeEscapeHatch ?? registry.getQueryHandler(type)?.escapeHatch;
280
+ const allowSystemIdentity = isSystem || handlerEscapeHatch !== undefined;
221
281
  const buildTenantScopedDb = (source: DbConnection | DbTx, signal: AbortSignal | undefined) =>
222
282
  createTenantDb(
223
283
  source,
@@ -226,7 +286,7 @@ export async function buildHandlerContext(
226
286
  context.tracer,
227
287
  context.meter,
228
288
  signal,
229
- escapeHatch,
289
+ { globalWrites: writeEscapeHatch, unsafeRaw: handlerEscapeHatch },
230
290
  );
231
291
  // Propagate the request's AbortSignal so every TenantDb query throws when
232
292
  // the client has disconnected — handlers with many sequential queries skip
@@ -326,6 +386,17 @@ export async function buildHandlerContext(
326
386
  writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
327
387
  executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
328
388
  });
389
+ // Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
390
+ // on first actual use, not on every HandlerContext build.
391
+ let ungatedMemberReader: MemberReader | undefined;
392
+ const queryAsMember = createGatedMemberReader(
393
+ `handler "${type}"`,
394
+ allowSystemIdentity,
395
+ (userId, qn, payload) => {
396
+ ungatedMemberReader ??= createMemberReaderFn(ctx, user.tenantId, tx);
397
+ return ungatedMemberReader(userId, qn, payload);
398
+ },
399
+ );
329
400
  const bridge = {
330
401
  query: (targetType: string, payload: unknown) =>
331
402
  executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
@@ -604,6 +675,9 @@ export async function buildHandlerContext(
604
675
  return resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY); // @wrapper-known semantic-alias
605
676
  },
606
677
 
678
+ // Needs the same grant as a SYSTEM queryAs.
679
+ queryAsMember,
680
+
607
681
  // Feature-effective check for in-handler opt-in logic. Scope:
608
682
  // **current user's tenant** — for cross-tenant lookups (rare,
609
683
  // SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
@@ -688,7 +762,7 @@ export async function buildHandlerContext(
688
762
  user.locale !== undefined && isValidLocaleTag(user.locale) ? user.locale : undefined;
689
763
  const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
690
764
 
691
- return {
765
+ const handlerContext = {
692
766
  ...context,
693
767
  registry,
694
768
  db: exposedDb,
@@ -740,6 +814,11 @@ export async function buildHandlerContext(
740
814
  ...(includeDeleted && { includeDeleted: true }),
741
815
  ...bridge,
742
816
  } as HandlerContext; // @cast-boundary engine-bridge
817
+
818
+ // A resolved member principal is read-only by construction.
819
+ return user.origin === "member-resolution"
820
+ ? applyMemberResolutionReadOnly(handlerContext)
821
+ : handlerContext;
743
822
  }
744
823
 
745
824
  // Wrap handler execution in a dispatcher.handler span AND emit the standard
@@ -7,6 +7,7 @@ import {
7
7
  type DispatchContext,
8
8
  enforceRateLimit,
9
9
  ensureFeatureEnabled,
10
+ memberResolutionReadOnlyDenied,
10
11
  runStreamInstrumented,
11
12
  } from "./dispatch-shared";
12
13
 
@@ -35,6 +36,12 @@ async function* executeStreamInner(
35
36
  const handler = registry.getStreamHandler(type);
36
37
  if (!handler) throw new NotFoundError("handler", type);
37
38
 
39
+ // A resolved member principal (ctx.queryAsMember) is read-only — streams
40
+ // are excluded the same way executeWriteInner excludes writes.
41
+ if (user.origin === "member-resolution") {
42
+ throw memberResolutionReadOnlyDenied();
43
+ }
44
+
38
45
  await ensureFeatureEnabled(ctx, type, user.tenantId);
39
46
 
40
47
  if (handler.rateLimit !== undefined) {
@@ -96,11 +103,8 @@ async function* executeStreamInner(
96
103
  }
97
104
  } finally {
98
105
  unsubscribeAccessInvalidation?.();
99
- // Consumer break / throw — always close the handler generator so its
100
- // finally (cleanup) runs (for-await would do this). Do NOT swallow
101
- // return() errors — close-time cleanup failures must surface to
102
- // runStreamInstrumented (#1543). Skip the await after access-revoke
103
- // abandonment (overlapping next+return deadlocks — see above).
106
+ // Close the generator so cleanup runs; skip awaiting return() after
107
+ // access-revoke abandonment — overlapping next()+return() can deadlock.
104
108
  if (iterator !== undefined && !abandonedForInvalidation) {
105
109
  await iterator.return?.(undefined);
106
110
  }
@@ -27,6 +27,7 @@ import {
27
27
  CONFIG_WRITE_SET_TYPE,
28
28
  checkFeatureEnabled,
29
29
  enforceRateLimit,
30
+ memberResolutionReadOnlyDenied,
30
31
  runHandlerInstrumented,
31
32
  TENANT_TIMEZONE_CONFIG_KEY,
32
33
  } from "./dispatch-shared";
@@ -327,6 +328,12 @@ async function executeWriteInner(
327
328
  const handler = registry.getWriteHandler(type);
328
329
  if (!handler) return writeFailure(new NotFoundError("handler", type));
329
330
 
331
+ // Defense in depth — covers runBatch and a spread-copied user that kept
332
+ // `origin`, beyond buildHandlerContext's own ctx.write replacement.
333
+ if (user.origin === "member-resolution") {
334
+ return writeFailure(memberResolutionReadOnlyDenied());
335
+ }
336
+
330
337
  // Feature-toggle gate: disabled handlers must short-circuit before any
331
338
  // rate-limit/access/validation work — see executeQueryInner comment.
332
339
  const disabledErr = await checkFeatureEnabled(ctx, type, user.tenantId);
@@ -49,6 +49,7 @@ export function dispatcherSpanAttributes(
49
49
  "kumiko.tenant_id": user.tenantId,
50
50
  };
51
51
  if (feature) attrs["kumiko.feature"] = feature;
52
+ if (user.origin) attrs["kumiko.user_origin"] = user.origin;
52
53
  return attrs;
53
54
  }
54
55
 
@@ -6,7 +6,9 @@ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extensio
6
6
  import type {
7
7
  ActiveMembershipResult,
8
8
  AppContext,
9
+ DispatchWriteRef,
9
10
  JobRunnerRef,
11
+ MemberReader,
10
12
  Registry,
11
13
  SessionUser,
12
14
  WriteResult,
@@ -23,6 +25,7 @@ import { executeStream } from "./dispatch-stream";
23
25
  import { type HandlerType, resolveType } from "./dispatcher-utils";
24
26
  import type { IdempotencyGuard } from "./idempotency";
25
27
  import type { LifecycleHooks } from "./lifecycle-pipeline";
28
+ import { createMemberReaderFn } from "./member-reader";
26
29
  import { createTenantTimezoneCache } from "./tenant-timezone-cache";
27
30
 
28
31
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
@@ -97,8 +100,21 @@ export type Dispatcher = {
97
100
  // Membership check for interactive sign-in paths (login, MFA completion,
98
101
  // tenant switch) — single resolve implementation so callers can't independently drift.
99
102
  resolveActiveMembership(userId: string, tenantId: TenantId): Promise<ActiveMembershipResult>;
103
+ // Trusted server surface, ungated like resolveActiveMembership.
104
+ // Handler/hook ctx.queryAsMember uses buildHandlerContext's own gated reader instead.
105
+ createMemberReader(tenantId: TenantId): MemberReader;
100
106
  };
101
107
 
108
+ // Adapts Dispatcher's (type, payload, user) call shape to DispatchWriteRef's
109
+ // (user, qn, payload) — JobRunner.attachDispatcher needs the latter.
110
+ export function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
111
+ return {
112
+ write: (user, qn, payload) => dispatcher.write(qn, payload, user),
113
+ queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
114
+ createMemberReader: (tenantId) => dispatcher.createMemberReader(tenantId),
115
+ };
116
+ }
117
+
102
118
  export function createDispatcher(
103
119
  registry: Registry,
104
120
  context: AppContext,
@@ -164,5 +180,7 @@ export function createDispatcher(
164
180
 
165
181
  resolveActiveMembership: (userId, tenantId) =>
166
182
  resolveActiveMembershipFn(ctx, userId, tenantId, INTERACTIVE_SIGN_IN_POLICY),
183
+
184
+ createMemberReader: (tenantId) => createMemberReaderFn(ctx, tenantId),
167
185
  };
168
186
  }
@@ -2,7 +2,7 @@ export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver";
2
2
  export { resolveAuthClaims } from "./auth-claims-resolver";
3
3
  export { createCascadeDeleteHook } from "./cascade-handler";
4
4
  export type { Dispatcher } from "./dispatcher";
5
- export { createDispatcher } from "./dispatcher";
5
+ export { createDispatcher, dispatcherToWriteRef } from "./dispatcher";
6
6
  export type { DistributedLock } from "./distributed-lock";
7
7
  export { createDistributedLock } from "./distributed-lock";
8
8
  export type { EntityCache, EntityCacheOptions } from "./entity-cache";