@cosmicdrift/kumiko-framework 0.304.0 → 0.306.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 (92) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  3. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  4. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  5. package/src/api/__tests__/server-error-logging.test.ts +104 -0
  6. package/src/api/api-constants.ts +13 -0
  7. package/src/api/extra-route.ts +33 -4
  8. package/src/api/index.ts +1 -0
  9. package/src/api/request-context.ts +5 -4
  10. package/src/api/routes.ts +26 -1
  11. package/src/api/server.ts +8 -2
  12. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  13. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  14. package/src/bun-db/query.ts +42 -18
  15. package/src/changes.json +108 -0
  16. package/src/db/__tests__/pg-error.test.ts +14 -0
  17. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  18. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  19. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  20. package/src/db/event-store-executor-write.ts +7 -0
  21. package/src/db/index.ts +1 -1
  22. package/src/db/pg-error.ts +13 -0
  23. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  24. package/src/db/tenant-db.ts +140 -16
  25. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  26. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  27. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  28. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  29. package/src/engine/boot-validator/access-declarations.ts +5 -66
  30. package/src/engine/extension-names.ts +55 -25
  31. package/src/engine/extensions/storage-provider.ts +14 -41
  32. package/src/engine/extensions/tenant-data.ts +4 -0
  33. package/src/engine/extensions/tenant-resource.ts +40 -0
  34. package/src/engine/extensions/user-data.ts +8 -7
  35. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  36. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  37. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  38. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  39. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  40. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  41. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  42. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  43. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  44. package/src/engine/feature-ast/index.ts +11 -1
  45. package/src/engine/feature-ast/patch.ts +338 -5
  46. package/src/engine/feature-ast/patcher.ts +2 -2
  47. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  48. package/src/engine/feature-ast/patterns.ts +22 -15
  49. package/src/engine/feature-ast/render.ts +1 -0
  50. package/src/engine/feature-ui-extensions.ts +8 -7
  51. package/src/engine/index.ts +23 -5
  52. package/src/engine/personal-data-fields.ts +66 -0
  53. package/src/engine/registry-validate.ts +15 -0
  54. package/src/engine/registry.ts +2 -0
  55. package/src/engine/types/extension-options-map.ts +1 -0
  56. package/src/engine/types/index.ts +8 -0
  57. package/src/env/__tests__/dry-run.test.ts +43 -3
  58. package/src/env/dry-run.ts +28 -15
  59. package/src/errors/__tests__/write-failures.test.ts +47 -4
  60. package/src/errors/i18n/de.yaml +12 -0
  61. package/src/errors/i18n/en.yaml +12 -0
  62. package/src/errors/reasons.ts +4 -0
  63. package/src/errors/write-error-info.ts +12 -3
  64. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  65. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  66. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  67. package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
  68. package/src/jobs/job-runner.ts +170 -19
  69. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  70. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  71. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  72. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  73. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  74. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  75. package/src/pipeline/active-membership.ts +5 -1
  76. package/src/pipeline/dispatch-batch.ts +59 -13
  77. package/src/pipeline/dispatch-query.ts +16 -5
  78. package/src/pipeline/dispatch-shared.ts +12 -5
  79. package/src/pipeline/dispatch-stream.ts +7 -2
  80. package/src/pipeline/dispatch-write.ts +22 -5
  81. package/src/pipeline/dispatcher.ts +9 -2
  82. package/src/pipeline/idempotency.ts +16 -0
  83. package/src/pipeline/member-reader.ts +3 -1
  84. package/src/pipeline/system-identity-switch.ts +22 -4
  85. package/src/pipeline/write-origin.ts +107 -0
  86. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  87. package/src/rate-limit/middleware.ts +3 -0
  88. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  89. package/src/stack/test-stack.ts +5 -0
  90. package/src/testing/closed-connection-error.ts +62 -0
  91. package/src/testing/index.ts +1 -0
  92. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -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
+ });
@@ -223,6 +223,65 @@ describe("idempotency guard", () => {
223
223
  if (final.status !== "cached") throw new Error("expected cached value");
224
224
  expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
225
225
  });
226
+
227
+ test("release() frees the lock — a waiting check() reclaims immediately instead of polling out", async () => {
228
+ const guard = createIdempotencyGuard(testRedis.redis, {
229
+ pendingTtlSeconds: 5,
230
+ pollIntervalMs: 20,
231
+ waitTimeoutMs: 10_000,
232
+ });
233
+ const requestId = "req-release-1";
234
+
235
+ const first = await guard.check(tenantA, userA, requestId);
236
+ expect(first.status).toBe("acquired");
237
+ if (first.status !== "acquired") throw new Error("expected to acquire the lock");
238
+
239
+ // Waiter starts BEFORE release — if release is a no-op this only resolves
240
+ // once waitTimeoutMs elapses (10s), which the 500ms race below catches.
241
+ const waiterPromise = guard.check(tenantA, userA, requestId);
242
+
243
+ await guard.release(tenantA, userA, requestId, first.token);
244
+
245
+ const raced = await Promise.race([
246
+ waiterPromise.then((v) => ({ done: true as const, v })),
247
+ new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), 500)),
248
+ ]);
249
+ expect(raced.done).toBe(true);
250
+ if (!raced.done) throw new Error("waiter did not reclaim after release()");
251
+ expect(raced.v.status).toBe("acquired");
252
+ });
253
+
254
+ test("release() with a stale token is a no-op — it must not clear a new owner's lock", async () => {
255
+ const guard = createIdempotencyGuard(testRedis.redis, {
256
+ pendingTtlSeconds: 1,
257
+ pollIntervalMs: 20,
258
+ waitTimeoutMs: 6_000,
259
+ });
260
+ const requestId = "req-release-stale";
261
+
262
+ const original = await guard.check(tenantA, userA, requestId);
263
+ expect(original.status).toBe("acquired");
264
+ if (original.status !== "acquired") throw new Error("expected to acquire the lock");
265
+
266
+ // Let the lock expire, then a new owner reclaims it.
267
+ await new Promise((r) => setTimeout(r, 1100));
268
+ const reclaimer = await guard.check(tenantA, userA, requestId);
269
+ expect(reclaimer.status).toBe("acquired");
270
+ if (reclaimer.status !== "acquired") throw new Error("expected to reclaim the lock");
271
+
272
+ // The original (stale) owner's release() must not touch the reclaimer's lock.
273
+ await guard.release(tenantA, userA, requestId, original.token);
274
+
275
+ await guard.store(tenantA, userA, requestId, reclaimer.token, {
276
+ isSuccess: true,
277
+ data: { owner: "reclaimer" },
278
+ });
279
+
280
+ const final = await guard.check(tenantA, userA, requestId);
281
+ expect(final.status).toBe("cached");
282
+ if (final.status !== "cached") throw new Error("expected cached value");
283
+ expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
284
+ });
226
285
  });
227
286
 
228
287
  // --- Event Dedup ---
@@ -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({
@@ -1,3 +1,4 @@
1
+ import { requestContext } from "../api/request-context";
1
2
  import type { DbConnection } from "../db/connection";
2
3
  import { transaction } from "../db/query";
3
4
  import type { DeleteContext, SaveContext, SessionUser, WriteResult } from "../engine/types";
@@ -13,6 +14,7 @@ import {
13
14
  isLifecycleResult,
14
15
  wrapToKumiko,
15
16
  } from "./dispatcher-utils";
17
+ import { rootWriteOrigin } from "./write-origin";
16
18
 
17
19
  // Core batch logic extracted so write() and command() can reuse it
18
20
  // (a single write = batch of one, running in its own transaction).
@@ -21,6 +23,22 @@ export async function runBatch(
21
23
  commands: readonly BatchCommand[],
22
24
  user: SessionUser,
23
25
  requestId?: string,
26
+ ): Promise<BatchResult> {
27
+ const current = requestContext.get();
28
+ if (!current?.signal) {
29
+ return runBatchBody(ctx, commands, user, requestId);
30
+ }
31
+ // Strip the signal: a disconnect would roll back the tx, idempotency would
32
+ // cache a 500 for the uncommitted write and afterCommit effects would be lost.
33
+ const { signal: _signal, ...withoutSignal } = current;
34
+ return requestContext.run(withoutSignal, () => runBatchBody(ctx, commands, user, requestId));
35
+ }
36
+
37
+ async function runBatchBody(
38
+ ctx: DispatchContext,
39
+ commands: readonly BatchCommand[],
40
+ user: SessionUser,
41
+ requestId?: string,
24
42
  ): Promise<BatchResult> {
25
43
  const { idempotency, lifecycle, appContext: context } = ctx;
26
44
  if (commands.length === 0) {
@@ -44,8 +62,8 @@ export async function runBatch(
44
62
  }
45
63
  }
46
64
 
47
- // Wrap return paths: cache the final result under requestId so retries get
48
- // the same answer (both success and failure results are cached).
65
+ // Cache the result under requestId so retries get the same answer. Only a
66
+ // provably rolled-back 5xx releases the lock instead (releaseOrFinalize).
49
67
  const finalize = async (result: BatchResult): Promise<BatchResult> => {
50
68
  if (requestId && idempotency && idempotencyToken) {
51
69
  await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, result);
@@ -53,6 +71,19 @@ export async function runBatch(
53
71
  return result;
54
72
  };
55
73
 
74
+ // Never for the no-tx fallback: without a rollback, a re-run would repeat
75
+ // the side effects of the commands that already ran.
76
+ const releaseOrFinalize = async (
77
+ result: BatchResult,
78
+ isRetryableRollback: boolean,
79
+ ): Promise<BatchResult> => {
80
+ if (isRetryableRollback && requestId && idempotency && idempotencyToken) {
81
+ await idempotency.release(user.tenantId, user.id, requestId, idempotencyToken);
82
+ return result;
83
+ }
84
+ return finalize(result);
85
+ };
86
+
56
87
  const afterCommitHooks: AfterCommitHook[] = [];
57
88
  const results: WriteResult[] = [];
58
89
 
@@ -117,6 +148,7 @@ export async function runBatch(
117
148
  cmd.type,
118
149
  cmd.payload,
119
150
  user,
151
+ rootWriteOrigin(ctx.registry, cmd.type, user),
120
152
  undefined,
121
153
  afterCommitHooks,
122
154
  );
@@ -132,6 +164,7 @@ export async function runBatch(
132
164
  return finalize({ isSuccess: true, results });
133
165
  }
134
166
 
167
+ let transactionCallbackCompleted = false;
135
168
  try {
136
169
  await transaction(db, async (tx) => {
137
170
  for (let i = 0; i < commands.length; i++) {
@@ -142,6 +175,7 @@ export async function runBatch(
142
175
  cmd.type,
143
176
  cmd.payload,
144
177
  user,
178
+ rootWriteOrigin(ctx.registry, cmd.type, user),
145
179
  tx,
146
180
  afterCommitHooks,
147
181
  );
@@ -150,22 +184,34 @@ export async function runBatch(
150
184
  throw new BatchRollback(i, res.error);
151
185
  }
152
186
  }
187
+ transactionCallbackCompleted = true;
153
188
  });
154
189
  } catch (e) {
155
190
  if (e instanceof BatchRollback) {
156
- return finalize({
191
+ // Thrown inside the callback, so the tx rolled back. A 4xx is
192
+ // deterministic and stays cached; a 5xx may be transient.
193
+ return releaseOrFinalize(
194
+ {
195
+ isSuccess: false,
196
+ error: e.failureError,
197
+ failedIndex: e.failedIndex,
198
+ results,
199
+ },
200
+ e.failureError.httpStatus >= 500,
201
+ );
202
+ }
203
+ // A completed callback means the throw came from COMMIT (outcome unknown),
204
+ // so cache it; otherwise COMMIT was never sent and releasing is safe.
205
+ const error = toWriteErrorInfo(wrapToKumiko(e));
206
+ return releaseOrFinalize(
207
+ {
157
208
  isSuccess: false,
158
- error: e.failureError,
159
- failedIndex: e.failedIndex,
209
+ error,
210
+ failedIndex: results.length,
160
211
  results,
161
- });
162
- }
163
- return finalize({
164
- isSuccess: false,
165
- error: toWriteErrorInfo(wrapToKumiko(e)),
166
- failedIndex: results.length,
167
- results,
168
- });
212
+ },
213
+ !transactionCallbackCompleted && error.httpStatus >= 500,
214
+ );
169
215
  }
170
216
 
171
217
  // Commit succeeded — fire deferred side-effects.