@cosmicdrift/kumiko-bundled-features 0.193.0 → 0.194.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 -7
- package/src/channel-email/smtp-transport.ts +4 -4
- package/src/derivatives-sharp/__tests__/render.test.ts +19 -0
- package/src/derivatives-sharp/render.ts +5 -2
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +122 -14
- package/src/file-derivatives/feature.ts +35 -17
- package/src/file-derivatives/handlers/public-variant.query.ts +13 -11
- package/src/file-derivatives/index.ts +1 -1
- package/src/file-derivatives/presets.ts +4 -7
- package/src/form-draft/__tests__/cleanup.integration.test.ts +27 -0
- package/src/form-draft/__tests__/form-draft.integration.test.ts +29 -4
- package/src/form-draft/entity.ts +1 -1
- package/src/form-draft/feature.ts +0 -3
- package/src/form-draft/handlers/__tests__/list.query.test.ts +26 -0
- package/src/form-draft/handlers/cleanup.job.ts +14 -11
- package/src/form-draft/handlers/list.query.ts +10 -1
- package/src/template-resolver/feature.ts +2 -2
- package/src/template-resolver/web/__tests__/client-plugin.test.tsx +140 -0
- package/src/template-resolver/web/client-plugin.tsx +16 -22
- package/src/tenant/handlers/invitations.query.ts +6 -8
- package/src/user-data-rights/__tests__/boot-checks.test.ts +1 -1
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +129 -2
- package/src/user-data-rights/run-user-export.ts +51 -28
- package/src/form-draft/i18n.ts +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.194.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -125,12 +125,12 @@
|
|
|
125
125
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
126
126
|
},
|
|
127
127
|
"dependencies": {
|
|
128
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.194.0",
|
|
129
|
+
"@cosmicdrift/kumiko-framework": "0.194.0",
|
|
130
|
+
"@cosmicdrift/kumiko-headless": "0.194.0",
|
|
131
|
+
"@cosmicdrift/kumiko-renderer": "0.194.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer-web": "0.194.0",
|
|
133
|
+
"@cosmicdrift/kumiko-types": "0.194.0",
|
|
134
134
|
"@mollie/api-client": "^4.5.0",
|
|
135
135
|
"@node-rs/argon2": "^2.0.2",
|
|
136
136
|
"@types/mailparser": "^3.4.6",
|
|
@@ -35,10 +35,10 @@ export type SmtpTransportOptions = {
|
|
|
35
35
|
readonly user: string;
|
|
36
36
|
readonly pass: string;
|
|
37
37
|
};
|
|
38
|
-
/**
|
|
39
|
-
* `EmailMessage.from`
|
|
40
|
-
*
|
|
41
|
-
* "
|
|
38
|
+
/** App-wide default From. A single send can override it via
|
|
39
|
+
* `EmailMessage.from` (reply from a specific mailbox); without an
|
|
40
|
+
* override this applies. Accepts both "noreply@ex.com" and
|
|
41
|
+
* "Name <noreply@ex.com>". */
|
|
42
42
|
readonly from: string;
|
|
43
43
|
};
|
|
44
44
|
|
|
@@ -216,6 +216,25 @@ describe("renderImage — validation", () => {
|
|
|
216
216
|
const meta = await sharp(output).metadata();
|
|
217
217
|
expect(meta.format).toBe("webp");
|
|
218
218
|
});
|
|
219
|
+
|
|
220
|
+
test("a sourceMimeType matching an inherited Object.prototype key throws instead of using it as an encoder", async () => {
|
|
221
|
+
// Plain-object lookup on SOURCE_FORMAT_ENCODERS would resolve
|
|
222
|
+
// "constructor" to Object (an inherited key, not a real entry) without
|
|
223
|
+
// an Object.hasOwn guard — proving the throw path, not a silent pass-through.
|
|
224
|
+
const input = await unmappedFormatFixture(50, 50);
|
|
225
|
+
await expect(renderImage(input, {}, "constructor")).rejects.toThrow(/no output encoder/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("image/png ignores spec.quality instead of switching into lossy palette quantization", async () => {
|
|
229
|
+
const input = await pngFixture(50, 50);
|
|
230
|
+
const withQuality = await renderImage(input, { quality: 10 }, "image/png");
|
|
231
|
+
const withoutQuality = await renderImage(input, {}, "image/png");
|
|
232
|
+
const withQualityMeta = await sharp(withQuality).metadata();
|
|
233
|
+
// A quality-driven palette encode would quantize to a small color count;
|
|
234
|
+
// an ignored quality keeps the full-color (non-palette) PNG.
|
|
235
|
+
expect(withQualityMeta.format).toBe("png");
|
|
236
|
+
expect(Buffer.compare(withQuality, withoutQuality)).toBe(0);
|
|
237
|
+
});
|
|
219
238
|
});
|
|
220
239
|
|
|
221
240
|
describe("imageMetadata", () => {
|
|
@@ -98,7 +98,10 @@ const SOURCE_FORMAT_ENCODERS: Record<string, (pipeline: Sharp, quality?: number)
|
|
|
98
98
|
// The upload whitelist admits the sloppy-but-common image/jpg alias
|
|
99
99
|
"image/jpg": (pipeline, quality) =>
|
|
100
100
|
pipeline.jpeg(quality !== undefined ? { quality } : undefined),
|
|
101
|
-
|
|
101
|
+
// sharp's PngOptions.quality doesn't mean JPEG/WebP-style compression —
|
|
102
|
+
// it switches PNG into palette quantization (lossless -> color-reduced,
|
|
103
|
+
// banding instead of a softer/smaller image). Ignore it, same as gif.
|
|
104
|
+
"image/png": (pipeline) => pipeline.png(),
|
|
102
105
|
"image/webp": (pipeline, quality) =>
|
|
103
106
|
pipeline.webp(quality !== undefined ? { quality } : undefined),
|
|
104
107
|
"image/avif": (pipeline, quality) =>
|
|
@@ -125,7 +128,7 @@ function applyEncoder(pipeline: Sharp, spec: VariantSpec, sourceMimeType: string
|
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
const encode = SOURCE_FORMAT_ENCODERS[sourceMimeType];
|
|
128
|
-
if (!encode) {
|
|
131
|
+
if (!Object.hasOwn(SOURCE_FORMAT_ENCODERS, sourceMimeType) || encode === undefined) {
|
|
129
132
|
throw new Error(
|
|
130
133
|
`derivatives-sharp: no output encoder for source mimeType "${sourceMimeType}" — set spec.format to pick an explicit output format.`,
|
|
131
134
|
);
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// Proves GET {basePath}/:fileRefId/:variant end-to-end: anonymous, no
|
|
2
2
|
// Authorization header, tenant resolved ONLY from Host (never the payload),
|
|
3
3
|
// default-deny when no `derivativePublicPredicate` is registered for the
|
|
4
|
-
// FileRef's entityType (or it returns false), the
|
|
5
|
-
//
|
|
6
|
-
// requestContext fix that makes `rateLimit: {per: "ip"}` actually
|
|
7
|
-
// r.httpRoute handler invoked via systemQuery (#1951).
|
|
4
|
+
// FileRef's entityType (or it returns false), the variant spec resolving
|
|
5
|
+
// from the FileRef's field declaration (not a fixed preset map, #1985), and
|
|
6
|
+
// the Step-1 requestContext fix that makes `rateLimit: {per: "ip"}` actually
|
|
7
|
+
// gate an r.httpRoute handler invoked via systemQuery (#1951).
|
|
8
8
|
|
|
9
9
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
10
|
import {
|
|
11
|
+
createEntity,
|
|
12
|
+
createImageField,
|
|
11
13
|
defineFeature,
|
|
12
14
|
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
13
15
|
EXT_DERIVATIVE_RENDERER,
|
|
@@ -26,7 +28,10 @@ import {
|
|
|
26
28
|
buildMultipartBody,
|
|
27
29
|
patchFileInstanceofForBunTest,
|
|
28
30
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
29
|
-
import type {
|
|
31
|
+
import type {
|
|
32
|
+
DerivativeRendererPlugin,
|
|
33
|
+
VariantSpec,
|
|
34
|
+
} from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
30
35
|
import { createConfigFeature } from "../../config";
|
|
31
36
|
import { fileFoundationFeature } from "../../file-foundation";
|
|
32
37
|
import { createFileDerivativesFeature } from "../feature";
|
|
@@ -35,16 +40,43 @@ import type { DerivativePublicPredicateArgs } from "../handlers/public-variant.q
|
|
|
35
40
|
const VARIANT_BYTES = new Uint8Array([7, 7, 7]);
|
|
36
41
|
|
|
37
42
|
let renderCalls = 0;
|
|
38
|
-
|
|
43
|
+
let lastRenderSpec: VariantSpec | null = null;
|
|
44
|
+
const fakeRender: DerivativeRendererPlugin["render"] = async (_input, spec) => {
|
|
39
45
|
renderCalls++;
|
|
46
|
+
lastRenderSpec = spec;
|
|
40
47
|
return VARIANT_BYTES;
|
|
41
48
|
};
|
|
42
49
|
|
|
43
50
|
const PUBLIC_WIDGET_ID = "widget-public";
|
|
44
51
|
const OTHER_WIDGET_ID = "widget-other";
|
|
45
52
|
|
|
53
|
+
// `full` deliberately overrides the built-in preset's maxEdge (2560) — this
|
|
54
|
+
// is what proves the route resolves the spec from the field declaration
|
|
55
|
+
// (#1985), not from the frozen preset constants. `plan` is a name with no
|
|
56
|
+
// preset counterpart at all — the exact motivating case from #1985 (an app
|
|
57
|
+
// declaring its own variant name). `heroWide` uses characters (uppercase,
|
|
58
|
+
// underscore-adjacent camelCase) outside a naive `[a-z0-9-]` guard — the
|
|
59
|
+
// `variants` map has no runtime charset constraint, so the route's
|
|
60
|
+
// syntactic pre-check must not narrow it. `plain` has no variants at all,
|
|
61
|
+
// for the "field declares no variants" 404 case.
|
|
62
|
+
const widgetEntity = createEntity({
|
|
63
|
+
table: "public_variant_widgets",
|
|
64
|
+
fields: {
|
|
65
|
+
img: createImageField({
|
|
66
|
+
variants: {
|
|
67
|
+
thumb: { maxEdge: 160, format: "webp" },
|
|
68
|
+
full: { maxEdge: 4096, format: "webp" },
|
|
69
|
+
plan: { maxEdge: 4096, format: "webp" },
|
|
70
|
+
heroWide: { maxEdge: 3200, format: "webp" },
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
plain: createImageField(),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
46
77
|
let predicateCalls = 0;
|
|
47
78
|
const widgetPredicateFeature = defineFeature("publicvariantroutetest", (r) => {
|
|
79
|
+
r.entity("widget", widgetEntity);
|
|
48
80
|
r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "widget", {
|
|
49
81
|
isPublic: (args: DerivativePublicPredicateArgs) => {
|
|
50
82
|
predicateCalls++;
|
|
@@ -91,6 +123,7 @@ afterAll(async () => {
|
|
|
91
123
|
|
|
92
124
|
beforeEach(async () => {
|
|
93
125
|
renderCalls = 0;
|
|
126
|
+
lastRenderSpec = null;
|
|
94
127
|
predicateCalls = 0;
|
|
95
128
|
// Fresh rate-limit bucket per test — no carry-over.
|
|
96
129
|
await stack.redis.flushNamespace();
|
|
@@ -99,13 +132,14 @@ beforeEach(async () => {
|
|
|
99
132
|
async function uploadFile(
|
|
100
133
|
asUser: typeof userA,
|
|
101
134
|
attach: { entityType: string; entityId: string },
|
|
135
|
+
fieldName = "img",
|
|
102
136
|
): Promise<string> {
|
|
103
137
|
const token = await stack.jwt.sign(asUser);
|
|
104
138
|
const fd = new FormData();
|
|
105
139
|
fd.append("file", new File([Buffer.from([1, 2, 3])], "img.jpg", { type: "image/jpeg" }));
|
|
106
140
|
fd.append("entityType", attach.entityType);
|
|
107
141
|
fd.append("entityId", attach.entityId);
|
|
108
|
-
fd.append("fieldName",
|
|
142
|
+
fd.append("fieldName", fieldName);
|
|
109
143
|
const { body, contentType } = await buildMultipartBody(fd);
|
|
110
144
|
const res = await stack.app.request("/api/files", {
|
|
111
145
|
method: "POST",
|
|
@@ -174,21 +208,84 @@ describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
|
|
|
174
208
|
expect(await revalidate.arrayBuffer()).toEqual(new ArrayBuffer(0));
|
|
175
209
|
});
|
|
176
210
|
|
|
177
|
-
test("
|
|
211
|
+
test("syntactically malformed variant names → 404, pre-check runs before any DB/systemQuery work", async () => {
|
|
212
|
+
const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
|
|
213
|
+
// Well-formed-looking but unregistered names (e.g. "not-a-preset") now
|
|
214
|
+
// reach the DB — the pre-check is purely syntactic since #1985, not a
|
|
215
|
+
// name list. Only pathological input is rejected here.
|
|
216
|
+
const malformedNames = [
|
|
217
|
+
"a".repeat(65), // over the 64-char cap
|
|
218
|
+
"foo%2Fbar", // decodes to a path separator
|
|
219
|
+
"foo..bar", // "." isn't in the allowed charset either
|
|
220
|
+
"foo%20bar", // decodes to a space
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
for (const name of malformedNames) {
|
|
224
|
+
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/${name}`);
|
|
225
|
+
expect(res.status).toBe(404);
|
|
226
|
+
}
|
|
227
|
+
expect(predicateCalls).toBe(0);
|
|
228
|
+
expect(renderCalls).toBe(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("the field's own spec is used, not the built-in preset — proves resolution comes from the field declaration (#1985)", async () => {
|
|
232
|
+
const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
|
|
233
|
+
|
|
234
|
+
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/full`);
|
|
235
|
+
|
|
236
|
+
expect(res.status).toBe(200);
|
|
237
|
+
// widgetEntity's "img" field declares maxEdge:4096 for "full" — the
|
|
238
|
+
// built-in `full` preset (presets.ts) is maxEdge:2560. Getting 4096 here
|
|
239
|
+
// proves the spec came from the field, not the frozen preset constant.
|
|
240
|
+
expect(lastRenderSpec?.maxEdge).toBe(4096);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("a field-declared name with no preset counterpart is served end-to-end — the exact case #1985 reported", async () => {
|
|
178
244
|
const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
|
|
179
245
|
|
|
180
|
-
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/
|
|
246
|
+
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/plan`);
|
|
247
|
+
|
|
248
|
+
expect(res.status).toBe(200);
|
|
249
|
+
expect(lastRenderSpec?.maxEdge).toBe(4096);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("a declared name outside [a-z0-9-] (camelCase) is still reachable — the syntactic gate isn't a narrower name list", async () => {
|
|
253
|
+
const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
|
|
254
|
+
|
|
255
|
+
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/heroWide`);
|
|
256
|
+
|
|
257
|
+
expect(res.status).toBe(200);
|
|
258
|
+
expect(lastRenderSpec?.maxEdge).toBe(3200);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("a preset name the field doesn't declare in its variants → 404", async () => {
|
|
262
|
+
const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
|
|
263
|
+
|
|
264
|
+
// widgetEntity's "img" field declares only thumb/full — "card" and
|
|
265
|
+
// "hero" aren't in its variants map.
|
|
266
|
+
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/card`);
|
|
181
267
|
|
|
182
268
|
expect(res.status).toBe(404);
|
|
183
|
-
expect(predicateCalls).
|
|
269
|
+
expect(predicateCalls).toBeGreaterThan(0);
|
|
270
|
+
expect(renderCalls).toBe(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a FileRef whose field declares no variants at all → 404", async () => {
|
|
274
|
+
const fileId = await uploadFile(
|
|
275
|
+
userA,
|
|
276
|
+
{ entityType: "widget", entityId: PUBLIC_WIDGET_ID },
|
|
277
|
+
"plain",
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
|
|
281
|
+
|
|
282
|
+
expect(res.status).toBe(404);
|
|
283
|
+
expect(predicateCalls).toBeGreaterThan(0);
|
|
184
284
|
expect(renderCalls).toBe(0);
|
|
185
285
|
});
|
|
186
286
|
|
|
187
287
|
test("unknown fileRefId → 404", async () => {
|
|
188
|
-
// Valid UUID shape (the id column's type) but no matching row
|
|
189
|
-
// malformed/non-UUID id is a separate concern shared with the
|
|
190
|
-
// already-merged #1950 route (same fetchOne-by-id pattern), out of
|
|
191
|
-
// scope here.
|
|
288
|
+
// Valid UUID shape (the id column's type) but no matching row.
|
|
192
289
|
const res = await stack.app.request(
|
|
193
290
|
`http://${HOST_A}/media/00000000-0000-4000-8000-000000000000/thumb`,
|
|
194
291
|
);
|
|
@@ -196,6 +293,17 @@ describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
|
|
|
196
293
|
expect(res.status).toBe(404);
|
|
197
294
|
});
|
|
198
295
|
|
|
296
|
+
test("malformed fileRefId → 404, pre-check runs before any DB/systemQuery work", async () => {
|
|
297
|
+
// Unlike #1950 (auth-gated), this route is anonymous — a non-UUID id
|
|
298
|
+
// reaching fetchOne() would throw Postgres 22P02 on an unauthenticated
|
|
299
|
+
// request, an unauth DoS primitive. Must 404 at the httpRoute pre-check.
|
|
300
|
+
const res = await stack.app.request(`http://${HOST_A}/media/not-a-uuid/thumb`);
|
|
301
|
+
|
|
302
|
+
expect(res.status).toBe(404);
|
|
303
|
+
expect(predicateCalls).toBe(0);
|
|
304
|
+
expect(renderCalls).toBe(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
199
307
|
test("cross-tenant: FileRef under tenant A, request resolves to tenant B → 404", async () => {
|
|
200
308
|
const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
|
|
201
309
|
|
|
@@ -16,13 +16,27 @@ import {
|
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
17
17
|
import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
|
|
18
18
|
import { PUBLIC_VARIANT_QN, publicVariantQuery } from "./handlers/public-variant.query";
|
|
19
|
-
import { PRESET_VARIANT_NAMES } from "./presets";
|
|
20
19
|
|
|
21
20
|
const FEATURE_NAME = "file-derivatives";
|
|
22
21
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
22
|
+
// fileRefsTable.id is a UUID column — a malformed id must fail here, not at
|
|
23
|
+
// the DB. Without this, an anonymous, internet-facing caller can throw a
|
|
24
|
+
// Postgres 22P02 on every request (Bun.SQL pools the failed connection),
|
|
25
|
+
// a DoS primitive with no auth required.
|
|
26
|
+
const FILE_REF_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
|
|
28
|
+
// Syntactic only, no registry/name-list lookup: a valid preset name plus a
|
|
29
|
+
// random UUID reaches the same DB read anyway, so an allow-list of known
|
|
30
|
+
// names defends nothing a determined caller can't route around — it only
|
|
31
|
+
// blocks the cheaper of two equally-costly attacks. What a name actually
|
|
32
|
+
// resolves to is decided by the field declaration in publicVariantQuery;
|
|
33
|
+
// this just keeps pathological input (path separators, `..`, oversized
|
|
34
|
+
// strings) out before that DB round-trip. `variants` keys have no runtime
|
|
35
|
+
// charset constraint (`Readonly<Record<string, VariantSpec>>`), so this
|
|
36
|
+
// stays permissive rather than guessing a convention — narrower than this
|
|
37
|
+
// would silently 404 a legitimately declared name again. Do not reintroduce
|
|
38
|
+
// a name list here "for safety" — see #1985.
|
|
39
|
+
const VARIANT_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
26
40
|
|
|
27
41
|
export type PublicVariantResolveApexTenant = (
|
|
28
42
|
host: string,
|
|
@@ -46,21 +60,23 @@ type PublicVariantQueryResult = {
|
|
|
46
60
|
} | null;
|
|
47
61
|
|
|
48
62
|
// file-derivatives — derive-on-first-use file variants (thumbnails,
|
|
49
|
-
// resized/reformatted images) plus an opt-in ANONYMOUS route that serves
|
|
50
|
-
//
|
|
63
|
+
// resized/reformatted images) plus an opt-in ANONYMOUS route that serves any
|
|
64
|
+
// variant an app declared on the FileRef's field (`createImageField({
|
|
65
|
+
// variants: {...} })`, including but not limited to the `thumb`/`card`/
|
|
66
|
+
// `hero`/`full` presets exported from `./presets`), for a FileRef whose
|
|
51
67
|
// entityType has a registered, default-deny `derivativePublicPredicate`.
|
|
52
68
|
//
|
|
53
69
|
// The route never serves the original and never accepts a free VariantSpec
|
|
54
|
-
// — only a
|
|
55
|
-
// registered `isPublic` predicate for the FileRef's
|
|
56
|
-
// tenantId is resolved from the request Host via
|
|
57
|
-
// read from the request payload.
|
|
70
|
+
// — only a name, resolved against the field's own `variants` declaration —
|
|
71
|
+
// and only after the app's registered `isPublic` predicate for the FileRef's
|
|
72
|
+
// entityType says yes. tenantId is resolved from the request Host via
|
|
73
|
+
// `resolveApexTenant`, NEVER read from the request payload.
|
|
58
74
|
export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}): FeatureDefinition {
|
|
59
75
|
const basePath = opts.basePath ?? "/media";
|
|
60
76
|
|
|
61
77
|
return defineFeature(FEATURE_NAME, (r) => {
|
|
62
78
|
r.describe(
|
|
63
|
-
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves
|
|
79
|
+
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name.",
|
|
64
80
|
);
|
|
65
81
|
r.uiHints({
|
|
66
82
|
displayLabel: "File Derivatives",
|
|
@@ -100,12 +116,14 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
100
116
|
// template, so Hono can't infer the param type from a literal.
|
|
101
117
|
const fileRefId = c.req.param("fileRefId");
|
|
102
118
|
const variant = c.req.param("variant");
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
// 404, not 400 — a syntactically bad name and "doesn't exist" must
|
|
120
|
+
// answer identically, no name-oracle.
|
|
121
|
+
if (
|
|
122
|
+
!fileRefId ||
|
|
123
|
+
!variant ||
|
|
124
|
+
!VARIANT_NAME_RE.test(variant) ||
|
|
125
|
+
!FILE_REF_ID_RE.test(fileRefId)
|
|
126
|
+
) {
|
|
109
127
|
return c.text("not found", 404);
|
|
110
128
|
}
|
|
111
129
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Public-Read of a derived FileRef variant
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Public-Read of a derived FileRef variant — the anonymous counterpart to
|
|
2
|
+
// GET /files/:id/variant/:name (#1950, auth-gated). Both routes resolve the
|
|
3
|
+
// same way: the variant spec comes from the FileRef's field declaration
|
|
4
|
+
// (`createImageField({ variants: {...} })`), not a fixed list — an app that
|
|
5
|
+
// declares its own variant name gets it served here too. This route never
|
|
6
|
+
// serves the original and never accepts a free VariantSpec, only a name.
|
|
5
7
|
// Default-deny: an entityType with no `EXT_DERIVATIVE_PUBLIC_PREDICATE`
|
|
6
8
|
// registration, or a predicate that returns false, both answer `null` —
|
|
7
9
|
// the httpRoute wrapper in feature.ts turns that into a 404, same as a
|
|
@@ -11,6 +13,7 @@
|
|
|
11
13
|
// httpRoute wrapper's `resolveApexTenant`), never from the payload.
|
|
12
14
|
|
|
13
15
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { resolveFieldVariant } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
14
17
|
import {
|
|
15
18
|
defineQueryHandler,
|
|
16
19
|
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
@@ -19,13 +22,11 @@ import {
|
|
|
19
22
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
23
|
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
21
24
|
import { z } from "zod";
|
|
22
|
-
import { card, full, hero, PRESET_VARIANT_NAMES, thumb } from "../presets";
|
|
23
|
-
|
|
24
|
-
const VARIANT_SPECS = { thumb, card, hero, full } as const;
|
|
25
25
|
|
|
26
26
|
type FileRefRow = {
|
|
27
27
|
readonly entityType: string | null;
|
|
28
28
|
readonly entityId: string | null;
|
|
29
|
+
readonly fieldName: string | null;
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
export type DerivativePublicPredicateArgs = {
|
|
@@ -58,7 +59,7 @@ export const publicVariantQuery = defineQueryHandler({
|
|
|
58
59
|
name: "public-variant",
|
|
59
60
|
schema: z.object({
|
|
60
61
|
fileRefId: z.string(),
|
|
61
|
-
variant: z.
|
|
62
|
+
variant: z.string().min(1).max(64),
|
|
62
63
|
}),
|
|
63
64
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
64
65
|
// ponytail: "ip" trusts the first x-forwarded-for hop (buildRequestContextData
|
|
@@ -76,8 +77,8 @@ export const publicVariantQuery = defineQueryHandler({
|
|
|
76
77
|
isDeleted: false,
|
|
77
78
|
});
|
|
78
79
|
if (!row) return null;
|
|
79
|
-
const { entityType, entityId } = row;
|
|
80
|
-
if (entityType === null || entityId === null) return null;
|
|
80
|
+
const { entityType, entityId, fieldName } = row;
|
|
81
|
+
if (entityType === null || entityId === null || fieldName === null) return null;
|
|
81
82
|
|
|
82
83
|
const usage = ctx.registry
|
|
83
84
|
.getExtensionUsages(EXT_DERIVATIVE_PUBLIC_PREDICATE)
|
|
@@ -103,7 +104,8 @@ export const publicVariantQuery = defineQueryHandler({
|
|
|
103
104
|
);
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
const spec =
|
|
107
|
+
const spec = resolveFieldVariant(ctx.registry, entityType, fieldName, query.payload.variant);
|
|
108
|
+
if (!spec) return null;
|
|
107
109
|
const result = await derivatives.variant(query.payload.fileRefId, spec, query.payload.variant);
|
|
108
110
|
const bytes = await files.ref(result.storageKey).read();
|
|
109
111
|
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export type { FileDerivativesOptions, PublicVariantResolveApexTenant } from "./feature";
|
|
2
2
|
export { createFileDerivativesFeature, fileDerivativesFeature } from "./feature";
|
|
3
|
-
export { card, full, hero,
|
|
3
|
+
export { card, full, hero, thumb } from "./presets";
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
2
|
|
|
3
|
+
// Ready-made specs to spread into a field's own `variants` declaration
|
|
4
|
+
// (`createImageField({ variants: { thumb, full: { ...full, maxEdge: 4096 } } })`)
|
|
5
|
+
// — not an allow-list. Which names a route serves is decided entirely by
|
|
6
|
+
// what a field declares (#1985), these are just common defaults.
|
|
3
7
|
export const thumb = { maxEdge: 160, fit: "cover", format: "webp" } as const satisfies VariantSpec;
|
|
4
8
|
export const card = { maxEdge: 640, fit: "inside", format: "webp" } as const satisfies VariantSpec;
|
|
5
9
|
export const hero = { maxEdge: 1600, fit: "inside", format: "webp" } as const satisfies VariantSpec;
|
|
6
10
|
export const full = { maxEdge: 2560, fit: "inside", format: "webp" } as const satisfies VariantSpec;
|
|
7
|
-
|
|
8
|
-
// Single source of truth for the 4 preset variant names — the public
|
|
9
|
-
// variant route (#1951) validates its `:variant` path param against this
|
|
10
|
-
// list BEFORE any DB lookup or systemQuery dispatch, and the query
|
|
11
|
-
// handler's Zod schema enums against it too. Keep in sync with the
|
|
12
|
-
// preset exports above.
|
|
13
|
-
export const PRESET_VARIANT_NAMES = ["thumb", "card", "hero", "full"] as const;
|
|
@@ -120,6 +120,20 @@ async function backdate(draftKey: string, daysAgo: number): Promise<void> {
|
|
|
120
120
|
);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// applyEntityEvent only stamps modified_at on updated/deleted/restored, never
|
|
124
|
+
// on created (framework/src/db/apply-entity-event.ts) — a draft saved exactly
|
|
125
|
+
// once has modified_at IS NULL, which is the real-world case the cleanup
|
|
126
|
+
// query's COALESCE(modified_at, inserted_at) fallback exists for.
|
|
127
|
+
async function backdateInsertedOnly(draftKey: string, daysAgo: number): Promise<void> {
|
|
128
|
+
await asRawClient(stack.db).unsafe(
|
|
129
|
+
`UPDATE "read_form_drafts"
|
|
130
|
+
SET "inserted_at" = now() - ($1::int * interval '1 day'),
|
|
131
|
+
"modified_at" = NULL
|
|
132
|
+
WHERE "draft_key" = $2`,
|
|
133
|
+
[daysAgo, draftKey],
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
async function backdateFileRef(storageKey: string, daysAgo: number): Promise<void> {
|
|
124
138
|
await asRawClient(stack.db).unsafe(
|
|
125
139
|
`UPDATE "file_refs" SET "inserted_at" = now() - ($1::int * interval '1 day')
|
|
@@ -190,6 +204,19 @@ describe("form-draft cleanup job", () => {
|
|
|
190
204
|
});
|
|
191
205
|
expect(await draftExists("wizard:three-days-old")).toBe(true);
|
|
192
206
|
});
|
|
207
|
+
|
|
208
|
+
test("deletes a draft saved exactly once (modified_at IS NULL), falling back to inserted_at", async () => {
|
|
209
|
+
await saveDraft("wizard:once-saved");
|
|
210
|
+
await backdateInsertedOnly("wizard:once-saved", 31);
|
|
211
|
+
await saveDraft("wizard:fresh-once-saved");
|
|
212
|
+
await backdateInsertedOnly("wizard:fresh-once-saved", 1);
|
|
213
|
+
await dispatchCleanup();
|
|
214
|
+
|
|
215
|
+
await waitFor(async () => {
|
|
216
|
+
expect(await draftExists("wizard:once-saved")).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
expect(await draftExists("wizard:fresh-once-saved")).toBe(true);
|
|
219
|
+
});
|
|
193
220
|
});
|
|
194
221
|
|
|
195
222
|
describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
@@ -184,13 +184,22 @@ describe("form-draft integration — ownership isolation", () => {
|
|
|
184
184
|
describe("form-draft integration — list", () => {
|
|
185
185
|
test("list returns open drafts for a screenId, newest first, without the blob's values", async () => {
|
|
186
186
|
await saveDraft("wizard:a", { name: "First" }, 0);
|
|
187
|
+
// savedAt is a millisecond stamp (#1960) — sleep is best-effort only;
|
|
188
|
+
// under CI load two saves can still tie, so assert the comparator
|
|
189
|
+
// contract (savedAt desc, then id desc) rather than a fixed key order.
|
|
190
|
+
await Bun.sleep(20);
|
|
187
191
|
await saveDraft("wizard:b", { name: "Second" }, 1);
|
|
188
192
|
|
|
189
193
|
const { drafts } = await listDrafts("wizard");
|
|
190
|
-
expect(drafts
|
|
191
|
-
expect(drafts
|
|
192
|
-
|
|
193
|
-
expect(
|
|
194
|
+
expect(drafts).toHaveLength(2);
|
|
195
|
+
expect(new Set(drafts.map((d) => d.draftKey))).toEqual(new Set(["wizard:a", "wizard:b"]));
|
|
196
|
+
const [first, second] = drafts;
|
|
197
|
+
expect(first?.savedAt).toBeTruthy();
|
|
198
|
+
expect(first).not.toHaveProperty("values");
|
|
199
|
+
const byTime = second!.savedAt.localeCompare(first!.savedAt);
|
|
200
|
+
const byId = second!.id.localeCompare(first!.id);
|
|
201
|
+
expect(byTime < 0 || (byTime === 0 && byId < 0)).toBe(true);
|
|
202
|
+
if (first!.draftKey === "wizard:b") expect(first!.stepIndex).toBe(1);
|
|
194
203
|
});
|
|
195
204
|
|
|
196
205
|
test("list excludes a draft whose screenId is only a prefix, not a full segment match", async () => {
|
|
@@ -209,6 +218,22 @@ describe("form-draft integration — list", () => {
|
|
|
209
218
|
expect(drafts.map((d) => d.draftKey)).toEqual(["scr%en:a"]);
|
|
210
219
|
});
|
|
211
220
|
|
|
221
|
+
test("a screenId containing an underscore is matched literally, not as a single-char wildcard", async () => {
|
|
222
|
+
await saveDraft("wizar_:a", {}, 0);
|
|
223
|
+
await saveDraft("wizardX:a", {}, 0);
|
|
224
|
+
|
|
225
|
+
const { drafts } = await listDrafts("wizar_");
|
|
226
|
+
expect(drafts.map((d) => d.draftKey)).toEqual(["wizar_:a"]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a screenId containing a literal backslash is matched literally", async () => {
|
|
230
|
+
await saveDraft("wiz\\ard:a", {}, 0);
|
|
231
|
+
await saveDraft("wizard:a", {}, 0);
|
|
232
|
+
|
|
233
|
+
const { drafts } = await listDrafts("wiz\\ard");
|
|
234
|
+
expect(drafts.map((d) => d.draftKey)).toEqual(["wiz\\ard:a"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
212
237
|
test("list resolves an empty array when nothing was ever saved for the screenId", async () => {
|
|
213
238
|
expect((await listDrafts("no-such-screen")).drafts).toEqual([]);
|
|
214
239
|
});
|
package/src/form-draft/entity.ts
CHANGED
|
@@ -24,7 +24,7 @@ export const formDraftEntity = createEntity({
|
|
|
24
24
|
// feeds the GDPR-hook-coverage boot guard (it's a plain FK into `user`,
|
|
25
25
|
// not content of its own) — see ../form-draft-user-data for the
|
|
26
26
|
// required export/delete hook coverage.
|
|
27
|
-
ownerId: createTextField({ subjectRef: true }),
|
|
27
|
+
ownerId: createTextField({ subjectRef: true, required: true }),
|
|
28
28
|
draftKey: createTextField({ required: true, maxLength: FORM_DRAFT_KEY_MAX_LENGTH }),
|
|
29
29
|
// The blob shape is fixed by issue #1889, not left to the caller:
|
|
30
30
|
// { values: Record<string, unknown>, stepIndex: number, savedAt: string }.
|
|
@@ -13,7 +13,6 @@ import { discardDraftWrite } from "./handlers/discard.write";
|
|
|
13
13
|
import { getDraftQuery } from "./handlers/get.query";
|
|
14
14
|
import { listDraftsQuery } from "./handlers/list.query";
|
|
15
15
|
import { saveDraftWrite } from "./handlers/save.write";
|
|
16
|
-
import { FORM_DRAFT_FEATURE_I18N } from "./i18n";
|
|
17
16
|
|
|
18
17
|
function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>): void {
|
|
19
18
|
r.describe(
|
|
@@ -41,8 +40,6 @@ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>):
|
|
|
41
40
|
|
|
42
41
|
r.config({ keys: { retentionDays: formDraftRetentionDaysConfig } });
|
|
43
42
|
r.job("cleanup", { trigger: { cron: "0 3 * * *" }, concurrency: "skip" }, cleanupDraftsJob);
|
|
44
|
-
|
|
45
|
-
r.translations({ keys: FORM_DRAFT_FEATURE_I18N });
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
export const formDraftFeature = defineFeature(FORM_DRAFT_FEATURE_NAME, registerFormDraft);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// byNewestFirst — the sort issue #1960 traced the flake to: form-draft.integration.test.ts
|
|
2
|
+
// creates two drafts fast enough to land on the same millisecond savedAt, and the query had
|
|
3
|
+
// no ORDER BY at all, so ties fell back to Postgres' undefined row order. These cases can't be
|
|
4
|
+
// forced through the integration stack (no clock control on the real save path, and seeding a
|
|
5
|
+
// row directly would bypass the event store), so the comparator is unit-tested directly here.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { byNewestFirst } from "../list.query";
|
|
9
|
+
|
|
10
|
+
function draft(id: string, savedAt: string) {
|
|
11
|
+
return { id, draftKey: `wizard:${id}`, stepIndex: 0, savedAt };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("byNewestFirst", () => {
|
|
15
|
+
test("a later savedAt sorts before an earlier one", () => {
|
|
16
|
+
const older = draft("a", "2026-01-01T00:00:00.000Z");
|
|
17
|
+
const newer = draft("b", "2026-01-01T00:00:00.001Z");
|
|
18
|
+
expect([older, newer].sort(byNewestFirst)).toEqual([newer, older]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("a tied savedAt still sorts deterministically, regardless of input order", () => {
|
|
22
|
+
const a = draft("aaaa", "2026-01-01T00:00:00.000Z");
|
|
23
|
+
const b = draft("bbbb", "2026-01-01T00:00:00.000Z");
|
|
24
|
+
expect([a, b].sort(byNewestFirst)).toEqual([b, a].sort(byNewestFirst));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -57,18 +57,21 @@ async function releaseRowFileRefs(
|
|
|
57
57
|
return;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Only storageKeys with a real file_refs row owned by this row's draft
|
|
61
|
+
// owner are releasable — `draft.values` is free-form JSON the owning user
|
|
62
|
+
// controls, so an unverified key could target someone else's file (see
|
|
63
|
+
// db/queries/owned-file-refs.ts). Not wrapped in try/catch: a query
|
|
64
|
+
// failure here (pool exhaustion, missing table) is a real error, not the
|
|
65
|
+
// "no provider resolvable" case below — let it propagate so the job retries.
|
|
66
|
+
const ownedKeys = await filterOwnedStorageKeys(
|
|
67
|
+
db,
|
|
68
|
+
row.tenantId,
|
|
69
|
+
row.ownerId,
|
|
70
|
+
keys,
|
|
71
|
+
row.insertedAt,
|
|
72
|
+
);
|
|
73
|
+
|
|
60
74
|
try {
|
|
61
|
-
// Only storageKeys with a real file_refs row owned by this row's
|
|
62
|
-
// draft owner are releasable — `draft.values` is free-form JSON the
|
|
63
|
-
// owning user controls, so an unverified key could target someone
|
|
64
|
-
// else's file (see db/queries/owned-file-refs.ts).
|
|
65
|
-
const ownedKeys = await filterOwnedStorageKeys(
|
|
66
|
-
db,
|
|
67
|
-
row.tenantId,
|
|
68
|
-
row.ownerId,
|
|
69
|
-
keys,
|
|
70
|
-
row.insertedAt,
|
|
71
|
-
);
|
|
72
75
|
const provider = await fileProviderResolver(row.tenantId);
|
|
73
76
|
await releaseDraftFileRefs(ownedKeys, (key) => provider.delete(key), log);
|
|
74
77
|
} catch (err) {
|
|
@@ -12,6 +12,15 @@ export type ListDraftsResult = {
|
|
|
12
12
|
}[];
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
type DraftListItem = ListDraftsResult["drafts"][number];
|
|
16
|
+
|
|
17
|
+
// Two saves landing in the same millisecond tie on savedAt — there's no
|
|
18
|
+
// knowable "true" order between them, so `id` only buys a deterministic,
|
|
19
|
+
// repeatable result, not chronological accuracy.
|
|
20
|
+
export function byNewestFirst(a: DraftListItem, b: DraftListItem): number {
|
|
21
|
+
return b.savedAt.localeCompare(a.savedAt) || b.id.localeCompare(a.id);
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
// list — the fallback path for resuming a draft whose draftId the client
|
|
16
25
|
// lost (new tab, cleared storage, another device): returns just enough to
|
|
17
26
|
// pick one (id, draftKey, stepIndex, savedAt), never the blob's `values`.
|
|
@@ -34,7 +43,7 @@ export const listDraftsQuery = defineQueryHandler({
|
|
|
34
43
|
stepIndex: row.draft.stepIndex,
|
|
35
44
|
savedAt: row.draft.savedAt,
|
|
36
45
|
}))
|
|
37
|
-
.sort(
|
|
46
|
+
.sort(byNewestFirst);
|
|
38
47
|
return { drafts };
|
|
39
48
|
},
|
|
40
49
|
});
|
|
@@ -44,9 +44,9 @@ export function createTemplateResolverFeature(opts: TemplateResolverOptions = {}
|
|
|
44
44
|
r.describe(
|
|
45
45
|
[
|
|
46
46
|
"Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail-html`, `document-pdf`, `ai-prompt`, `text-block`, `image-snapshot`).",
|
|
47
|
-
"Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes.",
|
|
47
|
+
"Reading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers.",
|
|
48
48
|
"Text an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.",
|
|
49
|
-
"How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links).
|
|
49
|
+
"How a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning).",
|
|
50
50
|
'A collection is tenant-wide by default. With `ownership: "user"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.',
|
|
51
51
|
"Replaces the former `text-content` feature; its blocks now live here as kind `text-block`.",
|
|
52
52
|
].join("\n\n"),
|
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
// mock.module replaces imports for all consumers — static imports before
|
|
3
|
+
// mock.module still see the mocked version because Bun intercepts at the
|
|
4
|
+
// loader level (same pattern as editor-read-only.test.tsx).
|
|
5
|
+
import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
2
6
|
import type { TreeNode } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
CONTENT_EDITOR_ELEMENT_ID,
|
|
9
|
+
type ContentEditorComponent,
|
|
10
|
+
ContentEditorsProvider,
|
|
11
|
+
createStaticLocaleResolver,
|
|
12
|
+
LocaleProvider,
|
|
13
|
+
PrimitivesProvider,
|
|
14
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
15
|
+
import { defaultPrimitives, PlainContentEditor } from "@cosmicdrift/kumiko-renderer-web";
|
|
16
|
+
import { render, screen } from "@testing-library/react";
|
|
17
|
+
import type { ReactNode } from "react";
|
|
3
18
|
import { textBlocksClient } from "../client-plugin";
|
|
19
|
+
import { defaultTranslations } from "../i18n";
|
|
20
|
+
|
|
21
|
+
mock.module("@cosmicdrift/kumiko-bundled-features/auth-email-password/web", () => ({
|
|
22
|
+
useShellUser: mock(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
26
|
+
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
27
|
+
...actual_renderer,
|
|
28
|
+
useDispatcher: mock(() => ({ write: mock(), query: mock() })),
|
|
29
|
+
useQuery: mock(() => ({
|
|
30
|
+
data: {
|
|
31
|
+
slug: "reminder",
|
|
32
|
+
locale: "de",
|
|
33
|
+
title: "Reminder",
|
|
34
|
+
content: "Hi",
|
|
35
|
+
contentFormat: "plain",
|
|
36
|
+
folder: null,
|
|
37
|
+
},
|
|
38
|
+
loading: false,
|
|
39
|
+
error: null,
|
|
40
|
+
refetch: mock(),
|
|
41
|
+
})),
|
|
42
|
+
useAppFeatures: mock(() => [
|
|
43
|
+
{
|
|
44
|
+
featureName: "mail",
|
|
45
|
+
contentCollections: [
|
|
46
|
+
{
|
|
47
|
+
id: "prompts",
|
|
48
|
+
kind: "ai-prompt",
|
|
49
|
+
contentFormat: "plain",
|
|
50
|
+
variableSchema: { customerName: "Jane" },
|
|
51
|
+
nav: { label: "mail:nav.prompts" },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "richDocs",
|
|
55
|
+
kind: "mail-html",
|
|
56
|
+
contentFormat: "rich",
|
|
57
|
+
variableSchema: {},
|
|
58
|
+
nav: { label: "mail:nav.richDocs" },
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
]),
|
|
63
|
+
}));
|
|
4
64
|
|
|
5
65
|
// Covers the three new migration paths (advisor gap): navId attach + SSE
|
|
6
66
|
// entities, no-leak without navId (conditional spread), and the unwrap (the
|
|
@@ -192,3 +252,83 @@ describe("textBlocksClient — content collections", () => {
|
|
|
192
252
|
expect(sentType).toBe("template-resolver:query:by-tenant");
|
|
193
253
|
});
|
|
194
254
|
});
|
|
255
|
+
|
|
256
|
+
describe("textBlocksClient — plain contentFormat editor wiring", () => {
|
|
257
|
+
const localeResolver = createStaticLocaleResolver({ locale: "de" });
|
|
258
|
+
|
|
259
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
260
|
+
return (
|
|
261
|
+
<LocaleProvider resolver={localeResolver} fallbackBundles={[defaultTranslations]}>
|
|
262
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
263
|
+
<ContentEditorsProvider value={textBlocksClient().contentEditors ?? {}}>
|
|
264
|
+
{children}
|
|
265
|
+
</ContentEditorsProvider>
|
|
266
|
+
</PrimitivesProvider>
|
|
267
|
+
</LocaleProvider>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
test("contentEditors.plain is registered as PlainContentEditor", () => {
|
|
272
|
+
expect(textBlocksClient().contentEditors?.["plain"]).toBe(PlainContentEditor);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Deleting the registry line would leave useContentEditor("markdown")
|
|
276
|
+
// silently falling back to TextareaContentEditor — chips gone, suite green.
|
|
277
|
+
test("contentEditors.markdown is registered as PlainContentEditor", () => {
|
|
278
|
+
expect(textBlocksClient().contentEditors?.["markdown"]).toBe(PlainContentEditor);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("a collection's variableSchema renders as chip placeholders on its plain editor", () => {
|
|
282
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bun mock function
|
|
283
|
+
(useShellUser as any).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
|
|
284
|
+
const Editor = textBlocksClient().resolvers?.["template-resolver:edit"];
|
|
285
|
+
if (Editor === undefined) throw new Error("Editor not registered");
|
|
286
|
+
|
|
287
|
+
const target = {
|
|
288
|
+
featureId: "template-resolver",
|
|
289
|
+
action: "edit",
|
|
290
|
+
args: { slug: "reminder", locale: "de", collectionId: "prompts" },
|
|
291
|
+
} as const;
|
|
292
|
+
render(<Editor target={target} onClose={() => {}} />, { wrapper: Wrapper });
|
|
293
|
+
|
|
294
|
+
// A typo in the registry key or a field rename on contentCollections
|
|
295
|
+
// silently falls back to variables={[]} — no chip renders, no red test.
|
|
296
|
+
expect(screen.getByText("{{customerName}}")).toBeTruthy();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("textBlocksClient — findContentFormat resolves collectionId to the registered editor", () => {
|
|
301
|
+
const localeResolver = createStaticLocaleResolver({ locale: "de" });
|
|
302
|
+
const RichStub: ContentEditorComponent = () => <div data-testid="rich-stub-editor" />;
|
|
303
|
+
|
|
304
|
+
function RichStubWrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
305
|
+
return (
|
|
306
|
+
<LocaleProvider resolver={localeResolver} fallbackBundles={[defaultTranslations]}>
|
|
307
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
308
|
+
<ContentEditorsProvider value={{ rich: RichStub }}>{children}</ContentEditorsProvider>
|
|
309
|
+
</PrimitivesProvider>
|
|
310
|
+
</LocaleProvider>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
test("a collection declared with contentFormat 'rich' renders the registered rich editor, not the textarea fallback", () => {
|
|
315
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bun mock function
|
|
316
|
+
(useShellUser as any).mockReturnValue({ id: "u1", roles: ["TenantAdmin"] });
|
|
317
|
+
const Editor = textBlocksClient().resolvers?.["template-resolver:edit"];
|
|
318
|
+
if (Editor === undefined) throw new Error("Editor not registered");
|
|
319
|
+
|
|
320
|
+
const target = {
|
|
321
|
+
featureId: "template-resolver",
|
|
322
|
+
action: "edit",
|
|
323
|
+
args: { slug: "reminder", locale: "de", collectionId: "richDocs" },
|
|
324
|
+
} as const;
|
|
325
|
+
render(<Editor target={target} onClose={() => {}} />, { wrapper: RichStubWrapper });
|
|
326
|
+
|
|
327
|
+
// A broken collectionId → contentFormat mapping (wrong field name,
|
|
328
|
+
// array-vs-record drift on the schema) resolves contentFormat to
|
|
329
|
+
// undefined, and useContentEditor's own fallback silently swaps in the
|
|
330
|
+
// plain textarea instead — no red test without this assertion.
|
|
331
|
+
expect(screen.getByTestId("rich-stub-editor")).toBeTruthy();
|
|
332
|
+
expect(document.getElementById(CONTENT_EDITOR_ELEMENT_ID)).toBeNull();
|
|
333
|
+
});
|
|
334
|
+
});
|
|
@@ -206,44 +206,38 @@ function makeTreeProvider(tenantIdOverride?: string, collectionId?: string): Tre
|
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
209
|
+
type ContentCollectionSchema = NonNullable<FeatureSchema["contentCollections"]>[number];
|
|
210
|
+
|
|
211
|
+
// A node without a collectionId (the hand-wired text-block tree) has no
|
|
212
|
+
// collection to look up — contentFormat/variableSchema then fall back to
|
|
213
|
+
// their own callers' defaults.
|
|
214
|
+
function findCollection(
|
|
213
215
|
features: readonly FeatureSchema[],
|
|
214
216
|
collectionId?: string,
|
|
215
|
-
):
|
|
217
|
+
): ContentCollectionSchema | undefined {
|
|
216
218
|
if (collectionId === undefined) return undefined;
|
|
217
219
|
for (const feature of features) {
|
|
218
220
|
const match = feature.contentCollections?.find((c) => c.id === collectionId);
|
|
219
|
-
if (match !== undefined) return match
|
|
221
|
+
if (match !== undefined) return match;
|
|
220
222
|
}
|
|
221
223
|
return undefined;
|
|
222
224
|
}
|
|
223
225
|
|
|
224
|
-
|
|
225
|
-
// names (ContentCollectionDefinition.variableSchema) — the chip bar's data
|
|
226
|
-
// source. A node without a collectionId gets no chips: the hand-wired
|
|
227
|
-
// text-block tree has no collection to declare variables on.
|
|
228
|
-
function findVariableSchema(
|
|
226
|
+
function findContentFormat(
|
|
229
227
|
features: readonly FeatureSchema[],
|
|
230
228
|
collectionId?: string,
|
|
231
|
-
):
|
|
232
|
-
return
|
|
229
|
+
): string | undefined {
|
|
230
|
+
return findCollection(features, collectionId)?.contentFormat;
|
|
233
231
|
}
|
|
234
232
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
233
|
+
// The collection's fixed variable names (ContentCollectionDefinition.
|
|
234
|
+
// variableSchema) — the chip bar's data source and, keyed, the example
|
|
235
|
+
// values the Preview substitutes in for `{{name}}`.
|
|
237
236
|
function findVariableExamples(
|
|
238
237
|
features: readonly FeatureSchema[],
|
|
239
238
|
collectionId?: string,
|
|
240
239
|
): Readonly<Record<string, string>> {
|
|
241
|
-
|
|
242
|
-
for (const feature of features) {
|
|
243
|
-
const match = feature.contentCollections?.find((c) => c.id === collectionId);
|
|
244
|
-
if (match !== undefined) return match.variableSchema ?? {};
|
|
245
|
-
}
|
|
246
|
-
return {};
|
|
240
|
+
return findCollection(features, collectionId)?.variableSchema ?? {};
|
|
247
241
|
}
|
|
248
242
|
|
|
249
243
|
// Edit form: loads the current values via by-slug, lets TenantAdmin and
|
|
@@ -311,8 +305,8 @@ function TextBlockEditor({
|
|
|
311
305
|
const t = useTranslation();
|
|
312
306
|
const features = useAppFeatures();
|
|
313
307
|
const contentFormat = findContentFormat(features, collectionId);
|
|
314
|
-
const variables = findVariableSchema(features, collectionId);
|
|
315
308
|
const variableExamples = findVariableExamples(features, collectionId);
|
|
309
|
+
const variables = Object.keys(variableExamples);
|
|
316
310
|
const ContentEditor = useContentEditor(contentFormat);
|
|
317
311
|
const canWrite =
|
|
318
312
|
user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
|
|
@@ -11,15 +11,13 @@ import { INVITATION_STATUS, tenantInvitationsTable } from "../invitation-table";
|
|
|
11
11
|
// sequential loop caps concurrency at 1 and leaves 3 pool slots idle.
|
|
12
12
|
const KMS_POOL_CONCURRENCY = 4;
|
|
13
13
|
|
|
14
|
-
// Pending
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// für historische gehört in ein separates Audit-Feature).
|
|
14
|
+
// Pending invitations for the current tenant, admin-only, filtered to
|
|
15
|
+
// status="pending" — accepted/cancelled/expired don't belong in this UI
|
|
16
|
+
// (historical entries belong in a separate audit feature).
|
|
18
17
|
//
|
|
19
|
-
// SQL-side filter (
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// (tenantId, …)-key, JS-filter ist redundant.
|
|
18
|
+
// SQL-side filter (was JS-side .filter): a tenant with many historical
|
|
19
|
+
// invitations would otherwise load every row into the node process just to
|
|
20
|
+
// discard most of them — the DB indexes on (tenantId, …), a JS filter is redundant.
|
|
23
21
|
export const invitationsQuery = defineQueryHandler({
|
|
24
22
|
name: "invitations",
|
|
25
23
|
schema: z.object({}),
|
|
@@ -80,7 +80,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
80
80
|
).not.toThrow();
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
test("form-draft mounted WITHOUT form-draft-user-data → V3 throws (
|
|
83
|
+
test("form-draft mounted WITHOUT form-draft-user-data → V3 throws (subjectRef ownerId has no EXT_USER_DATA hook)", () => {
|
|
84
84
|
expect(() => validateBoot([...baseFeatures(), formDraftFeature])).toThrow(
|
|
85
85
|
/EXT_USER_DATA hook.*Art\.17 gap/,
|
|
86
86
|
);
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
27
27
|
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
28
28
|
import { createSystemUser, type UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
|
|
29
|
-
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
29
|
+
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
30
30
|
import {
|
|
31
31
|
setupTestStack,
|
|
32
32
|
type TestStack,
|
|
@@ -48,6 +48,7 @@ import { createSessionsFeature, userSessionEntity } from "../../sessions";
|
|
|
48
48
|
import { createTenantFeature, tenantInvitationEntity, tenantInvitationsTable } from "../../tenant";
|
|
49
49
|
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
50
50
|
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
51
|
+
import { fileRefExportHook } from "../../user-data-rights-defaults/hooks/file-ref.userdata-hook";
|
|
51
52
|
import { tenantInvitationExportHook } from "../../user-data-rights-defaults/hooks/tenant-invitation.userdata-hook";
|
|
52
53
|
import { createUserDataRightsFeature } from "../feature";
|
|
53
54
|
import { runUserExport } from "../run-user-export";
|
|
@@ -396,12 +397,33 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
|
|
|
396
397
|
// KMS + bidx MUST be configured before the executor write so the row
|
|
397
398
|
// lands encrypted with a real blind index, matching the KMS-active
|
|
398
399
|
// production shape (order matches anonymous-deletion-kms.integration.test.ts).
|
|
399
|
-
|
|
400
|
+
const kms = new InMemoryKmsAdapter();
|
|
401
|
+
configurePiiSubjectKms(kms);
|
|
400
402
|
configureBlindIndexKey(BIDX_KEY);
|
|
401
403
|
|
|
402
404
|
await seedUser(ALICE_ID, { email: INVITE_EMAIL, displayName: "Alice Invite" });
|
|
403
405
|
await seedMembership(ALICE_ID, TENANT_A);
|
|
404
406
|
|
|
407
|
+
// seedUser/seedRow does not encrypt — encrypt the user row's email the
|
|
408
|
+
// same way an executor write would, so resolveUserEmail's decryptStoredPii
|
|
409
|
+
// branch runs over real ciphertext, not plaintext it happens to pass through.
|
|
410
|
+
const encryptedUser = await encryptPiiFieldValues(
|
|
411
|
+
{ id: ALICE_ID, email: INVITE_EMAIL },
|
|
412
|
+
userEntity,
|
|
413
|
+
["email"],
|
|
414
|
+
kms,
|
|
415
|
+
{ requestId: "test" },
|
|
416
|
+
);
|
|
417
|
+
await asRawClient(stack.db).unsafe(`UPDATE read_users SET email = $1 WHERE id = $2`, [
|
|
418
|
+
String(encryptedUser["email"]),
|
|
419
|
+
ALICE_ID,
|
|
420
|
+
]);
|
|
421
|
+
const userRow = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
|
|
422
|
+
`SELECT email FROM read_users WHERE id = $1`,
|
|
423
|
+
[ALICE_ID],
|
|
424
|
+
);
|
|
425
|
+
expect(isPiiCiphertext(userRow[0]?.["email"])).toBe(true);
|
|
426
|
+
|
|
405
427
|
// Real executor write path — the same one tenantInvitationExportHook's
|
|
406
428
|
// sibling delete hook and the invite-create handler use — so the row
|
|
407
429
|
// carries genuine ciphertext + a real email_bidx, not a hand-seeded stand-in.
|
|
@@ -435,6 +457,38 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
|
|
|
435
457
|
expect(isPiiCiphertext(rawRows[0]?.["email"])).toBe(true);
|
|
436
458
|
expect(String(rawRows[0]?.["email_bidx"])).toStartWith("kumiko-bidx:v1:");
|
|
437
459
|
|
|
460
|
+
// Cross-user isolation: a foreign invitee's invite in the same tenant
|
|
461
|
+
// must not leak into Alice's export via the entity-wide selectMany.
|
|
462
|
+
const foreignInviteeResult = await invitationExecutor.create(
|
|
463
|
+
{
|
|
464
|
+
email: "bob.invite@example.com",
|
|
465
|
+
role: "Member",
|
|
466
|
+
status: "pending",
|
|
467
|
+
invitedBy: ALICE_ID,
|
|
468
|
+
expiresAt: NOW().add({ seconds: 7 * 24 * 60 * 60 }),
|
|
469
|
+
},
|
|
470
|
+
systemUser,
|
|
471
|
+
tenantDb,
|
|
472
|
+
);
|
|
473
|
+
expect(foreignInviteeResult.isSuccess).toBe(true);
|
|
474
|
+
|
|
475
|
+
// Cross-tenant isolation: Alice's own email invited in a different
|
|
476
|
+
// tenant must land in that tenant's section, not TENANT_A's.
|
|
477
|
+
const foreignTenantSystemUser = createSystemUser(TENANT_B);
|
|
478
|
+
const foreignTenantDb = createTenantDb(stack.db, TENANT_B, "system");
|
|
479
|
+
const foreignTenantResult = await invitationExecutor.create(
|
|
480
|
+
{
|
|
481
|
+
email: INVITE_EMAIL,
|
|
482
|
+
role: "Member",
|
|
483
|
+
status: "pending",
|
|
484
|
+
invitedBy: ALICE_ID,
|
|
485
|
+
expiresAt: NOW().add({ seconds: 7 * 24 * 60 * 60 }),
|
|
486
|
+
},
|
|
487
|
+
foreignTenantSystemUser,
|
|
488
|
+
foreignTenantDb,
|
|
489
|
+
);
|
|
490
|
+
expect(foreignTenantResult.isSuccess).toBe(true);
|
|
491
|
+
|
|
438
492
|
// Pins the hook boundary directly: tenantInvitationExportHook itself must
|
|
439
493
|
// hand back ciphertext, not plaintext — decryption is the central sweep's
|
|
440
494
|
// job (decryptSnippetFields in run-user-export.ts), not the hook's. If the
|
|
@@ -463,7 +517,80 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
|
|
|
463
517
|
// mean the hook found nothing and the bidx hypothesis is wrong.
|
|
464
518
|
const invitationSnippet = tenantA?.entities.find((e) => e.entity === "tenant-invitation");
|
|
465
519
|
expect(invitationSnippet).toBeDefined();
|
|
520
|
+
// Isolation: neither the foreign-email invite in the same tenant nor
|
|
521
|
+
// Alice's own email invited in TENANT_B leak into TENANT_A's snippet.
|
|
466
522
|
expect(invitationSnippet?.rows).toHaveLength(1);
|
|
467
523
|
expect(invitationSnippet?.rows[0]?.["email"]).toBe(INVITE_EMAIL);
|
|
524
|
+
expect(JSON.stringify(bundle)).not.toContain("bob.invite@example.com");
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("runUserExport :: fileRef PII export via the fileRefs side-channel (#1955)", () => {
|
|
529
|
+
afterEach(() => {
|
|
530
|
+
resetPiiSubjectKmsForTests();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("bundle.fileRefs carries plaintext fileName + a readable zipPath, even though the hook returns ciphertext", async () => {
|
|
534
|
+
const kms = new InMemoryKmsAdapter();
|
|
535
|
+
configurePiiSubjectKms(kms);
|
|
536
|
+
|
|
537
|
+
await seedUser(ALICE_ID, { email: "alice.fileref@example.com" });
|
|
538
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
539
|
+
|
|
540
|
+
const fileRefId = uuid(401);
|
|
541
|
+
const PLAINTEXT_NAME = "alice-cv.pdf";
|
|
542
|
+
// fileRefEntity.fileName is `pii: true` with no `userOwned` override, so
|
|
543
|
+
// resolveSubjectForField treats the row itself as the subject (its own
|
|
544
|
+
// `id`), not `insertedById`. Encrypting with that subject reproduces the
|
|
545
|
+
// real write-path ciphertext shape.
|
|
546
|
+
const encrypted = await encryptPiiFieldValues(
|
|
547
|
+
{ id: fileRefId, fileName: PLAINTEXT_NAME },
|
|
548
|
+
fileRefEntity,
|
|
549
|
+
["fileName"],
|
|
550
|
+
kms,
|
|
551
|
+
{ requestId: "test" },
|
|
552
|
+
);
|
|
553
|
+
await asRawClient(stack.db).unsafe(
|
|
554
|
+
`
|
|
555
|
+
INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
556
|
+
VALUES ($1, $2, $3, $4, 'application/pdf', 2048, $5)
|
|
557
|
+
ON CONFLICT (id) DO NOTHING
|
|
558
|
+
`,
|
|
559
|
+
[fileRefId, TENANT_A, `storage/${fileRefId}`, String(encrypted["fileName"]), ALICE_ID],
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
// Pins the hook boundary directly: fileRefExportHook must still hand
|
|
563
|
+
// back ciphertext in the fileRefs side-channel — decryption is the
|
|
564
|
+
// central sweep's job (decryptSnippetFields in run-user-export.ts), not
|
|
565
|
+
// the hook's. If the hook ever started decrypting fileRefs locally, this
|
|
566
|
+
// assertion would catch it even though the runUserExport() assertions
|
|
567
|
+
// below would stay green either way.
|
|
568
|
+
const hookCtx: UserDataHookCtx = {
|
|
569
|
+
db: stack.db,
|
|
570
|
+
registry: stack.registry,
|
|
571
|
+
tenantId: TENANT_A,
|
|
572
|
+
userId: ALICE_ID,
|
|
573
|
+
};
|
|
574
|
+
const rawSnippet = await fileRefExportHook(hookCtx);
|
|
575
|
+
expect(isPiiCiphertext(rawSnippet?.fileRefs?.[0]?.fileName)).toBe(true);
|
|
576
|
+
|
|
577
|
+
const bundle = await runUserExport({
|
|
578
|
+
db: stack.db,
|
|
579
|
+
registry: stack.registry,
|
|
580
|
+
userId: ALICE_ID,
|
|
581
|
+
now: NOW(),
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
expect(bundle.fileRefs).toHaveLength(1);
|
|
585
|
+
const ref = bundle.fileRefs[0];
|
|
586
|
+
// Bug #1955: this used to be the raw `kumiko-pii:v2:...` ciphertext
|
|
587
|
+
// string instead of the person's actual file name.
|
|
588
|
+
expect(ref?.fileName).toBe(PLAINTEXT_NAME);
|
|
589
|
+
// Bug #1955 effect 2: buildFileRefZipPath sanitized the ciphertext into
|
|
590
|
+
// a garbled underscore-mangled path — the ZIP entry must carry the
|
|
591
|
+
// readable name instead.
|
|
592
|
+
expect(ref?.zipPath).toContain("alice-cv");
|
|
593
|
+
expect(ref?.zipPath).not.toContain("kumiko-pii");
|
|
594
|
+
expect(JSON.stringify(bundle)).not.toContain("kumiko-pii:");
|
|
468
595
|
});
|
|
469
596
|
});
|
|
@@ -223,36 +223,35 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
|
|
|
223
223
|
// base64-Blob als "Wert" auszuliefern (leak-by-confusion).
|
|
224
224
|
const ENCRYPTED_UNAVAILABLE = "[encrypted:unavailable]";
|
|
225
225
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
// Shared decrypt sweep, used for both `snippet.rows` and `snippet.fileRefs`
|
|
227
|
+
// (#1955) — `encryptedFields` is keyed by field NAME, so it stays a no-op
|
|
228
|
+
// for today's `fileRef` entity but covers a future generically-encrypted
|
|
229
|
+
// side-channel field too.
|
|
230
|
+
async function decryptRecords(
|
|
231
|
+
records: ReadonlyArray<Record<string, unknown>>,
|
|
232
|
+
encryptedFields: ReadonlySet<string>,
|
|
233
|
+
kms: ReturnType<typeof configuredPiiSubjectKms>,
|
|
234
|
+
): Promise<Record<string, unknown>[]> {
|
|
235
|
+
let rows: Record<string, unknown>[] = [...records];
|
|
232
236
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
return out;
|
|
246
|
-
}),
|
|
247
|
-
);
|
|
248
|
-
}
|
|
237
|
+
if (encryptedFields.size > 0) {
|
|
238
|
+
const cipher = configuredEntityFieldEncryption();
|
|
239
|
+
rows = await Promise.all(
|
|
240
|
+
rows.map(async (row) => {
|
|
241
|
+
if (cipher) return decryptEntityFieldValues(row, encryptedFields, cipher);
|
|
242
|
+
const out = { ...row };
|
|
243
|
+
for (const name of encryptedFields) {
|
|
244
|
+
if (typeof out[name] === "string") out[name] = ENCRYPTED_UNAVAILABLE;
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
// PII-
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
rows = await Promise.all(
|
|
251
|
+
// PII-subject ciphertexts (kumiko-pii:) are self-describing — no entity
|
|
252
|
+
// context needed, covers every hook past and future. Erased subject →
|
|
253
|
+
// sentinel (honest: the value is shredded).
|
|
254
|
+
return Promise.all(
|
|
256
255
|
rows.map(async (row) => {
|
|
257
256
|
const piiKeys = Object.keys(row).filter((k) => isPiiCiphertext(row[k]));
|
|
258
257
|
if (piiKeys.length === 0) return row;
|
|
@@ -266,7 +265,31 @@ async function decryptSnippetFields(
|
|
|
266
265
|
});
|
|
267
266
|
}),
|
|
268
267
|
);
|
|
269
|
-
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function decryptSnippetFields(
|
|
271
|
+
registry: Registry,
|
|
272
|
+
hookEntityName: string,
|
|
273
|
+
snippet: UserDataExportSnippet,
|
|
274
|
+
): Promise<UserDataExportSnippet> {
|
|
275
|
+
const entity = registry.getEntity(snippet.entity) ?? registry.getEntity(hookEntityName);
|
|
276
|
+
const encryptedFields = entity ? collectEncryptedFieldNames(entity) : new Set<string>();
|
|
277
|
+
const kms = configuredPiiSubjectKms();
|
|
278
|
+
|
|
279
|
+
const rows = await decryptRecords(snippet.rows, encryptedFields, kms);
|
|
280
|
+
|
|
281
|
+
if (!snippet.fileRefs || snippet.fileRefs.length === 0) {
|
|
282
|
+
return { ...snippet, rows };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const decryptedFileRefs = await decryptRecords(snippet.fileRefs, encryptedFields, kms);
|
|
286
|
+
const fileRefs = decryptedFileRefs.map((r) => ({
|
|
287
|
+
fileRefId: String(r["fileRefId"]),
|
|
288
|
+
storageKey: String(r["storageKey"]),
|
|
289
|
+
fileName: String(r["fileName"]),
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
return { ...snippet, rows, fileRefs };
|
|
270
293
|
}
|
|
271
294
|
|
|
272
295
|
// Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
|
package/src/form-draft/i18n.ts
DELETED