@cosmicdrift/kumiko-framework 0.220.1 → 0.221.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 (69) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/store-table.integration.test.ts +2 -1
  3. package/src/__tests__/upgrade-cli.test.ts +81 -12
  4. package/src/api/api-constants.ts +10 -0
  5. package/src/api/index.ts +1 -0
  6. package/src/api/server.ts +17 -1
  7. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  8. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  9. package/src/bun-db/index.ts +1 -0
  10. package/src/bun-db/query.ts +30 -14
  11. package/src/crypto/index.ts +1 -0
  12. package/src/crypto/is-self-pii-field.ts +8 -0
  13. package/src/crypto/subject-resolver.ts +4 -3
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  15. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  16. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  17. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  18. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  19. package/src/db/blind-index-cleanup.ts +36 -19
  20. package/src/db/event-store-executor-context.ts +2 -2
  21. package/src/db/event-store-executor-read.ts +7 -6
  22. package/src/db/event-store-executor-write.ts +103 -49
  23. package/src/db/index.ts +2 -0
  24. package/src/db/migrate-generator.ts +14 -0
  25. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  26. package/src/db/queries/backfill-pii.ts +13 -10
  27. package/src/db/queries/raw-sql.ts +14 -2
  28. package/src/db/queries/seed-context.ts +8 -4
  29. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  30. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  31. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  32. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  33. package/src/engine/__tests__/schema-builder.test.ts +3 -3
  34. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  35. package/src/engine/boot-validator/entity-handler.ts +5 -0
  36. package/src/engine/boot-validator/pii-retention.ts +16 -4
  37. package/src/engine/boot-validator/screens.ts +9 -2
  38. package/src/engine/embedded-derived.ts +11 -10
  39. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  40. package/src/engine/feature-ast/patch.ts +9 -0
  41. package/src/engine/role-assignment.ts +36 -18
  42. package/src/errors/__tests__/classes.test.ts +5 -0
  43. package/src/errors/__tests__/write-failures.test.ts +3 -3
  44. package/src/errors/kumiko-error.ts +11 -11
  45. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  46. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  47. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  48. package/src/event-store/event-store.ts +7 -0
  49. package/src/event-store/index.ts +1 -0
  50. package/src/files/__tests__/files.integration.test.ts +181 -2
  51. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  52. package/src/files/file-routes.ts +53 -6
  53. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  54. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  55. package/src/i18n/index.ts +7 -1
  56. package/src/i18n/mail-registry.ts +10 -0
  57. package/src/i18n/request-locale.ts +11 -2
  58. package/src/i18n/required-surface-keys.ts +3 -1
  59. package/src/lifecycle/signal-handlers.ts +2 -0
  60. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  61. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
  62. package/src/pipeline/distributed-lock.ts +3 -0
  63. package/src/schema-cli.ts +9 -4
  64. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  65. package/src/search/purge-subject.ts +4 -3
  66. package/src/search/reindex-entity.ts +2 -2
  67. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  68. package/src/ui-types/index.ts +1 -0
  69. package/src/upgrade-cli.ts +100 -14
@@ -19,10 +19,10 @@ export type ErrorCtorInput = {
19
19
  readonly cause?: Error;
20
20
  };
21
21
 
22
- // Default-Doku-URL für Self-Service-Errors. Kann via env-var
23
- // `KUMIKO_DOCS_URL` überschrieben werden z.B. für Self-Hosted-Kunden
24
- // die ihre eigene Doku-Instanz hosten.
22
+ // Default docs URL for self-service errors. Override via `KUMIKO_DOCS_URL`
23
+ // (e.g. self-hosted customers pointing at their own docs instance).
25
24
  const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.rocks";
25
+ const REASON_SLUG_RE = /^[a-z0-9_.-]+$/;
26
26
 
27
27
  function docsBaseUrl(): string {
28
28
  return process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
@@ -43,20 +43,20 @@ export abstract class KumikoError extends Error {
43
43
  this.details = input.details;
44
44
  }
45
45
 
46
- // Doku-URL für Self-Service. Pro-Reason-Slug aus `details.reason` wenn
47
- // vorhanden (z.B. ConflictError → "stale_state"), sonst Fallback auf
48
- // den Error-Code (z.B. "not_found", "validation_error"). Default-Renderer
49
- // im Client zeigt "Mehr erfahren →" Link auf diese URL.
46
+ // Docs URL for self-service. Prefer a well-formed `details.reason` slug
47
+ // (e.g. ConflictError → "stale_state"); otherwise fall back to the error
48
+ // code. Encode the path segment so spaces/`../` in free-form reasons
49
+ // cannot break or redirect the link the default renderer shows.
50
50
  get docsUrl(): string {
51
- return `${docsBaseUrl()}/errors/${this.reasonSlug}`;
51
+ return `${docsBaseUrl()}/errors/${encodeURIComponent(this.reasonSlug)}`;
52
52
  }
53
53
 
54
54
  private get reasonSlug(): string {
55
55
  if (this.details && typeof this.details === "object") {
56
- // @cast-boundary error-details — KumikoError.details ist per-error
57
- // typed, hier reines reflection-shape für reasonSlug-Lookup.
56
+ // @cast-boundary error-details — per-error typed details; reflection
57
+ // shape only for the reasonSlug lookup.
58
58
  const r = (this.details as Record<string, unknown>)["reason"];
59
- if (typeof r === "string") return r;
59
+ if (typeof r === "string" && REASON_SLUG_RE.test(r)) return r;
60
60
  }
61
61
  return this.code;
62
62
  }
@@ -7,6 +7,7 @@
7
7
  // blind-index column, so equality lookups keep working.
8
8
 
9
9
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { randomBytes } from "node:crypto";
10
11
  import {
11
12
  resetBlindIndexKeyForTests,
12
13
  resetPiiSubjectKmsForTests,
@@ -18,6 +19,7 @@ import {
18
19
  InMemoryKmsAdapter,
19
20
  isPiiCiphertext,
20
21
  KeyNotFoundError,
22
+ PgKmsAdapter,
21
23
  PII_ERASED_SENTINEL,
22
24
  } from "../../crypto";
23
25
  import { applyEntityEvent } from "../../db/apply-entity-event";
@@ -277,6 +279,62 @@ describe("backfillEventPiiEncryption", () => {
277
279
  expect(real.erasedUnresolvable).toBe(dry.erasedUnresolvable);
278
280
  });
279
281
 
282
+ // The prod bug this regression test guards (fw#2255) only ever manifested
283
+ // against a real subject-keys store (kumiko_subject_keys 20→22 rows during
284
+ // a dry run) — the InMemoryKmsAdapter tests above can't see that, since
285
+ // there is no row store to leak into. Run the same dry-run invariant
286
+ // against PgKmsAdapter on real Postgres and assert on the table itself.
287
+ test("dryRun against PgKmsAdapter mints no row in kumiko_subject_keys (fw#2255)", async () => {
288
+ const c1 = generateId();
289
+ await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
290
+
291
+ const baseUrl = process.env["TEST_DATABASE_URL"];
292
+ if (!baseUrl) throw new Error("Missing required env var: TEST_DATABASE_URL");
293
+ const pgKms = new PgKmsAdapter({
294
+ databaseUrl: baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`),
295
+ platformKek: randomBytes(32).toString("base64"),
296
+ maxConnections: 1,
297
+ });
298
+ const raw = asRawClient(testDb.db);
299
+ try {
300
+ // health() creates kumiko_subject_keys lazily so the counts below see
301
+ // a real (empty) table instead of failing on "relation does not exist".
302
+ await pgKms.health();
303
+ configurePiiSubjectKms(pgKms);
304
+
305
+ const before = (await raw.unsafe(
306
+ `SELECT count(*)::int AS n FROM kumiko_subject_keys`,
307
+ )) as ReadonlyArray<{ n: number }>;
308
+
309
+ const dry = await backfillEventPiiEncryption(testDb.db, registry, { dryRun: true });
310
+ expect(dry.failures).toEqual([]);
311
+ expect(dry.encryptedFields).toBe(1);
312
+
313
+ const afterDry = (await raw.unsafe(
314
+ `SELECT count(*)::int AS n FROM kumiko_subject_keys`,
315
+ )) as ReadonlyArray<{ n: number }>;
316
+ expect(afterDry[0]?.n).toBe(before[0]?.n);
317
+ await expect(
318
+ pgKms.getKey({ kind: "user", userId: c1 }, { requestId: "backfill-pii-test" }),
319
+ ).rejects.toThrow(KeyNotFoundError);
320
+
321
+ // The real run does mint exactly one row through the same Pg path —
322
+ // proves the invariant above is a genuine "dry run creates nothing",
323
+ // not an adapter that never creates rows at all.
324
+ const real = await backfillEventPiiEncryption(testDb.db, registry);
325
+ expect(real.encryptedFields).toBe(1);
326
+ const afterReal = (await raw.unsafe(
327
+ `SELECT count(*)::int AS n FROM kumiko_subject_keys`,
328
+ )) as ReadonlyArray<{ n: number }>;
329
+ expect(afterReal[0]?.n).toBe((before[0]?.n ?? 0) + 1);
330
+ await expect(
331
+ pgKms.getKey({ kind: "user", userId: c1 }, { requestId: "backfill-pii-test" }),
332
+ ).resolves.toBeInstanceOf(Buffer);
333
+ } finally {
334
+ await pgKms.close();
335
+ }
336
+ });
337
+
280
338
  test("dryRun on a KMS-era-erased subject (no *.forgotten event) predicts [[erased]] without touching the key store", async () => {
281
339
  const author = generateId();
282
340
  const noteId = generateId();
@@ -13,7 +13,7 @@
13
13
  // are the ceiling.
14
14
  //
15
15
  // Runs isolated in the `event-store-perf` CI job (test:integration:perf:eventstore,
16
- // #1940) — see that job's comment in ci.yml for why the gate is p95 not p99.
16
+ // #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for checkpoint/fsync spikes.
17
17
 
18
18
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
19
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
@@ -103,6 +103,8 @@ describe("event-store performance — Gate A", () => {
103
103
  );
104
104
 
105
105
  expect(p95).toBeLessThan(30);
106
+ // Tail budget: cold-checkpoint/fsync spikes after warm-up, not connection warm-up.
107
+ expect(p99).toBeLessThan(100);
106
108
  });
107
109
 
108
110
  test("read-latency p95 < 25ms for loadAggregate detail reads", async () => {
@@ -143,6 +145,7 @@ describe("event-store performance — Gate A", () => {
143
145
  // 25ms budget kept from the original spike doc's 10ms — an
144
146
  // order-of-magnitude gate, not an idle-best-case one. Tracking: #325.
145
147
  expect(p95).toBeLessThan(25);
148
+ expect(p99).toBeLessThan(100);
146
149
  });
147
150
 
148
151
  test("update-latency p95 < 30ms — exercises predecessor-check WHERE EXISTS path", async () => {
@@ -202,6 +205,7 @@ describe("event-store performance — Gate A", () => {
202
205
  );
203
206
 
204
207
  expect(p95).toBeLessThan(30);
208
+ expect(p99).toBeLessThan(100);
205
209
  });
206
210
 
207
211
  test("snapshot-load < 50ms for 1000-event aggregate (Gate A)", async () => {
@@ -51,6 +51,7 @@ describe("unscoped stream primitives — caller allowlist", () => {
51
51
  // Positive control — proves the scan actually ran and found the known
52
52
  // caller, not just that it (silently) found nothing.
53
53
  expect(matches.has("packages/bundled-features/src/tenant/seeding.ts")).toBe(true);
54
+ expect(matches.has("packages/framework/src/event-store/event-store.ts")).toBe(true);
54
55
 
55
56
  const offenders = [...matches].filter((relPath) => !ALLOWED_FILES.has(relPath));
56
57
  expect(offenders).toEqual([]);
@@ -279,6 +279,13 @@ export async function getUnscopedAggregateStreamMaxVersion(
279
279
  // the bigserial PK index — sub-millisecond cost. Returns 0n on an empty log
280
280
  // (boot, fresh tenant, post-archive).
281
281
  // @wrapper-known semantic-alias
282
+ /** Seed/orphan helper — prefer this over importing the restricted existence-oracle by name. */
283
+ export async function getUnscopedStreamMaxVersionForSeed(
284
+ ...args: Parameters<typeof getUnscopedAggregateStreamMaxVersion>
285
+ ): ReturnType<typeof getUnscopedAggregateStreamMaxVersion> {
286
+ return getUnscopedAggregateStreamMaxVersion(...args);
287
+ }
288
+
282
289
  export async function getEventsHighWaterMark(db: DbRunner): Promise<bigint> {
283
290
  return selectEventsHighWaterMark(db);
284
291
  }
@@ -21,6 +21,7 @@ export {
21
21
  getEventsHighWaterMark,
22
22
  getStreamVersion,
23
23
  getUnscopedAggregateStreamMaxVersion,
24
+ getUnscopedStreamMaxVersionForSeed,
24
25
  LOAD_ALL_EVENTS_ROW_LIMIT,
25
26
  loadAggregate,
26
27
  loadAggregateAsOf,
@@ -1,10 +1,11 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { Hono } from "hono";
6
6
  import type { JwtHelper } from "../../api/jwt";
7
7
  import { buildServer } from "../../api/server";
8
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "../../crypto";
8
9
  import {
9
10
  createEntity,
10
11
  createImageField,
@@ -26,13 +27,14 @@ import {
26
27
  buildMultipartBody,
27
28
  expectErrorIncludes,
28
29
  patchFileInstanceofForBunTest,
30
+ resetPiiSubjectKmsForTests,
29
31
  } from "../../testing";
30
32
  import { createFilesFeature } from "../feature";
31
33
  import { fileRefsTable } from "../file-ref-table";
32
34
  import type { FileRoutesOptions } from "../file-routes";
33
35
  import { createInMemoryFileProvider } from "../in-memory-provider";
34
36
  import { createLocalProvider } from "../local-provider";
35
- import type { FileStorageProvider } from "../types";
37
+ import type { FileStorageProvider, SignedUrlOptions } from "../types";
36
38
  import { parseMaxSize, sniffMimeType, validateFile } from "../types";
37
39
 
38
40
  // UUID for "this row doesn't exist" assertions. Valid v4 format so PG accepts
@@ -980,3 +982,180 @@ describe("download-url endpoint", () => {
980
982
  expect(body.error).toContain("signed_urls_not_supported");
981
983
  });
982
984
  });
985
+
986
+ // --- fw#2423: GET /:id/meta with field-level PII encryption active ---
987
+
988
+ describe("meta route with field-encrypted fileName", () => {
989
+ const testPng = new Uint8Array([
990
+ 0x89,
991
+ 0x50,
992
+ 0x4e,
993
+ 0x47,
994
+ 0x0d,
995
+ 0x0a,
996
+ 0x1a,
997
+ 0x0a,
998
+ ...Array(20).fill(0),
999
+ ]);
1000
+
1001
+ afterEach(() => {
1002
+ // configurePiiSubjectKms is process-global — every test in this block
1003
+ // must leave it clean for the rest of the suite.
1004
+ resetPiiSubjectKmsForTests();
1005
+ });
1006
+
1007
+ test("returns the decrypted fileName instead of 500 pii_ciphertext_leak", async () => {
1008
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
1009
+
1010
+ const uploadRes = await uploadFile(
1011
+ adminUser,
1012
+ "Krankheitsattest-Mai.png",
1013
+ testPng,
1014
+ "image/png",
1015
+ { entityType: "tenant", entityId: "1", fieldName: "logo" },
1016
+ );
1017
+ expect(uploadRes.status).toBe(201);
1018
+ const { id } = await uploadRes.json();
1019
+
1020
+ const metaRes = await getFileMeta(adminUser, id);
1021
+ expect(metaRes.status).toBe(200);
1022
+ const body = await metaRes.json();
1023
+ expect(body.fileName).toBe("Krankheitsattest-Mai.png");
1024
+ expect(body.mimeType).toBe("image/png");
1025
+ expect(body.size).toBe(testPng.length);
1026
+ expect(body.entityType).toBe("tenant");
1027
+ expect(body.fieldName).toBe("logo");
1028
+ });
1029
+
1030
+ test("other tenant still gets 404 (tenant isolation survives the executor swap)", async () => {
1031
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
1032
+
1033
+ const uploadRes = await uploadFile(adminUser, "cross-tenant.png", testPng, "image/png", {
1034
+ entityType: "tenant",
1035
+ entityId: "1",
1036
+ fieldName: "logo",
1037
+ });
1038
+ const { id } = await uploadRes.json();
1039
+
1040
+ const res = await getFileMeta(otherTenantUser, id);
1041
+ expect(res.status).toBe(404);
1042
+ });
1043
+
1044
+ test("malformed (non-UUID) id still 404s instead of hitting the raw UUID column", async () => {
1045
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
1046
+
1047
+ const res = await getFileMeta(adminUser, "not-a-uuid");
1048
+ expect(res.status).toBe(404);
1049
+ });
1050
+
1051
+ test("soft-deleted file's meta still 404s (executor.detail() doesn't filter isDeleted)", async () => {
1052
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
1053
+
1054
+ const uploadRes = await uploadFile(adminUser, "trashed.png", testPng, "image/png", {
1055
+ entityType: "tenant",
1056
+ entityId: "1",
1057
+ fieldName: "logo",
1058
+ });
1059
+ const { id } = await uploadRes.json();
1060
+ expect((await deleteFile(adminUser, id)).status).toBe(200);
1061
+
1062
+ const res = await getFileMeta(adminUser, id);
1063
+ expect(res.status).toBe(404);
1064
+ });
1065
+ });
1066
+
1067
+ // --- fw#2442: byte-serving + signed-URL routes with field-encrypted fileName ---
1068
+
1069
+ describe("byte-serving routes with field-encrypted fileName", () => {
1070
+ const testPng = new Uint8Array([
1071
+ 0x89,
1072
+ 0x50,
1073
+ 0x4e,
1074
+ 0x47,
1075
+ 0x0d,
1076
+ 0x0a,
1077
+ 0x1a,
1078
+ 0x0a,
1079
+ ...Array(20).fill(0),
1080
+ ]);
1081
+
1082
+ afterEach(() => {
1083
+ resetPiiSubjectKmsForTests();
1084
+ });
1085
+
1086
+ test("GET /files/:id serves the decrypted fileName in Content-Disposition, not the ciphertext", async () => {
1087
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
1088
+
1089
+ const uploadRes = await uploadFile(
1090
+ adminUser,
1091
+ "Krankheitsattest-Mai.png",
1092
+ testPng,
1093
+ "image/png",
1094
+ { entityType: "tenant", entityId: "1", fieldName: "logo" },
1095
+ );
1096
+ expect(uploadRes.status).toBe(201);
1097
+ const { id } = await uploadRes.json();
1098
+
1099
+ const res = await getFile(adminUser, id);
1100
+ expect(res.status).toBe(200);
1101
+ const header = res.headers.get("Content-Disposition") ?? "";
1102
+ expect(header).toContain('filename="Krankheitsattest-Mai.png"');
1103
+ expect(header).not.toContain("kumiko-pii");
1104
+ });
1105
+
1106
+ test("GET /files/:id/download-url hints the decrypted fileName, never the ciphertext", async () => {
1107
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
1108
+
1109
+ let capturedDisposition: string | undefined;
1110
+ const capturingProvider = {
1111
+ ...createInMemoryFileProvider(),
1112
+ async getSignedUrl(key: string, expiresInSeconds: number, options?: SignedUrlOptions) {
1113
+ capturedDisposition = options?.contentDisposition;
1114
+ return `memory://${key}?expires=${expiresInSeconds}`;
1115
+ },
1116
+ };
1117
+
1118
+ const isolatedDb = await createTestDb();
1119
+ await unsafePushTables(isolatedDb.db, { fileRefsTable });
1120
+ await unsafeCreateEntityTable(isolatedDb.db, testTenantEntity);
1121
+ const isolatedRegistry = createRegistry([tenantFeature]);
1122
+ const isolatedServer = buildServer({
1123
+ registry: isolatedRegistry,
1124
+ context: {
1125
+ db: isolatedDb.db,
1126
+ _fileProviderResolver: () => Promise.resolve(capturingProvider),
1127
+ },
1128
+ jwtSecret: JWT_SECRET,
1129
+ });
1130
+
1131
+ try {
1132
+ const fd = new FormData();
1133
+ fd.append(
1134
+ "file",
1135
+ new File([Buffer.from(testPng)], "Krankheitsattest-Mai.png", { type: "image/png" }),
1136
+ );
1137
+ fd.append("entityType", "tenant");
1138
+ fd.append("entityId", "1");
1139
+ fd.append("fieldName", "logo");
1140
+ const { body: multipartBody, contentType } = await buildMultipartBody(fd);
1141
+ const token = await isolatedServer.jwt.sign(adminUser);
1142
+ const uploadRes = await isolatedServer.app.request("/api/files", {
1143
+ method: "POST",
1144
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
1145
+ body: multipartBody,
1146
+ });
1147
+ expect(uploadRes.status).toBe(201);
1148
+ const { id } = await uploadRes.json();
1149
+
1150
+ const res = await isolatedServer.app.request(`/api/files/${id}/download-url`, {
1151
+ method: "GET",
1152
+ headers: { Authorization: `Bearer ${token}` },
1153
+ });
1154
+ expect(res.status).toBe(200);
1155
+ expect(capturedDisposition).toContain('filename="Krankheitsattest-Mai.png"');
1156
+ expect(capturedDisposition ?? "").not.toContain("kumiko-pii");
1157
+ } finally {
1158
+ await isolatedDb.cleanup();
1159
+ }
1160
+ });
1161
+ });
@@ -9,6 +9,7 @@
9
9
  // Drizzle's mode:"number", so arithmetic in assertions Just Works).
10
10
 
11
11
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { Temporal } from "temporal-polyfill";
12
13
  import { createEventStoreExecutor } from "../../db/event-store-executor";
13
14
  import { asRawClient, selectMany } from "../../db/query";
14
15
  import { createTenantDb } from "../../db/tenant-db";
@@ -180,6 +181,7 @@ describe("tenant-storage-usage MSP", () => {
180
181
  const [first] = await selectMany(stack.db, tenantStorageUsageTable, {
181
182
  tenantId: admin.tenantId,
182
183
  });
184
+ expect(first?.["lastUpdatedAt"]).toBeInstanceOf(Temporal.Instant);
183
185
 
184
186
  // Postgres NOW() resolution is microseconds; a second upload a beat
185
187
  // later must produce a strictly later timestamp (or at least not an
@@ -192,6 +194,7 @@ describe("tenant-storage-usage MSP", () => {
192
194
  const [second] = await selectMany(stack.db, tenantStorageUsageTable, {
193
195
  tenantId: admin.tenantId,
194
196
  });
197
+ expect(second?.["lastUpdatedAt"]).toBeInstanceOf(Temporal.Instant);
195
198
  if (!first?.["lastUpdatedAt"] || !second?.["lastUpdatedAt"]) throw new Error("missing rows");
196
199
  expect(
197
200
  Temporal.Instant.compare(second["lastUpdatedAt"], first["lastUpdatedAt"]),
@@ -1,6 +1,14 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { Hono } from "hono";
3
3
  import { getUser } from "../api/auth-middleware";
4
+ import { requestContext } from "../api/request-context";
5
+ import {
6
+ collectPiiSubjectFields,
7
+ configuredPiiSubjectKms,
8
+ decryptPiiFieldValues,
9
+ isPiiCiphertext,
10
+ type KmsContext,
11
+ } from "../crypto";
4
12
  import type { DbConnection } from "../db/connection";
5
13
  import { createEventStoreExecutor } from "../db/event-store-executor";
6
14
  import { createTenantDb } from "../db/tenant-db";
@@ -35,6 +43,7 @@ export type FileRef = {
35
43
  entityId: string | null;
36
44
  fieldName: string | null;
37
45
  insertedById: string | null;
46
+ isDeleted: boolean;
38
47
  };
39
48
 
40
49
  // fileRef is a standard ES entity: upload/delete go through the entity
@@ -102,8 +111,33 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
102
111
  const executor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
103
112
  entityName: "fileRef",
104
113
  });
114
+ const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
105
115
  const api = new Hono();
106
116
 
117
+ function kmsContextFor(): KmsContext {
118
+ return { requestId: requestContext.get()?.requestId ?? "file-routes" };
119
+ }
120
+
121
+ // loadFileForTenant is a raw selectMany — unlike executor.detail() (used by
122
+ // /meta), it never decrypts. Decrypting via detail() here would add its
123
+ // getStreamVersion/entity-cache overhead to these binary-serving hot
124
+ // paths, so this calls the same PII cipher directly on the already-
125
+ // fetched row: one KMS key fetch instead of detail()'s full read path.
126
+ async function resolveFileName(fileRef: FileRef): Promise<string> {
127
+ if (!isPiiCiphertext(fileRef.fileName)) return fileRef.fileName;
128
+ const kms = configuredPiiSubjectKms();
129
+ if (!kms) {
130
+ return "download";
131
+ }
132
+ const decrypted = await decryptPiiFieldValues(
133
+ { fileName: fileRef.fileName },
134
+ piiSubjectFields,
135
+ kms,
136
+ kmsContextFor(),
137
+ );
138
+ return typeof decrypted["fileName"] === "string" ? decrypted["fileName"] : "download";
139
+ }
140
+
107
141
  // POST /files — multipart upload.
108
142
  api.post("/files", async (c) => {
109
143
  const user = getUser(c);
@@ -244,7 +278,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
244
278
  return new Response(Buffer.from(data), {
245
279
  headers: {
246
280
  "Content-Type": contentType,
247
- "Content-Disposition": buildContentDispositionHeader(fileRef.fileName),
281
+ "Content-Disposition": buildContentDispositionHeader(await resolveFileName(fileRef)),
248
282
  "Content-Length": String(fileRef.size),
249
283
  "X-Content-Type-Options": "nosniff",
250
284
  },
@@ -381,19 +415,32 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
381
415
  // with the original filename instead of the UUID-based storage key.
382
416
  // Sanitised via buildContentDispositionHeader — the same attacker-
383
417
  // controlled fileName reaches the provider's presigned response.
384
- contentDisposition: buildContentDispositionHeader(fileRef.fileName),
418
+ contentDisposition: buildContentDispositionHeader(await resolveFileName(fileRef)),
385
419
  });
386
420
  const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
387
421
  return c.json({ url, expiresAt });
388
422
  });
389
423
 
390
- // GET /files/:id/meta — metadata without the bytes. Guarded exactly like
391
- // download (meta leaks fileName/mimeType/size).
424
+ // GET /files/:id/meta — metadata without the bytes. Unlike the byte-serving
425
+ // routes above, this returns fileName as JSON — and fileName is
426
+ // `personal: "self"` (fw#2423), so it must go through the entity executor's
427
+ // detail() to decrypt instead of loadFileForTenant's raw, still-encrypted read.
392
428
  api.get("/files/:id/meta", async (c) => {
393
429
  const user = getUser(c);
394
430
  const id = c.req.param("id");
395
- const fileRef = await loadFileForTenant(id, user.tenantId);
396
- if (!fileRef) return c.json({ error: "not_found" }, 404);
431
+ // Same 22P02-avoidance as loadFileForTenant — detail() doesn't validate
432
+ // id shape before it hits the UUID column.
433
+ if (!isUuid(id)) return c.json({ error: "not_found" }, 404);
434
+ const row = await executor.detail({ id }, user, createTenantDb(db, user.tenantId));
435
+ if (!row) return c.json({ error: "not_found" }, 404);
436
+ const fileRef = row as FileRef; // @cast-boundary db-row (decrypted via executor.detail)
437
+ // detail()'s "pass"-ownership read widens to tenantId IN (self, SYSTEM)
438
+ // and doesn't filter isDeleted (list() does, detail() doesn't) — both
439
+ // narrower in loadFileForTenant's selectMany. Restore that here so meta
440
+ // doesn't leak reference-tenant or soft-deleted rows.
441
+ if (fileRef.tenantId !== user.tenantId || fileRef.isDeleted) {
442
+ return c.json({ error: "not_found" }, 404);
443
+ }
397
444
 
398
445
  const decision = await guard({ fileRef, user, operation: "read" });
399
446
  if (decision === "deny") return c.json({ error: "not_found" }, 404);
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { hasMailTranslations, mailT, registerMailTranslations } from "../mail-registry";
2
+ import {
3
+ hasMailTranslations,
4
+ mailT,
5
+ registerMailTranslations,
6
+ resolveMailLocale,
7
+ } from "../mail-registry";
3
8
 
4
9
  describe("mail-registry", () => {
5
10
  registerMailTranslations("en", { "test.hi": "Hello {name}" });
@@ -21,3 +26,10 @@ test("hasMailTranslations is true only for registered locales", () => {
21
26
  expect(hasMailTranslations("de")).toBe(true);
22
27
  expect(hasMailTranslations("de-AT")).toBe(true);
23
28
  });
29
+
30
+ test("resolveMailLocale prefers exact, then root, then en", () => {
31
+ registerMailTranslations("de", { "test.hi": "Hallo" });
32
+ expect(resolveMailLocale("de")).toBe("de");
33
+ expect(resolveMailLocale("de-AT")).toBe("de");
34
+ expect(resolveMailLocale("fr-CA")).toBe("en");
35
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { canonicalizeLocaleTag, resolveHeaderLocale } from "../request-locale";
3
+
4
+ describe("canonicalizeLocaleTag", () => {
5
+ test("lowercases the primary subtag", () => {
6
+ expect(canonicalizeLocaleTag("DE")).toBe("de");
7
+ expect(canonicalizeLocaleTag("DE-at")).toBe("de-at");
8
+ });
9
+ });
10
+
11
+ describe("resolveHeaderLocale", () => {
12
+ test("canonicalizes X-Locale", () => {
13
+ expect(resolveHeaderLocale({ headerLocale: "DE" })).toBe("de");
14
+ });
15
+
16
+ test("canonicalizes Accept-Language pick", () => {
17
+ expect(resolveHeaderLocale({ acceptLanguage: "DE-at,en;q=0.8" })).toBe("de-at");
18
+ });
19
+ });
package/src/i18n/index.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import type { Registry, TranslationKeys } from "../engine/types";
2
2
 
3
- export { hasMailTranslations, mailT, registerMailTranslations } from "./mail-registry";
4
3
  export {
4
+ hasMailTranslations,
5
+ mailT,
6
+ registerMailTranslations,
7
+ resolveMailLocale,
8
+ } from "./mail-registry";
9
+ export {
10
+ canonicalizeLocaleTag,
5
11
  DEFAULT_LOCALE,
6
12
  isValidLocaleTag,
7
13
  pickAcceptLanguage,
@@ -24,3 +24,13 @@ export function mailT(
24
24
  if (params === undefined) return raw;
25
25
  return raw.replace(/\{(\w+)\}/g, (_, name: string) => params[name] ?? `{${name}}`);
26
26
  }
27
+
28
+ /** Locale key mailT would actually use (exact → root → en). Use for appUrl
29
+ * path negotiation so the link language matches the rendered mail body. */
30
+ export function resolveMailLocale(locale: string): string {
31
+ const root = locale.split("-")[0] ?? locale;
32
+ if (tables.has(locale)) return locale;
33
+ if (tables.has(root)) return root;
34
+ if (tables.has("en")) return "en";
35
+ return "en";
36
+ }
@@ -18,6 +18,14 @@ export function isValidLocaleTag(value: string): boolean {
18
18
  return value.length <= MAX_LOCALE_TAG_LENGTH && LOCALE_TAG_RE.test(value);
19
19
  }
20
20
 
21
+ /** BCP-47 is case-insensitive; canonicalize the primary subtag to lowercase
22
+ * so registry lookups (`mailT`, `hasMailTranslations`) hit registered keys. */
23
+ export function canonicalizeLocaleTag(tag: string): string {
24
+ const dash = tag.indexOf("-");
25
+ if (dash === -1) return tag.toLowerCase();
26
+ return `${tag.slice(0, dash).toLowerCase()}${tag.slice(dash)}`;
27
+ }
28
+
21
29
  type AcceptLanguageCandidate = { readonly tag: string; readonly q: number; readonly index: number };
22
30
 
23
31
  /**
@@ -57,7 +65,8 @@ export function resolveHeaderLocale(options: {
57
65
  readonly acceptLanguage?: string;
58
66
  }): string | undefined {
59
67
  if (options.headerLocale !== undefined && isValidLocaleTag(options.headerLocale)) {
60
- return options.headerLocale;
68
+ return canonicalizeLocaleTag(options.headerLocale);
61
69
  }
62
- return pickAcceptLanguage(options.acceptLanguage);
70
+ const picked = pickAcceptLanguage(options.acceptLanguage);
71
+ return picked !== undefined ? canonicalizeLocaleTag(picked) : undefined;
63
72
  }
@@ -328,7 +328,9 @@ export function buildEffectiveTranslationKeys(features: readonly FeatureDefiniti
328
328
  for (const feature of features) {
329
329
  for (const key of Object.keys(feature.translations ?? {})) {
330
330
  out.add(`${feature.name}:${key}`);
331
- if (key.includes(":")) out.add(key);
331
+ // Blank form too — Settings-Hub group namespaces (`group: "tenant-settings"`)
332
+ // require `${group}.settings` which is not `${feature}:${key}` (fw#2314).
333
+ out.add(key);
332
334
  }
333
335
  }
334
336
  return out;
@@ -47,6 +47,8 @@ export function attachSignalHandlers(
47
47
  .then(() => exitFn(0))
48
48
  .catch(() => exitFn(1));
49
49
  };
50
+ // bun-types vs @types/node: signal overload on process.on/off is not
51
+ // selectable via bind() under the merged types — EventEmitter seam is intentional.
50
52
  (process as NodeJS.EventEmitter).on(sig, handler);
51
53
  listeners.set(sig, handler);
52
54
  }