@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.
- 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-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -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/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +108 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/tenant-db.ts +140 -16
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/boot-validator/access-declarations.ts +5 -66
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +23 -5
- 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/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -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__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
- package/src/jobs/job-runner.ts +170 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- 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/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- 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
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- 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
|
-
|
|
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
|
-
//
|
|
48
|
-
//
|
|
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
|
-
|
|
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
|
|
159
|
-
failedIndex:
|
|
209
|
+
error,
|
|
210
|
+
failedIndex: results.length,
|
|
160
211
|
results,
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
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.
|