@cosmicdrift/kumiko-bundled-features 0.193.1 → 0.195.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 +20 -25
- 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.195.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.195.0",
|
|
129
|
+
"@cosmicdrift/kumiko-framework": "0.195.0",
|
|
130
|
+
"@cosmicdrift/kumiko-headless": "0.195.0",
|
|
131
|
+
"@cosmicdrift/kumiko-renderer": "0.195.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer-web": "0.195.0",
|
|
133
|
+
"@cosmicdrift/kumiko-types": "0.195.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
|
+
});
|