@cosmicdrift/kumiko-framework 0.299.0 → 0.305.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 (66) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/api.test.ts +3 -2
  3. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  4. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  5. package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
  6. package/src/api/__tests__/server-error-logging.test.ts +33 -0
  7. package/src/api/api-constants.ts +13 -0
  8. package/src/api/auth-routes.ts +53 -24
  9. package/src/api/extra-route.ts +33 -4
  10. package/src/api/index.ts +1 -0
  11. package/src/api/server.ts +79 -34
  12. package/src/changes.json +68 -0
  13. package/src/db/event-store-executor-write.ts +7 -0
  14. package/src/db/tenant-db.ts +50 -3
  15. package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +13 -4
  17. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
  18. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
  19. package/src/engine/boot-validator/access-declarations.ts +58 -67
  20. package/src/engine/boot-validator/entity-handler.ts +20 -0
  21. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  22. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  23. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
  24. package/src/engine/feature-ast/extractors/hooks.ts +3 -1
  25. package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
  26. package/src/engine/feature-ast/patcher.ts +2 -2
  27. package/src/engine/feature-ast/patterns.ts +1 -1
  28. package/src/engine/feature-ast/render.ts +1 -1
  29. package/src/engine/feature-ui-extensions.ts +6 -0
  30. package/src/engine/index.ts +4 -0
  31. package/src/engine/membership-roles.ts +20 -4
  32. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  33. package/src/engine/pattern-library/mixed-schemas.ts +1 -0
  34. package/src/engine/personal-data-fields.ts +66 -0
  35. package/src/engine/registry-validate.ts +15 -0
  36. package/src/engine/registry.ts +2 -0
  37. package/src/engine/types/index.ts +4 -0
  38. package/src/env/__tests__/dry-run.test.ts +43 -3
  39. package/src/env/dry-run.ts +28 -15
  40. package/src/errors/__tests__/write-failures.test.ts +47 -4
  41. package/src/errors/i18n/de.yaml +12 -0
  42. package/src/errors/i18n/en.yaml +12 -0
  43. package/src/errors/reasons.ts +4 -0
  44. package/src/errors/write-error-info.ts +12 -3
  45. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  46. package/src/jobs/__tests__/jobs.integration.test.ts +35 -0
  47. package/src/jobs/job-runner.ts +19 -5
  48. package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
  49. package/src/observability/index.ts +5 -0
  50. package/src/observability/metrics-wiring.ts +32 -0
  51. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  52. package/src/pipeline/active-membership.ts +5 -1
  53. package/src/pipeline/dispatch-batch.ts +3 -0
  54. package/src/pipeline/dispatch-query.ts +16 -5
  55. package/src/pipeline/dispatch-shared.ts +12 -5
  56. package/src/pipeline/dispatch-stream.ts +7 -2
  57. package/src/pipeline/dispatch-write.ts +22 -5
  58. package/src/pipeline/dispatcher.ts +9 -2
  59. package/src/pipeline/member-reader.ts +3 -1
  60. package/src/pipeline/write-origin.ts +107 -0
  61. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  62. package/src/rate-limit/middleware.ts +3 -0
  63. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  64. package/src/stack/test-stack.ts +5 -0
  65. package/src/testing/handler-context.ts +3 -1
  66. package/src/ui-types/index.ts +2 -0
@@ -0,0 +1,425 @@
1
+ // Every anonymous handler here keeps personal-data keys out of its own input schema,
2
+ // so the static boot check passes and only the runtime gate can stop the write.
3
+
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { z } from "zod";
6
+ import type { SchemaTable } from "../../db";
7
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
8
+ import { asRawClient, selectMany } from "../../db/query";
9
+ import { buildEntityTable } from "../../db/table-builder";
10
+ import type { TenantDb } from "../../db/tenant-db";
11
+ import { createEntity, createSystemUser, createTextField, defineFeature } from "../../engine";
12
+ import { SYSTEM_ROLE } from "../../engine/system-user";
13
+ import type { TenantId } from "../../engine/types";
14
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
15
+
16
+ const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
17
+ const RATE_LIMIT = { per: "ip", limit: 1000, windowSeconds: 60 } as const;
18
+
19
+ // --- Feature B: owns the PII-carrying entity ---
20
+
21
+ const contactEntity = createEntity({
22
+ table: "intake_contacts",
23
+ fields: {
24
+ email: createTextField({ personal: "self", find: "none", default: "" }),
25
+ note: createTextField({ personal: false, reason: "test_fixture", default: "" }),
26
+ },
27
+ });
28
+ const contactTable = buildEntityTable("contact", contactEntity);
29
+
30
+ // No `table:` override: the gate must resolve the default, entity-name-derived table.
31
+ const leadEntity = createEntity({
32
+ fields: {
33
+ phone: createTextField({ personal: "self", find: "none", default: "" }),
34
+ },
35
+ });
36
+ const leadTable = buildEntityTable("intakeLead", leadEntity);
37
+
38
+ const featureB = defineFeature("intakeb", (r) => {
39
+ r.entity("contact", contactEntity);
40
+ r.entity("intakeLead", leadEntity);
41
+
42
+ r.writeHandler(
43
+ "create-lead",
44
+ z.object({ phone: z.string() }),
45
+ async (event, ctx) => {
46
+ const crud = createEventStoreExecutor(leadTable, leadEntity, { entityName: "intakeLead" });
47
+ return crud.create({ phone: event.payload.phone }, event.user, ctx.db);
48
+ },
49
+ { access: { roles: [SYSTEM_ROLE] } },
50
+ );
51
+
52
+ r.writeHandler(
53
+ "create",
54
+ z.object({ email: z.string(), note: z.string().optional() }),
55
+ async (event, ctx) => {
56
+ const crud = createEventStoreExecutor(contactTable, contactEntity, { entityName: "contact" });
57
+ return crud.create(
58
+ { email: event.payload.email, note: event.payload.note ?? "" },
59
+ event.user,
60
+ ctx.db,
61
+ );
62
+ },
63
+ { access: { roles: [SYSTEM_ROLE] } },
64
+ );
65
+ });
66
+
67
+ // --- Feature A: anonymous entry points that try to leak B's PII by every route ---
68
+
69
+ const probeEntity = createEntity({
70
+ table: "intake_probes",
71
+ fields: {
72
+ note: createTextField({ personal: false, reason: "test_fixture", required: true }),
73
+ },
74
+ });
75
+ const probeTable = buildEntityTable("probe", probeEntity);
76
+
77
+ const featureA = defineFeature("intakea", (r) => {
78
+ r.entity("probe", probeEntity);
79
+
80
+ r.writeHandler(
81
+ "direct-insert-no-declare",
82
+ z.object({ note: z.string() }),
83
+ async (event, ctx) => {
84
+ // @cast-boundary test-fixture — proving the RUNTIME gate stops this
85
+ // write, not the compile-time ExecutorOnly brand on contactTable.
86
+ await ctx.db.insertOne(contactTable as unknown as SchemaTable, {
87
+ email: "leak-direct@example.com",
88
+ note: event.payload.note,
89
+ });
90
+ return { isSuccess: true as const, data: { ok: true as const } };
91
+ },
92
+ { access: { roles: ["anonymous"] }, rateLimit: RATE_LIMIT },
93
+ );
94
+
95
+ r.writeHandler(
96
+ "direct-insert-declare",
97
+ z.object({ note: z.string() }),
98
+ async (event, ctx) => {
99
+ // @cast-boundary test-fixture — see direct-insert-no-declare above.
100
+ await ctx.db.insertOne(contactTable as unknown as SchemaTable, {
101
+ email: "leak-direct-declared@example.com",
102
+ note: event.payload.note,
103
+ });
104
+ return { isSuccess: true as const, data: { ok: true as const } };
105
+ },
106
+ { access: { roles: ["anonymous"], personalData: "public-intake" }, rateLimit: RATE_LIMIT },
107
+ );
108
+
109
+ r.writeHandler(
110
+ "writeas-no-declare",
111
+ z.object({ note: z.string() }),
112
+ async (event, ctx) =>
113
+ ctx.writeAs(createSystemUser(event.user.tenantId), "intakeb:write:create", {
114
+ email: "leak-writeas@example.com",
115
+ note: event.payload.note,
116
+ }),
117
+ {
118
+ access: { roles: ["anonymous"] },
119
+ rateLimit: RATE_LIMIT,
120
+ escapeHatch: { reason: "test: cross-identity detour to prove the gate survives writeAs" },
121
+ },
122
+ );
123
+
124
+ r.writeHandler(
125
+ "writeas-declare",
126
+ z.object({ note: z.string() }),
127
+ async (event, ctx) =>
128
+ ctx.writeAs(createSystemUser(event.user.tenantId), "intakeb:write:create", {
129
+ email: "leak-writeas-declared@example.com",
130
+ note: event.payload.note,
131
+ }),
132
+ {
133
+ access: { roles: ["anonymous"], personalData: "public-intake" },
134
+ rateLimit: RATE_LIMIT,
135
+ escapeHatch: { reason: "test: cross-identity detour to prove the gate survives writeAs" },
136
+ },
137
+ );
138
+
139
+ // The postSave hook only fires for handlers whose result is a genuine
140
+ // SaveContext (createEventStoreExecutor's crud.create), so these write to
141
+ // a harmless non-PII probe entity purely to trigger the hook.
142
+ r.writeHandler(
143
+ "hook-no-declare",
144
+ z.object({ note: z.string() }),
145
+ async (event, ctx) => {
146
+ const crud = createEventStoreExecutor(probeTable, probeEntity, { entityName: "probe" });
147
+ return crud.create({ note: event.payload.note }, event.user, ctx.db);
148
+ },
149
+ { access: { roles: ["anonymous"] }, rateLimit: RATE_LIMIT },
150
+ );
151
+ r.writeHandler(
152
+ "hook-declare",
153
+ z.object({ note: z.string() }),
154
+ async (event, ctx) => {
155
+ const crud = createEventStoreExecutor(probeTable, probeEntity, { entityName: "probe" });
156
+ return crud.create({ note: event.payload.note }, event.user, ctx.db);
157
+ },
158
+ { access: { roles: ["anonymous"], personalData: "public-intake" }, rateLimit: RATE_LIMIT },
159
+ );
160
+ r.hook("postSave", "hook-no-declare", async (_result, ctx) => {
161
+ // @cast-boundary test-fixture — postSave hooks receive HandlerContext as
162
+ // AppContext, whose `db` is typed as the DbConnection|TenantDb union
163
+ // (fail-closed at the outer boundary); at runtime it's always the
164
+ // TenantDb built for this write. See direct-insert-no-declare above.
165
+ await (ctx.db as TenantDb).insertOne(contactTable as unknown as SchemaTable, {
166
+ email: "leak-hook@example.com",
167
+ note: "from-afterCommit-hook",
168
+ });
169
+ });
170
+ r.hook("postSave", "hook-declare", async (_result, ctx) => {
171
+ // @cast-boundary test-fixture — see hook-no-declare above.
172
+ await (ctx.db as TenantDb).insertOne(contactTable as unknown as SchemaTable, {
173
+ email: "leak-hook-declared@example.com",
174
+ note: "from-afterCommit-hook",
175
+ });
176
+ });
177
+
178
+ r.queryHandler(
179
+ "detour-query",
180
+ z.object({ note: z.string() }),
181
+ async (event, ctx) =>
182
+ ctx.write("intakeb:write:create", {
183
+ email: "leak-queryas@example.com",
184
+ note: event.payload.note,
185
+ }),
186
+ { access: { roles: [SYSTEM_ROLE] } },
187
+ );
188
+
189
+ r.writeHandler(
190
+ "queryas-no-declare",
191
+ z.object({ note: z.string() }),
192
+ async (event, ctx) =>
193
+ // Detour: anonymous root -> ctx.queryAs(SYSTEM) -> that query handler's
194
+ // own ctx.write. The origin travels with the root call, not the
195
+ // identity-switched SYSTEM user, so this must still be blocked.
196
+ ctx.queryAs(createSystemUser(event.user.tenantId), "intakea:query:detour-query", {
197
+ note: event.payload.note,
198
+ }) as ReturnType<typeof ctx.write>,
199
+ {
200
+ access: { roles: ["anonymous"] },
201
+ rateLimit: RATE_LIMIT,
202
+ escapeHatch: {
203
+ reason: "test: queryAs detour to prove the gate survives nested query->write",
204
+ },
205
+ },
206
+ );
207
+ r.writeHandler(
208
+ "queryas-declare",
209
+ z.object({ note: z.string() }),
210
+ async (event, ctx) =>
211
+ ctx.queryAs(createSystemUser(event.user.tenantId), "intakea:query:detour-query", {
212
+ note: event.payload.note,
213
+ }) as ReturnType<typeof ctx.write>,
214
+ {
215
+ access: { roles: ["anonymous"], personalData: "public-intake" },
216
+ rateLimit: RATE_LIMIT,
217
+ escapeHatch: {
218
+ reason: "test: queryAs detour to prove the gate survives nested query->write",
219
+ },
220
+ },
221
+ );
222
+
223
+ r.writeHandler(
224
+ "default-table-insert-no-declare",
225
+ z.object({ note: z.string() }),
226
+ async (_event, ctx) => {
227
+ // @cast-boundary test-fixture — see direct-insert-no-declare above.
228
+ await ctx.db.insertOne(leadTable as unknown as SchemaTable, { phone: "+49 30 1234" });
229
+ return { isSuccess: true as const, data: { ok: true as const } };
230
+ },
231
+ { access: { roles: ["anonymous"] }, rateLimit: RATE_LIMIT },
232
+ );
233
+
234
+ r.writeHandler(
235
+ "default-table-writeas-no-declare",
236
+ z.object({ note: z.string() }),
237
+ async (event, ctx) =>
238
+ ctx.writeAs(createSystemUser(event.user.tenantId), "intakeb:write:create-lead", {
239
+ phone: "+49 30 5678",
240
+ }),
241
+ {
242
+ access: { roles: ["anonymous"] },
243
+ rateLimit: RATE_LIMIT,
244
+ escapeHatch: { reason: "test: cross-identity detour to prove the gate survives writeAs" },
245
+ },
246
+ );
247
+
248
+ r.writeHandler(
249
+ "non-pii-insert",
250
+ z.object({ note: z.string() }),
251
+ async (event, ctx) => {
252
+ // @cast-boundary test-fixture — only the non-PII `note` column is
253
+ // written here; `email` is deliberately absent so the gate has
254
+ // nothing to block.
255
+ await ctx.db.insertOne(contactTable as unknown as SchemaTable, {
256
+ note: event.payload.note,
257
+ });
258
+ return { isSuccess: true as const, data: { ok: true as const } };
259
+ },
260
+ { access: { roles: ["anonymous"] }, rateLimit: RATE_LIMIT },
261
+ );
262
+
263
+ r.writeHandler(
264
+ "mixed-role-insert",
265
+ z.object({ note: z.string() }),
266
+ async (event, ctx) => {
267
+ // @cast-boundary test-fixture — see direct-insert-no-declare above.
268
+ await ctx.db.insertOne(contactTable as unknown as SchemaTable, {
269
+ email: "leak-mixed-role@example.com",
270
+ note: event.payload.note,
271
+ });
272
+ return { isSuccess: true as const, data: { ok: true as const } };
273
+ },
274
+ { access: { roles: ["anonymous", "Admin"] }, rateLimit: RATE_LIMIT },
275
+ );
276
+ });
277
+
278
+ describe("public-intake runtime gate", () => {
279
+ let stack: TestStack;
280
+
281
+ beforeAll(async () => {
282
+ stack = await setupTestStack({
283
+ features: [featureB, featureA],
284
+ anonymousAccess: { defaultTenantId: TENANT_ID },
285
+ });
286
+ await unsafeCreateEntityTable(stack.db, contactEntity);
287
+ await unsafeCreateEntityTable(stack.db, probeEntity);
288
+ await unsafeCreateEntityTable(stack.db, leadEntity, "intakeLead");
289
+ });
290
+
291
+ afterAll(() => stack.cleanup());
292
+
293
+ beforeEach(async () => {
294
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${contactTable.tableName}"`);
295
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${probeTable.tableName}"`);
296
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${leadTable.tableName}"`);
297
+ });
298
+
299
+ async function rowCount(): Promise<number> {
300
+ const rows = await selectMany(stack.db, contactTable);
301
+ return rows.length;
302
+ }
303
+
304
+ async function probeRowCount(): Promise<number> {
305
+ const rows = await selectMany(stack.db, probeTable);
306
+ return rows.length;
307
+ }
308
+
309
+ test("(a) direct ctx.db.insertOne on a foreign PII table — blocked without declaration", async () => {
310
+ const res = await stack.http.raw("POST", "/api/write", {
311
+ type: "intakea:write:direct-insert-no-declare",
312
+ payload: { note: "x" },
313
+ });
314
+ expect(res.status).toBe(403);
315
+ const body = (await res.json()) as { error: { details: { reason: string } } };
316
+ expect(body.error.details.reason).toBe("public_intake_required");
317
+ expect(await rowCount()).toBe(0);
318
+ });
319
+
320
+ test("(a) direct ctx.db.insertOne on a foreign PII table — allowed once declared", async () => {
321
+ const res = await stack.http.raw("POST", "/api/write", {
322
+ type: "intakea:write:direct-insert-declare",
323
+ payload: { note: "x" },
324
+ });
325
+ expect(res.status).toBe(200);
326
+ expect(await rowCount()).toBe(1);
327
+ });
328
+
329
+ test("(b) ctx.writeAs(SYSTEM, ...) detour — blocked without declaration", async () => {
330
+ const res = await stack.http.raw("POST", "/api/write", {
331
+ type: "intakea:write:writeas-no-declare",
332
+ payload: { note: "x" },
333
+ });
334
+ expect(res.status).toBe(403);
335
+ const body = (await res.json()) as { error: { details: { reason: string } } };
336
+ expect(body.error.details.reason).toBe("public_intake_required");
337
+ expect(await rowCount()).toBe(0);
338
+ });
339
+
340
+ test("(b) ctx.writeAs(SYSTEM, ...) detour — allowed once declared", async () => {
341
+ const res = await stack.http.raw("POST", "/api/write", {
342
+ type: "intakea:write:writeas-declare",
343
+ payload: { note: "x" },
344
+ });
345
+ expect(res.status).toBe(200);
346
+ expect(await rowCount()).toBe(1);
347
+ });
348
+
349
+ test("(c) afterCommit postSave hook writing foreign PII — blocked without declaration", async () => {
350
+ const res = await stack.http.raw("POST", "/api/write", {
351
+ type: "intakea:write:hook-no-declare",
352
+ payload: { note: "x" },
353
+ });
354
+ // The outer write itself already succeeded before the hook ran —
355
+ // afterCommit errors are logged, never surfaced on the HTTP response
356
+ // (flushAfterCommit is awaited inside runBatch before it returns, so
357
+ // there is no race to sleep past). The probe-row assertion proves the
358
+ // hook actually ran (and was gated), not merely that it never fired.
359
+ expect(res.status).toBe(200);
360
+ expect(await probeRowCount()).toBe(1);
361
+ expect(await rowCount()).toBe(0);
362
+ });
363
+
364
+ test("(c) afterCommit postSave hook writing foreign PII — allowed once declared", async () => {
365
+ const res = await stack.http.raw("POST", "/api/write", {
366
+ type: "intakea:write:hook-declare",
367
+ payload: { note: "x" },
368
+ });
369
+ expect(res.status).toBe(200);
370
+ expect(await probeRowCount()).toBe(1);
371
+ expect(await rowCount()).toBe(1);
372
+ });
373
+
374
+ test("(d) anonymous write -> queryAs(SYSTEM) -> query handler's own ctx.write — blocked without declaration", async () => {
375
+ const res = await stack.http.raw("POST", "/api/write", {
376
+ type: "intakea:write:queryas-no-declare",
377
+ payload: { note: "x" },
378
+ });
379
+ expect(res.status).toBe(403);
380
+ const body = (await res.json()) as { error: { details: { reason: string } } };
381
+ expect(body.error.details.reason).toBe("public_intake_required");
382
+ expect(await rowCount()).toBe(0);
383
+ });
384
+
385
+ test("(d) anonymous write -> queryAs(SYSTEM) -> query handler's own ctx.write — allowed once declared", async () => {
386
+ const res = await stack.http.raw("POST", "/api/write", {
387
+ type: "intakea:write:queryas-declare",
388
+ payload: { note: "x" },
389
+ });
390
+ expect(res.status).toBe(200);
391
+ expect(await rowCount()).toBe(1);
392
+ });
393
+
394
+ test("default entity table name — insertOne and executor create both blocked", async () => {
395
+ for (const type of [
396
+ "intakea:write:default-table-insert-no-declare",
397
+ "intakea:write:default-table-writeas-no-declare",
398
+ ]) {
399
+ const res = await stack.http.raw("POST", "/api/write", { type, payload: { note: "x" } });
400
+ expect(res.status).toBe(403);
401
+ const body = (await res.json()) as { error: { details: { reason: string } } };
402
+ expect(body.error.details.reason).toBe("public_intake_required");
403
+ }
404
+ expect(await selectMany(stack.db, leadTable)).toHaveLength(0);
405
+ });
406
+
407
+ test("(e) anonymous write of a non-PII field only — allowed without declaration", async () => {
408
+ const res = await stack.http.raw("POST", "/api/write", {
409
+ type: "intakea:write:non-pii-insert",
410
+ payload: { note: "hello" },
411
+ });
412
+ expect(res.status).toBe(200);
413
+ expect(await rowCount()).toBe(1);
414
+ });
415
+
416
+ test("(f) authenticated user on a mixed-role handler (anonymous + Admin) — allowed without declaration", async () => {
417
+ const res = await stack.http.write(
418
+ "intakea:write:mixed-role-insert",
419
+ { note: "hi" },
420
+ TestUsers.admin,
421
+ );
422
+ expect(res.status).toBe(200);
423
+ expect(await rowCount()).toBe(1);
424
+ });
425
+ });
@@ -16,6 +16,7 @@ import type { TenantId } from "../engine/types/identifiers";
16
16
  import { InternalError } from "../errors";
17
17
  import { executeQuery } from "./dispatch-query";
18
18
  import { type DispatchContext, resolveDbSource } from "./dispatch-shared";
19
+ import { rootWriteOrigin } from "./write-origin";
19
20
 
20
21
  export type ActiveMembershipPolicy = {
21
22
  // destroyRequested still counts as active — owners must be able to cancel
@@ -60,11 +61,14 @@ async function findMembership(
60
61
  userId: string,
61
62
  tenantId: TenantId,
62
63
  ): Promise<RawMembershipRow | undefined> {
64
+ const membershipUser = createSystemUser(tenantId);
65
+ // Auth-flow entry point with no enclosing handler, so it is its own root.
63
66
  const rawMemberships = await executeQuery(
64
67
  ctx,
65
68
  ctx.membershipQuery,
66
69
  { userId },
67
- createSystemUser(tenantId),
70
+ membershipUser,
71
+ rootWriteOrigin(ctx.registry, ctx.membershipQuery, membershipUser),
68
72
  );
69
73
  if (!Array.isArray(rawMemberships) || !rawMemberships.every(isMembershipRow)) {
70
74
  throw new InternalError({
@@ -13,6 +13,7 @@ import {
13
13
  isLifecycleResult,
14
14
  wrapToKumiko,
15
15
  } from "./dispatcher-utils";
16
+ import { rootWriteOrigin } from "./write-origin";
16
17
 
17
18
  // Core batch logic extracted so write() and command() can reuse it
18
19
  // (a single write = batch of one, running in its own transaction).
@@ -117,6 +118,7 @@ export async function runBatch(
117
118
  cmd.type,
118
119
  cmd.payload,
119
120
  user,
121
+ rootWriteOrigin(ctx.registry, cmd.type, user),
120
122
  undefined,
121
123
  afterCommitHooks,
122
124
  );
@@ -142,6 +144,7 @@ export async function runBatch(
142
144
  cmd.type,
143
145
  cmd.payload,
144
146
  user,
147
+ rootWriteOrigin(ctx.registry, cmd.type, user),
145
148
  tx,
146
149
  afterCommitHooks,
147
150
  );
@@ -4,7 +4,7 @@ import { filterReadFields } from "../engine/field-access";
4
4
  import type { QueryHandlerDef, SessionUser } from "../engine/types";
5
5
  import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
6
6
  import { assertNoSecretLeak } from "../secrets";
7
- import type { DispatchContext } from "./dispatch-shared";
7
+ import type { DispatchContext, WriteOrigin } from "./dispatch-shared";
8
8
  import {
9
9
  buildHandlerContext,
10
10
  enforceRateLimit,
@@ -21,10 +21,11 @@ export async function executeQuery(
21
21
  type: string,
22
22
  payload: unknown,
23
23
  user: SessionUser,
24
+ origin: WriteOrigin,
24
25
  tx?: DbTx,
25
26
  ): Promise<unknown> {
26
27
  return runHandlerInstrumented(ctx, type, "query", user, () =>
27
- executeQueryInner(ctx, type, payload, user, tx),
28
+ executeQueryInner(ctx, type, payload, user, origin, tx),
28
29
  );
29
30
  }
30
31
 
@@ -33,6 +34,7 @@ async function executeQueryInner(
33
34
  type: string,
34
35
  payload: unknown,
35
36
  user: SessionUser,
37
+ origin: WriteOrigin,
36
38
  tx?: DbTx,
37
39
  ): Promise<unknown> {
38
40
  const { registry } = ctx;
@@ -83,9 +85,9 @@ async function executeQueryInner(
83
85
  // A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
84
86
  return user.origin === "member-resolution"
85
87
  ? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) =>
86
- runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, readOnlyTx),
88
+ runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx),
87
89
  )
88
- : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, tx);
90
+ : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
89
91
  }
90
92
 
91
93
  async function runQueryHandler(
@@ -95,10 +97,19 @@ async function runQueryHandler(
95
97
  payload: unknown,
96
98
  includeDeleted: boolean,
97
99
  user: SessionUser,
100
+ origin: WriteOrigin,
98
101
  tx: DbTx | undefined,
99
102
  ): Promise<unknown> {
100
103
  const { registry } = ctx;
101
- const handlerContext = await buildHandlerContext(ctx, type, user, tx, undefined, includeDeleted);
104
+ const handlerContext = await buildHandlerContext(
105
+ ctx,
106
+ type,
107
+ user,
108
+ origin,
109
+ tx,
110
+ undefined,
111
+ includeDeleted,
112
+ );
102
113
  let result = await handler.handler({ type, payload, user }, handlerContext);
103
114
 
104
115
  // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
@@ -100,6 +100,9 @@ import {
100
100
  systemIdentitySwitchDenied,
101
101
  } from "./system-identity-switch";
102
102
  import type { TenantTimezoneCache } from "./tenant-timezone-cache";
103
+ import { buildPersonalDataGate, rootWriteOrigin, type WriteOrigin } from "./write-origin";
104
+
105
+ export type { WriteOrigin } from "./write-origin";
103
106
 
104
107
  // Framework/pipeline stays bundled-features-free, so this can't import the
105
108
  // `tenant` feature — the literal below IS the coupling to its `timezone`
@@ -277,6 +280,7 @@ export async function buildHandlerContext(
277
280
  ctx: DispatchContext,
278
281
  type: string,
279
282
  user: SessionUser,
283
+ origin: WriteOrigin,
280
284
  tx?: DbTx,
281
285
  afterCommitHooks?: AfterCommitHook[],
282
286
  includeDeleted?: boolean,
@@ -320,6 +324,7 @@ export async function buildHandlerContext(
320
324
  unsafeRaw: handlerEscapeHatch,
321
325
  report: reportEscapeHatch,
322
326
  memberReadOnly: isMemberResolutionPrincipal(user),
327
+ personalDataGate: buildPersonalDataGate(registry, origin),
323
328
  },
324
329
  );
325
330
  // Propagate the request's AbortSignal so every TenantDb query throws when
@@ -421,10 +426,11 @@ export async function buildHandlerContext(
421
426
  user,
422
427
  hasIdentitySwitchGrant,
423
428
  {
429
+ // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
424
430
  queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
425
- executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
431
+ executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
426
432
  writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
427
- executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
433
+ executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink),
428
434
  },
429
435
  identitySwitchAudit,
430
436
  );
@@ -483,10 +489,10 @@ export async function buildHandlerContext(
483
489
  );
484
490
  const bridge = {
485
491
  query: (targetType: string, payload: unknown) =>
486
- executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
492
+ executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
487
493
  queryAs: identitySwitch.queryAs,
488
494
  write: async (targetType: string, payload: unknown) => {
489
- const res = await executeWrite(ctx, targetType, payload, user, tx, bridgeSink);
495
+ const res = await executeWrite(ctx, targetType, payload, user, origin, tx, bridgeSink);
490
496
  return res;
491
497
  },
492
498
  writeAs: identitySwitch.writeAs,
@@ -1175,8 +1181,9 @@ function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthCl
1175
1181
  })
1176
1182
  : undefined;
1177
1183
  const identitySwitch = createGatedIdentitySwitch("r.authClaims hook", user, false, {
1184
+ // Login is itself the root operation; the hook context is read-only.
1178
1185
  queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
1179
- executeQuery(ctx, qn, payload, asUser), // @wrapper-known semantic-alias
1186
+ executeQuery(ctx, qn, payload, asUser, rootWriteOrigin(ctx.registry, qn, asUser)), // @wrapper-known semantic-alias
1180
1187
  writeAs: async () => {
1181
1188
  throw new InternalError({
1182
1189
  message: "r.authClaims hook context has no writeAs — auth-claims hooks are read-only.",
@@ -14,6 +14,7 @@ import {
14
14
  ensureFeatureEnabled,
15
15
  isMemberResolutionPrincipal,
16
16
  runStreamInstrumented,
17
+ type WriteOrigin,
17
18
  } from "./dispatch-shared";
18
19
 
19
20
  // Standalone stream execution — used by the public dispatcher.stream().
@@ -27,8 +28,11 @@ export async function* executeStream(
27
28
  type: string,
28
29
  payload: unknown,
29
30
  user: SessionUser,
31
+ origin: WriteOrigin,
30
32
  ): AsyncGenerator<unknown> {
31
- yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user));
33
+ yield* runStreamInstrumented(ctx, type, user, () =>
34
+ executeStreamInner(ctx, type, payload, user, origin),
35
+ );
32
36
  }
33
37
 
34
38
  async function* executeStreamInner(
@@ -36,6 +40,7 @@ async function* executeStreamInner(
36
40
  type: string,
37
41
  payload: unknown,
38
42
  user: SessionUser,
43
+ origin: WriteOrigin,
39
44
  ): AsyncGenerator<unknown> {
40
45
  const { registry } = ctx;
41
46
  const handler = registry.getStreamHandler(type);
@@ -80,7 +85,7 @@ async function* executeStreamInner(
80
85
  // await; close is fire-and-forget instead (#1563).
81
86
  let abandonedForInvalidation = false;
82
87
  try {
83
- const handlerContext = await buildHandlerContext(ctx, type, user);
88
+ const handlerContext = await buildHandlerContext(ctx, type, user, origin);
84
89
  const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
85
90
  iterator = chunks[Symbol.asyncIterator]();
86
91