@cosmicdrift/kumiko-framework 0.304.0 → 0.306.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  3. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  4. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  5. package/src/api/__tests__/server-error-logging.test.ts +104 -0
  6. package/src/api/api-constants.ts +13 -0
  7. package/src/api/extra-route.ts +33 -4
  8. package/src/api/index.ts +1 -0
  9. package/src/api/request-context.ts +5 -4
  10. package/src/api/routes.ts +26 -1
  11. package/src/api/server.ts +8 -2
  12. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  13. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  14. package/src/bun-db/query.ts +42 -18
  15. package/src/changes.json +108 -0
  16. package/src/db/__tests__/pg-error.test.ts +14 -0
  17. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  18. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  19. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  20. package/src/db/event-store-executor-write.ts +7 -0
  21. package/src/db/index.ts +1 -1
  22. package/src/db/pg-error.ts +13 -0
  23. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  24. package/src/db/tenant-db.ts +140 -16
  25. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  26. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  27. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  28. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  29. package/src/engine/boot-validator/access-declarations.ts +5 -66
  30. package/src/engine/extension-names.ts +55 -25
  31. package/src/engine/extensions/storage-provider.ts +14 -41
  32. package/src/engine/extensions/tenant-data.ts +4 -0
  33. package/src/engine/extensions/tenant-resource.ts +40 -0
  34. package/src/engine/extensions/user-data.ts +8 -7
  35. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  36. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  37. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  38. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  39. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  40. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  41. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  42. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  43. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  44. package/src/engine/feature-ast/index.ts +11 -1
  45. package/src/engine/feature-ast/patch.ts +338 -5
  46. package/src/engine/feature-ast/patcher.ts +2 -2
  47. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  48. package/src/engine/feature-ast/patterns.ts +22 -15
  49. package/src/engine/feature-ast/render.ts +1 -0
  50. package/src/engine/feature-ui-extensions.ts +8 -7
  51. package/src/engine/index.ts +23 -5
  52. package/src/engine/personal-data-fields.ts +66 -0
  53. package/src/engine/registry-validate.ts +15 -0
  54. package/src/engine/registry.ts +2 -0
  55. package/src/engine/types/extension-options-map.ts +1 -0
  56. package/src/engine/types/index.ts +8 -0
  57. package/src/env/__tests__/dry-run.test.ts +43 -3
  58. package/src/env/dry-run.ts +28 -15
  59. package/src/errors/__tests__/write-failures.test.ts +47 -4
  60. package/src/errors/i18n/de.yaml +12 -0
  61. package/src/errors/i18n/en.yaml +12 -0
  62. package/src/errors/reasons.ts +4 -0
  63. package/src/errors/write-error-info.ts +12 -3
  64. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  65. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  66. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  67. package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
  68. package/src/jobs/job-runner.ts +170 -19
  69. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  70. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  71. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  72. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  73. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  74. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  75. package/src/pipeline/active-membership.ts +5 -1
  76. package/src/pipeline/dispatch-batch.ts +59 -13
  77. package/src/pipeline/dispatch-query.ts +16 -5
  78. package/src/pipeline/dispatch-shared.ts +12 -5
  79. package/src/pipeline/dispatch-stream.ts +7 -2
  80. package/src/pipeline/dispatch-write.ts +22 -5
  81. package/src/pipeline/dispatcher.ts +9 -2
  82. package/src/pipeline/idempotency.ts +16 -0
  83. package/src/pipeline/member-reader.ts +3 -1
  84. package/src/pipeline/system-identity-switch.ts +22 -4
  85. package/src/pipeline/write-origin.ts +107 -0
  86. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  87. package/src/rate-limit/middleware.ts +3 -0
  88. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  89. package/src/stack/test-stack.ts +5 -0
  90. package/src/testing/closed-connection-error.ts +62 -0
  91. package/src/testing/index.ts +1 -0
  92. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -4,7 +4,7 @@ import { filterReadFields } from "../engine/field-access";
4
4
  import type { QueryHandlerDef, SessionUser } from "../engine/types";
5
5
  import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
6
6
  import { assertNoSecretLeak } from "../secrets";
7
- import type { DispatchContext } from "./dispatch-shared";
7
+ import type { DispatchContext, WriteOrigin } from "./dispatch-shared";
8
8
  import {
9
9
  buildHandlerContext,
10
10
  enforceRateLimit,
@@ -21,10 +21,11 @@ export async function executeQuery(
21
21
  type: string,
22
22
  payload: unknown,
23
23
  user: SessionUser,
24
+ origin: WriteOrigin,
24
25
  tx?: DbTx,
25
26
  ): Promise<unknown> {
26
27
  return runHandlerInstrumented(ctx, type, "query", user, () =>
27
- executeQueryInner(ctx, type, payload, user, tx),
28
+ executeQueryInner(ctx, type, payload, user, origin, tx),
28
29
  );
29
30
  }
30
31
 
@@ -33,6 +34,7 @@ async function executeQueryInner(
33
34
  type: string,
34
35
  payload: unknown,
35
36
  user: SessionUser,
37
+ origin: WriteOrigin,
36
38
  tx?: DbTx,
37
39
  ): Promise<unknown> {
38
40
  const { registry } = ctx;
@@ -83,9 +85,9 @@ async function executeQueryInner(
83
85
  // A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
84
86
  return user.origin === "member-resolution"
85
87
  ? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) =>
86
- runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, readOnlyTx),
88
+ runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx),
87
89
  )
88
- : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, tx);
90
+ : runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
89
91
  }
90
92
 
91
93
  async function runQueryHandler(
@@ -95,10 +97,19 @@ async function runQueryHandler(
95
97
  payload: unknown,
96
98
  includeDeleted: boolean,
97
99
  user: SessionUser,
100
+ origin: WriteOrigin,
98
101
  tx: DbTx | undefined,
99
102
  ): Promise<unknown> {
100
103
  const { registry } = ctx;
101
- const handlerContext = await buildHandlerContext(ctx, type, user, tx, undefined, includeDeleted);
104
+ const handlerContext = await buildHandlerContext(
105
+ ctx,
106
+ type,
107
+ user,
108
+ origin,
109
+ tx,
110
+ undefined,
111
+ includeDeleted,
112
+ );
102
113
  let result = await handler.handler({ type, payload, user }, handlerContext);
103
114
 
104
115
  // postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
@@ -100,6 +100,9 @@ import {
100
100
  systemIdentitySwitchDenied,
101
101
  } from "./system-identity-switch";
102
102
  import type { TenantTimezoneCache } from "./tenant-timezone-cache";
103
+ import { buildPersonalDataGate, rootWriteOrigin, type WriteOrigin } from "./write-origin";
104
+
105
+ export type { WriteOrigin } from "./write-origin";
103
106
 
104
107
  // Framework/pipeline stays bundled-features-free, so this can't import the
105
108
  // `tenant` feature — the literal below IS the coupling to its `timezone`
@@ -277,6 +280,7 @@ export async function buildHandlerContext(
277
280
  ctx: DispatchContext,
278
281
  type: string,
279
282
  user: SessionUser,
283
+ origin: WriteOrigin,
280
284
  tx?: DbTx,
281
285
  afterCommitHooks?: AfterCommitHook[],
282
286
  includeDeleted?: boolean,
@@ -320,6 +324,7 @@ export async function buildHandlerContext(
320
324
  unsafeRaw: handlerEscapeHatch,
321
325
  report: reportEscapeHatch,
322
326
  memberReadOnly: isMemberResolutionPrincipal(user),
327
+ personalDataGate: buildPersonalDataGate(registry, origin),
323
328
  },
324
329
  );
325
330
  // Propagate the request's AbortSignal so every TenantDb query throws when
@@ -421,10 +426,11 @@ export async function buildHandlerContext(
421
426
  user,
422
427
  hasIdentitySwitchGrant,
423
428
  {
429
+ // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
424
430
  queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
425
- executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
431
+ executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
426
432
  writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
427
- executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
433
+ executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink),
428
434
  },
429
435
  identitySwitchAudit,
430
436
  );
@@ -483,10 +489,10 @@ export async function buildHandlerContext(
483
489
  );
484
490
  const bridge = {
485
491
  query: (targetType: string, payload: unknown) =>
486
- executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
492
+ executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
487
493
  queryAs: identitySwitch.queryAs,
488
494
  write: async (targetType: string, payload: unknown) => {
489
- const res = await executeWrite(ctx, targetType, payload, user, tx, bridgeSink);
495
+ const res = await executeWrite(ctx, targetType, payload, user, origin, tx, bridgeSink);
490
496
  return res;
491
497
  },
492
498
  writeAs: identitySwitch.writeAs,
@@ -1175,8 +1181,9 @@ function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthCl
1175
1181
  })
1176
1182
  : undefined;
1177
1183
  const identitySwitch = createGatedIdentitySwitch("r.authClaims hook", user, false, {
1184
+ // Login is itself the root operation; the hook context is read-only.
1178
1185
  queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
1179
- executeQuery(ctx, qn, payload, asUser), // @wrapper-known semantic-alias
1186
+ executeQuery(ctx, qn, payload, asUser, rootWriteOrigin(ctx.registry, qn, asUser)), // @wrapper-known semantic-alias
1180
1187
  writeAs: async () => {
1181
1188
  throw new InternalError({
1182
1189
  message: "r.authClaims hook context has no writeAs — auth-claims hooks are read-only.",
@@ -14,6 +14,7 @@ import {
14
14
  ensureFeatureEnabled,
15
15
  isMemberResolutionPrincipal,
16
16
  runStreamInstrumented,
17
+ type WriteOrigin,
17
18
  } from "./dispatch-shared";
18
19
 
19
20
  // Standalone stream execution — used by the public dispatcher.stream().
@@ -27,8 +28,11 @@ export async function* executeStream(
27
28
  type: string,
28
29
  payload: unknown,
29
30
  user: SessionUser,
31
+ origin: WriteOrigin,
30
32
  ): AsyncGenerator<unknown> {
31
- yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user));
33
+ yield* runStreamInstrumented(ctx, type, user, () =>
34
+ executeStreamInner(ctx, type, payload, user, origin),
35
+ );
32
36
  }
33
37
 
34
38
  async function* executeStreamInner(
@@ -36,6 +40,7 @@ async function* executeStreamInner(
36
40
  type: string,
37
41
  payload: unknown,
38
42
  user: SessionUser,
43
+ origin: WriteOrigin,
39
44
  ): AsyncGenerator<unknown> {
40
45
  const { registry } = ctx;
41
46
  const handler = registry.getStreamHandler(type);
@@ -80,7 +85,7 @@ async function* executeStreamInner(
80
85
  // await; close is fire-and-forget instead (#1563).
81
86
  let abandonedForInvalidation = false;
82
87
  try {
83
- const handlerContext = await buildHandlerContext(ctx, type, user);
88
+ const handlerContext = await buildHandlerContext(ctx, type, user, origin);
84
89
  const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
85
90
  iterator = chunks[Symbol.asyncIterator]();
86
91
 
@@ -22,7 +22,7 @@ import {
22
22
  writeFailure,
23
23
  } from "../errors";
24
24
  import { assertNoSecretLeak } from "../secrets";
25
- import type { DispatchContext } from "./dispatch-shared";
25
+ import type { DispatchContext, WriteOrigin } from "./dispatch-shared";
26
26
  import {
27
27
  buildHandlerContext,
28
28
  CONFIG_WRITE_RESET_TYPE,
@@ -129,6 +129,7 @@ async function runLifecycle(
129
129
  data: unknown,
130
130
  handlerContext: HandlerContext,
131
131
  user: SessionUser,
132
+ origin: WriteOrigin,
132
133
  afterCommitHooks: AfterCommitHook[],
133
134
  runner: DbRunner | undefined,
134
135
  ): Promise<void> {
@@ -156,6 +157,7 @@ async function runLifecycle(
156
157
  ctx,
157
158
  type,
158
159
  user,
160
+ origin,
159
161
  undefined,
160
162
  afterCommitHooks,
161
163
  );
@@ -169,6 +171,7 @@ async function runLifecycle(
169
171
  ctx,
170
172
  type,
171
173
  user,
174
+ origin,
172
175
  undefined,
173
176
  afterCommitHooks,
174
177
  );
@@ -192,11 +195,12 @@ export async function executeWrite(
192
195
  type: string,
193
196
  payload: unknown,
194
197
  user: SessionUser,
198
+ origin: WriteOrigin,
195
199
  tx: DbTx | undefined,
196
200
  afterCommitHooks: AfterCommitHook[],
197
201
  ): Promise<WriteResult> {
198
202
  return runHandlerInstrumented(ctx, type, "write", user, () =>
199
- executeWriteInner(ctx, type, payload, user, tx, afterCommitHooks),
203
+ executeWriteInner(ctx, type, payload, user, origin, tx, afterCommitHooks),
200
204
  );
201
205
  }
202
206
 
@@ -227,12 +231,13 @@ export async function executeNestedWrite(
227
231
  type: string,
228
232
  payload: unknown,
229
233
  user: SessionUser,
234
+ origin: WriteOrigin,
230
235
  tx: DbTx | undefined,
231
236
  afterCommitHooks: AfterCommitHook[],
232
237
  ): Promise<WriteResult> {
233
238
  const { registry } = ctx;
234
239
  const nested = extractNestedSpecs(type, payload, registry);
235
- if (!nested) return executeWrite(ctx, type, payload, user, tx, afterCommitHooks);
240
+ if (!nested) return executeWrite(ctx, type, payload, user, origin, tx, afterCommitHooks);
236
241
 
237
242
  // Pre-flight client-shape checks. Merge non-array issues (collected up
238
243
  // front by extractNestedSpecs) with fk-injection issues into one error
@@ -264,6 +269,7 @@ export async function executeNestedWrite(
264
269
  type,
265
270
  nested.cleanPayload,
266
271
  user,
272
+ origin,
267
273
  tx,
268
274
  afterCommitHooks,
269
275
  );
@@ -339,6 +345,7 @@ export async function executeNestedWrite(
339
345
  spec.subType,
340
346
  subPayload,
341
347
  user,
348
+ origin,
342
349
  tx,
343
350
  afterCommitHooks,
344
351
  );
@@ -363,6 +370,7 @@ async function executeWriteInner(
363
370
  type: string,
364
371
  payload: unknown,
365
372
  user: SessionUser,
373
+ origin: WriteOrigin,
366
374
  tx: DbTx | undefined,
367
375
  afterCommitHooks: AfterCommitHook[],
368
376
  ): Promise<WriteResult> {
@@ -461,7 +469,7 @@ async function executeWriteInner(
461
469
  }
462
470
  }
463
471
 
464
- const handlerContext = await buildHandlerContext(ctx, type, user, tx, afterCommitHooks);
472
+ const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks);
465
473
 
466
474
  // Auto transition guard: if entity has transitions and handler doesn't skip it.
467
475
  // Reads via the guard's own db handle — for r.systemScope() handlers
@@ -553,7 +561,16 @@ async function executeWriteInner(
553
561
  if (result.isSuccess) {
554
562
  try {
555
563
  const runner = resolveDbSource(ctx, tx);
556
- await runLifecycle(ctx, type, result.data, handlerContext, user, afterCommitHooks, runner);
564
+ await runLifecycle(
565
+ ctx,
566
+ type,
567
+ result.data,
568
+ handlerContext,
569
+ user,
570
+ origin,
571
+ afterCommitHooks,
572
+ runner,
573
+ );
557
574
  } catch (e) {
558
575
  return writeFailure(wrapToKumiko(e));
559
576
  }
@@ -32,6 +32,7 @@ import type { IdempotencyGuard } from "./idempotency";
32
32
  import type { LifecycleHooks } from "./lifecycle-pipeline";
33
33
  import { createMemberReaderFn } from "./member-reader";
34
34
  import { createTenantTimezoneCache } from "./tenant-timezone-cache";
35
+ import { rootWriteOrigin } from "./write-origin";
35
36
 
36
37
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
37
38
  // HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
@@ -182,9 +183,15 @@ export function createDispatcher(
182
183
 
183
184
  batch: (commands, user, requestId?) => runBatch(ctx, commands, user, requestId),
184
185
 
185
- query: (typeOrRef, payload, user) => executeQuery(ctx, resolveType(typeOrRef), payload, user),
186
+ query: (typeOrRef, payload, user) => {
187
+ const type = resolveType(typeOrRef);
188
+ return executeQuery(ctx, type, payload, user, rootWriteOrigin(registry, type, user));
189
+ },
186
190
 
187
- stream: (typeOrRef, payload, user) => executeStream(ctx, resolveType(typeOrRef), payload, user),
191
+ stream: (typeOrRef, payload, user) => {
192
+ const type = resolveType(typeOrRef);
193
+ return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user));
194
+ },
188
195
 
189
196
  async command(typeOrRef, payload, user) {
190
197
  const type = resolveType(typeOrRef);
@@ -19,6 +19,7 @@ export type IdempotencyGuard = {
19
19
  token: string,
20
20
  result: unknown,
21
21
  ): Promise<void>;
22
+ release(tenantId: string, userId: string, requestId: string, token: string): Promise<void>;
22
23
  };
23
24
 
24
25
  // Sentinel prefix stored under the key while the handler is running. Each
@@ -64,6 +65,16 @@ export function createIdempotencyGuard(
64
65
  end
65
66
  `;
66
67
 
68
+ // Same CAS guard as storeScript: only clear the lock if we still own it.
69
+ // A stale token (lock already reclaimed by a new owner) is a no-op.
70
+ const releaseScript = `
71
+ if redis.call("get", KEYS[1]) == ARGV[1] then
72
+ return redis.call("del", KEYS[1])
73
+ else
74
+ return 0
75
+ end
76
+ `;
77
+
67
78
  async function tryAcquire(key: string): Promise<string | null> {
68
79
  const token = `${PENDING_PREFIX}${generateId()}`;
69
80
  const acquired = await redis.set(key, token, "EX", pendingTtl, "NX");
@@ -138,5 +149,10 @@ export function createIdempotencyGuard(
138
149
  // SET and could stomp that fresher result with our stale one.
139
150
  void written;
140
151
  },
152
+
153
+ async release(tenantId, userId, requestId, token) {
154
+ const key = `${prefix}${tenantId}:${userId}:${requestId}`;
155
+ await redis.eval(releaseScript, 1, key, token);
156
+ },
141
157
  };
142
158
  }
@@ -15,6 +15,7 @@ import {
15
15
  import { executeQuery } from "./dispatch-query";
16
16
  import { type DispatchContext, resolveAuthClaimsFn, resolveDbSource } from "./dispatch-shared";
17
17
  import { isSystemIdentity } from "./system-identity-switch";
18
+ import { rootWriteOrigin } from "./write-origin";
18
19
 
19
20
  // Stricter than interactive sign-in: an unknown principal or a tenant
20
21
  // mid-teardown must not resolve — there is no user-facing flow to recover.
@@ -103,6 +104,7 @@ export function createMemberReaderFn(
103
104
 
104
105
  return async (userId, qn, payload) => {
105
106
  const user = await resolve(userId);
106
- return executeQuery(ctx, qn, payload, user, tx);
107
+ // A resolved member is never anonymous and read-only, so it may start its own root.
108
+ return executeQuery(ctx, qn, payload, user, rootWriteOrigin(ctx.registry, qn, user), tx);
107
109
  };
108
110
  }
@@ -1,4 +1,9 @@
1
- import { type TenantDb, withUnsafeRawGrant } from "../db/tenant-db";
1
+ import {
2
+ type TenantDb,
3
+ type UncheckedSystemDb,
4
+ withSystemDbUnsafeRawGrant,
5
+ withUnsafeRawGrant,
6
+ } from "../db/tenant-db";
2
7
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
3
8
  import type {
4
9
  ActiveMembershipResult,
@@ -214,7 +219,10 @@ function readIdentitySwitchFn<
214
219
  }
215
220
 
216
221
  // context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
217
- function readDbLikeValue(context: object, key: "db" | "dbOutsideTransaction"): object | undefined {
222
+ function readDbLikeValue(
223
+ context: object,
224
+ key: "db" | "dbOutsideTransaction" | "systemDb",
225
+ ): object | undefined {
218
226
  if (!(key in context)) return undefined;
219
227
  const value = (context as Record<string, unknown>)[key];
220
228
  return typeof value === "object" && value !== null ? value : undefined;
@@ -340,6 +348,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
340
348
  const ctxQueryProjection = readIdentitySwitchFn<ProjectionReader>(context, "queryProjection");
341
349
  const ctxDb = readDbLikeValue(context, "db");
342
350
  const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
351
+ const ctxSystemDb = readDbLikeValue(context, "systemDb");
343
352
  if (
344
353
  !ctxQueryAs &&
345
354
  !ctxWriteAs &&
@@ -347,7 +356,8 @@ export function withHookEscapeHatchGrant<TContext extends object>(
347
356
  !ctxQueryAsMember &&
348
357
  !ctxQueryProjection &&
349
358
  !ctxDb &&
350
- !ctxDbOutsideTransaction
359
+ !ctxDbOutsideTransaction &&
360
+ !ctxSystemDb
351
361
  ) {
352
362
  return context;
353
363
  }
@@ -366,10 +376,18 @@ export function withHookEscapeHatchGrant<TContext extends object>(
366
376
  ...(ctxDbOutsideTransaction && {
367
377
  dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction as TenantDb, escapeHatch),
368
378
  }),
379
+ // @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
380
+ ...(ctxSystemDb && {
381
+ systemDb: withSystemDbUnsafeRawGrant(
382
+ ctxSystemDb as UncheckedSystemDb,
383
+ escapeHatch,
384
+ callerLabel,
385
+ ),
386
+ }),
369
387
  };
370
388
  }
371
389
 
372
- // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
390
+ // Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction/ctx.systemDb instead of inheriting the handler's grant.
373
391
  export function bindHookEscapeHatchGrant(
374
392
  fn: LifecycleHookFn,
375
393
  label: string,
@@ -0,0 +1,107 @@
1
+ // The static check in boot-validator/access-declarations.ts only sees a handler's own
2
+ // input schema; writes reached via ctx.write/writeAs/queryAs, hooks or foreign-feature
3
+ // tables are only visible at the actual write, so the gate runs there at runtime.
4
+ import { buildEntityTable } from "../db/table-builder";
5
+ import { type PersonalDataGate, tableNameOf } from "../db/tenant-db";
6
+ import {
7
+ accessAllowsAnonymous,
8
+ declaredPersonalData,
9
+ personalFieldNames,
10
+ } from "../engine/personal-data-fields";
11
+ import { ANONYMOUS_ROLE } from "../engine/system-user";
12
+ import type { AccessRule, Registry, SessionUser } from "../engine/types";
13
+ import type { EntityDefinition } from "../engine/types/fields";
14
+ import { AccessDeniedError } from "../errors";
15
+ import { FrameworkReasons } from "../errors/reasons";
16
+ import { toSnakeCase } from "../utils/case";
17
+
18
+ export type WriteOrigin = {
19
+ readonly rootHandler: string;
20
+ readonly anonymousRoot: boolean;
21
+ // Only a write-handler root can declare public-intake; a query/stream root never does.
22
+ readonly publicIntake: boolean;
23
+ };
24
+
25
+ function declaresPublicIntake(access: AccessRule): boolean {
26
+ return accessAllowsAnonymous(access) && declaredPersonalData(access) === "public-intake";
27
+ }
28
+
29
+ // Only the public dispatcher entry points compute a root; every nested call inherits
30
+ // it, so switching identity via ctx.writeAs(SYSTEM, ...) cannot shed an anonymous root.
31
+ export function rootWriteOrigin(registry: Registry, type: string, user: SessionUser): WriteOrigin {
32
+ const writeHandler = registry.getWriteHandler(type);
33
+ return {
34
+ rootHandler: type,
35
+ anonymousRoot: user.roles.includes(ANONYMOUS_ROLE),
36
+ publicIntake: writeHandler !== undefined && declaresPublicIntake(writeHandler.access),
37
+ };
38
+ }
39
+
40
+ // Owner binding is ignored (honorOwnerBinding=false): all anonymous callers share one
41
+ // user id, so from("user:id", ...) vouches for nobody.
42
+ const personalDataTableMaps = new WeakMap<Registry, ReadonlyMap<string, ReadonlySet<string>>>();
43
+
44
+ function personalColumnNames(entity: EntityDefinition): ReadonlySet<string> {
45
+ return new Set([...personalFieldNames(entity, false)].map(toSnakeCase));
46
+ }
47
+
48
+ function buildPersonalDataTableMap(registry: Registry): ReadonlyMap<string, ReadonlySet<string>> {
49
+ const map = new Map<string, ReadonlySet<string>>();
50
+ for (const [entityName, entity] of registry.getAllEntities()) {
51
+ const columns = personalColumnNames(entity);
52
+ if (columns.size === 0) continue;
53
+ const table = buildEntityTable(entityName, entity, {
54
+ relations: registry.getRelations(entityName),
55
+ });
56
+ map.set(tableNameOf(table), columns);
57
+ }
58
+ return map;
59
+ }
60
+
61
+ function personalDataTableMap(registry: Registry): ReadonlyMap<string, ReadonlySet<string>> {
62
+ const cached = personalDataTableMaps.get(registry);
63
+ if (cached) return cached;
64
+ const built = buildPersonalDataTableMap(registry);
65
+ personalDataTableMaps.set(registry, built);
66
+ return built;
67
+ }
68
+
69
+ // Names fields only, never values: the error reaches the anonymous HTTP caller.
70
+ function publicIntakeRequiredError(
71
+ origin: WriteOrigin,
72
+ target: string,
73
+ fields: readonly string[],
74
+ ): AccessDeniedError {
75
+ return new AccessDeniedError({
76
+ message:
77
+ `Anonymous root handler "${origin.rootHandler}" wrote personal-data field(s) ` +
78
+ `${fields.map((f) => `"${f}"`).join(", ")} on "${target}". Declare ` +
79
+ 'access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
80
+ `"${origin.rootHandler}" to allow anonymous callers to write personal data.`,
81
+ details: {
82
+ reason: FrameworkReasons.publicIntakeRequired,
83
+ rootHandler: origin.rootHandler,
84
+ target,
85
+ fields,
86
+ },
87
+ });
88
+ }
89
+
90
+ // Without an entity the table name is looked up in the registry map; tables outside it
91
+ // (unmanaged stores, hand-built tables) carry no personal-data annotations and pass.
92
+ export function buildPersonalDataGate(
93
+ registry: Registry,
94
+ origin: WriteOrigin,
95
+ ): PersonalDataGate | undefined {
96
+ if (!origin.anonymousRoot || origin.publicIntake) return undefined;
97
+ const map = personalDataTableMap(registry);
98
+ return (tableName, keys, entity) => {
99
+ const personalFields = entity ? personalColumnNames(entity) : map.get(tableName);
100
+ // skip: table carries no personal-data annotations
101
+ if (!personalFields) return;
102
+ const offending = [...new Set(keys.map(toSnakeCase))].filter((k) => personalFields.has(k));
103
+ // skip: write touches no personal-data field
104
+ if (offending.length === 0) return;
105
+ throw publicIntakeRequiredError(origin, tableName, offending);
106
+ };
107
+ }
@@ -186,4 +186,44 @@ describe("authEndpointRateLimit (L2)", () => {
186
186
  const otherAcc = await reqA("user-b");
187
187
  expect(otherAcc.status).toBe(200);
188
188
  });
189
+
190
+ test("GET /api/auth/tenants is exempt (session read), but POST on the same path is not", async () => {
191
+ const app = new Hono();
192
+ app.use(
193
+ "/api/auth/*",
194
+ authEndpointRateLimit({ resolver, limit: 2, windowSeconds: 60, onFailClosed: () => {} }),
195
+ );
196
+ app.get("/api/auth/tenants", (c) => c.text("ok"));
197
+ app.post("/api/auth/tenants", (c) => c.text("ok"));
198
+ app.post("/api/auth/login", (c) => c.text("ok"));
199
+
200
+ const ipHeader = { "x-forwarded-for": "10.0.2.1" };
201
+
202
+ // 10 GETs — none consume the l2:ip:/api/auth/tenants bucket.
203
+ for (let i = 0; i < 10; i++) {
204
+ const res = await app.request("/api/auth/tenants", { headers: ipHeader });
205
+ expect(res.status).toBe(200);
206
+ // Exempt requests skip the resolver entirely — no rate-limit headers.
207
+ expect(res.headers.get("X-RateLimit-Limit")).toBeNull();
208
+ }
209
+
210
+ // A separate credential-endpoint bucket is untouched by the above.
211
+ await app.request("/api/auth/login", { method: "POST", headers: ipHeader });
212
+ await app.request("/api/auth/login", { method: "POST", headers: ipHeader });
213
+ const loginBlocked = await app.request("/api/auth/login", {
214
+ method: "POST",
215
+ headers: ipHeader,
216
+ });
217
+ expect(loginBlocked.status).toBe(429);
218
+
219
+ // POST on the exempt path is method-exact, not path-exact — it still
220
+ // shares the ordinary ip+path bucket and trips at the same limit.
221
+ await app.request("/api/auth/tenants", { method: "POST", headers: ipHeader });
222
+ await app.request("/api/auth/tenants", { method: "POST", headers: ipHeader });
223
+ const postTenantsBlocked = await app.request("/api/auth/tenants", {
224
+ method: "POST",
225
+ headers: ipHeader,
226
+ });
227
+ expect(postTenantsBlocked.status).toBe(429);
228
+ });
189
229
  });
@@ -1,4 +1,5 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
+ import { isAuthRateLimitExempt } from "../api/api-constants";
2
3
  import { requestContext } from "../api/request-context";
3
4
  import { RateLimitError, serializeError } from "../errors";
4
5
  import type { RateLimitDecision, RateLimitResolver } from "./resolver";
@@ -84,6 +85,8 @@ export function authEndpointRateLimit(opts: AuthEndpointRateLimitOptions): Middl
84
85
  const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("l2-auth-endpoints");
85
86
 
86
87
  return async (c, next) => {
88
+ if (isAuthRateLimitExempt(c.req.method, c.req.path)) return next();
89
+
87
90
  const ip = extractIp(c);
88
91
  if (!ip) return next();
89
92
 
@@ -0,0 +1,79 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../engine";
4
+ import { resolveObservabilityWiring } from "../../observability/metrics-wiring";
5
+ import { setupTestStack, type TestStack } from "../test-stack";
6
+ import { TestUsers } from "../test-users";
7
+
8
+ const METRICS_TOKEN = "setup-test-stack-metrics-token-minimum-32-chars!!";
9
+
10
+ const pingFeature = defineFeature("stmetrics", (r) => {
11
+ r.writeHandler(
12
+ "ping",
13
+ z.object({}),
14
+ async () => ({ isSuccess: true as const, data: { ok: true } }),
15
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
16
+ );
17
+ });
18
+
19
+ let stack: TestStack | undefined;
20
+
21
+ afterEach(async () => {
22
+ await stack?.cleanup();
23
+ stack = undefined;
24
+ });
25
+
26
+ describe("setupTestStack metrics option (integration)", () => {
27
+ test("mirrors runProdApp wiring: /metrics scrapes after a real request, token-gated", async () => {
28
+ stack = await setupTestStack({
29
+ features: [pingFeature],
30
+ ...resolveObservabilityWiring(METRICS_TOKEN),
31
+ });
32
+
33
+ // kumiko_http_requests_total is only recorded by the http middleware, so scrape after a real request.
34
+ await stack.http.command("stmetrics:write:ping", {}, TestUsers.admin);
35
+
36
+ const noAuth = await stack.app.request("/metrics");
37
+ expect(noAuth.status).toBe(401);
38
+
39
+ const wrongToken = await stack.app.request("/metrics", {
40
+ headers: { Authorization: "Bearer wrong-token" },
41
+ });
42
+ expect(wrongToken.status).toBe(401);
43
+
44
+ const scraped = await stack.app.request("/metrics", {
45
+ headers: { Authorization: `Bearer ${METRICS_TOKEN}` },
46
+ });
47
+ expect(scraped.status).toBe(200);
48
+ expect(scraped.headers.get("Content-Type")).toMatch(/openmetrics-text/);
49
+ const body = await scraped.text();
50
+ expect(body).toMatch(/kumiko_http_requests_total\{[^}]*route="\/api\/command"[^}]*\} 1/);
51
+ });
52
+
53
+ test("no metrics option: /metrics is unmounted (404)", async () => {
54
+ stack = await setupTestStack({ features: [pingFeature] });
55
+
56
+ const res = await stack.app.request("/metrics");
57
+ expect(res.status).toBe(404);
58
+ });
59
+
60
+ test("custom path override: mounts only at the overridden path", async () => {
61
+ const wiring = resolveObservabilityWiring(METRICS_TOKEN);
62
+ if (!("metrics" in wiring)) throw new Error("resolveObservabilityWiring did not wire metrics");
63
+
64
+ stack = await setupTestStack({
65
+ features: [pingFeature],
66
+ ...wiring,
67
+ metrics: { ...wiring.metrics, path: "/internal/metrics" },
68
+ });
69
+
70
+ const atDefault = await stack.app.request("/metrics");
71
+ expect(atDefault.status).toBe(404);
72
+
73
+ const atCustom = await stack.app.request("/internal/metrics", {
74
+ headers: { Authorization: `Bearer ${METRICS_TOKEN}` },
75
+ });
76
+ expect(atCustom.status).toBe(200);
77
+ expect(atCustom.headers.get("Content-Type")).toMatch(/openmetrics-text/);
78
+ });
79
+ });