@cosmicdrift/kumiko-framework 0.371.1 → 0.373.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 (79) hide show
  1. package/AGENTS.md +4 -1
  2. package/agents/examples/custom-handlers.md +3 -5
  3. package/agents/examples/embedded-entity-form.md +1 -1
  4. package/agents/examples/pdf-audit-export.md +1 -1
  5. package/agents/examples/rate-limiting.md +65 -1
  6. package/agents/examples/user-data-rights.md +176 -0
  7. package/dist/api/jwt.js +2 -1
  8. package/dist/api/observability-middleware.d.ts +1 -2
  9. package/dist/api/observability-middleware.js +9 -8
  10. package/dist/api/pii-leak-guard.js +2 -1
  11. package/dist/api/route-label.d.ts +3 -0
  12. package/dist/api/route-label.js +17 -0
  13. package/dist/api/server.d.ts +1 -2
  14. package/dist/api/server.js +3 -5
  15. package/dist/config/index.d.ts +5 -0
  16. package/dist/db/event-store-executor.d.ts +2 -1
  17. package/dist/db/index.d.ts +2 -2
  18. package/dist/db/index.js +1 -1
  19. package/dist/db/located-timestamp.d.ts +7 -0
  20. package/dist/db/query.d.ts +1 -1
  21. package/dist/db/query.js +8 -2
  22. package/dist/db/table-builder.d.ts +45 -11
  23. package/dist/db/tenant-db.js +3 -1
  24. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  25. package/dist/engine/boot-validator/entity-handler.js +47 -16
  26. package/dist/engine/entity-handlers.d.ts +3 -3
  27. package/dist/engine/factories.d.ts +2 -1
  28. package/dist/engine/factories.js +2 -0
  29. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  30. package/dist/engine/feature-ast/extractors/hooks.js +8 -8
  31. package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
  32. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  33. package/dist/engine/membership-roles.d.ts +1 -0
  34. package/dist/engine/membership-roles.js +8 -0
  35. package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
  36. package/dist/engine/types/index.d.ts +2 -2
  37. package/dist/engine/types/index.js +1 -1
  38. package/dist/entrypoint/index.d.ts +1 -0
  39. package/dist/entrypoint/index.js +0 -2
  40. package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
  41. package/dist/files/bind-pending-file-refs.d.ts +31 -1
  42. package/dist/files/bind-pending-file-refs.js +80 -6
  43. package/dist/files/create-file-ref-from-bytes.js +12 -6
  44. package/dist/files/create-file-ref.d.ts +20 -2
  45. package/dist/files/create-file-ref.js +10 -1
  46. package/dist/files/feature.d.ts +1 -0
  47. package/dist/files/feature.js +25 -0
  48. package/dist/files/file-handle.d.ts +4 -1
  49. package/dist/files/file-handle.js +30 -4
  50. package/dist/files/file-ref-entity.d.ts +7 -7
  51. package/dist/files/file-ref-table.d.ts +7 -7
  52. package/dist/files/file-routes.js +6 -1
  53. package/dist/files/index.d.ts +5 -1
  54. package/dist/files/index.js +4 -1
  55. package/dist/files/load-save-file-refs.js +3 -1
  56. package/dist/files/provider-resolver.js +3 -1
  57. package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
  58. package/dist/files/sweep-orphaned-uploads.js +53 -0
  59. package/dist/files/upload-ledger.d.ts +10 -0
  60. package/dist/files/upload-ledger.js +47 -0
  61. package/dist/i18n/index.d.ts +1 -1
  62. package/dist/i18n/index.js +1 -1
  63. package/dist/i18n/request-locale.d.ts +15 -4
  64. package/dist/i18n/request-locale.js +26 -9
  65. package/dist/jobs/job-runner.js +2 -4
  66. package/dist/pipeline/dispatch-shared.js +57 -39
  67. package/dist/pipeline/index.d.ts +1 -0
  68. package/dist/pipeline/index.js +1 -0
  69. package/dist/rate-limit/index.d.ts +2 -1
  70. package/dist/rate-limit/index.js +1 -0
  71. package/dist/rate-limit/resolver.d.ts +1 -1
  72. package/dist/rate-limit/resolver.js +104 -2
  73. package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
  74. package/dist/rate-limit/unlimited-resolver.js +22 -0
  75. package/dist/schema-cli.js +46 -5
  76. package/package.json +5 -5
  77. package/src/changes.json +55 -0
  78. package/src/scripts/codemod/README.md +22 -0
  79. package/src/scripts/codemod/remove-engine-payload-casts.ts +318 -0
package/AGENTS.md CHANGED
@@ -57,7 +57,10 @@ verbs off with `verbs: { delete: false }` and register them yourself when
57
57
  they need different access or logic. Custom logic goes into
58
58
  `r.writeHandler({ name, schema, handler, access })` and
59
59
  `r.queryHandler({ name, schema, handler, access })`; inside, use the entity
60
- executor from `createEntityExecutor(name, entity)`.
60
+ executor from `createEntityExecutor(name, entity)`. Its `detail` and `list`
61
+ return rows typed from the entity (`EntityRow<typeof entity>`), so
62
+ `row.title` needs no cast; money and located-timestamp fields come back as
63
+ objects, and `create` / `update` payloads stay untyped.
61
64
 
62
65
  **Screen.** A declarative page: `r.screen({ id, type, ... })`. Types include
63
66
  `entityList`, `entityEdit`, `projectionList`, `projectionDetail`,
@@ -326,9 +326,6 @@ import {
326
326
  import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
327
327
  import { z } from "zod";
328
328
 
329
- // detail() returns an untyped row; parse the two fields the increment needs once.
330
- const counterVersionRowSchema = z.object({ count: z.number(), version: z.number() });
331
-
332
329
  export const counterEntity = createEntity({
333
330
  table: "read_sample_counters",
334
331
  fields: {
@@ -365,8 +362,9 @@ export const counterFeature = defineFeature("counters", (r) => {
365
362
  return failNotFound("counter", event.payload.id);
366
363
  }
367
364
 
368
- const { count, version } = counterVersionRowSchema.parse(current);
369
- const newCount = count + event.payload.amount;
365
+ // detail() returns the entity's own row type; count is nullable because the field is not required.
366
+ const { count, version } = current;
367
+ const newCount = (count ?? 0) + event.payload.amount;
370
368
  // Read-modify-write on counter: use the version we just read so two
371
369
  // concurrent increments don't clobber each other's changes.
372
370
  return counterExecutor.update(
@@ -427,7 +427,7 @@ export const prospectsFeature = defineFeature("prospects", (r) => {
427
427
  const flipped = await suggestionExecutor.update(
428
428
  {
429
429
  id: event.payload.suggestionId,
430
- version: suggestion["version"] as number,
430
+ version: suggestion["version"],
431
431
  changes: { status: "accepted" },
432
432
  },
433
433
  event.user,
@@ -14,7 +14,7 @@ A write handler exports tenant data as a PDF and keeps it as a FileRef.
14
14
 
15
15
  - `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
16
16
  - The export is a `beforeTransaction` handler: it reads the rows (reads work in that phase), renders the PDF and calls `uploadFileBytesBeforeTransaction` (`@cosmicdrift/kumiko-framework/files`), which validates the bytes and writes them to the tenant-prefixed storage key. The upload runs before the transaction because storage I/O inside it would hold a pool connection for as long as the upload takes.
17
- - The handler itself calls `createFileRefFromUploadedBytes(ctx, event.prepared)` and creates the `file_refs` row and the `fileRef.created` event in the transaction. If the transaction does not commit, the phase's `release` deletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage; nothing sweeps them.
17
+ - The handler itself calls `createFileRefFromUploadedBytes(ctx, event.prepared)` and creates the `file_refs` row and the `fileRef.created` event in the transaction. If the transaction does not commit, the phase's `release` deletes the uploaded bytes again (only when no row exists for the storage key). A crash between upload and commit leaves the bytes in storage; the hourly `files:job:sweep-orphaned-uploads` job deletes them once they are older than the grace period (24 hours by default, `uploadSweepGraceHours`).
18
18
  - The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
19
19
 
20
20
  ## Source
@@ -13,8 +13,29 @@ End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.
13
13
  ## What it shows
14
14
 
15
15
  - Handler `rateLimit: { per, limit, windowSeconds }`
16
+ - Several buckets on one handler: `rateLimit: [{ per: "user", ... }, { per: "tenant+handler", ... }]`
16
17
  - How L1/L2 from `buildServer` stack with L3
17
18
 
19
+ ## Combined buckets
20
+
21
+ Pass a list to enforce several budgets on one handler, for example a per-caller and a per-tenant cap:
22
+
23
+ ```ts illustration
24
+ r.writeHandler({
25
+ // ...
26
+ rateLimit: [
27
+ { per: "user", limit: 2, windowSeconds: 3600 },
28
+ { per: "tenant+handler", limit: 3, windowSeconds: 3600 },
29
+ ],
30
+ });
31
+ ```
32
+
33
+ Each bucket applies on its own. The check is all-or-nothing: if any bucket is exhausted the call
34
+ answers 429 `rate_limited` for that bucket (the one with the longest wait when several are
35
+ exhausted) and no bucket is charged. Each `per` may appear once; an empty list fails at boot.
36
+ An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
37
+ stays a single declaration.
38
+
18
39
  ## Per-payload limits
19
40
 
20
41
  A public write handler that mails an address typed by the caller is also limited per
@@ -67,7 +88,8 @@ bun test
67
88
  // in each layer's test.
68
89
 
69
90
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
70
- import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
91
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
92
+ import { createTestUser, TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
71
93
  import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
72
94
  import { createRateLimitShowcaseFeature } from "../feature";
73
95
 
@@ -130,6 +152,35 @@ describe("L3 — handler opt-in (per-user budget on expensive-search)", () => {
130
152
  });
131
153
  });
132
154
 
155
+ describe("L3 — combined buckets (per-user and per-tenant budget on submit-report)", () => {
156
+ const submitReport = "rl-showcase:write:submit-report";
157
+
158
+ async function rejectedBucket(user: SessionUser): Promise<string | undefined> {
159
+ const res = await stack.http.write(submitReport, {}, user);
160
+ expect(res.status).toBe(429);
161
+ const body = (await res.json()) as { error: { details?: { bucket?: string } } };
162
+ return body.error.details?.bucket;
163
+ }
164
+
165
+ test("the user bucket rejects first; the tenant bucket only counts accepted requests", async () => {
166
+ await stack.http.writeOk(submitReport, {}, admin);
167
+ await stack.http.writeOk(submitReport, {}, admin);
168
+ expect(await rejectedBucket(admin)).toBe("user");
169
+
170
+ // The rejected third call did not touch the tenant bucket: one of its 3 tokens is left.
171
+ await stack.http.writeOk(submitReport, {}, TestUsers.user);
172
+ expect(await rejectedBucket(TestUsers.user)).toBe("tenant+handler");
173
+ });
174
+
175
+ test("a caller without Admin or User role is rejected with access_denied before any bucket is charged", async () => {
176
+ const outsider = createTestUser({ id: 9, tenantId: admin.tenantId, roles: ["Guest"] });
177
+ const res = await stack.http.write(submitReport, {}, outsider);
178
+ expect(res.status).toBe(403);
179
+ const body = (await res.json()) as { error: { code: string } };
180
+ expect(body.error.code).toBe("access_denied");
181
+ });
182
+ });
183
+
133
184
  describe("L1 — global IP middleware (covers every /api/* request)", () => {
134
185
  test("21st request from same IP returns 429 with l1:ip bucket", async () => {
135
186
  // L1 sits ahead of auth + every /api/* route. Target an authless,
@@ -205,6 +256,19 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
205
256
  access: { roles: ["Admin", "User"] },
206
257
  rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
207
258
  });
259
+
260
+ // Combined budget: each caller gets 2 submits per hour and the tenant 3 per hour in
261
+ // total. Both buckets are checked together; a request one bucket rejects charges neither.
262
+ r.writeHandler({
263
+ name: "submit-report",
264
+ schema: z.object({}),
265
+ handler: async () => ({ isSuccess: true as const, data: {} }),
266
+ access: { roles: ["Admin", "User"] },
267
+ rateLimit: [
268
+ { per: "user", limit: 2, windowSeconds: 3600 },
269
+ { per: "tenant+handler", limit: 3, windowSeconds: 3600 },
270
+ ],
271
+ });
208
272
  });
209
273
  }
210
274
  ```
@@ -83,6 +83,28 @@ Step-by-step proof:
83
83
  2. Deletion + expired grace → `runForgetCleanup` removes all notes
84
84
  3. Strategy `anonymize` → `authorId=null`, title/body remain
85
85
 
86
+ ## Retention with files
87
+
88
+ `runRetentionCleanup` is exported for app tests. A `hardDelete` entity with a file field loses
89
+ its bytes (and derivatives), its `file_refs` row and then the row itself; a storage error keeps
90
+ the row and reports `file_delete_failed` in `skipped`. `src/retained-docs.ts` holds the entity,
91
+ `src/__tests__/retention-files.integration.test.ts` drives the run with only public imports:
92
+
93
+ ```ts illustration
94
+ import { runRetentionCleanup } from "@cosmicdrift/kumiko-bundled-features/data-retention";
95
+ import { createFileContext } from "@cosmicdrift/kumiko-framework/files";
96
+
97
+ const result = await runRetentionCleanup({
98
+ db: stack.db,
99
+ registry: stack.registry,
100
+ tenantId,
101
+ preloadedTenantPreset: null,
102
+ now: getTemporal().Now.instant().add({ hours: 31 * 24 }), // keepFor 30d + 1 day
103
+ files: createFileContext(() => Promise.resolve(provider)), // the stack's storage provider
104
+ });
105
+ // result.hardDeleted === 1, bytes and file_refs row gone
106
+ ```
107
+
86
108
  ## Full app vs this recipe
87
109
 
88
110
  | | This recipe | [user-data-rights-demo](/en/samples/apps-user-data-rights-demo/) |
@@ -300,6 +322,120 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
300
322
  });
301
323
  ```
302
324
 
325
+ ### src/__tests__/retention-files.integration.test.ts
326
+
327
+ ```ts
328
+ // runRetentionCleanup is public API: an app test drives the retention run with its own `now`
329
+ // and the stack's storage provider, then checks that the file bytes, the file_refs row and the
330
+ // entity row are gone and the erasure is on the audit trail.
331
+
332
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
333
+ import {
334
+ createDataRetentionFeature,
335
+ runRetentionCleanup,
336
+ tenantRetentionOverrideEntity,
337
+ } from "@cosmicdrift/kumiko-bundled-features/data-retention";
338
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
339
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
340
+ import {
341
+ createFileContext,
342
+ createFilesFeature,
343
+ createInMemoryFileProvider,
344
+ fileRefsTable,
345
+ } from "@cosmicdrift/kumiko-framework/files";
346
+ import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
347
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
348
+ import {
349
+ buildMultipartBody,
350
+ patchFileInstanceofForBunTest,
351
+ } from "@cosmicdrift/kumiko-testing/helpers";
352
+ import {
353
+ setupTestStack,
354
+ type TestStack,
355
+ unsafeCreateEntityTable,
356
+ } from "@cosmicdrift/kumiko-testing/stack";
357
+ import { retainedDocEntity, retainedDocsFeature } from "../retained-docs";
358
+
359
+ const CREATE = "retained-docs:write:retained-doc:create";
360
+ const DETAIL = "retained-docs:query:retained-doc:detail";
361
+ const tenantId = testTenantId(61);
362
+ const admin = createTestUser({ id: 61, tenantId, roles: ["Admin"] });
363
+ const provider = createInMemoryFileProvider();
364
+
365
+ let stack: TestStack;
366
+
367
+ beforeAll(async () => {
368
+ patchFileInstanceofForBunTest();
369
+ stack = await setupTestStack({
370
+ features: [createFilesFeature(), createDataRetentionFeature(), retainedDocsFeature],
371
+ files: { storageProvider: provider },
372
+ });
373
+ await unsafeCreateEntityTable(stack.db, retainedDocEntity, "retained-doc");
374
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
375
+ });
376
+
377
+ afterAll(async () => {
378
+ await stack.cleanup();
379
+ });
380
+
381
+ async function upload(): Promise<string> {
382
+ const fd = new FormData();
383
+ fd.append("entityType", "retained-doc");
384
+ fd.append("fieldName", "attachment");
385
+ fd.append(
386
+ "file",
387
+ new File([Buffer.from("%PDF-1.4 minimal")], "doc.pdf", { type: "application/pdf" }),
388
+ );
389
+ const { body, contentType } = await buildMultipartBody(fd);
390
+ const res = await stack.app.request("/api/files", {
391
+ method: "POST",
392
+ headers: {
393
+ Authorization: `Bearer ${await stack.jwt.sign(admin)}`,
394
+ "Content-Type": contentType,
395
+ },
396
+ body,
397
+ });
398
+ expect(res.status).toBe(201);
399
+ return ((await res.json()) as { id: string }).id;
400
+ }
401
+
402
+ describe("user-data-rights recipe :: retention run removes a hardDelete entity's files", () => {
403
+ test("after keepFor + 1 day the row, its bytes and its file_refs row are gone; the erasure is audited", async () => {
404
+ const fileRefId = await upload();
405
+ const created = await stack.http.writeOk<{ id: string }>(
406
+ CREATE,
407
+ { title: "contract", attachment: fileRefId },
408
+ admin,
409
+ );
410
+ const [fileRef] = await selectMany<{ storageKey: string }>(stack.db, fileRefsTable, {
411
+ id: fileRefId,
412
+ });
413
+ const storageKey = String(fileRef?.storageKey);
414
+ expect(await provider.exists(storageKey)).toBe(true);
415
+
416
+ const result = await runRetentionCleanup({
417
+ db: stack.db,
418
+ registry: stack.registry,
419
+ tenantId,
420
+ preloadedTenantPreset: null,
421
+ now: getTemporal()
422
+ .Now.instant()
423
+ .add({ hours: 31 * 24 }),
424
+ files: createFileContext(() => Promise.resolve(provider)),
425
+ });
426
+
427
+ expect(result.hardDeleted).toBe(1);
428
+ expect(result.skipped).toEqual([]);
429
+ expect(await provider.exists(storageKey)).toBe(false);
430
+ expect(await selectMany(stack.db, fileRefsTable, { id: fileRefId })).toHaveLength(0);
431
+ expect(await stack.http.queryOk(DETAIL, { id: created.id }, admin)).toBeNull();
432
+
433
+ const forgotten = await selectMany(stack.db, eventsTable, { aggregateId: created.id });
434
+ expect(forgotten.map((e) => (e as { type: string }).type)).toContain("retained-doc.forgotten");
435
+ });
436
+ });
437
+ ```
438
+
303
439
  ### src/feature.ts
304
440
 
305
441
  ```ts
@@ -405,3 +541,43 @@ export const notesFeature = defineFeature("notes", (r) => {
405
541
  });
406
542
  });
407
543
  ```
544
+
545
+ ### src/retained-docs.ts
546
+
547
+ ```ts
548
+ // Retention with files: a hardDelete entity whose file field is cleaned up together with the
549
+ // row. runRetentionCleanup removes the bytes (and derivatives) and the file_refs row first,
550
+ // then the row itself; the cron in data-retention does the same with the tenant's FileContext.
551
+
552
+ import {
553
+ createEntity,
554
+ createFileField,
555
+ createTextField,
556
+ defineEntityCreateHandler,
557
+ defineEntityDetailHandler,
558
+ defineFeature,
559
+ } from "@cosmicdrift/kumiko-framework/engine";
560
+
561
+ export const retainedDocEntity = createEntity({
562
+ table: "store_retained_docs",
563
+ fields: {
564
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
565
+ attachment: createFileField(),
566
+ },
567
+ retention: { keepFor: "30d", strategy: "hardDelete", reference: "createdAt" },
568
+ });
569
+
570
+ export const retainedDocsFeature = defineFeature("retained-docs", (r) => {
571
+ r.entity({ name: "retained-doc", ...retainedDocEntity });
572
+ r.writeHandler(
573
+ defineEntityCreateHandler("retained-doc", retainedDocEntity, {
574
+ access: { roles: ["Admin"] },
575
+ }),
576
+ );
577
+ r.queryHandler(
578
+ defineEntityDetailHandler("retained-doc", retainedDocEntity, {
579
+ access: { roles: ["Admin"] },
580
+ }),
581
+ );
582
+ });
583
+ ```
package/dist/api/jwt.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as jose from "jose";
2
+ import { stripReservedTokenRoles } from "../engine/membership-roles.js";
2
3
  import { parseTenantId } from "../engine/types/index.js";
3
4
  import { InternalError } from "../errors/index.js";
4
5
  function normalizeKeyring(secretOrKeyring) {
@@ -107,7 +108,7 @@ export function createJwtHelper(secretOrKeyring, issuer = "kumiko", ttlSeconds =
107
108
  const result = {
108
109
  sub: payload.sub,
109
110
  tenantId,
110
- roles,
111
+ roles: [...stripReservedTokenRoles(roles)],
111
112
  };
112
113
  if (typeof payload["timezone"] === "string") {
113
114
  result.timezone = payload["timezone"];
@@ -1,8 +1,7 @@
1
1
  import type { Context, Next } from "hono";
2
- import { type Meter, type SensitiveFilterConfig, type Tracer } from "../observability/index.ts";
2
+ import { type Meter, type Tracer } from "../observability/index.ts";
3
3
  export type ObservabilityMiddlewareOptions = {
4
4
  readonly tracer: Tracer;
5
5
  readonly meter: Meter;
6
- readonly sensitiveConfig: SensitiveFilterConfig;
7
6
  };
8
7
  export declare function observabilityMiddleware(opts: ObservabilityMiddlewareOptions): (c: Context, next: Next) => Promise<void>;
@@ -1,20 +1,21 @@
1
- import { emitHttpRequest, observabilityContext, redactQueryString, } from "../observability/index.js";
1
+ import { emitHttpRequest, observabilityContext, } from "../observability/index.js";
2
2
  import { getUser } from "./auth-middleware.js";
3
3
  import { requestContext } from "./request-context.js";
4
+ import { resolveRouteLabel } from "./route-label.js";
4
5
  export function observabilityMiddleware(opts) {
5
- const { tracer, meter, sensitiveConfig } = opts;
6
+ const { tracer, meter } = opts;
6
7
  return async (c, next) => {
7
8
  const method = c.req.method;
8
- const path = c.req.path;
9
- const target = redactQueryString(c.req.url.replace(/^https?:\/\/[^/]+/, ""), sensitiveConfig);
9
+ // The match list is fixed before the first middleware runs, so the label is known up front
10
+ // and also covers requests that a middleware answers or a handler throws on.
11
+ const route = resolveRouteLabel(c);
10
12
  // Start the root span for this request. kind=server marks it as an
11
13
  // incoming server-side span in OTel terms.
12
- const span = tracer.startSpan("http.request", {
14
+ const span = tracer.startSpan(`${method} ${route}`, {
13
15
  kind: "server",
14
16
  attributes: {
15
17
  "http.method": method,
16
- "http.route": path,
17
- "http.target": target,
18
+ "http.route": route,
18
19
  },
19
20
  });
20
21
  const reqCtx = requestContext.get();
@@ -61,7 +62,7 @@ export function observabilityMiddleware(opts) {
61
62
  // c.res may be undefined on very early throws (before any route handler);
62
63
  // fall back to 500 for the metric so the counter is always incremented.
63
64
  const status = c.res?.status ?? 500;
64
- emitHttpRequest(meter, { route: path, method, status }, durationSec);
65
+ emitHttpRequest(meter, { route, method, status }, durationSec);
65
66
  span.end();
66
67
  }
67
68
  };
@@ -1,4 +1,5 @@
1
1
  import { PII_CIPHERTEXT_PREFIX } from "../crypto/index.js";
2
+ import { resolveRouteLabel } from "./route-label.js";
2
3
  const isProductionEnv = () => process.env["NODE_ENV"] === "production";
3
4
  // Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
4
5
  // older/decrypt-only format version still present in unmigrated rows.
@@ -27,7 +28,7 @@ export function piiCiphertextResponseGuard() {
27
28
  // skip: clean response — the common case
28
29
  if (!CIPHERTEXT_RE.test(text))
29
30
  return;
30
- const detail = `[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
31
+ const detail = `[api] JSON response for ${c.req.method} ${resolveRouteLabel(c)} contains a PII ciphertext ` +
31
32
  `("${PII_CIPHERTEXT_PREFIX}…") — a raw DB read leaked to the API surface. ` +
32
33
  `Decrypt before returning (decryptStoredPii / executor read path).`;
33
34
  if (!isProductionEnv()) {
@@ -0,0 +1,3 @@
1
+ import type { Context } from "hono";
2
+ export declare const UNMATCHED_ROUTE_LABEL = "unmatched";
3
+ export declare function resolveRouteLabel(c: Context): string;
@@ -0,0 +1,17 @@
1
+ import { matchedRoutes } from "hono/route";
2
+ export const UNMATCHED_ROUTE_LABEL = "unmatched";
3
+ const MIDDLEWARE_METHOD = "ALL";
4
+ // Route pattern (`/api/heartbeat/:token`) instead of the raw path: raw paths carry path-param
5
+ // secrets and make every distinct id its own metric series. Middleware and the notFound fallback
6
+ // register as method "ALL", so only a method-specific match counts as a real route. Reading the
7
+ // match list instead of c.req.routeIndex keeps the label right when a middleware (401, 429)
8
+ // answers before the handler runs.
9
+ export function resolveRouteLabel(c) {
10
+ const routes = matchedRoutes(c);
11
+ for (let index = routes.length - 1; index >= 0; index--) {
12
+ const route = routes[index];
13
+ if (route && route.method !== MIDDLEWARE_METHOD)
14
+ return route.path;
15
+ }
16
+ return UNMATCHED_ROUTE_LABEL;
17
+ }
@@ -4,7 +4,7 @@ import type { PgListenClient } from "../db/connection.ts";
4
4
  import { type AppContext, type Registry, type RunIn, type WriteResult } from "../engine/types/index.ts";
5
5
  import type { ErrorDocsConfig } from "../errors/docs-url.ts";
6
6
  import type { Lifecycle } from "../lifecycle/index.ts";
7
- import { type ObservabilityOptions, type ObservabilityProvider } from "../observability/index.ts";
7
+ import { type ObservabilityProvider } from "../observability/index.ts";
8
8
  import type { DispatcherOptions } from "../pipeline/dispatcher.ts";
9
9
  import { type Dispatcher } from "../pipeline/dispatcher.ts";
10
10
  import type { EventDedup } from "../pipeline/event-dedup.ts";
@@ -46,7 +46,6 @@ export type ServerOptions = {
46
46
  pgClient?: PgListenClient;
47
47
  };
48
48
  observability?: ObservabilityProvider;
49
- observabilityOptions?: ObservabilityOptions;
50
49
  rateLimit?: {
51
50
  readonly global?: Omit<GlobalIpRateLimitOptions, "resolver">;
52
51
  readonly auth?: Omit<AuthEndpointRateLimitOptions, "resolver"> & {
@@ -10,13 +10,13 @@ import { resolveDisabledFeatureNames, stripDisabledFeaturesFromAppSchema, } from
10
10
  import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
11
11
  import { isFileField, } from "../engine/types/index.js";
12
12
  import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
13
- import { createFileContext } from "../files/file-handle.js";
13
+ import { createTenantFileContext } from "../files/file-handle.js";
14
14
  import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
15
15
  import { makeFileProviderResolver } from "../files/provider-resolver.js";
16
16
  import { createRecordReadCheck } from "../files/record-read-check.js";
17
17
  import { createRecordWriteCheck } from "../files/record-write-check.js";
18
18
  import { createFallbackLogger } from "../logging/index.js";
19
- import { createNoopProvider, DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
19
+ import { createNoopProvider, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
20
20
  import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
21
21
  import { createDispatcher } from "../pipeline/dispatcher.js";
22
22
  import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state.js";
@@ -389,7 +389,7 @@ export function buildServer(options) {
389
389
  // cross-feature ctx.appendEvent calls at emit-site.
390
390
  const mspOwner = msp.name.split(":")[0];
391
391
  const mspFiles = fileProviderResolver
392
- ? createFileContext(() => fileProviderResolver(event.tenantId), event.tenantId)
392
+ ? createTenantFileContext(fileProviderResolver, event.tenantId)
393
393
  : undefined;
394
394
  const applyCtx = createMultiStreamApplyContext({
395
395
  registry: options.registry,
@@ -467,7 +467,6 @@ export function buildServer(options) {
467
467
  // (tenant-by-host), entry:"user" needs c.get("user") populated.
468
468
  const extraRoutePublicMatchers = compileExtraRoutePublicMatchers(options.extraRoutes);
469
469
  const isExtraRoutePublicPath = (c) => extraRoutePublicMatchers.some((m) => m.method === c.req.method && m.pattern.test(c.req.path));
470
- const sensitiveConfig = mergeSensitiveConfig(options.observabilityOptions?.sensitiveFilter ?? DEFAULT_SENSITIVE_CONFIG);
471
470
  registerHealthRoutes(app, {
472
471
  lifecycle: options.lifecycle,
473
472
  readiness: {
@@ -519,7 +518,6 @@ export function buildServer(options) {
519
518
  app.use("/api/*", observabilityMiddleware({
520
519
  tracer: observability.tracer,
521
520
  meter: observability.meter,
522
- sensitiveConfig,
523
521
  }));
524
522
  // Tripwire: a kumiko-pii: ciphertext in a JSON response is always a bug
525
523
  // (raw read leaked past a decrypt) — dev fails loud, prod redacts (#820).
@@ -1,3 +1,4 @@
1
+ import type { ValidateBootOptions } from "../engine/boot-validator.ts";
1
2
  import type { FeatureDefinition } from "../engine/types/index.ts";
2
3
  export type AppInfraPorts = {
3
4
  readonly postgres?: number;
@@ -19,6 +20,8 @@ export type AppClientConfig = {
19
20
  /** Client bundle entry files, relative to the app root (e.g. "src/client.tsx"). */
20
21
  readonly entries: readonly string[];
21
22
  };
23
+ /** The boot-validator options an app owns; `env` and the cipher are injected by the runners. */
24
+ export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "warnOnUniqueAccessRoles">;
22
25
  export type AppConfig = {
23
26
  /** Features mounted in every environment: dev, tests, prod. */
24
27
  readonly features: readonly FeatureDefinition[];
@@ -26,6 +29,8 @@ export type AppConfig = {
26
29
  readonly prodFeatures?: readonly FeatureDefinition[];
27
30
  readonly client: AppClientConfig;
28
31
  readonly ports: AppPorts;
32
+ /** Passed to validateBoot by `kumiko check` and, via the app's bin files, by the runners. */
33
+ readonly validateBootOptions?: AppValidateBootOptions;
29
34
  };
30
35
  export declare const APP_CONFIG_FILE = "kumiko.config.ts";
31
36
  declare const INFRA_PORT_NAMES: readonly ["postgres", "redis", "meilisearch", "s3"];
@@ -5,6 +5,7 @@ import type { EntityCache } from "../pipeline/entity-cache.ts";
5
5
  import type { SearchAdapter } from "../search/types.ts";
6
6
  import type { EnvelopeCipher } from "../secrets/envelope-cipher.ts";
7
7
  import { type Table } from "./event-store-executor-context.ts";
8
+ import type { EntityRow } from "./table-builder.ts";
8
9
  export type { EventStoreExecutor, LockGuardOption, WritePurpose, } from "@cosmicdrift/kumiko-types/event-store-executor-types";
9
10
  export { WritePurposes } from "@cosmicdrift/kumiko-types/event-store-executor-types";
10
11
  export type { EntityLifecycleVerb } from "./event-store-executor-context.ts";
@@ -18,4 +19,4 @@ export type EventStoreExecutorOptions = {
18
19
  /** Override the boot-injected subject KMS for pii-annotated fields. */
19
20
  kms?: LocalKeyKmsAdapter;
20
21
  };
21
- export declare function createEventStoreExecutor(table: Table, entity: EntityDefinition, options: EventStoreExecutorOptions): EventStoreExecutor;
22
+ export declare function createEventStoreExecutor<E extends EntityDefinition>(table: Table, entity: E, options: EventStoreExecutorOptions): EventStoreExecutor<EntityRow<E>>;
@@ -18,7 +18,7 @@ export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } fr
18
18
  export type { EntityLifecycleVerb, EventStoreExecutor, EventStoreExecutorOptions, LockGuardOption, WritePurpose, } from "./event-store-executor.ts";
19
19
  export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.ts";
20
20
  export { enumerateFeatureTableSources, type FeatureTableSource, } from "./feature-table-sources.ts";
21
- export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.ts";
21
+ export { flattenLocatedTimestamp, type LocatedTimestampRead, rehydrateLocatedTimestamp, } from "./located-timestamp.ts";
22
22
  export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.ts";
23
23
  export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.ts";
24
24
  export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.ts";
@@ -34,6 +34,6 @@ export { renderTableDdl, renderTablesDdl } from "./render-ddl.ts";
34
34
  export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, type ReplayMismatch, replayMigrationsDir, } from "./replay-migration-sql.ts";
35
35
  export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.ts";
36
36
  export { tableExists } from "./schema-inspection.ts";
37
- export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
37
+ export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.ts";
38
38
  export type { CreateTenantDbOptions, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
39
39
  export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
package/dist/db/index.js CHANGED
@@ -12,7 +12,7 @@ export { entityTableFromRegistry } from "./entity-table-from-registry.js";
12
12
  export { buildEntityTableMeta, defineUnmanagedTable, deriveEntityTableMeta, } from "./entity-table-meta.js";
13
13
  export { createEventStoreExecutor, entityEventName, WritePurposes, } from "./event-store-executor.js";
14
14
  export { enumerateFeatureTableSources, } from "./feature-table-sources.js";
15
- export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp.js";
15
+ export { flattenLocatedTimestamp, rehydrateLocatedTimestamp, } from "./located-timestamp.js";
16
16
  export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
17
17
  export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
18
18
  export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
@@ -10,6 +10,13 @@ import type { EntityDefinition } from "../engine/types/index.ts";
10
10
  * Pure — mutiert nicht.
11
11
  */
12
12
  export declare function flattenLocatedTimestamp(payload: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
13
+ /** Shape of a single rehydrated locatedTimestamp field. `at` is the wall clock in `tz`
14
+ * (no offset), `utc` the ISO instant string; both are strings, not Temporal values. */
15
+ export type LocatedTimestampRead = {
16
+ readonly at: string;
17
+ readonly tz: string;
18
+ readonly utc: string;
19
+ };
13
20
  /**
14
21
  * DB → API: zwei flache Spalten zu combined { at, tz, utc } rehydraten.
15
22
  *
@@ -68,7 +68,7 @@ export declare function selectInnerJoin<TLeft = Record<string, unknown>, TRight
68
68
  export declare function insertMany<TRow = any>(db: AnyDb, table: WritableTable, rows: ReadonlyArray<Record<string, unknown>>): Promise<readonly TRow[]>;
69
69
  export declare function insertOne<TRow = any>(db: AnyDb, table: WritableTable, values: Record<string, unknown>): Promise<TRow | undefined>;
70
70
  export declare function updateMany<TRow = any>(db: AnyDb, table: WritableTable, set: Record<string, unknown>, where: WhereObject): Promise<readonly TRow[]>;
71
- export declare function deleteMany(db: AnyDb, table: WritableTable, where: WhereObject): Promise<void>;
71
+ export declare function deleteMany(db: AnyDb, table: WritableTable, where: WhereObject): Promise<number>;
72
72
  export declare function resolveConflictColumns(table: TableLike, info: TableInfo, conflictKeys: readonly string[] | undefined): readonly string[];
73
73
  export type UpsertOnConflictOptions = {
74
74
  readonly conflictKeys: readonly string[];
package/dist/db/query.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // fetchOne<T>(db, table, where) → T | undefined
6
6
  // insertOne<T>(db, table, values) → T | undefined
7
7
  // updateMany<T>(db, table, set, where) → readonly T[]
8
- // deleteMany(db, table, where) → void
8
+ // deleteMany(db, table, where) → number of deleted rows
9
9
  // countWhere(db, table, where?) → number
10
10
  // upsertByPk / upsertOnConflict / incrementCounter
11
11
  // deleteManyBatched(db, table, where, { limit }) → { deleted, batches }
@@ -1108,10 +1108,16 @@ export async function deleteMany(db, table, where) {
1108
1108
  let sqlText = `DELETE FROM ${quoteIdent(info.name)}`;
1109
1109
  if (w.sqlText)
1110
1110
  sqlText += ` WHERE ${w.sqlText}`;
1111
- await unsafeLoggingClosedConnection(db, sqlText, w.values, {
1111
+ const result = await unsafeLoggingClosedConnection(db, sqlText, w.values, {
1112
1112
  operation: "deleteMany",
1113
1113
  table: info.name,
1114
1114
  });
1115
+ // postgres-js reports the affected-row count of a DELETE on the result, no RETURNING needed.
1116
+ const count = propertyOf(result, "count");
1117
+ if (typeof count !== "number") {
1118
+ throw new Error(`deleteMany(${info.name}): the driver result carries no affected-row count`);
1119
+ }
1120
+ return count;
1115
1121
  }
1116
1122
  function insertEntries(info, values) {
1117
1123
  return Object.entries(values)