@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.
- package/package.json +7 -3
- package/src/__tests__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +1 -3
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -3
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +17 -5
- package/src/api/api-constants.ts +10 -0
- package/src/api/auth-routes.ts +3 -0
- package/src/api/index.ts +1 -0
- package/src/api/pii-leak-guard.ts +4 -5
- package/src/api/server.ts +17 -1
- package/src/arg-parser.ts +1 -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/__tests__/table-builder-meta-lockstep.test.ts +29 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/entity-table-meta.ts +6 -1
- 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/db/table-builder.ts +10 -3
- package/src/derivatives/__tests__/variant-key.test.ts +123 -1
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- package/src/derivatives/derivatives-context.ts +4 -0
- package/src/derivatives/index.ts +9 -1
- package/src/derivatives/variant-key.ts +68 -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 +7 -7
- 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/extensions/storage-provider.ts +28 -0
- package/src/engine/extensions/user-data.ts +4 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +1 -3
- package/src/engine/feature-ast/extractors/ai-steps.ts +26 -40
- package/src/engine/feature-ast/extractors/index.ts +2 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -1
- package/src/engine/feature-ast/parse.ts +10 -25
- package/src/engine/feature-ast/patch.ts +23 -16
- package/src/engine/feature-ast/render.ts +3 -3
- package/src/engine/field-helpers.ts +1 -1
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/mixed-schemas.ts +6 -0
- package/src/engine/role-assignment.ts +36 -18
- package/src/engine/schema-builder.ts +14 -2
- package/src/errors/__tests__/classes.test.ts +21 -2
- package/src/errors/__tests__/write-failures.test.ts +13 -3
- package/src/errors/classes.ts +14 -13
- package/src/errors/kumiko-error.ts +11 -11
- package/src/errors/write-error-info.ts +1 -1
- 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__/local-provider.contract.test.ts +14 -0
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/files/in-memory-provider.ts +4 -0
- package/src/files/local-provider.ts +22 -1
- 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/jobs/job-runner.ts +22 -10
- 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/__tests__/tenant-timezone-cache.test.ts +89 -0
- package/src/pipeline/dispatch-shared.ts +39 -2
- package/src/pipeline/dispatch-write.ts +48 -0
- package/src/pipeline/dispatcher.ts +5 -0
- package/src/pipeline/distributed-lock.ts +3 -0
- package/src/pipeline/tenant-timezone-cache.ts +92 -0
- package/src/schema-cli.ts +39 -48
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/scripts/codemod/pii-personal-migration.ts +7 -7
- 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/stack/__tests__/request-helper.test.ts +2 -2
- package/src/testing/file-provider-contract.ts +19 -0
- package/src/ui-types/index.ts +1 -0
- package/src/upgrade-cli.ts +112 -15
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
|
+
});
|
|
@@ -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"]),
|
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);
|
|
@@ -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 {
|
|
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;
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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(
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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);
|