@cosmicdrift/kumiko-framework 0.220.1 → 0.222.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 (110) hide show
  1. package/package.json +7 -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/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
  5. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
  7. package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
  8. package/src/api/api-constants.ts +10 -0
  9. package/src/api/auth-routes.ts +3 -0
  10. package/src/api/index.ts +1 -0
  11. package/src/api/pii-leak-guard.ts +4 -5
  12. package/src/api/server.ts +17 -1
  13. package/src/arg-parser.ts +1 -1
  14. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  15. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  16. package/src/bun-db/index.ts +1 -0
  17. package/src/bun-db/query.ts +30 -14
  18. package/src/crypto/index.ts +1 -0
  19. package/src/crypto/is-self-pii-field.ts +8 -0
  20. package/src/crypto/subject-resolver.ts +4 -3
  21. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  22. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  23. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  24. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  25. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  26. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +29 -0
  27. package/src/db/blind-index-cleanup.ts +36 -19
  28. package/src/db/entity-table-meta.ts +6 -1
  29. package/src/db/event-store-executor-context.ts +2 -2
  30. package/src/db/event-store-executor-read.ts +7 -6
  31. package/src/db/event-store-executor-write.ts +103 -49
  32. package/src/db/index.ts +2 -0
  33. package/src/db/migrate-generator.ts +14 -0
  34. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  35. package/src/db/queries/backfill-pii.ts +13 -10
  36. package/src/db/queries/raw-sql.ts +14 -2
  37. package/src/db/queries/seed-context.ts +8 -4
  38. package/src/db/table-builder.ts +10 -3
  39. package/src/derivatives/__tests__/variant-key.test.ts +123 -1
  40. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  41. package/src/derivatives/derivatives-context.ts +4 -0
  42. package/src/derivatives/index.ts +9 -1
  43. package/src/derivatives/variant-key.ts +68 -0
  44. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  45. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  46. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  47. package/src/engine/__tests__/schema-builder.test.ts +7 -7
  48. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  49. package/src/engine/boot-validator/entity-handler.ts +5 -0
  50. package/src/engine/boot-validator/pii-retention.ts +16 -4
  51. package/src/engine/boot-validator/screens.ts +9 -2
  52. package/src/engine/embedded-derived.ts +11 -10
  53. package/src/engine/extensions/storage-provider.ts +28 -0
  54. package/src/engine/extensions/user-data.ts +4 -0
  55. package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
  56. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  57. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
  58. package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
  59. package/src/engine/feature-ast/extractors/index.ts +2 -0
  60. package/src/engine/feature-ast/extractors/shared.ts +26 -1
  61. package/src/engine/feature-ast/parse.ts +10 -25
  62. package/src/engine/feature-ast/patch.ts +23 -16
  63. package/src/engine/feature-ast/render.ts +3 -3
  64. package/src/engine/field-helpers.ts +1 -1
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/pattern-library/mixed-schemas.ts +6 -0
  67. package/src/engine/role-assignment.ts +36 -18
  68. package/src/engine/schema-builder.ts +14 -2
  69. package/src/errors/__tests__/classes.test.ts +21 -2
  70. package/src/errors/__tests__/write-failures.test.ts +13 -3
  71. package/src/errors/classes.ts +14 -13
  72. package/src/errors/kumiko-error.ts +11 -11
  73. package/src/errors/write-error-info.ts +1 -1
  74. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  75. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  76. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  77. package/src/event-store/event-store.ts +7 -0
  78. package/src/event-store/index.ts +1 -0
  79. package/src/files/__tests__/files.integration.test.ts +181 -2
  80. package/src/files/__tests__/local-provider.contract.test.ts +14 -0
  81. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  82. package/src/files/file-routes.ts +53 -6
  83. package/src/files/in-memory-provider.ts +4 -0
  84. package/src/files/local-provider.ts +22 -1
  85. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  86. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  87. package/src/i18n/index.ts +7 -1
  88. package/src/i18n/mail-registry.ts +10 -0
  89. package/src/i18n/request-locale.ts +11 -2
  90. package/src/i18n/required-surface-keys.ts +3 -1
  91. package/src/jobs/job-runner.ts +22 -10
  92. package/src/lifecycle/signal-handlers.ts +2 -0
  93. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  94. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
  95. package/src/pipeline/__tests__/tenant-timezone-cache.test.ts +89 -0
  96. package/src/pipeline/dispatch-shared.ts +39 -2
  97. package/src/pipeline/dispatch-write.ts +48 -0
  98. package/src/pipeline/dispatcher.ts +5 -0
  99. package/src/pipeline/distributed-lock.ts +3 -0
  100. package/src/pipeline/tenant-timezone-cache.ts +92 -0
  101. package/src/schema-cli.ts +39 -48
  102. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  103. package/src/scripts/codemod/pii-personal-migration.ts +7 -7
  104. package/src/search/purge-subject.ts +4 -3
  105. package/src/search/reindex-entity.ts +2 -2
  106. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  107. package/src/stack/__tests__/request-helper.test.ts +2 -2
  108. package/src/testing/file-provider-contract.ts +19 -0
  109. package/src/ui-types/index.ts +1 -0
  110. package/src/upgrade-cli.ts +112 -15
@@ -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
+ });
@@ -0,0 +1,14 @@
1
+ import { afterAll } from "bun:test";
2
+ import { rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describeFileProviderContract } from "../../testing/file-provider-contract";
6
+ import { createLocalProvider } from "../local-provider";
7
+
8
+ const basePath = join(tmpdir(), `kumiko-local-provider-contract-${Date.now()}`);
9
+
10
+ describeFileProviderContract("LocalFileProvider", () => createLocalProvider(basePath));
11
+
12
+ afterAll(async () => {
13
+ await rm(basePath, { recursive: true, force: true });
14
+ });
@@ -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);
@@ -86,6 +86,10 @@ export function createInMemoryFileProvider(): InMemoryFileProvider {
86
86
  return store.has(key);
87
87
  },
88
88
 
89
+ async list(prefix) {
90
+ return Array.from(store.keys()).filter((key) => key.startsWith(prefix));
91
+ },
92
+
89
93
  // Deterministic fake URL — encodes the key + expiry so tests can assert
90
94
  // the route wired through without running a real presigner. Shape
91
95
  // (memory://<key>?expires=<seconds>) intentionally differs from any real
@@ -1,5 +1,5 @@
1
1
  import { createReadStream, createWriteStream } from "node:fs";
2
- import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import { assertSafeStorageKey, type FileStorageProvider } from "./types";
@@ -107,5 +107,26 @@ export function createLocalProvider(basePath: string): FileStorageProvider {
107
107
  return false;
108
108
  }
109
109
  },
110
+
111
+ async list(prefix: string): Promise<readonly string[]> {
112
+ // recursive:true returns POSIX- or OS-sep-joined relative paths for
113
+ // both files and directories; normalize to "/" (storage keys are
114
+ // always "/"-joined, matching S3) before the prefix match, then stat
115
+ // only the (few) matches to drop directory entries.
116
+ let entries: string[];
117
+ try {
118
+ entries = await readdir(resolvedBase, { recursive: true });
119
+ } catch (err) {
120
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
121
+ throw err;
122
+ }
123
+ const results: string[] = [];
124
+ for (const entry of entries) {
125
+ const key = entry.split(sep).join("/");
126
+ if (!key.startsWith(prefix)) continue;
127
+ if ((await stat(join(resolvedBase, entry))).isFile()) results.push(key);
128
+ }
129
+ return results;
130
+ },
110
131
  };
111
132
  }
@@ -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;
@@ -206,10 +206,20 @@ function parseRedisOpts(url: string): { host: string; port: number; db?: number
206
206
  // would otherwise hang start() forever with no health endpoint to notice.
207
207
  const BOOT_REDIS_TIMEOUT_MS = 10_000;
208
208
 
209
- function timeoutReject(ms: number, message: string): Promise<never> {
210
- return new Promise((_, reject) => {
211
- setTimeout(() => reject(new Error(message)), ms);
209
+ function timeoutReject(
210
+ ms: number,
211
+ message: string,
212
+ ): { promise: Promise<never>; cancel: () => void } {
213
+ let timer: ReturnType<typeof setTimeout> | undefined;
214
+ const promise = new Promise<never>((_, reject) => {
215
+ timer = setTimeout(() => reject(new Error(message)), ms);
212
216
  });
217
+ return {
218
+ promise,
219
+ cancel: () => {
220
+ if (timer !== undefined) clearTimeout(timer);
221
+ },
222
+ };
213
223
  }
214
224
 
215
225
  export function createJobRunner(options: JobRunnerOptions): JobRunner {
@@ -592,13 +602,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
592
602
  // upsertJobScheduler()/add() below when the lane has a cron/boot job.
593
603
  // Racing a timeout against it keeps an unreachable Redis from hanging
594
604
  // start() forever — there's no worker health endpoint to notice.
595
- await Promise.race([
596
- worker.waitUntilReady(),
597
- timeoutReject(
598
- bootRedisTimeoutMs,
599
- `job-runner: Redis not reachable within ${bootRedisTimeoutMs}ms (lane=${consumerLane})`,
600
- ),
601
- ]);
605
+ const bootTimeout = timeoutReject(
606
+ bootRedisTimeoutMs,
607
+ `job-runner: Redis not reachable within ${bootRedisTimeoutMs}ms (lane=${consumerLane})`,
608
+ );
609
+ try {
610
+ await Promise.race([worker.waitUntilReady(), bootTimeout.promise]);
611
+ } finally {
612
+ bootTimeout.cancel();
613
+ }
602
614
 
603
615
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
604
616
  // assigned to the other lane get their cron/boot wiring from the
@@ -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
  }
@@ -90,6 +90,18 @@ describe("distributed lock", () => {
90
90
  expect(await lock.acquire("test-lock-7")).not.toBeNull();
91
91
  });
92
92
 
93
+ test("renew with ttlSeconds 0 does not drop the lock", async () => {
94
+ const lock = createDistributedLock(testRedis.redis);
95
+ const token = await lock.acquire("test-lock-ttl0", { ttlSeconds: 5 });
96
+ if (!token) throw new Error("expected token");
97
+ const renewed = await lock.renew("test-lock-ttl0", token, 0);
98
+ expect(renewed).toBe(false);
99
+ // Original lock still held — a peer acquire must fail.
100
+ const peer = await lock.acquire("test-lock-ttl0", { ttlSeconds: 5 });
101
+ expect(peer).toBeNull();
102
+ await lock.release("test-lock-ttl0", token);
103
+ });
104
+
93
105
  test("renew on an expired/absent key fails", async () => {
94
106
  const lock = createDistributedLock(testRedis.redis);
95
107
  const renewed = await lock.renew("test-lock-8-never-acquired", "some-token", 5);