@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.
- package/package.json +3 -3
- package/src/__tests__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- package/src/api/api-constants.ts +10 -0
- package/src/api/index.ts +1 -0
- package/src/api/server.ts +17 -1
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +30 -14
- package/src/crypto/index.ts +1 -0
- package/src/crypto/is-self-pii-field.ts +8 -0
- package/src/crypto/subject-resolver.ts +4 -3
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
- package/src/db/__tests__/migrate-generator.test.ts +12 -0
- package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
- package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/event-store-executor-context.ts +2 -2
- package/src/db/event-store-executor-read.ts +7 -6
- package/src/db/event-store-executor-write.ts +103 -49
- package/src/db/index.ts +2 -0
- package/src/db/migrate-generator.ts +14 -0
- package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
- package/src/db/queries/backfill-pii.ts +13 -10
- package/src/db/queries/raw-sql.ts +14 -2
- package/src/db/queries/seed-context.ts +8 -4
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
- package/src/engine/__tests__/boot-validator.test.ts +29 -3
- package/src/engine/__tests__/role-assignment.test.ts +41 -17
- package/src/engine/__tests__/schema-builder.test.ts +3 -3
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
- package/src/engine/boot-validator/entity-handler.ts +5 -0
- package/src/engine/boot-validator/pii-retention.ts +16 -4
- package/src/engine/boot-validator/screens.ts +9 -2
- package/src/engine/embedded-derived.ts +11 -10
- package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
- package/src/engine/feature-ast/patch.ts +9 -0
- package/src/engine/role-assignment.ts +36 -18
- package/src/errors/__tests__/classes.test.ts +5 -0
- package/src/errors/__tests__/write-failures.test.ts +3 -3
- package/src/errors/kumiko-error.ts +11 -11
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
- package/src/event-store/__tests__/perf.integration.test.ts +5 -1
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
- package/src/event-store/event-store.ts +7 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +181 -2
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/i18n/__tests__/mail-registry.test.ts +13 -1
- package/src/i18n/__tests__/request-locale.test.ts +19 -0
- package/src/i18n/index.ts +7 -1
- package/src/i18n/mail-registry.ts +10 -0
- package/src/i18n/request-locale.ts +11 -2
- package/src/i18n/required-surface-keys.ts +3 -1
- package/src/lifecycle/signal-handlers.ts +2 -0
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
- package/src/pipeline/distributed-lock.ts +3 -0
- package/src/schema-cli.ts +9 -4
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/search/purge-subject.ts +4 -3
- package/src/search/reindex-entity.ts +2 -2
- package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
- package/src/ui-types/index.ts +1 -0
- 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
|
|
23
|
-
//
|
|
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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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 —
|
|
57
|
-
//
|
|
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) —
|
|
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
|
}
|
package/src/event-store/index.ts
CHANGED
|
@@ -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"]),
|
package/src/files/file-routes.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
391
|
-
//
|
|
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
|
-
|
|
396
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|