@cosmicdrift/kumiko-framework 0.382.0 → 0.383.1

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 (94) hide show
  1. package/agents/examples/api-worker-split.md +57 -0
  2. package/agents/examples/record-detail-layout.md +20 -18
  3. package/agents/examples/screens-nav.md +3 -2
  4. package/agents/examples/writeform-section.md +2 -1
  5. package/dist/api/api-constants.d.ts +1 -0
  6. package/dist/api/api-constants.js +2 -0
  7. package/dist/api/auth-middleware.d.ts +1 -0
  8. package/dist/api/auth-middleware.js +17 -0
  9. package/dist/api/auth-routes.d.ts +1 -0
  10. package/dist/api/auth-routes.js +14 -4
  11. package/dist/api/client-error-route.d.ts +12 -0
  12. package/dist/api/client-error-route.js +113 -0
  13. package/dist/api/request-context.d.ts +6 -0
  14. package/dist/api/request-context.js +9 -0
  15. package/dist/api/routes.js +7 -4
  16. package/dist/api/server.js +11 -1
  17. package/dist/db/connection.d.ts +11 -0
  18. package/dist/db/connection.js +63 -3
  19. package/dist/db/index.d.ts +2 -1
  20. package/dist/db/index.js +2 -1
  21. package/dist/db/migrate-runner.js +4 -0
  22. package/dist/db/queries/backfill-pii.js +5 -1
  23. package/dist/db/queries/event-store.js +9 -2
  24. package/dist/db/statement-timeout.d.ts +2 -0
  25. package/dist/db/statement-timeout.js +18 -0
  26. package/dist/db/tenant-db.js +5 -12
  27. package/dist/engine/boot-validator/access-declarations.js +17 -18
  28. package/dist/engine/boot-validator/query-output-columns.js +1 -2
  29. package/dist/engine/boot-validator/screens.js +12 -16
  30. package/dist/engine/boot-validator/url-prefill-fields.js +1 -3
  31. package/dist/engine/build-config-feature-schema.js +4 -4
  32. package/dist/engine/entity-handlers.js +5 -7
  33. package/dist/engine/escape-hatch-declaration.d.ts +1 -1
  34. package/dist/engine/escape-hatch-declaration.js +5 -2
  35. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  36. package/dist/engine/escape-hatch-reason.js +35 -0
  37. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  38. package/dist/engine/feature-config-events-jobs.js +6 -4
  39. package/dist/engine/feature-ui-extensions.js +12 -6
  40. package/dist/engine/iban.d.ts +2 -0
  41. package/dist/engine/iban.js +19 -0
  42. package/dist/engine/index.d.ts +1 -1
  43. package/dist/engine/project-app-schema-for-roles.js +1 -1
  44. package/dist/engine/registry-validate.d.ts +1 -0
  45. package/dist/engine/registry-validate.js +8 -0
  46. package/dist/engine/registry.js +2 -1
  47. package/dist/engine/schema-builder.js +9 -0
  48. package/dist/engine/screen-helpers.d.ts +3 -3
  49. package/dist/engine/screen-helpers.js +1 -2
  50. package/dist/engine/steps/_read-source.js +2 -0
  51. package/dist/engine/types/index.d.ts +2 -2
  52. package/dist/engine/types/index.js +1 -1
  53. package/dist/engine/types/screen.d.ts +1 -1
  54. package/dist/engine/types/screen.js +1 -1
  55. package/dist/entrypoint/index.d.ts +1 -0
  56. package/dist/entrypoint/index.js +1 -0
  57. package/dist/event-store/event-store.d.ts +0 -3
  58. package/dist/event-store/event-store.js +1 -26
  59. package/dist/event-store/index.d.ts +1 -1
  60. package/dist/event-store/index.js +1 -1
  61. package/dist/event-store/snapshot.js +6 -1
  62. package/dist/files/file-ref-entity.d.ts +6 -6
  63. package/dist/files/file-ref-table.d.ts +6 -6
  64. package/dist/i18n/required-surface-keys.js +0 -1
  65. package/dist/jobs/job-runner.d.ts +1 -0
  66. package/dist/jobs/job-runner.js +74 -7
  67. package/dist/logging/index.d.ts +2 -1
  68. package/dist/logging/index.js +2 -1
  69. package/dist/logging/pino-logger.d.ts +1 -0
  70. package/dist/logging/pino-logger.js +35 -0
  71. package/dist/logging/request-log-fields.d.ts +1 -0
  72. package/dist/logging/request-log-fields.js +21 -0
  73. package/dist/logging/scrub.d.ts +2 -0
  74. package/dist/logging/scrub.js +21 -0
  75. package/dist/logging/utils.d.ts +1 -0
  76. package/dist/logging/utils.js +40 -6
  77. package/dist/migrations/pii-event-backfill.js +5 -1
  78. package/dist/observability/standard-metrics.d.ts +1 -0
  79. package/dist/observability/standard-metrics.js +9 -0
  80. package/dist/pipeline/msp-rebuild.js +5 -1
  81. package/dist/pipeline/projection-rebuild.js +5 -1
  82. package/dist/rate-limit/middleware.d.ts +8 -3
  83. package/dist/rate-limit/middleware.js +28 -10
  84. package/dist/rate-limit/resolver.js +10 -0
  85. package/dist/redis/client.d.ts +2 -0
  86. package/dist/redis/client.js +20 -0
  87. package/dist/redis/index.d.ts +1 -1
  88. package/dist/redis/index.js +1 -1
  89. package/dist/search/purge-subject.js +6 -8
  90. package/dist/search/reindex-entity.js +11 -10
  91. package/dist/ui-types/index.d.ts +1 -1
  92. package/dist/ui-types/index.js +1 -1
  93. package/package.json +3 -3
  94. package/src/changes.json +70 -0
@@ -62,6 +62,14 @@ With only the API running, `read_orders` gains the row but
62
62
  `read_order_activity` stays empty. Start the worker and both the activity
63
63
  row and the `read_fulfillments` row appear.
64
64
 
65
+ ## Deferring a job
66
+
67
+ `process-order` returns `context.defer(ms)` while the (simulated) carrier is busy. The job
68
+ is re-run after `ms` on the same queue job: no retry attempt is used, no failure is logged
69
+ or recorded, and the run log shows only the runs that actually executed. `maxDefers`
70
+ (default 100) bounds it; beyond that the run fails. It needs a queued run: an inline boot
71
+ gate throws.
72
+
65
73
  ## Test
66
74
 
67
75
  Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
@@ -125,6 +133,7 @@ import {
125
133
  fulfillmentEntity,
126
134
  orderActivityTable,
127
135
  orderEntity,
136
+ setCarrierRetryAfter,
128
137
  setOrderFulfillWrite,
129
138
  } from "../feature";
130
139
 
@@ -258,6 +267,44 @@ describe("api-worker-split", () => {
258
267
  await Promise.all([api.stop(), worker.stop()]);
259
268
  }
260
269
  });
270
+
271
+ test("a busy carrier defers the job without failing it, then the fulfillment lands", async () => {
272
+ const prefix = uniquePrefix();
273
+ const api = createApiEntrypoint({
274
+ registry: createRegistry([createApiWorkerSplitFeature()]),
275
+ context: { db: testDb.db, redis: testRedis.redis },
276
+ jwtSecret: JWT,
277
+ jobs: { redisUrl: redisUrl(), queueNamePrefix: prefix },
278
+ });
279
+ const worker = createWorkerEntrypoint({
280
+ registry: createRegistry([createApiWorkerSplitFeature()]),
281
+ context: { db: testDb.db, redis: testRedis.redis },
282
+ jwtSecret: JWT,
283
+ redisUrl: redisUrl(),
284
+ queueNamePrefix: prefix,
285
+ });
286
+ setOrderFulfillWrite(makeDispatchSystemWrite(worker.dispatcher));
287
+ let busyChecks = 0;
288
+ setCarrierRetryAfter(() => {
289
+ busyChecks += 1;
290
+ return busyChecks === 1 ? 200 : 0;
291
+ });
292
+
293
+ await Promise.all([api.start(), worker.start()]);
294
+ try {
295
+ expect(await writeOrder(api, "Initech AG", 250)).toBe(true);
296
+ await waitFor(async () => {
297
+ const fulfillments = await selectMany<{ orderKey: string }>(testDb.db, fulfillmentTable, {
298
+ tenantId: adminUser.tenantId,
299
+ });
300
+ expect(fulfillments.some((row) => row.orderKey === "Initech AG")).toBe(true);
301
+ });
302
+ expect(busyChecks).toBe(2);
303
+ } finally {
304
+ setCarrierRetryAfter(() => 0);
305
+ await Promise.all([api.stop(), worker.stop()]);
306
+ }
307
+ });
261
308
  });
262
309
  ```
263
310
 
@@ -350,6 +397,13 @@ export function setOrderFulfillWrite(fn: FulfillWrite): void {
350
397
  fulfillWrite = fn;
351
398
  }
352
399
 
400
+ let carrierRetryAfterMs: () => number = () => 0;
401
+
402
+ /** Stand-in for a rate-limited carrier API: ms until it accepts work again, 0 when free. */
403
+ export function setCarrierRetryAfter(fn: () => number): void {
404
+ carrierRetryAfterMs = fn;
405
+ }
406
+
353
407
  // Job payloads arrive as `Record<string, unknown>` (JobHandlerFn) — an
354
408
  // unchecked `as string` cast here would let a missing/non-string field
355
409
  // through silently, writing `orderKey: undefined` / `label: "label-
@@ -389,6 +443,9 @@ export function createApiWorkerSplitFeature() {
389
443
  );
390
444
  }
391
445
  const { customerName } = processOrderPayloadSchema.parse(payload);
446
+ // Busy is not a failure: ctx.defer re-runs the job later without burning a retry.
447
+ const retryAfterMs = carrierRetryAfterMs();
448
+ if (retryAfterMs > 0) return context.defer(retryAfterMs);
392
449
  const result = await fulfillWrite({
393
450
  handlerQn: "orders:write:fulfillment:create",
394
451
  payload: {
@@ -13,12 +13,12 @@ header, a metrics band, and a tabbed layout instead of one long stacked form.
13
13
 
14
14
  ## What it shows
15
15
 
16
- - `header` (title/subtitle/status) and `metrics` (labeled via `fieldLabels`)
16
+ - `header` (title/subtitle/status) and `metrics` (each with its own i18n `label`)
17
17
  rendered from the columns of the screen's own query row
18
18
  - `layout.mode: "tabs"` with two `relatedList` tabs (order items, payments)
19
19
  and one `fields` tab (master data) — each `relatedList` runs its own query
20
20
  - boot-validator catching two common author mistakes: a `metrics` entry
21
- without a `fieldLabels` override, and a `tabs` layout with fewer than two
21
+ with an empty `label`, and a `tabs` layout with fewer than two
22
22
  sections
23
23
 
24
24
  ## Source
@@ -43,7 +43,7 @@ bun test
43
43
  // exactly as declared (column names, not literals)
44
44
  // - relatedList columns collapse the string shorthand like entityList columns do
45
45
  // - the boot-validator catches the two mistakes the projectionDetail tabs
46
- // layout introduces: a metric without a fieldLabels entry, and a tabs
46
+ // layout introduces: a metric with an empty label, and a tabs
47
47
  // layout with fewer than two sections
48
48
 
49
49
  import { describe, expect, test } from "bun:test";
@@ -82,7 +82,12 @@ describe("record-detail-layout showcase — registration", () => {
82
82
  subtitle: "orderNumber",
83
83
  status: "status",
84
84
  });
85
- expect(screen.metrics).toEqual(["totalAmount", "outstandingAmount", "itemCount", "placedAt"]);
85
+ expect(screen.metrics?.map((metric) => metric.field)).toEqual([
86
+ "totalAmount",
87
+ "outstandingAmount",
88
+ "itemCount",
89
+ "placedAt",
90
+ ]);
86
91
  });
87
92
 
88
93
  test("layout.mode is tabs with two relatedList sections, one fields section, and one extension section", () => {
@@ -113,7 +118,7 @@ describe("record-detail-layout showcase — registration", () => {
113
118
  });
114
119
 
115
120
  describe("record-detail-layout showcase — boot-validator catches author mistakes", () => {
116
- test("metric without a fieldLabels entry fails boot — no fallback to the raw column name", () => {
121
+ test("metric with an empty label fails boot — no fallback to the raw column name", () => {
117
122
  const broken = defineFeature("broken-order-desk", (r) => {
118
123
  r.queryHandler({
119
124
  name: "order:detail",
@@ -125,13 +130,11 @@ describe("record-detail-layout showcase — boot-validator catches author mistak
125
130
  id: "order-detail",
126
131
  type: "projectionDetail",
127
132
  query: "broken-order-desk:query:order:detail",
128
- metrics: ["totalAmount"],
133
+ metrics: [{ field: "totalAmount", label: "" }],
129
134
  layout: { sections: [{ title: "Details", fields: ["id"] }] },
130
135
  });
131
136
  });
132
- expect(() => validateBoot([broken])).toThrow(
133
- /metric "totalAmount" has no entry in fieldLabels/,
134
- );
137
+ expect(() => validateBoot([broken])).toThrow(/metric "totalAmount" has no "label"/);
135
138
  });
136
139
 
137
140
  test("tabs with only one section fails boot", () => {
@@ -165,8 +168,8 @@ describe("record-detail-layout showcase — boot-validator catches author mistak
165
168
  // 1. `header` renders the query row's customer/order/status columns above
166
169
  // the layout — the identity a support agent scans first.
167
170
  // 2. `metrics` renders a labeled band of key numbers from the same row —
168
- // labels come from `fieldLabels`, there is no fallback to the raw
169
- // column name.
171
+ // each metric carries its own i18n `label`, there is no fallback to the
172
+ // raw column name.
170
173
  // 3. `layout.mode: "tabs"` splits the record into four tabs: two
171
174
  // `relatedList` sections (own query, own pagination, mounts only when
172
175
  // active), one `fields` section (master data from the detail row), and
@@ -238,13 +241,12 @@ export function createOrderDeskFeature(): FeatureDefinition {
238
241
  type: "projectionDetail",
239
242
  query: "order-desk:query:order:detail",
240
243
  header: { title: "customerName", subtitle: "orderNumber", status: "status" },
241
- metrics: ["totalAmount", "outstandingAmount", "itemCount", "placedAt"],
242
- fieldLabels: {
243
- totalAmount: "order-desk.metric.totalAmount",
244
- outstandingAmount: "order-desk.metric.outstandingAmount",
245
- itemCount: "order-desk.metric.itemCount",
246
- placedAt: "order-desk.metric.placedAt",
247
- },
244
+ metrics: [
245
+ { field: "totalAmount", label: "order-desk.metric.totalAmount" },
246
+ { field: "outstandingAmount", label: "order-desk.metric.outstandingAmount" },
247
+ { field: "itemCount", label: "order-desk.metric.itemCount" },
248
+ { field: "placedAt", label: "order-desk.metric.placedAt" },
249
+ ],
248
250
  layout: {
249
251
  mode: "tabs",
250
252
  sections: [
@@ -49,6 +49,7 @@ import {
49
49
  isFieldsEditSection,
50
50
  normalizeEditField,
51
51
  normalizeListColumn,
52
+ sectionFieldSpecs,
52
53
  validateBoot as validateBootRaw,
53
54
  } from "@cosmicdrift/kumiko-framework/engine";
54
55
  import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
@@ -160,9 +161,9 @@ describe("screens-nav showcase — field specs", () => {
160
161
  if (!publishingSection || !isFieldsEditSection(publishingSection)) {
161
162
  throw new Error("expected fields section");
162
163
  }
163
- const priceField = normalizeEditField(publishingSection.fields[0]!);
164
+ const priceField = normalizeEditField(sectionFieldSpecs(publishingSection)[0]!);
164
165
  expect(priceField.readOnly).toEqual({ field: "published", eq: true });
165
- const authorField = normalizeEditField(basicsSection.fields[1]!);
166
+ const authorField = normalizeEditField(sectionFieldSpecs(basicsSection)[1]!);
166
167
  expect(authorField.visible).toEqual({ field: "published", eq: false });
167
168
  });
168
169
  });
@@ -55,6 +55,7 @@ import {
55
55
  createRegistry,
56
56
  createTextField,
57
57
  defineFeature,
58
+ sectionFieldSpecs,
58
59
  validateBoot as validateBootRaw,
59
60
  } from "@cosmicdrift/kumiko-framework/engine";
60
61
  import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
@@ -107,7 +108,7 @@ describe("writeform-section sample — registration", () => {
107
108
  throw new Error("expected an entityEdit fields section and a writeForm section");
108
109
  }
109
110
 
110
- const editFields = editSection.fields.map(fieldName);
111
+ const editFields = sectionFieldSpecs(editSection).map(fieldName);
111
112
  const writeFormFields = detailSection.fields.map(fieldName);
112
113
  expect(writeFormFields).toEqual(editFields);
113
114
  });
@@ -30,6 +30,7 @@ export declare const Routes: {
30
30
  readonly authInviteInfo: "/auth/invite-info";
31
31
  readonly files: "/files";
32
32
  readonly schema: "/schema";
33
+ readonly clientErrors: "/client-errors";
33
34
  };
34
35
  export declare const PUBLIC_API_PATHS: ReadonlySet<string>;
35
36
  export declare const NON_PUBLIC_API_PATHS: ReadonlySet<string>;
@@ -36,6 +36,7 @@ export const Routes = {
36
36
  authInviteInfo: "/auth/invite-info",
37
37
  files: "/files",
38
38
  schema: "/schema",
39
+ clientErrors: "/client-errors",
39
40
  };
40
41
  // Routes that must be reachable WITHOUT a valid JWT.
41
42
  // The auth middleware skips these paths.
@@ -61,6 +62,7 @@ export const PUBLIC_API_PATHS = new Set([
61
62
  `/api${Routes.health}`,
62
63
  `/api${Routes.healthReady}`,
63
64
  `/api${Routes.version}`,
65
+ `/api${Routes.clientErrors}`,
64
66
  ]);
65
67
  // Every other route in `Routes` — explicit, so a route can never fall
66
68
  // through to "public" by simply being absent from PUBLIC_API_PATHS. A
@@ -33,6 +33,7 @@ export type AnonymousAccessResolved = AnonymousAccessConfig & {
33
33
  readonly tenantExists?: TenantExists;
34
34
  readonly resolverTrust?: "authoritative" | "fallback-only";
35
35
  };
36
+ export declare function identifyOptionalSession(c: Context, jwt: JwtHelper): Promise<void>;
36
37
  export declare function authMiddleware(jwt: JwtHelper, options?: AuthMiddlewareOptions): (c: Context, next: Next) => Promise<Response | undefined>;
37
38
  export declare function getUser(c: Context): SessionUser;
38
39
  export declare function getAuthTransport(c: Context): AuthTransport | undefined;
@@ -5,6 +5,7 @@ import { createAnonymousUser } from "../engine/system-user.js";
5
5
  import { parseTenantId, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
6
6
  import { isSessionRequiredApiPath, TENANT_HEADER_NAME } from "./api-constants.js";
7
7
  import { isForeignCookieOrigin } from "./origin-middleware.js";
8
+ import { setRequestIdentity } from "./request-context.js";
8
9
  import { clearSessionCookies, getSessionCookies, hasDuplicateSessionCookie, } from "./session-cookies.js";
9
10
  const USER_KEY = "pipelineUser";
10
11
  const AUTH_TRANSPORT_KEY = "authTransport";
@@ -86,6 +87,20 @@ function extractToken(c) {
86
87
  return { token: bearerToken, transport: "bearer" };
87
88
  return { error: "missing" };
88
89
  }
90
+ // Best-effort for public routes that only want to attribute the log line: a bad or absent token is ignored.
91
+ export async function identifyOptionalSession(c, jwt) {
92
+ const extracted = extractToken(c);
93
+ // skip: without a token the report stays anonymous
94
+ if ("error" in extracted)
95
+ return;
96
+ try {
97
+ const payload = await jwt.verify(extracted.token);
98
+ setRequestIdentity({ tenantId: payload.tenantId, userId: payload.sub });
99
+ }
100
+ catch {
101
+ // skip: an unverifiable token only means the report stays anonymous
102
+ }
103
+ }
89
104
  export function authMiddleware(jwt, options = {}) {
90
105
  const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus, foreignCookieOrigins, isTenantlessAnonymousWrite, } = options;
91
106
  // Fail loud at boot, not silently at request time: a tenantResolver
@@ -224,6 +239,7 @@ export function authMiddleware(jwt, options = {}) {
224
239
  if (lifecycleReject)
225
240
  return lifecycleReject;
226
241
  c.set(USER_KEY, user);
242
+ setRequestIdentity({ tenantId: user.tenantId, userId: user.id });
227
243
  c.set(AUTH_TRANSPORT_KEY, transport);
228
244
  if (payload.exp !== undefined)
229
245
  c.set(AUTH_TOKEN_EXPIRY_KEY, payload.exp);
@@ -266,6 +282,7 @@ async function handleVerifiedBearerUser(c, user, next, resolveTenantLifecycleSta
266
282
  });
267
283
  }
268
284
  c.set(USER_KEY, user);
285
+ setRequestIdentity({ tenantId: user.tenantId, userId: user.id });
269
286
  c.set(AUTH_TRANSPORT_KEY, "bearer");
270
287
  const lifecycleReject = await rejectIfTenantTeardown(c, user.tenantId, resolveTenantLifecycleStatus);
271
288
  if (lifecycleReject)
@@ -80,6 +80,7 @@ export type SignupConfig = {
80
80
  requestHandler: string;
81
81
  confirmHandler: string;
82
82
  };
83
+ export declare function inMemoryLoginRateLimiterFallback(): LoginRateLimiter;
83
84
  export declare function createInMemoryLoginRateLimiter(maxAttempts?: number, windowMs?: number, { maxEntries, sweepThreshold, }?: {
84
85
  maxEntries?: number;
85
86
  sweepThreshold?: number;
@@ -6,6 +6,7 @@ import { buildSessionRoles } from "../engine/membership-roles.js";
6
6
  import { createAnonymousUser, createSystemUser } from "../engine/system-user.js";
7
7
  import { SYSTEM_TENANT_ID, } from "../engine/types/index.js";
8
8
  import { NotFoundError, toKumikoError, toWriteErrorInfo } from "../errors/index.js";
9
+ import { createFallbackLogger } from "../logging/index.js";
9
10
  import { assertUnreachable } from "../utils/index.js";
10
11
  import { parseRoles } from "../utils/serialization.js";
11
12
  import { Routes } from "./api-constants.js";
@@ -209,6 +210,15 @@ function requestMeta(c, resolver) {
209
210
  const userAgent = c.req.header("user-agent") ?? "unknown";
210
211
  return { ip, userAgent };
211
212
  }
213
+ let warnedInMemoryLimiterInProduction = false;
214
+ // Without Redis-backed limiters each replica counts on its own, so the effective limit scales with replica count.
215
+ export function inMemoryLoginRateLimiterFallback() {
216
+ if (process.env["NODE_ENV"] === "production" && !warnedInMemoryLimiterInProduction) {
217
+ warnedInMemoryLimiterInProduction = true;
218
+ createFallbackLogger("auth-routes").warn("login/MFA rate limiter falls back to in-memory; limits are per replica");
219
+ }
220
+ return createInMemoryLoginRateLimiter();
221
+ }
212
222
  // Default: in-memory fixed window. Fine for a single Node process; for
213
223
  // multi-process deployments, inject a Redis-backed LoginRateLimiter instead
214
224
  // so attempts can't be spread across replicas.
@@ -413,7 +423,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
413
423
  // Default to in-memory limiter unless the caller opted out via null.
414
424
  const rateLimiter = config.loginRateLimit === null
415
425
  ? null
416
- : (config.loginRateLimit ?? createInMemoryLoginRateLimiter());
426
+ : (config.loginRateLimit ?? inMemoryLoginRateLimiterFallback());
417
427
  api.post(Routes.authLogin, async (c) => {
418
428
  const raw = await c.req.json().catch(() => null);
419
429
  const parsed = LoginBody.safeParse(raw);
@@ -503,7 +513,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
503
513
  const statusMap = config.mfaVerifyErrorStatusMap ?? {};
504
514
  const rateLimiter = config.mfaVerifyRateLimit === null
505
515
  ? null
506
- : (config.mfaVerifyRateLimit ?? createInMemoryLoginRateLimiter());
516
+ : (config.mfaVerifyRateLimit ?? inMemoryLoginRateLimiterFallback());
507
517
  api.post(Routes.authMfaVerify, async (c) => {
508
518
  const raw = await c.req.json().catch(() => null);
509
519
  const parsed = MfaVerifyBody.safeParse(raw);
@@ -560,7 +570,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
560
570
  const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
561
571
  const rateLimiter = config.mfaPreauthEnableStartRateLimit === null
562
572
  ? null
563
- : (config.mfaPreauthEnableStartRateLimit ?? createInMemoryLoginRateLimiter());
573
+ : (config.mfaPreauthEnableStartRateLimit ?? inMemoryLoginRateLimiterFallback());
564
574
  api.post(Routes.authMfaPreauthEnableStart, async (c) => {
565
575
  const raw = await c.req.json().catch(() => null);
566
576
  const parsed = MfaPreauthEnableStartBody.safeParse(raw);
@@ -610,7 +620,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
610
620
  const statusMap = config.mfaPreauthConfirmErrorStatusMap ?? {};
611
621
  const rateLimiter = config.mfaPreauthConfirmRateLimit === null
612
622
  ? null
613
- : (config.mfaPreauthConfirmRateLimit ?? createInMemoryLoginRateLimiter());
623
+ : (config.mfaPreauthConfirmRateLimit ?? inMemoryLoginRateLimiterFallback());
614
624
  api.post(Routes.authMfaPreauthConfirm, async (c) => {
615
625
  const raw = await c.req.json().catch(() => null);
616
626
  const parsed = MfaPreauthConfirmBody.safeParse(raw);
@@ -0,0 +1,12 @@
1
+ import { type Context, Hono } from "hono";
2
+ import type { Meter } from "../observability/types/index.ts";
3
+ import type { RateLimitResolver } from "../rate-limit/resolver.ts";
4
+ import { type ClientIpResolver } from "./client-ip.ts";
5
+ export type ClientErrorRouteOptions = {
6
+ readonly resolver: RateLimitResolver | undefined;
7
+ readonly clientIpResolver: ClientIpResolver;
8
+ readonly meter?: Meter;
9
+ readonly now?: () => number;
10
+ readonly identifySession: (c: Context) => Promise<void>;
11
+ };
12
+ export declare function createClientErrorRoute(options: ClientErrorRouteOptions): Hono;
@@ -0,0 +1,113 @@
1
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ import { Hono } from "hono";
3
+ import { bodyLimit } from "hono/body-limit";
4
+ import * as z from "zod";
5
+ import { createFallbackLogger, scrubLogText } from "../logging/index.js";
6
+ import { httpRouteRateLimit, respondRateLimited } from "../rate-limit/middleware.js";
7
+ import { Routes } from "./api-constants.js";
8
+ import { clientIpSourceFromHonoContext } from "./client-ip.js";
9
+ const MAX_BODY_BYTES = 8 * 1024;
10
+ const REPORTS_PER_WINDOW = 10;
11
+ const WINDOW_SECONDS = 60;
12
+ const clientErrorReportSchema = z.strictObject({
13
+ errorId: z.uuid(),
14
+ kind: z.enum(["render", "unhandled"]),
15
+ message: z.string().max(500),
16
+ stack: z.string().max(4000).optional(),
17
+ screen: z.string().max(200).optional(),
18
+ path: z.string().max(300).optional(),
19
+ buildVersion: z
20
+ .string()
21
+ .regex(/^[\w.+-]{1,64}$/)
22
+ .optional(),
23
+ });
24
+ // Query and fragment can carry tokens or personal data; only the route shape is useful for triage.
25
+ function stripQueryAndFragment(path) {
26
+ return path.split(/[?#]/, 1)[0] ?? "";
27
+ }
28
+ const MAX_TRACKED_IPS = 10_000;
29
+ // Without Redis there is no shared limiter; a per-process window still bounds log flooding.
30
+ function inMemoryIpRateLimit(clientIpResolver, now) {
31
+ const windowMs = WINDOW_SECONDS * 1000;
32
+ const windows = new Map();
33
+ function evictExpired(nowMs) {
34
+ if (windows.size > MAX_TRACKED_IPS) {
35
+ for (const [ip, window] of windows) {
36
+ if (nowMs - window.startedAt >= windowMs)
37
+ windows.delete(ip);
38
+ }
39
+ }
40
+ if (windows.size > MAX_TRACKED_IPS)
41
+ windows.clear();
42
+ }
43
+ return async (c, next) => {
44
+ const ip = clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
45
+ const nowMs = now();
46
+ evictExpired(nowMs);
47
+ const existing = windows.get(ip);
48
+ const window = existing && nowMs - existing.startedAt < windowMs ? existing : { count: 0, startedAt: nowMs };
49
+ window.count += 1;
50
+ windows.set(ip, window);
51
+ if (window.count > REPORTS_PER_WINDOW) {
52
+ const resetAtMs = window.startedAt + windowMs;
53
+ const decision = {
54
+ allowed: false,
55
+ limit: REPORTS_PER_WINDOW,
56
+ remaining: 0,
57
+ retryAfterSeconds: Math.max(1, Math.ceil((resetAtMs - nowMs) / 1000)),
58
+ windowSeconds: WINDOW_SECONDS,
59
+ resetAt: Temporal.Instant.fromEpochMilliseconds(resetAtMs),
60
+ };
61
+ return respondRateLimited(c, decision, `http:${ip}`);
62
+ }
63
+ await next();
64
+ };
65
+ }
66
+ export function createClientErrorRoute(options) {
67
+ const app = new Hono();
68
+ const logger = createFallbackLogger("client-errors");
69
+ const routePath = `/api${Routes.clientErrors}`;
70
+ if (options.resolver) {
71
+ app.post(Routes.clientErrors, httpRouteRateLimit({
72
+ resolver: options.resolver,
73
+ rateLimit: { per: "ip", limit: REPORTS_PER_WINDOW, windowSeconds: WINDOW_SECONDS },
74
+ routeKey: `POST ${routePath}`,
75
+ clientIpResolver: options.clientIpResolver,
76
+ ...(options.meter && { meter: options.meter }),
77
+ }));
78
+ }
79
+ else {
80
+ app.post(Routes.clientErrors, inMemoryIpRateLimit(options.clientIpResolver, options.now ?? Date.now));
81
+ }
82
+ app.post(Routes.clientErrors, bodyLimit({
83
+ maxSize: MAX_BODY_BYTES,
84
+ onError: (c) => c.json({ error: { code: "payload_too_large" } }, 413),
85
+ }), async (c) => {
86
+ let raw;
87
+ try {
88
+ raw = await c.req.json();
89
+ }
90
+ catch {
91
+ return c.json({ error: { code: "invalid_client_error_report" } }, 400);
92
+ }
93
+ const parsed = clientErrorReportSchema.safeParse(raw);
94
+ if (!parsed.success) {
95
+ return c.json({ error: { code: "invalid_client_error_report" } }, 400);
96
+ }
97
+ await options.identifySession(c);
98
+ const report = parsed.data;
99
+ logger.warn("client error", {
100
+ errorId: report.errorId,
101
+ kind: report.kind,
102
+ message: scrubLogText(report.message),
103
+ ...(report.stack !== undefined && { stack: scrubLogText(report.stack) }),
104
+ ...(report.screen !== undefined && { screen: scrubLogText(report.screen) }),
105
+ ...(report.path !== undefined && {
106
+ path: scrubLogText(stripQueryAndFragment(report.path)),
107
+ }),
108
+ ...(report.buildVersion !== undefined && { buildVersion: report.buildVersion }),
109
+ });
110
+ return c.body(null, 204);
111
+ });
112
+ return app;
113
+ }
@@ -12,6 +12,8 @@ export type RequestContextData = {
12
12
  readonly locale?: string;
13
13
  readonly feature?: string;
14
14
  readonly handler?: string;
15
+ tenantId?: string;
16
+ userId?: string;
15
17
  readonly startedAt?: number;
16
18
  readonly writeOrigin?: WriteOrigin;
17
19
  readonly entryHandler?: {
@@ -25,6 +27,10 @@ export declare const requestContext: {
25
27
  get(): RequestContextData | undefined;
26
28
  generateId(): string;
27
29
  };
30
+ export declare function setRequestIdentity(identity: {
31
+ tenantId: string;
32
+ userId?: string;
33
+ }): void;
28
34
  export declare const readonlyRequestContext: Pick<typeof requestContext, "get">;
29
35
  export declare function runWithOrigin<T>(origin: {
30
36
  readonly feature?: string;
@@ -13,6 +13,15 @@ export const requestContext = {
13
13
  return generateId();
14
14
  },
15
15
  };
16
+ export function setRequestIdentity(identity) {
17
+ const store = storage.getStore();
18
+ // skip: outside a request scope there is no log context to enrich
19
+ if (!store)
20
+ return;
21
+ store.tenantId = identity.tenantId;
22
+ if (identity.userId !== undefined)
23
+ store.userId = identity.userId;
24
+ }
16
25
  // Public view: `run` would let a caller replace the request scope (entryHandler,
17
26
  // writeOrigin), so only the read side is exposed through the /api barrel.
18
27
  export const readonlyRequestContext = {
@@ -2,7 +2,7 @@ import { Hono } from "hono";
2
2
  import { HTTPException } from "hono/http-exception";
3
3
  import { streamSSE } from "hono/streaming";
4
4
  import { AccessDeniedError, reraiseAsKumikoError, serializeError, toKumikoError, ValidationError, } from "../errors/index.js";
5
- import { createFallbackLogger } from "../logging/index.js";
5
+ import { createFallbackLogger, scrubLogText, scrubLogValue } from "../logging/index.js";
6
6
  import { isPlainObject } from "../utils/is-plain-object.js";
7
7
  import { stringifyJson } from "../utils/safe-json.js";
8
8
  import { Routes } from "./api-constants.js";
@@ -378,6 +378,9 @@ function isClientAbort(err) {
378
378
  const signal = requestContext.get()?.signal;
379
379
  return signal?.aborted === true && err.cause !== undefined && err.cause === signal.reason;
380
380
  }
381
+ function scrubOptionalText(text) {
382
+ return text === undefined ? undefined : scrubLogText(text);
383
+ }
381
384
  // Unexpected server faults (5xx) carry their diagnostic stack only on the
382
385
  // in-process error — serializeError strips cause/details from the wire body.
383
386
  // Without this a wrapped throw (InternalError{cause}) returns a 500 with zero
@@ -395,9 +398,9 @@ function logServerFault(err, requestId, type) {
395
398
  requestId,
396
399
  type: loggableType(type),
397
400
  code: err.code,
398
- message: err.message,
399
- cause: cause instanceof Error ? cause.message : cause,
400
- stack: cause instanceof Error ? cause.stack : err.stack,
401
+ message: scrubLogText(err.message),
402
+ cause: cause instanceof Error ? scrubLogText(cause.message) : scrubLogValue(cause),
403
+ stack: scrubOptionalText(cause instanceof Error ? cause.stack : err.stack),
401
404
  });
402
405
  }
403
406
  // Same floor of 1 as the rate-limit middleware: "Retry-After: 0" invites an instant retry loop.
@@ -31,8 +31,9 @@ import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-confi
31
31
  import { assertUnreachable, generateId } from "../utils/index.js";
32
32
  import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
33
33
  import { NO_ROUTE_MATCH_HEADER_NAME, NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS, Routes, } from "./api-constants.js";
34
- import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
34
+ import { authMiddleware, getAuthTokenExpiry, getUser, identifyOptionalSession, } from "./auth-middleware.js";
35
35
  import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
36
+ import { createClientErrorRoute } from "./client-error-route.js";
36
37
  import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
37
38
  import { csrfMiddleware } from "./csrf-middleware.js";
38
39
  import { collectEntityReadAccess } from "./entity-read-access.js";
@@ -511,6 +512,7 @@ export function buildServer(options) {
511
512
  ...options.rateLimit.global,
512
513
  resolver: rateLimitResolver,
513
514
  clientIpResolver,
515
+ meter: observability.meter,
514
516
  });
515
517
  app.use("/api/*", globalRateLimitGuard);
516
518
  }
@@ -663,6 +665,13 @@ export function buildServer(options) {
663
665
  resolver: clientIpResolver,
664
666
  }));
665
667
  }
668
+ app.route("/api", createClientErrorRoute({
669
+ resolver: rateLimitResolver ??
670
+ (wrappedRedis ? createRateLimitResolver({ redis: wrappedRedis }) : undefined),
671
+ clientIpResolver,
672
+ meter: observability.meter,
673
+ identifySession: (c) => identifyOptionalSession(c, jwt),
674
+ }));
666
675
  app.route("/api", createApiRoutes(dispatcher, {
667
676
  ...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
668
677
  }));
@@ -785,6 +794,7 @@ export function buildServer(options) {
785
794
  rateLimit: route.rateLimit,
786
795
  routeKey: `${route.method} ${route.path}`,
787
796
  clientIpResolver,
797
+ meter: observability.meter,
788
798
  }),
789
799
  ]
790
800
  : [];
@@ -9,6 +9,9 @@ export type DbConnectionOptions = {
9
9
  readonly closeTimeoutSeconds?: number;
10
10
  readonly ssl?: PostgresDriverOptions["ssl"];
11
11
  readonly onNotice?: (notice: postgres.Notice) => void;
12
+ readonly statementTimeoutMs?: number;
13
+ readonly idleInTransactionSessionTimeoutMs?: number;
14
+ readonly lockTimeoutMs?: number;
12
15
  };
13
16
  export type CreateDbConnectionOptions = DbConnectionOptions & {
14
17
  readonly url: string;
@@ -19,7 +22,15 @@ export type DbConnectionHandle = {
19
22
  close: () => Promise<void>;
20
23
  };
21
24
  export declare const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
25
+ export type UnboundedSiblingPool = {
26
+ readonly client: PgClient;
27
+ readonly close: (timeoutSeconds: number) => Promise<void>;
28
+ };
29
+ export declare function openUnboundedSiblingPool(db: unknown): UnboundedSiblingPool | undefined;
22
30
  export declare function createDbConnection({ url, ...options }: CreateDbConnectionOptions): DbConnectionHandle;
23
31
  export declare function dbConnectionOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;
32
+ export declare const DEFAULT_SERVER_STATEMENT_TIMEOUT_MS = 60000;
33
+ export declare const DEFAULT_SERVER_IDLE_IN_TRANSACTION_TIMEOUT_MS = 300000;
34
+ export declare function serverTimeoutOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;
24
35
  export declare const DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX = 3;
25
36
  export declare function outsideTransactionPoolOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;