@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.
Files changed (41) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  3. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  4. package/src/api/__tests__/server-error-logging.test.ts +33 -0
  5. package/src/api/api-constants.ts +13 -0
  6. package/src/api/extra-route.ts +33 -4
  7. package/src/api/index.ts +1 -0
  8. package/src/api/server.ts +8 -2
  9. package/src/changes.json +42 -0
  10. package/src/db/event-store-executor-write.ts +7 -0
  11. package/src/db/tenant-db.ts +50 -3
  12. package/src/engine/boot-validator/access-declarations.ts +5 -66
  13. package/src/engine/index.ts +2 -0
  14. package/src/engine/personal-data-fields.ts +66 -0
  15. package/src/engine/registry-validate.ts +15 -0
  16. package/src/engine/registry.ts +2 -0
  17. package/src/engine/types/index.ts +2 -0
  18. package/src/env/__tests__/dry-run.test.ts +43 -3
  19. package/src/env/dry-run.ts +28 -15
  20. package/src/errors/__tests__/write-failures.test.ts +47 -4
  21. package/src/errors/i18n/de.yaml +12 -0
  22. package/src/errors/i18n/en.yaml +12 -0
  23. package/src/errors/reasons.ts +4 -0
  24. package/src/errors/write-error-info.ts +12 -3
  25. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  26. package/src/jobs/__tests__/jobs.integration.test.ts +35 -0
  27. package/src/jobs/job-runner.ts +19 -5
  28. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  29. package/src/pipeline/active-membership.ts +5 -1
  30. package/src/pipeline/dispatch-batch.ts +3 -0
  31. package/src/pipeline/dispatch-query.ts +16 -5
  32. package/src/pipeline/dispatch-shared.ts +12 -5
  33. package/src/pipeline/dispatch-stream.ts +7 -2
  34. package/src/pipeline/dispatch-write.ts +22 -5
  35. package/src/pipeline/dispatcher.ts +9 -2
  36. package/src/pipeline/member-reader.ts +3 -1
  37. package/src/pipeline/write-origin.ts +107 -0
  38. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  39. package/src/rate-limit/middleware.ts +3 -0
  40. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  41. 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.304.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.304.0",
202
- "@cosmicdrift/kumiko-types": "0.304.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.304.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);
@@ -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([
@@ -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
- constructor(status: ExtraRouteRejectionStatus, body: unknown, message?: string) {
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
@@ -33,6 +33,7 @@ export type {
33
33
  AnonymousExtraRouteDeps,
34
34
  ExtraRouteDefinition,
35
35
  ExtraRouteEntry,
36
+ ExtraRouteRejectionOptions,
36
37
  ExtraRouteRejectionStatus,
37
38
  SignatureExtraRoute,
38
39
  SignatureExtraRouteDeps,
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 —
@@ -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) ?? foreignTenantOnGlobalWrite(table, values["tenantId"]);
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) ?? foreignTenantOnGlobalWrite(table, set["tenantId"]);
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
  }
@@ -410,6 +410,8 @@ export type {
410
410
  HookMap,
411
411
  ImageFieldDef,
412
412
  ImagesFieldDef,
413
+ JobBackoff,
414
+ JobBackoffStrategy,
413
415
  JobContext,
414
416
  JobDefinition,
415
417
  JobHandlerFn,
@@ -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
+ }