@cosmicdrift/kumiko-framework 0.304.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.
- package/package.json +4 -4
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-error-logging.test.ts +33 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/server.ts +8 -2
- package/src/changes.json +42 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/tenant-db.ts +50 -3
- package/src/engine/boot-validator/access-declarations.ts +5 -66
- package/src/engine/index.ts +2 -0
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/index.ts +2 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +35 -0
- package/src/jobs/job-runner.ts +19 -5
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +3 -0
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -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
|
-
|
|
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(
|
|
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, () =>
|
|
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
|
|