@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
@@ -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);
@@ -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
  }
@@ -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
+ });
@@ -110,6 +110,10 @@ export type TestStackOptions = {
110
110
  * MUST go through here (real HTTP via `stack.http`/`stack.app.fetch`),
111
111
  * never `createTestDispatcher`. */
112
112
  extraRoutes?: import("../api/server").ServerOptions["extraRoutes"];
113
+ /** Forwarded to buildServer like runProdApp. Without a PrometheusMeter-backed
114
+ * `observability` the route answers 503, so spread `resolveObservabilityWiring(token)`
115
+ * rather than setting `metrics` alone. */
116
+ metrics?: import("../api/server").ServerOptions["metrics"];
113
117
  /** Inject a MasterKeyProvider for secrets-backed tests. Lands typed in
114
118
  * AppContext — set/delete/get + rotation job pick it up. Omit for
115
119
  * suites that don't touch secrets. */
@@ -410,6 +414,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
410
414
  eventDedup,
411
415
  sseBroker,
412
416
  ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
417
+ ...(options.metrics && { metrics: options.metrics }),
413
418
  // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
414
419
  // deterministic drains — no timer-induced flakiness. pollIntervalMs
415
420
  // stays short anyway in case a test opts into `.start()`. pgClient