@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.305.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.305.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.305.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.305.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Constructor invariants for ExtraRouteRejection's `retryAfterSeconds` option
|
|
2
|
+
// (kumiko-framework#3168): it only makes sense together with 503 and must
|
|
3
|
+
// render as a valid Retry-After delta-seconds header.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { ExtraRouteRejection } from "../extra-route";
|
|
7
|
+
|
|
8
|
+
describe("ExtraRouteRejection retryAfterSeconds invariants", () => {
|
|
9
|
+
test("retryAfterSeconds with a non-503 status throws RangeError", () => {
|
|
10
|
+
expect(
|
|
11
|
+
() => new ExtraRouteRejection(404, { error: "x" }, undefined, { retryAfterSeconds: 30 }),
|
|
12
|
+
).toThrow(RangeError);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("negative retryAfterSeconds throws RangeError", () => {
|
|
16
|
+
expect(
|
|
17
|
+
() => new ExtraRouteRejection(503, { error: "x" }, undefined, { retryAfterSeconds: -1 }),
|
|
18
|
+
).toThrow(RangeError);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("non-integer retryAfterSeconds throws RangeError", () => {
|
|
22
|
+
expect(
|
|
23
|
+
() => new ExtraRouteRejection(503, { error: "x" }, undefined, { retryAfterSeconds: 1.5 }),
|
|
24
|
+
).toThrow(RangeError);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("retryAfterSeconds: 0 with status 503 is accepted", () => {
|
|
28
|
+
const rejection = new ExtraRouteRejection(503, { error: "x" }, undefined, {
|
|
29
|
+
retryAfterSeconds: 0,
|
|
30
|
+
});
|
|
31
|
+
expect(rejection.retryAfterSeconds).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("no options leaves retryAfterSeconds undefined", () => {
|
|
35
|
+
const rejection = new ExtraRouteRejection(503, { error: "x" });
|
|
36
|
+
expect(rejection.retryAfterSeconds).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -461,6 +461,14 @@ describe("extraRoutes: entry:signature", () => {
|
|
|
461
461
|
if (req.headers["x-force-404"] === "1") {
|
|
462
462
|
throw new ExtraRouteRejection(404, { error: "unknown-provider" });
|
|
463
463
|
}
|
|
464
|
+
if (req.headers["x-force-503"] === "retry") {
|
|
465
|
+
throw new ExtraRouteRejection(503, { error: "not-ready" }, undefined, {
|
|
466
|
+
retryAfterSeconds: 30,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
if (req.headers["x-force-503"] === "plain") {
|
|
470
|
+
throw new ExtraRouteRejection(503, { error: "not-ready" });
|
|
471
|
+
}
|
|
464
472
|
if (req.headers["x-hmac"] !== signHmac(req.rawBody)) {
|
|
465
473
|
throw new Error("signature mismatch");
|
|
466
474
|
}
|
|
@@ -536,6 +544,28 @@ describe("extraRoutes: entry:signature", () => {
|
|
|
536
544
|
expect(await res.json()).toEqual({ error: "unknown-provider" });
|
|
537
545
|
});
|
|
538
546
|
|
|
547
|
+
test("verify() throwing ExtraRouteRejection(503, body, options) surfaces the body and Retry-After header", async () => {
|
|
548
|
+
const res = await stack.app.request("/webhooks/probe", {
|
|
549
|
+
method: "POST",
|
|
550
|
+
headers: { "x-force-503": "retry", "content-type": "application/json" },
|
|
551
|
+
body: JSON.stringify({ note: "x" }),
|
|
552
|
+
});
|
|
553
|
+
expect(res.status).toBe(503);
|
|
554
|
+
expect(await res.json()).toEqual({ error: "not-ready" });
|
|
555
|
+
expect(res.headers.get("retry-after")).toBe("30");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("verify() throwing ExtraRouteRejection(503, body) without options omits the Retry-After header", async () => {
|
|
559
|
+
const res = await stack.app.request("/webhooks/probe", {
|
|
560
|
+
method: "POST",
|
|
561
|
+
headers: { "x-force-503": "plain", "content-type": "application/json" },
|
|
562
|
+
body: JSON.stringify({ note: "x" }),
|
|
563
|
+
});
|
|
564
|
+
expect(res.status).toBe(503);
|
|
565
|
+
expect(await res.json()).toEqual({ error: "not-ready" });
|
|
566
|
+
expect(res.headers.get("retry-after")).toBeNull();
|
|
567
|
+
});
|
|
568
|
+
|
|
539
569
|
test("mounted under /api/:provider — no session needed, honoPathToRegex matches the :param, rawBody arrives intact through /api/*", async () => {
|
|
540
570
|
const rawBody = JSON.stringify({ event: "payment.succeeded" });
|
|
541
571
|
const res = await stack.app.request("/api/webhooks/stripe", {
|
|
@@ -27,6 +27,14 @@ const boomFeature = defineFeature("boom", (r) => {
|
|
|
27
27
|
},
|
|
28
28
|
openToAll,
|
|
29
29
|
);
|
|
30
|
+
r.writeHandler(
|
|
31
|
+
"explode",
|
|
32
|
+
z.object({}),
|
|
33
|
+
async () => {
|
|
34
|
+
throw new Error("disk on fire during write");
|
|
35
|
+
},
|
|
36
|
+
openToAll,
|
|
37
|
+
);
|
|
30
38
|
r.queryHandler(
|
|
31
39
|
"decode",
|
|
32
40
|
z.object({}),
|
|
@@ -140,6 +148,31 @@ describe("HTTP layer logs unexpected 5xx faults", () => {
|
|
|
140
148
|
}
|
|
141
149
|
});
|
|
142
150
|
|
|
151
|
+
test("a throwing write 500s AND its cause reaches the log (reraise keeps the cause)", async () => {
|
|
152
|
+
const calls: unknown[][] = [];
|
|
153
|
+
const spy = spyOn(console, "error").mockImplementation((...args) => {
|
|
154
|
+
calls.push(args);
|
|
155
|
+
});
|
|
156
|
+
try {
|
|
157
|
+
const res = await app.request("/api/write", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: await auth(),
|
|
160
|
+
body: JSON.stringify({ type: "boom:write:explode", payload: {} }),
|
|
161
|
+
});
|
|
162
|
+
expect(res.status).toBe(500);
|
|
163
|
+
const hit = calls.find(
|
|
164
|
+
(args) => typeof args[0] === "string" && args[0].includes("[api] handler failed"),
|
|
165
|
+
);
|
|
166
|
+
const data = hit?.[1];
|
|
167
|
+
expect(isRecord(data)).toBe(true);
|
|
168
|
+
if (!isRecord(data)) return;
|
|
169
|
+
expect(data["type"]).toBe("boom:write:explode");
|
|
170
|
+
expect(data["cause"]).toBe("disk on fire during write");
|
|
171
|
+
} finally {
|
|
172
|
+
spy.mockRestore();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
143
176
|
test("a 404 stays off the error level (it is a client outcome, not a server fault)", async () => {
|
|
144
177
|
const { status, errors } = await queryWithCapturedWarnings("nope:query:nothing", {});
|
|
145
178
|
expect(status).toBe(404);
|
package/src/api/api-constants.ts
CHANGED
|
@@ -101,6 +101,19 @@ export const BODY_LIMIT_OPT_OUT_PATHS: ReadonlySet<string> = new Set([
|
|
|
101
101
|
`/api${Routes.files}`,
|
|
102
102
|
]);
|
|
103
103
|
|
|
104
|
+
// authEndpointRateLimit (L2) exceptions, exact method+path match, no prefix/glob.
|
|
105
|
+
// A forgotten entry here is safe (over-limited, not unlimited).
|
|
106
|
+
export const AUTH_RATE_LIMIT_EXEMPT_ROUTES: ReadonlyArray<{
|
|
107
|
+
readonly method: string;
|
|
108
|
+
readonly path: string;
|
|
109
|
+
}> = [{ method: "GET", path: `/api${Routes.authTenants}` }];
|
|
110
|
+
|
|
111
|
+
export function isAuthRateLimitExempt(method: string, path: string): boolean {
|
|
112
|
+
return AUTH_RATE_LIMIT_EXEMPT_ROUTES.some(
|
|
113
|
+
(route) => route.method === method && route.path === path,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
// Methods that can mutate server state. GET/HEAD/OPTIONS are safe under
|
|
105
118
|
// CORS + SameSite-cookie semantics and skip the CSRF / Origin guards entirely.
|
|
106
119
|
export const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set([
|
package/src/api/extra-route.ts
CHANGED
|
@@ -128,19 +128,48 @@ export function signatureRoute<T>(def: SignatureExtraRoute<T>): ExtraRouteDefini
|
|
|
128
128
|
return def as unknown as SignatureExtraRoute<unknown>;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
export type ExtraRouteRejectionStatus = 400 | 401 | 403 | 404 | 500;
|
|
131
|
+
export type ExtraRouteRejectionStatus = 400 | 401 | 403 | 404 | 500 | 503;
|
|
132
|
+
|
|
133
|
+
export type ExtraRouteRejectionOptions = { readonly retryAfterSeconds?: number };
|
|
132
134
|
|
|
133
135
|
/** Thrown by `verify()` to reject a signature route with a specific status +
|
|
134
136
|
* JSON body. Any other throw from `verify()` is mapped to 401
|
|
135
|
-
* `extra_route_signature_invalid` by the buildServer wrapper.
|
|
137
|
+
* `extra_route_signature_invalid` by the buildServer wrapper.
|
|
138
|
+
*
|
|
139
|
+
* 503 signals "temporarily not ready" (e.g. a dependency the verify step
|
|
140
|
+
* needs is down) — webhook providers like Stripe retry on 503.
|
|
141
|
+
* `options.retryAfterSeconds` renders as the `Retry-After` header. */
|
|
136
142
|
export class ExtraRouteRejection extends Error {
|
|
137
143
|
readonly status: ExtraRouteRejectionStatus;
|
|
138
144
|
readonly body: unknown;
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
readonly retryAfterSeconds: number | undefined;
|
|
146
|
+
|
|
147
|
+
constructor(
|
|
148
|
+
status: ExtraRouteRejectionStatus,
|
|
149
|
+
body: unknown,
|
|
150
|
+
message?: string,
|
|
151
|
+
options?: ExtraRouteRejectionOptions,
|
|
152
|
+
) {
|
|
141
153
|
super(message ?? `extra route rejected with status ${status}`);
|
|
142
154
|
this.name = "ExtraRouteRejection";
|
|
143
155
|
this.status = status;
|
|
144
156
|
this.body = body;
|
|
157
|
+
const retryAfterSeconds = options?.retryAfterSeconds;
|
|
158
|
+
if (retryAfterSeconds !== undefined) {
|
|
159
|
+
// Retry-After is only meaningful for 503 in this union (RFC 9110) and
|
|
160
|
+
// is rendered as a delta-seconds header — reject anything that could
|
|
161
|
+
// not survive that round-trip.
|
|
162
|
+
if (status !== 503) {
|
|
163
|
+
throw new RangeError(
|
|
164
|
+
"ExtraRouteRejection: retryAfterSeconds is only valid with status 503",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (!Number.isInteger(retryAfterSeconds) || retryAfterSeconds < 0) {
|
|
168
|
+
throw new RangeError(
|
|
169
|
+
"ExtraRouteRejection: retryAfterSeconds must be a non-negative integer",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
145
174
|
}
|
|
146
175
|
}
|
package/src/api/index.ts
CHANGED
package/src/api/server.ts
CHANGED
|
@@ -160,14 +160,17 @@ export type ServerOptions = {
|
|
|
160
160
|
// of the app process.
|
|
161
161
|
// - `auth`: gates a single path-pattern (default `/api/auth/*`)
|
|
162
162
|
// with tighter limits. Typically `limit: 5, windowSeconds: 60`
|
|
163
|
-
// to slow brute-force without breaking real users.
|
|
163
|
+
// to slow brute-force without breaking real users. GET /api/auth/tenants
|
|
164
|
+
// (session read, called on every page load) is always exempt — see
|
|
165
|
+
// AUTH_RATE_LIMIT_EXEMPT_ROUTES in api-constants.ts.
|
|
164
166
|
// Both omitted → no L1/L2 wired and no resolver auto-built unless an
|
|
165
167
|
// L3 handler declared `rateLimit:`. This keeps zero-cost when unused.
|
|
166
168
|
rateLimit?: {
|
|
167
169
|
readonly global?: Omit<GlobalIpRateLimitOptions, "resolver">;
|
|
168
170
|
readonly auth?: Omit<AuthEndpointRateLimitOptions, "resolver"> & {
|
|
169
171
|
// Path-pattern the L2 middleware applies to. Default `/api/auth/*`.
|
|
170
|
-
// Override for apps with a different auth route layout.
|
|
172
|
+
// Override for apps with a different auth route layout. The
|
|
173
|
+
// GET /api/auth/tenants exemption above applies regardless of `path`.
|
|
171
174
|
readonly path?: string;
|
|
172
175
|
};
|
|
173
176
|
};
|
|
@@ -1178,6 +1181,9 @@ function buildExtraRouteHonoHandler(
|
|
|
1178
1181
|
);
|
|
1179
1182
|
} catch (e) {
|
|
1180
1183
|
if (e instanceof ExtraRouteRejection) {
|
|
1184
|
+
if (e.retryAfterSeconds !== undefined) {
|
|
1185
|
+
return c.json(e.body, e.status, { "Retry-After": String(e.retryAfterSeconds) });
|
|
1186
|
+
}
|
|
1181
1187
|
return c.json(e.body, e.status);
|
|
1182
1188
|
}
|
|
1183
1189
|
return c.json(
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.305.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "rateLimit.auth (L2) no longer throttles GET /api/auth/tenants",
|
|
6
|
+
"detail": "The SPA's own session bootstrap called GET /api/auth/tenants on every\npage load, which counted against the L2 auth-endpoint bucket and threw\n429 on the 6th page load within a minute. This route is now exempted\nfrom rateLimit.auth by exact method+path match; all other auth routes\n(including POST on the same path, if ever added) are unaffected.",
|
|
7
|
+
"migration": "Apps using the default rateLimit.auth need no changes. Apps that want\nto keep throttling GET /api/auth/tenants should rely on rateLimit.global\n(L1, IP-based) instead. Credential-submitting POST routes are unaffected\neven when rateLimit.auth's `path` option is customized."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.305.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "KUMIKO_DRY_RUN_ENV=pulumi and =k8s now list optional env keys (e.g. PROMETHEUS_METRICS_TOKEN) as commented-out lines under an \"Optional\" header, with secret flag, generator and description; defaulted keys stay omitted",
|
|
13
|
+
"migration": "No action needed. Uncomment and set an optional line only when you want to enable that feature."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.305.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "ExtraRouteRejection supports 503 + Retry-After for signature routes whose verify() is temporarily unable to run"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.305.0",
|
|
22
|
+
"type": "fix",
|
|
23
|
+
"title": "Job backoff now waits between retries: backoff defaults to a 1000 ms base delay and accepts { type, delayMs } (fw#3167)",
|
|
24
|
+
"detail": "Previously, jobs with backoff set retried immediately: BullMQ received only { type } with no delay, and its fixed/exponential strategies compute NaN/undefined without one (falsy, so no wait). Now \"fixed\" waits a constant 1000 ms and \"exponential\" waits 1000/2000/4000 ms... between attempts by default. Jobs with a high retries count will therefore take noticeably longer to reach their final failure. A new object form, backoff: { type, delayMs }, lets a job configure its own base delay instead of the 1000 ms default."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.305.0",
|
|
28
|
+
"type": "breaking",
|
|
29
|
+
"title": "Writes under an anonymous root need access.personalData: \"public-intake\" at runtime, across feature boundaries (fw#3165)",
|
|
30
|
+
"detail": "The boot check from fw#2885 only sees personal-data keys in an anonymous write handler's own input schema, for entities of its own feature. Every public dispatch (write, batch command, query, stream) now computes a WriteOrigin (root handler, anonymous root, public-intake declared) once. Every nested call inherits it: ctx.write, ctx.writeAs, ctx.query, ctx.queryAs, nested writes and afterCommit hooks. When the root is anonymous and does not declare public-intake, a write that touches a personal-data field (pii / userOwned / recordOwned) of any registered entity fails with AccessDeniedError, details.reason \"public_intake_required\". The error details name the root handler, the table and the fields, never the values. The check runs in TenantDb.insertOne/updateMany, in db.global().insertOne/updateMany and in the event-sourced create/update executor, after preSave and before the event append. It also covers rebound TenantDbs (acknowledgeCrossTenant, hook re-gating). Authenticated sessions are not affected. Not gated: ctx.db.unsafeRaw (covered by escapeHatch plus audit); ctx.appendEvent on a feature's own events (foreign events are already rejected); tables outside the registered entities; jobs and event subscribers queued from an anonymous root; and TenantDbs that handler code builds directly with createTenantDb. The last two are tracked in fw#3185, which also lists the bundled auth-email-password and user-data-rights flows that go through unsafeRaw or createTenantDb.",
|
|
31
|
+
"migration": "A write handler that anonymous callers can reach (roles include \"anonymous\") and that writes a personal-data field (pii / userOwned / recordOwned) of any entity must declare access: { roles: [..., \"anonymous\"], personalData: \"public-intake\" }. This applies whether the handler writes the field itself or through ctx.db, the CRUD executor, ctx.write, ctx.writeAs/queryAs or a postSave/afterCommit hook, and it applies across features. Without the declaration the write now fails with AccessDeniedError (details.reason \"public_intake_required\"). A failing afterCommit hook is only logged, and its write does not happen. Known consumer handlers, measured on 23.09.2026: offlot-app waitlist:submit, vehicle-enquiry:submit and try-first:set-contact; publicstatus email-subscriber:subscribe; show-pony rsvp:submit. Add the declaration if the anonymous intake is intended (the handler's rateLimit is then the only protection). Otherwise stop writing the field from the anonymous path."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "0.305.0",
|
|
35
|
+
"type": "improvement",
|
|
36
|
+
"title": "setupTestStack accepts a metrics option, forwarded to buildServer like runProdApp (fw#3182)",
|
|
37
|
+
"detail": "TestStackOptions gains an optional metrics field forwarded to buildServer, same as runProdApp. Spread resolveObservabilityWiring(token) into setupTestStack/setupTestStackFromFeatures/setupAppTestStack to get /metrics mounted in integration tests with the same token-gated PrometheusMeter-backed behavior as prod."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"version": "0.305.0",
|
|
41
|
+
"type": "fix",
|
|
42
|
+
"title": "Write-path 5xx logs now include the original cause chain"
|
|
43
|
+
},
|
|
2
44
|
{
|
|
3
45
|
"version": "0.303.0",
|
|
4
46
|
"type": "breaking",
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
tryMapUniqueViolation,
|
|
32
32
|
} from "./event-store-executor-context";
|
|
33
33
|
import { runInSavepointIfSupported } from "./query";
|
|
34
|
+
import { assertPersonalDataWrite, tableNameOf } from "./tenant-db";
|
|
34
35
|
import { tenantDbRunner } from "./tenant-db-runner";
|
|
35
36
|
|
|
36
37
|
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
@@ -145,6 +146,9 @@ export function createWriteVerbs(
|
|
|
145
146
|
if ("failure" in preSaveResult) return preSaveResult.failure;
|
|
146
147
|
const data = preSaveResult.data;
|
|
147
148
|
|
|
149
|
+
// After preSave so derived fields count, before the event append so nothing persists.
|
|
150
|
+
assertPersonalDataWrite(db, tableNameOf(table), Object.keys(data), entity);
|
|
151
|
+
|
|
148
152
|
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
149
153
|
// only the new row is checked. No Straddle concern for creates.
|
|
150
154
|
if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
|
|
@@ -334,6 +338,9 @@ export function createWriteVerbs(
|
|
|
334
338
|
if ("failure" in preSaveResult) return preSaveResult.failure;
|
|
335
339
|
const changes = preSaveResult.data;
|
|
336
340
|
|
|
341
|
+
// After preSave so derived fields count, before the event append so nothing persists.
|
|
342
|
+
assertPersonalDataWrite(db, tableNameOf(table), Object.keys(changes), entity);
|
|
343
|
+
|
|
337
344
|
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
338
345
|
// done above), build post-change row via shallow merge. Straddle-safe
|
|
339
346
|
// multi-role check: at least one role must accept BOTH old and new —
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type SelectOptions,
|
|
25
25
|
type WhereObject,
|
|
26
26
|
} from "../db/query";
|
|
27
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
27
28
|
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
28
29
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
|
|
29
30
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
@@ -45,6 +46,21 @@ const declaredUnsafeRawRunners = new WeakMap<
|
|
|
45
46
|
(reason: string) => DbRunner
|
|
46
47
|
>();
|
|
47
48
|
|
|
49
|
+
// The CRUD executor writes through tenantDbRunner, not insertOne, so it asks the
|
|
50
|
+
// TenantDb it was handed for its gate. Bound inside createTenantDb so rebound instances
|
|
51
|
+
// (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
|
|
52
|
+
const personalDataGates = new WeakMap<TenantDb, PersonalDataGate>();
|
|
53
|
+
|
|
54
|
+
// The executor passes its entity so the check does not depend on the table-name lookup.
|
|
55
|
+
export function assertPersonalDataWrite(
|
|
56
|
+
db: TenantDb,
|
|
57
|
+
tableName: string,
|
|
58
|
+
keys: readonly string[],
|
|
59
|
+
entity: EntityDefinition,
|
|
60
|
+
): void {
|
|
61
|
+
personalDataGates.get(db)?.(tableName, keys, entity);
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
// Framework-private (not re-exported from db/index.ts): same grant check + audit as unsafeRaw, for engine forwarding.
|
|
49
65
|
export function unsafeRawForDeclaredStep(
|
|
50
66
|
holder: TenantDb | UncheckedSystemDb,
|
|
@@ -170,7 +186,7 @@ export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): rea
|
|
|
170
186
|
return rows as unknown as readonly T[];
|
|
171
187
|
}
|
|
172
188
|
|
|
173
|
-
function tableNameOf(table: Table | EntityTableMeta): string {
|
|
189
|
+
export function tableNameOf(table: Table | EntityTableMeta): string {
|
|
174
190
|
const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
175
191
|
if (typeof sym === "string") return sym;
|
|
176
192
|
return asEntityTableMeta(table)?.tableName ?? "<unknown>";
|
|
@@ -205,8 +221,16 @@ export type TenantDbGrants = {
|
|
|
205
221
|
// Set for a resolved member principal (ctx.queryAsMember): no raw DbRunner leaves
|
|
206
222
|
// this TenantDb, so no handler can COMMIT/RELEASE SAVEPOINT out of the READ ONLY scope.
|
|
207
223
|
readonly memberReadOnly?: boolean;
|
|
224
|
+
// Set only for an anonymous root without personalData: "public-intake" (write-origin.ts).
|
|
225
|
+
readonly personalDataGate?: PersonalDataGate;
|
|
208
226
|
};
|
|
209
227
|
|
|
228
|
+
export type PersonalDataGate = (
|
|
229
|
+
tableName: string,
|
|
230
|
+
keys: readonly string[],
|
|
231
|
+
entity?: EntityDefinition,
|
|
232
|
+
) => void;
|
|
233
|
+
|
|
210
234
|
const unsafeRawRebinders = new WeakMap<
|
|
211
235
|
TenantDb,
|
|
212
236
|
(grant: EscapeHatchDeclaration | undefined) => TenantDb
|
|
@@ -347,6 +371,20 @@ export function createTenantDb(
|
|
|
347
371
|
return grants?.globalWrites?.reason ?? "";
|
|
348
372
|
}
|
|
349
373
|
|
|
374
|
+
function personalDataDenied(
|
|
375
|
+
table: Table | EntityTableMeta,
|
|
376
|
+
keys: readonly string[],
|
|
377
|
+
): AccessDeniedError | undefined {
|
|
378
|
+
if (!grants?.personalDataGate) return undefined;
|
|
379
|
+
try {
|
|
380
|
+
grants.personalDataGate(tableNameOf(table), keys);
|
|
381
|
+
return undefined;
|
|
382
|
+
} catch (e) {
|
|
383
|
+
if (e instanceof AccessDeniedError) return e;
|
|
384
|
+
throw e;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
350
388
|
function foreignTenantOnGlobalWrite(
|
|
351
389
|
table: Table | EntityTableMeta,
|
|
352
390
|
tenantIdValue: unknown,
|
|
@@ -379,7 +417,9 @@ export function createTenantDb(
|
|
|
379
417
|
values: Record<string, unknown>,
|
|
380
418
|
): Promise<T | undefined> {
|
|
381
419
|
const denied =
|
|
382
|
-
missingEscapeHatch(table) ??
|
|
420
|
+
missingEscapeHatch(table) ??
|
|
421
|
+
foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
|
|
422
|
+
personalDataDenied(table, Object.keys(values));
|
|
383
423
|
if (denied) return Promise.reject(denied);
|
|
384
424
|
report("global-write", globalWriteReason());
|
|
385
425
|
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
|
|
@@ -389,7 +429,9 @@ export function createTenantDb(
|
|
|
389
429
|
where: WhereObject,
|
|
390
430
|
): Promise<readonly T[]> {
|
|
391
431
|
const denied =
|
|
392
|
-
missingEscapeHatch(table) ??
|
|
432
|
+
missingEscapeHatch(table) ??
|
|
433
|
+
foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
|
|
434
|
+
personalDataDenied(table, Object.keys(set));
|
|
393
435
|
if (denied) return Promise.reject(denied);
|
|
394
436
|
if (!where || Object.keys(where).length === 0) {
|
|
395
437
|
return Promise.reject(
|
|
@@ -483,6 +525,8 @@ export function createTenantDb(
|
|
|
483
525
|
);
|
|
484
526
|
if (denied) return Promise.reject(denied);
|
|
485
527
|
}
|
|
528
|
+
const personalDenied = personalDataDenied(table, Object.keys(values));
|
|
529
|
+
if (personalDenied) return Promise.reject(personalDenied);
|
|
486
530
|
const data = insertValues(table, values);
|
|
487
531
|
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, data));
|
|
488
532
|
},
|
|
@@ -499,6 +543,8 @@ export function createTenantDb(
|
|
|
499
543
|
),
|
|
500
544
|
);
|
|
501
545
|
}
|
|
546
|
+
const personalDenied = personalDataDenied(table, Object.keys(set));
|
|
547
|
+
if (personalDenied) return Promise.reject(personalDenied);
|
|
502
548
|
const filter = writeWhere(table, where);
|
|
503
549
|
return withDbSpan("update", table, async () => bunUpdateMany<T>(db, table, set, filter));
|
|
504
550
|
},
|
|
@@ -525,6 +571,7 @@ export function createTenantDb(
|
|
|
525
571
|
return createTenantDb(db, tenantId, "system", tracer, meter, signal, grants);
|
|
526
572
|
});
|
|
527
573
|
bindTenantDbRunner(tenantDb, db);
|
|
574
|
+
if (grants?.personalDataGate) personalDataGates.set(tenantDb, grants.personalDataGate);
|
|
528
575
|
return tenantDb;
|
|
529
576
|
}
|
|
530
577
|
|
|
@@ -1,68 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accessAllowsAnonymous,
|
|
3
|
+
declaredPersonalData,
|
|
4
|
+
personalFieldNames,
|
|
5
|
+
} from "../personal-data-fields";
|
|
1
6
|
import { ANONYMOUS_ROLE } from "../system-user";
|
|
2
7
|
import type {
|
|
3
8
|
AccessRule,
|
|
4
9
|
FeatureDefinition,
|
|
5
|
-
OwnershipMap,
|
|
6
|
-
OwnershipRule,
|
|
7
10
|
QueryHandlerDef,
|
|
8
11
|
StreamHandlerDef,
|
|
9
12
|
WriteHandlerDef,
|
|
10
13
|
} from "../types";
|
|
11
|
-
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
12
14
|
import { collectZodObjectKeys } from "./zod-shape";
|
|
13
15
|
|
|
14
16
|
type HandlerKind = "write" | "query" | "stream";
|
|
15
17
|
|
|
16
|
-
// Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
|
|
17
|
-
// minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
|
|
18
|
-
// sense the openToAll personal-data check is guarding against.
|
|
19
|
-
function isPersonalDataField(field: unknown): boolean {
|
|
20
|
-
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
|
|
21
|
-
return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
|
|
25
|
-
if (rule === "all" || rule.kind !== "from") return false;
|
|
26
|
-
return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// The executor checks access.write against every created/updated row; one "all" role
|
|
30
|
-
// or an empty map (= public) lets a caller write rows owned by someone else.
|
|
31
|
-
function writeMapBindsRowsToCaller(
|
|
32
|
-
writeMap: OwnershipMap | undefined,
|
|
33
|
-
ownerColumn: string,
|
|
34
|
-
): boolean {
|
|
35
|
-
const rules = Object.values(writeMap ?? {});
|
|
36
|
-
return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const ROW_ID_COLUMN = "id";
|
|
40
|
-
|
|
41
|
-
// A self/record-owned field's subject is the row itself, so only from("user:id", "id")
|
|
42
|
-
// makes that row the caller — on any other entity "self" names a third party.
|
|
43
|
-
function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
|
|
44
|
-
if (annot.userOwned) return annot.userOwned.ownerField;
|
|
45
|
-
if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
|
|
50
|
-
const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
|
|
51
|
-
return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function personalFieldNames(
|
|
55
|
-
entity: EntityDefinition,
|
|
56
|
-
honorOwnerBinding: boolean,
|
|
57
|
-
): ReadonlySet<string> {
|
|
58
|
-
const names = new Set<string>();
|
|
59
|
-
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
60
|
-
const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
|
|
61
|
-
if (isPersonalDataField(field) && !exempt) names.add(fieldName);
|
|
62
|
-
}
|
|
63
|
-
return names;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
18
|
// escapeHatch and r.systemScope() can write around the entity's write map
|
|
67
19
|
// (db.global(), systemDb, SYSTEM identity), so the map no longer vouches for the row.
|
|
68
20
|
function canWriteAroundExecutor(feature: FeatureDefinition, handler: WriteHandlerDef): boolean {
|
|
@@ -104,19 +56,6 @@ function hasOpenToAll(access: AccessRule): boolean {
|
|
|
104
56
|
return "openToAll" in access;
|
|
105
57
|
}
|
|
106
58
|
|
|
107
|
-
// Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
|
|
108
|
-
function declaredPersonalData(access: AccessRule): unknown {
|
|
109
|
-
if (!("openToAll" in access)) return access.personalData;
|
|
110
|
-
const openToAll: unknown = access.openToAll;
|
|
111
|
-
if (typeof openToAll !== "object" || openToAll === null) return undefined;
|
|
112
|
-
return "personalData" in openToAll ? openToAll.personalData : undefined;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function accessAllowsAnonymous(access: AccessRule): boolean {
|
|
116
|
-
if ("openToAll" in access) return false;
|
|
117
|
-
return Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
59
|
function declaresTenantMembersPersonalData(access: AccessRule): boolean {
|
|
121
60
|
return declaredPersonalData(access) === "tenant-members";
|
|
122
61
|
}
|
package/src/engine/index.ts
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ANONYMOUS_ROLE } from "./system-user";
|
|
2
|
+
import type { AccessRule, OwnershipMap, OwnershipRule } from "./types";
|
|
3
|
+
import type { EntityDefinition, ResolvedPiiFlags } from "./types/fields";
|
|
4
|
+
|
|
5
|
+
// Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
|
|
6
|
+
// minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
|
|
7
|
+
// sense the openToAll / public-intake personal-data checks are guarding against.
|
|
8
|
+
function isPersonalDataField(field: unknown): boolean {
|
|
9
|
+
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
|
|
10
|
+
return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
|
|
14
|
+
if (rule === "all" || rule.kind !== "from") return false;
|
|
15
|
+
return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The executor checks access.write against every created/updated row; one "all" role
|
|
19
|
+
// or an empty map (= public) lets a caller write rows owned by someone else.
|
|
20
|
+
function writeMapBindsRowsToCaller(
|
|
21
|
+
writeMap: OwnershipMap | undefined,
|
|
22
|
+
ownerColumn: string,
|
|
23
|
+
): boolean {
|
|
24
|
+
const rules = Object.values(writeMap ?? {});
|
|
25
|
+
return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ROW_ID_COLUMN = "id";
|
|
29
|
+
|
|
30
|
+
// A self/record-owned field's subject is the row itself, so only from("user:id", "id")
|
|
31
|
+
// makes that row the caller — on any other entity "self" names a third party.
|
|
32
|
+
function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
|
|
33
|
+
if (annot.userOwned) return annot.userOwned.ownerField;
|
|
34
|
+
if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
|
|
39
|
+
const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
|
|
40
|
+
return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function personalFieldNames(
|
|
44
|
+
entity: EntityDefinition,
|
|
45
|
+
honorOwnerBinding: boolean,
|
|
46
|
+
): ReadonlySet<string> {
|
|
47
|
+
const names = new Set<string>();
|
|
48
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
49
|
+
const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
|
|
50
|
+
if (isPersonalDataField(field) && !exempt) names.add(fieldName);
|
|
51
|
+
}
|
|
52
|
+
return names;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
|
|
56
|
+
export function declaredPersonalData(access: AccessRule): unknown {
|
|
57
|
+
if (!("openToAll" in access)) return access.personalData;
|
|
58
|
+
const openToAll: unknown = access.openToAll;
|
|
59
|
+
if (typeof openToAll !== "object" || openToAll === null) return undefined;
|
|
60
|
+
return "personalData" in openToAll ? openToAll.personalData : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function accessAllowsAnonymous(access: AccessRule): boolean {
|
|
64
|
+
if ("openToAll" in access) return false;
|
|
65
|
+
return Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE);
|
|
66
|
+
}
|