@cosmicdrift/kumiko-framework 0.191.0 → 0.193.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/api/request-id-middleware.ts +48 -35
- package/src/api/server.ts +13 -2
- package/src/derivatives/__tests__/field-variants.test.ts +62 -0
- package/src/derivatives/__tests__/variant-key.test.ts +6 -0
- package/src/derivatives/__tests__/variant-route.integration.test.ts +169 -0
- package/src/derivatives/field-variants.ts +20 -0
- package/src/derivatives/index.ts +2 -1
- package/src/derivatives/variant-key.ts +1 -1
- package/src/engine/__tests__/boot-validator.test.ts +134 -0
- package/src/engine/__tests__/engine.test.ts +27 -0
- package/src/engine/boot-validator/entity-handler.ts +52 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/create-app.ts +11 -1
- package/src/engine/extension-names.ts +16 -0
- package/src/engine/index.ts +1 -0
- package/src/files/file-routes.ts +44 -0
- package/src/jobs/__tests__/job-runner-boot-timeout.test.ts +55 -0
- package/src/jobs/job-runner.ts +24 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.193.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
"./package.json": "./package.json"
|
|
187
187
|
},
|
|
188
188
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
189
|
+
"@cosmicdrift/kumiko-types": "0.193.0",
|
|
190
190
|
"bullmq": "^5.76.7",
|
|
191
191
|
"bun-types": "^1.3.13",
|
|
192
192
|
"hono": "^4.13.1",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"zod": "^4.4.3"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.193.0",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context, Next } from "hono";
|
|
2
|
-
import { requestContext } from "./request-context";
|
|
2
|
+
import { type RequestContextData, requestContext } from "./request-context";
|
|
3
3
|
|
|
4
4
|
const REQUEST_ID_HEADER = "X-Request-ID";
|
|
5
5
|
const CORRELATION_ID_HEADER = "X-Correlation-ID";
|
|
@@ -14,6 +14,47 @@ function sanitizeClientId(value: string | undefined): string | undefined {
|
|
|
14
14
|
return value !== undefined && SAFE_ID_RE.test(value) ? value : undefined;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Builds the RequestContextData record for a Hono request — requestId
|
|
19
|
+
* (client-supplied + sanitized, or generated), correlationId (mirrors
|
|
20
|
+
* requestId unless the client set its own), the underlying abort signal,
|
|
21
|
+
* and the client IP/User-Agent. Extracted out of `requestIdMiddleware` so
|
|
22
|
+
* call-sites that invoke a handler outside that middleware's `next()`
|
|
23
|
+
* chain (e.g. server.ts's httpRoute→systemQuery mount) can still populate
|
|
24
|
+
* the same AsyncLocalStorage record via `requestContext.run(...)`.
|
|
25
|
+
*/
|
|
26
|
+
export function buildRequestContextData(c: Context): RequestContextData {
|
|
27
|
+
const requestId =
|
|
28
|
+
sanitizeClientId(c.req.header(REQUEST_ID_HEADER)) ?? requestContext.generateId();
|
|
29
|
+
const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId;
|
|
30
|
+
|
|
31
|
+
// Hono exposes the underlying Fetch Request — its `signal` aborts
|
|
32
|
+
// when the client disconnects (mobile back-press, tab close). We
|
|
33
|
+
// propagate it through requestContext so framework internals can
|
|
34
|
+
// honour cancellation at long-running checkpoints. Older Hono /
|
|
35
|
+
// adapter combos may not populate `c.req.raw.signal`; conditional
|
|
36
|
+
// spread keeps `signal: undefined` out of the stored record so
|
|
37
|
+
// downstream `signal?` checks behave as if no signal exists.
|
|
38
|
+
const signal = c.req.raw?.signal;
|
|
39
|
+
// Client IP for per-IP rate limiting. Trust `x-forwarded-for` when
|
|
40
|
+
// present (proxy/CDN) — first hop is the originating client. Adapter-
|
|
41
|
+
// specific socket-address fallback (bun, node) is not standardized
|
|
42
|
+
// in Hono; deployments behind a proxy should always set xff. Without
|
|
43
|
+
// either we leave `ip` undefined and skip ip-bucketed checks rather
|
|
44
|
+
// than fabricate one.
|
|
45
|
+
const xff = c.req.header("x-forwarded-for");
|
|
46
|
+
const ip = xff?.split(",")[0]?.trim();
|
|
47
|
+
const userAgent = c.req.header("user-agent");
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
requestId,
|
|
51
|
+
correlationId,
|
|
52
|
+
...(signal ? { signal } : {}),
|
|
53
|
+
...(ip && ip.length > 0 ? { ip } : {}),
|
|
54
|
+
...(userAgent !== undefined ? { userAgent } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
17
58
|
/**
|
|
18
59
|
* Assigns a requestId + correlationId to every request and wraps execution
|
|
19
60
|
* in AsyncLocalStorage. Runs BEFORE auth — both ids are available even for
|
|
@@ -25,39 +66,11 @@ function sanitizeClientId(value: string | undefined): string | undefined {
|
|
|
25
66
|
*/
|
|
26
67
|
export function requestIdMiddleware() {
|
|
27
68
|
return async (c: Context, next: Next) => {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
c.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// Hono exposes the underlying Fetch Request — its `signal` aborts
|
|
36
|
-
// when the client disconnects (mobile back-press, tab close). We
|
|
37
|
-
// propagate it through requestContext so framework internals can
|
|
38
|
-
// honour cancellation at long-running checkpoints. Older Hono /
|
|
39
|
-
// adapter combos may not populate `c.req.raw.signal`; conditional
|
|
40
|
-
// spread keeps `signal: undefined` out of the stored record so
|
|
41
|
-
// downstream `signal?` checks behave as if no signal exists.
|
|
42
|
-
const signal = c.req.raw?.signal;
|
|
43
|
-
// Client IP for per-IP rate limiting. Trust `x-forwarded-for` when
|
|
44
|
-
// present (proxy/CDN) — first hop is the originating client. Adapter-
|
|
45
|
-
// specific socket-address fallback (bun, node) is not standardized
|
|
46
|
-
// in Hono; deployments behind a proxy should always set xff. Without
|
|
47
|
-
// either we leave `ip` undefined and skip ip-bucketed checks rather
|
|
48
|
-
// than fabricate one.
|
|
49
|
-
const xff = c.req.header("x-forwarded-for");
|
|
50
|
-
const ip = xff?.split(",")[0]?.trim();
|
|
51
|
-
const userAgent = c.req.header("user-agent");
|
|
52
|
-
await requestContext.run(
|
|
53
|
-
{
|
|
54
|
-
requestId,
|
|
55
|
-
correlationId,
|
|
56
|
-
...(signal ? { signal } : {}),
|
|
57
|
-
...(ip && ip.length > 0 ? { ip } : {}),
|
|
58
|
-
...(userAgent !== undefined ? { userAgent } : {}),
|
|
59
|
-
},
|
|
60
|
-
() => next(),
|
|
61
|
-
);
|
|
69
|
+
const data = buildRequestContextData(c);
|
|
70
|
+
c.header(REQUEST_ID_HEADER, data.requestId);
|
|
71
|
+
c.header(CORRELATION_ID_HEADER, data.correlationId);
|
|
72
|
+
c.set("requestId", data.requestId);
|
|
73
|
+
|
|
74
|
+
await requestContext.run(data, () => next());
|
|
62
75
|
};
|
|
63
76
|
}
|
package/src/api/server.ts
CHANGED
|
@@ -57,7 +57,8 @@ import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
|
57
57
|
import { observabilityMiddleware } from "./observability-middleware";
|
|
58
58
|
import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
|
|
59
59
|
import { piiCiphertextResponseGuard } from "./pii-leak-guard";
|
|
60
|
-
import {
|
|
60
|
+
import { requestContext } from "./request-context";
|
|
61
|
+
import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware";
|
|
61
62
|
import {
|
|
62
63
|
DEFAULT_MAX_REQUEST_BYTES,
|
|
63
64
|
registerBodyLimit,
|
|
@@ -769,7 +770,17 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
769
770
|
// it into a public response. The forced tenant already comes
|
|
770
771
|
// from bypassing the HTTP layer entirely; no elevated role
|
|
771
772
|
// is needed or wanted on top of that.
|
|
772
|
-
|
|
773
|
+
//
|
|
774
|
+
// httpRoute handlers run OUTSIDE /api/* — requestIdMiddleware
|
|
775
|
+
// (which wraps requestContext.run with ip/requestId/
|
|
776
|
+
// correlationId) never sees this request. Without this wrap,
|
|
777
|
+
// `rateLimit: {per: "ip", ...}` on a handler invoked via
|
|
778
|
+
// systemQuery is silent dead-code: enforceRateLimit reads
|
|
779
|
+
// requestContext.get()?.ip, which is undefined here, so
|
|
780
|
+
// buildBucketKey always returns {kind: "skip"}.
|
|
781
|
+
requestContext.run(buildRequestContextData(c), () =>
|
|
782
|
+
dispatcher.query(type, payload, createAnonymousUser(tenantId)),
|
|
783
|
+
),
|
|
773
784
|
});
|
|
774
785
|
switch (route.method) {
|
|
775
786
|
case "GET":
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
3
|
+
import type { EntityDefinition, FieldDefinition, Registry } from "../../engine/types";
|
|
4
|
+
import { resolveFieldVariant } from "../field-variants";
|
|
5
|
+
|
|
6
|
+
// Minimal fake Registry — same pattern as derivatives-context.test.ts's
|
|
7
|
+
// fakeRegistry: only getEntity is exercised by resolveFieldVariant.
|
|
8
|
+
function fakeRegistry(entities: Readonly<Record<string, EntityDefinition>>): Registry {
|
|
9
|
+
const map = new Map(Object.entries(entities));
|
|
10
|
+
return {
|
|
11
|
+
getEntity: (name: string) => map.get(name),
|
|
12
|
+
} as unknown as Registry;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const THUMB_SPEC: VariantSpec = { maxEdge: 200, format: "webp" };
|
|
16
|
+
|
|
17
|
+
function photoEntityWith(field: FieldDefinition): Readonly<Record<string, EntityDefinition>> {
|
|
18
|
+
return {
|
|
19
|
+
photo: { fields: { avatar: field } },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("resolveFieldVariant", () => {
|
|
24
|
+
test("returns the declared spec for a known variant name", () => {
|
|
25
|
+
const registry = fakeRegistry(
|
|
26
|
+
photoEntityWith({ type: "image", variants: { thumb: THUMB_SPEC } }),
|
|
27
|
+
);
|
|
28
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "thumb")).toBe(THUMB_SPEC);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("unknown variant name returns undefined", () => {
|
|
32
|
+
const registry = fakeRegistry(
|
|
33
|
+
photoEntityWith({ type: "image", variants: { thumb: THUMB_SPEC } }),
|
|
34
|
+
);
|
|
35
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "nope")).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("__proto__ as a name returns undefined — the prototype-pollution case Object.hasOwn catches", () => {
|
|
39
|
+
const registry = fakeRegistry(
|
|
40
|
+
photoEntityWith({ type: "image", variants: { thumb: THUMB_SPEC } }),
|
|
41
|
+
);
|
|
42
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "__proto__")).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("null entityType or fieldName returns undefined", () => {
|
|
46
|
+
const registry = fakeRegistry(
|
|
47
|
+
photoEntityWith({ type: "image", variants: { thumb: THUMB_SPEC } }),
|
|
48
|
+
);
|
|
49
|
+
expect(resolveFieldVariant(registry, null, "avatar", "thumb")).toBeUndefined();
|
|
50
|
+
expect(resolveFieldVariant(registry, "photo", null, "thumb")).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a non-image field returns undefined even when its own value looks variant-shaped", () => {
|
|
54
|
+
const registry = fakeRegistry(photoEntityWith({ type: "text" }));
|
|
55
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "thumb")).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("an image field without variants returns undefined", () => {
|
|
59
|
+
const registry = fakeRegistry(photoEntityWith({ type: "image" }));
|
|
60
|
+
expect(resolveFieldVariant(registry, "photo", "avatar", "thumb")).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -19,6 +19,12 @@ describe("specHash — key stability", () => {
|
|
|
19
19
|
test("a changed value produces a different hash — the whole point of hashing the spec", () => {
|
|
20
20
|
expect(specHash({ maxEdge: 512 })).not.toBe(specHash({ maxEdge: 640 }));
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
test("different blurRegions produce different hashes — corrected regions need a fresh URL", () => {
|
|
24
|
+
expect(specHash({ blurRegions: [{ x: 0, y: 0, width: 0.5, height: 0.5 }] })).not.toBe(
|
|
25
|
+
specHash({ blurRegions: [{ x: 0.5, y: 0.5, width: 0.5, height: 0.5 }] }),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
22
28
|
});
|
|
23
29
|
|
|
24
30
|
describe("variantSuffix", () => {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Proves GET /api/files/:id/variant/:name end-to-end: real HTTP calls
|
|
2
|
+
// through the auth+tenant+guard gate, resolveFieldVariant's whitelist, and
|
|
3
|
+
// createDerivativesContext's cache path — same "wiring, not unit logic"
|
|
4
|
+
// rationale as derivatives-context.integration.test.ts.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
8
|
+
import {
|
|
9
|
+
createEntity,
|
|
10
|
+
createImageField,
|
|
11
|
+
defineFeature,
|
|
12
|
+
EXT_DERIVATIVE_RENDERER,
|
|
13
|
+
} from "../../engine";
|
|
14
|
+
import { createFilesFeature } from "../../files/feature";
|
|
15
|
+
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
16
|
+
import { createTestUser, setupTestStack, type TestStack, testTenantId } from "../../stack";
|
|
17
|
+
import { buildMultipartBody, patchFileInstanceofForBunTest } from "../../testing";
|
|
18
|
+
|
|
19
|
+
const VARIANT_BYTES = new Uint8Array([9, 9, 9]);
|
|
20
|
+
|
|
21
|
+
let renderCalls = 0;
|
|
22
|
+
const fakeRender: DerivativeRendererPlugin["render"] = async () => {
|
|
23
|
+
renderCalls++;
|
|
24
|
+
return VARIANT_BYTES;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const photoEntity = createEntity({
|
|
28
|
+
table: "variant_route_photos",
|
|
29
|
+
fields: {
|
|
30
|
+
avatar: createImageField({ variants: { thumb: { maxEdge: 100, format: "webp" } } }),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const photoFeature = defineFeature("variantroutetest", (r) => {
|
|
35
|
+
r.entity("photo", photoEntity);
|
|
36
|
+
r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, { onRegister: () => {} });
|
|
37
|
+
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", { render: fakeRender });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let stack: TestStack;
|
|
41
|
+
const tenantId = testTenantId(1);
|
|
42
|
+
const user = createTestUser({ id: 1, tenantId, roles: ["Admin"] });
|
|
43
|
+
const otherTenantId = testTenantId(2);
|
|
44
|
+
const otherTenantUser = createTestUser({ id: 10, tenantId: otherTenantId, roles: ["Admin"] });
|
|
45
|
+
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
patchFileInstanceofForBunTest();
|
|
48
|
+
stack = await setupTestStack({
|
|
49
|
+
features: [createFilesFeature(), photoFeature],
|
|
50
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await stack.cleanup();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const DEFAULT_ATTACH = { entityType: "photo", fieldName: "avatar" };
|
|
59
|
+
|
|
60
|
+
// `null` (not the default-param-triggering `undefined`) is the explicit
|
|
61
|
+
// "unattached upload" sentinel — a caller passing `undefined` here would
|
|
62
|
+
// silently fall back to DEFAULT_ATTACH instead.
|
|
63
|
+
async function uploadFile(
|
|
64
|
+
asUser = user,
|
|
65
|
+
attach: { entityType: string; fieldName: string } | null = DEFAULT_ATTACH,
|
|
66
|
+
): Promise<string> {
|
|
67
|
+
const token = await stack.jwt.sign(asUser);
|
|
68
|
+
const fd = new FormData();
|
|
69
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "avatar.jpg", { type: "image/jpeg" }));
|
|
70
|
+
if (attach !== null) {
|
|
71
|
+
fd.append("entityType", attach.entityType);
|
|
72
|
+
fd.append("fieldName", attach.fieldName);
|
|
73
|
+
}
|
|
74
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
75
|
+
const res = await stack.app.request("/api/files", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
78
|
+
body,
|
|
79
|
+
});
|
|
80
|
+
expect(res.status).toBe(201);
|
|
81
|
+
const json = (await res.json()) as { id: string };
|
|
82
|
+
return json.id;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe("GET /api/files/:id/variant/:name", () => {
|
|
86
|
+
test("returns the rendered bytes with the spec's mimeType", async () => {
|
|
87
|
+
renderCalls = 0;
|
|
88
|
+
const fileId = await uploadFile();
|
|
89
|
+
const token = await stack.jwt.sign(user);
|
|
90
|
+
|
|
91
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
92
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
expect(res.headers.get("Content-Type")).toBe("image/webp");
|
|
97
|
+
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a second call hits the cache — the renderer runs only once", async () => {
|
|
101
|
+
renderCalls = 0;
|
|
102
|
+
const fileId = await uploadFile();
|
|
103
|
+
const token = await stack.jwt.sign(user);
|
|
104
|
+
|
|
105
|
+
const first = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
106
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
107
|
+
});
|
|
108
|
+
const second = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
109
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(first.status).toBe(200);
|
|
113
|
+
expect(second.status).toBe(200);
|
|
114
|
+
expect(new Uint8Array(await second.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
115
|
+
expect(renderCalls).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("an undeclared variant name returns 404", async () => {
|
|
119
|
+
const fileId = await uploadFile();
|
|
120
|
+
const token = await stack.jwt.sign(user);
|
|
121
|
+
|
|
122
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/nope`, {
|
|
123
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(res.status).toBe(404);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("__proto__ as the variant name returns 404", async () => {
|
|
130
|
+
const fileId = await uploadFile();
|
|
131
|
+
const token = await stack.jwt.sign(user);
|
|
132
|
+
|
|
133
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/__proto__`, {
|
|
134
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(res.status).toBe(404);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an unattached upload has no field to resolve a variant from — 404", async () => {
|
|
141
|
+
const fileId = await uploadFile(user, null);
|
|
142
|
+
const token = await stack.jwt.sign(user);
|
|
143
|
+
|
|
144
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
145
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(res.status).toBe(404);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a user from a different tenant gets 404 — tenant isolation", async () => {
|
|
152
|
+
const fileId = await uploadFile();
|
|
153
|
+
const token = await stack.jwt.sign(otherTenantUser);
|
|
154
|
+
|
|
155
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
156
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(res.status).toBe(404);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("no Authorization header does not return 200", async () => {
|
|
163
|
+
const fileId = await uploadFile();
|
|
164
|
+
|
|
165
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`);
|
|
166
|
+
|
|
167
|
+
expect(res.status).toBe(401);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
|
+
import type { Registry } from "../engine/types";
|
|
3
|
+
|
|
4
|
+
// The field definition IS the whitelist: a request carries a variant NAME,
|
|
5
|
+
// never a spec, so no caller can drive an arbitrary render. `hasOwn` and not
|
|
6
|
+
// a plain index — `name` comes off a URL segment, and `__proto__` would
|
|
7
|
+
// otherwise resolve to something truthy that is not a VariantSpec.
|
|
8
|
+
export function resolveFieldVariant(
|
|
9
|
+
registry: Registry,
|
|
10
|
+
entityType: string | null,
|
|
11
|
+
fieldName: string | null,
|
|
12
|
+
name: string,
|
|
13
|
+
): VariantSpec | undefined {
|
|
14
|
+
if (entityType === null || fieldName === null) return undefined;
|
|
15
|
+
const field = registry.getEntity(entityType)?.fields[fieldName];
|
|
16
|
+
if (field === undefined) return undefined;
|
|
17
|
+
if (field.type !== "image" && field.type !== "images") return undefined;
|
|
18
|
+
if (field.variants === undefined || !Object.hasOwn(field.variants, name)) return undefined;
|
|
19
|
+
return field.variants[name];
|
|
20
|
+
}
|
package/src/derivatives/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export type { DerivativesContextDeps } from "./derivatives-context";
|
|
2
2
|
export { createDerivativesContext, resolveRenderer } from "./derivatives-context";
|
|
3
|
-
export {
|
|
3
|
+
export { resolveFieldVariant } from "./field-variants";
|
|
4
|
+
export { canonicalJson, specHash, VARIANT_NAME_PATTERN, variantSuffix } from "./variant-key";
|
|
@@ -25,7 +25,7 @@ export function specHash(spec: VariantSpec): string {
|
|
|
25
25
|
return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 8);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;
|
|
28
|
+
export const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;
|
|
29
29
|
|
|
30
30
|
export function variantSuffix(name: string, spec: VariantSpec): string {
|
|
31
31
|
// `name` ends up in a storage key (see deriveKey) — an unvalidated value
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
createEmbeddedListField,
|
|
12
12
|
createEntity,
|
|
13
13
|
createFilesField,
|
|
14
|
+
createImageField,
|
|
15
|
+
createImagesField,
|
|
14
16
|
createJsonbField,
|
|
15
17
|
createMultiSelectField,
|
|
16
18
|
createTextField,
|
|
@@ -1355,6 +1357,138 @@ describe("boot-validator", () => {
|
|
|
1355
1357
|
});
|
|
1356
1358
|
});
|
|
1357
1359
|
|
|
1360
|
+
describe("image variants", () => {
|
|
1361
|
+
test("accepts a valid variant spec", () => {
|
|
1362
|
+
process.env["FILE_STORAGE_PROVIDER"] = "local";
|
|
1363
|
+
try {
|
|
1364
|
+
const features = [
|
|
1365
|
+
defineFeature("profile", (r) => {
|
|
1366
|
+
r.entity(
|
|
1367
|
+
"person",
|
|
1368
|
+
createEntity({
|
|
1369
|
+
fields: {
|
|
1370
|
+
avatar: createImageField({
|
|
1371
|
+
variants: {
|
|
1372
|
+
profile: { fit: "cover", size: { width: 512, height: 512 }, format: "webp" },
|
|
1373
|
+
},
|
|
1374
|
+
}),
|
|
1375
|
+
},
|
|
1376
|
+
}),
|
|
1377
|
+
);
|
|
1378
|
+
}),
|
|
1379
|
+
];
|
|
1380
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1381
|
+
} finally {
|
|
1382
|
+
delete process.env["FILE_STORAGE_PROVIDER"];
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
test("accepts a variant with only `format` — the legitimate format-only case, no size/maxEdge required", () => {
|
|
1387
|
+
process.env["FILE_STORAGE_PROVIDER"] = "local";
|
|
1388
|
+
try {
|
|
1389
|
+
const features = [
|
|
1390
|
+
defineFeature("profile", (r) => {
|
|
1391
|
+
r.entity(
|
|
1392
|
+
"person",
|
|
1393
|
+
createEntity({
|
|
1394
|
+
fields: {
|
|
1395
|
+
avatar: createImageField({ variants: { web: { format: "webp" } } }),
|
|
1396
|
+
},
|
|
1397
|
+
}),
|
|
1398
|
+
);
|
|
1399
|
+
}),
|
|
1400
|
+
];
|
|
1401
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
1402
|
+
} finally {
|
|
1403
|
+
delete process.env["FILE_STORAGE_PROVIDER"];
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("rejects a variant setting both `size` and `maxEdge`", () => {
|
|
1408
|
+
const features = [
|
|
1409
|
+
defineFeature("profile", (r) => {
|
|
1410
|
+
r.entity(
|
|
1411
|
+
"person",
|
|
1412
|
+
createEntity({
|
|
1413
|
+
fields: {
|
|
1414
|
+
avatar: createImageField({
|
|
1415
|
+
variants: {
|
|
1416
|
+
both: { size: { width: 100, height: 100 }, maxEdge: 200 },
|
|
1417
|
+
},
|
|
1418
|
+
}),
|
|
1419
|
+
},
|
|
1420
|
+
}),
|
|
1421
|
+
);
|
|
1422
|
+
}),
|
|
1423
|
+
];
|
|
1424
|
+
expect(() => validateBoot(features)).toThrow(/both/);
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test("rejects a variant name containing a slash", () => {
|
|
1428
|
+
const features = [
|
|
1429
|
+
defineFeature("profile", (r) => {
|
|
1430
|
+
r.entity(
|
|
1431
|
+
"person",
|
|
1432
|
+
createEntity({
|
|
1433
|
+
fields: {
|
|
1434
|
+
avatar: createImageField({ variants: { "a/b": { maxEdge: 200 } } }),
|
|
1435
|
+
},
|
|
1436
|
+
}),
|
|
1437
|
+
);
|
|
1438
|
+
}),
|
|
1439
|
+
];
|
|
1440
|
+
expect(() => validateBoot(features)).toThrow(/must match/);
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
test("rejects maxEdge: 0", () => {
|
|
1444
|
+
const features = [
|
|
1445
|
+
defineFeature("profile", (r) => {
|
|
1446
|
+
r.entity(
|
|
1447
|
+
"person",
|
|
1448
|
+
createEntity({
|
|
1449
|
+
fields: {
|
|
1450
|
+
avatar: createImageField({ variants: { thumb: { maxEdge: 0 } } }),
|
|
1451
|
+
},
|
|
1452
|
+
}),
|
|
1453
|
+
);
|
|
1454
|
+
}),
|
|
1455
|
+
];
|
|
1456
|
+
expect(() => validateBoot(features)).toThrow(/maxEdge/);
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
test("rejects quality: 101", () => {
|
|
1460
|
+
const features = [
|
|
1461
|
+
defineFeature("profile", (r) => {
|
|
1462
|
+
r.entity(
|
|
1463
|
+
"person",
|
|
1464
|
+
createEntity({
|
|
1465
|
+
fields: {
|
|
1466
|
+
avatar: createImageField({ variants: { thumb: { maxEdge: 200, quality: 101 } } }),
|
|
1467
|
+
},
|
|
1468
|
+
}),
|
|
1469
|
+
);
|
|
1470
|
+
}),
|
|
1471
|
+
];
|
|
1472
|
+
expect(() => validateBoot(features)).toThrow(/quality/);
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
test("the same validation applies to `images` (multi) fields", () => {
|
|
1476
|
+
const features = [
|
|
1477
|
+
defineFeature("profile", (r) => {
|
|
1478
|
+
r.entity(
|
|
1479
|
+
"person",
|
|
1480
|
+
createEntity({
|
|
1481
|
+
fields: {
|
|
1482
|
+
gallery: createImagesField({ variants: { "a/b": { maxEdge: 200 } } }),
|
|
1483
|
+
},
|
|
1484
|
+
}),
|
|
1485
|
+
);
|
|
1486
|
+
}),
|
|
1487
|
+
];
|
|
1488
|
+
expect(() => validateBoot(features)).toThrow(/must match/);
|
|
1489
|
+
});
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1358
1492
|
// --- entityList column-renderer form-check ---
|
|
1359
1493
|
// Validator akzeptiert die `{ react: { __component: "Name" } }`-Form
|
|
1360
1494
|
// (PlatformComponent → client-side Registry-Lookup) und prüft sie
|
|
@@ -814,6 +814,28 @@ describe("createApp", () => {
|
|
|
814
814
|
);
|
|
815
815
|
});
|
|
816
816
|
|
|
817
|
+
// hasMoneyField used to only look at top-level fields, so an entity whose
|
|
818
|
+
// only money lives inside an embedded-list sub-schema (e.g. invoice lines
|
|
819
|
+
// with no top-level money field) slipped past this check — its cells and
|
|
820
|
+
// totals would then silently render in entity.defaultCurrency ?? "EUR"
|
|
821
|
+
// regardless of the app's actual currency.
|
|
822
|
+
test("rejects an entity whose only money field is nested in an embedded list", () => {
|
|
823
|
+
const feature = defineFeature("test", (r) => {
|
|
824
|
+
r.entity(
|
|
825
|
+
"invoice",
|
|
826
|
+
createEntity({
|
|
827
|
+
table: "Invoices",
|
|
828
|
+
fields: {
|
|
829
|
+
lines: createEmbeddedListField({ amount: { type: "money", required: true } }),
|
|
830
|
+
},
|
|
831
|
+
}),
|
|
832
|
+
);
|
|
833
|
+
});
|
|
834
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
835
|
+
"has money fields but no defaultCurrency",
|
|
836
|
+
);
|
|
837
|
+
});
|
|
838
|
+
|
|
817
839
|
test("rejects unknown defaultCurrency", () => {
|
|
818
840
|
const feature = defineFeature("test", (r) => {
|
|
819
841
|
r.entity(
|
|
@@ -955,6 +977,9 @@ describe("createApp", () => {
|
|
|
955
977
|
qty: { type: "decimal", scale: 3 },
|
|
956
978
|
}),
|
|
957
979
|
},
|
|
980
|
+
// meta.amount is money nested in an embedded field — needs a
|
|
981
|
+
// defaultCurrency the same as a top-level money field would.
|
|
982
|
+
defaultCurrency: "EUR",
|
|
958
983
|
}),
|
|
959
984
|
);
|
|
960
985
|
});
|
|
@@ -1073,6 +1098,7 @@ describe("createApp", () => {
|
|
|
1073
1098
|
{ totalsMatch: { amount: "title" } },
|
|
1074
1099
|
),
|
|
1075
1100
|
},
|
|
1101
|
+
defaultCurrency: "EUR",
|
|
1076
1102
|
}),
|
|
1077
1103
|
);
|
|
1078
1104
|
});
|
|
@@ -1093,6 +1119,7 @@ describe("createApp", () => {
|
|
|
1093
1119
|
{ totalsMatch: { amount: "ghostTotal" } },
|
|
1094
1120
|
),
|
|
1095
1121
|
},
|
|
1122
|
+
defaultCurrency: "EUR",
|
|
1096
1123
|
}),
|
|
1097
1124
|
);
|
|
1098
1125
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
|
+
import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key";
|
|
1
3
|
import { parseRefTarget } from "../parse-ref-target";
|
|
2
4
|
import type { EmbeddedFieldDef, EntityDefinition, FeatureDefinition } from "../types";
|
|
3
5
|
|
|
@@ -685,6 +687,56 @@ export function validateMultiSelectFields(feature: FeatureDefinition): void {
|
|
|
685
687
|
}
|
|
686
688
|
}
|
|
687
689
|
|
|
690
|
+
// --- Image variant validation ---
|
|
691
|
+
|
|
692
|
+
function isPositiveInt(value: number): boolean {
|
|
693
|
+
return Number.isInteger(value) && value > 0;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function assertValidVariantSpec(name: string, spec: VariantSpec, where: string): void {
|
|
697
|
+
if (!VARIANT_NAME_PATTERN.test(name)) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
`Image variant "${name}" ${where} must match ${VARIANT_NAME_PATTERN.source} — the name becomes part of a storage key and a URL segment.`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (spec.size !== undefined && spec.maxEdge !== undefined) {
|
|
703
|
+
throw new Error(`Image variant "${name}" ${where} sets both "size" and "maxEdge" — pick one.`);
|
|
704
|
+
}
|
|
705
|
+
if (
|
|
706
|
+
spec.size !== undefined &&
|
|
707
|
+
!(isPositiveInt(spec.size.width) && isPositiveInt(spec.size.height))
|
|
708
|
+
) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
`Image variant "${name}" ${where} has a non-positive-integer "size" (${spec.size.width}x${spec.size.height}).`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
if (spec.maxEdge !== undefined && !isPositiveInt(spec.maxEdge)) {
|
|
714
|
+
throw new Error(
|
|
715
|
+
`Image variant "${name}" ${where} has a non-positive-integer "maxEdge" (${spec.maxEdge}).`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
if (spec.quality !== undefined && !(isPositiveInt(spec.quality) && spec.quality <= 100)) {
|
|
719
|
+
throw new Error(
|
|
720
|
+
`Image variant "${name}" ${where} has "quality" ${spec.quality} — must be an integer in 1..100.`,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// A bad spec is only visible when someone finally requests that variant —
|
|
726
|
+
// possibly months later, in production. Catch it at boot instead.
|
|
727
|
+
export function validateImageVariants(feature: FeatureDefinition): void {
|
|
728
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
729
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
730
|
+
if (field.type !== "image" && field.type !== "images") continue;
|
|
731
|
+
if (field.variants === undefined) continue;
|
|
732
|
+
const where = `on "${entityName}.${fieldName}" in feature "${feature.name}"`;
|
|
733
|
+
for (const [name, spec] of Object.entries(field.variants)) {
|
|
734
|
+
assertValidVariantSpec(name, spec, where);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
688
740
|
// --- Transition validation ---
|
|
689
741
|
|
|
690
742
|
export function validateTransitions(feature: FeatureDefinition): void {
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
validateExtensionPreSaveWiring,
|
|
26
26
|
validateFileFields,
|
|
27
27
|
validateHandlerAccess,
|
|
28
|
+
validateImageVariants,
|
|
28
29
|
validateLocatedTimestamps,
|
|
29
30
|
validateMultiSelectFields,
|
|
30
31
|
validateMultiStreamProjections,
|
|
@@ -168,6 +169,7 @@ export function validateBoot(
|
|
|
168
169
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
169
170
|
validateEmbeddedFields(feature, featureMap);
|
|
170
171
|
validateMultiSelectFields(feature);
|
|
172
|
+
validateImageVariants(feature);
|
|
171
173
|
validateReferenceFields(feature, featureMap);
|
|
172
174
|
validateTransitions(feature);
|
|
173
175
|
validateExtensionUsages(feature, extensionProviders);
|
package/src/engine/create-app.ts
CHANGED
|
@@ -85,7 +85,17 @@ export function createApp(config: AppConfig): App {
|
|
|
85
85
|
// Validate defaultCurrency on entities that have money fields
|
|
86
86
|
for (const feature of config.features) {
|
|
87
87
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
88
|
-
|
|
88
|
+
// A top-level money field isn't the only way an entity can hold money —
|
|
89
|
+
// an embedded-list's sub-schema (e.g. invoice lines) can carry a money
|
|
90
|
+
// cell with no top-level money field at all. Without this, that entity
|
|
91
|
+
// slips past the defaultCurrency check and its cells/totals render in
|
|
92
|
+
// the entity.defaultCurrency ?? "EUR" fallback regardless of the app's
|
|
93
|
+
// actual currency.
|
|
94
|
+
const hasMoneyField = Object.values(entity.fields).some(
|
|
95
|
+
(f) =>
|
|
96
|
+
f.type === "money" ||
|
|
97
|
+
(f.type === "embedded" && Object.values(f.schema).some((s) => s.type === "money")),
|
|
98
|
+
);
|
|
89
99
|
if (entity.defaultCurrency && !currencies.includes(entity.defaultCurrency)) {
|
|
90
100
|
throw new Error(
|
|
91
101
|
`Entity "${entityName}" in feature "${feature.name}" has defaultCurrency "${entity.defaultCurrency}" which is not in the currencies list. Available: ${currencies.join(", ")}`,
|
|
@@ -104,6 +104,22 @@ export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as con
|
|
|
104
104
|
*/
|
|
105
105
|
export const EXT_DERIVATIVE_RENDERER = "derivativeRenderer" as const;
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* `derivativePublicPredicate` — per-entityType "is this FileRef's derivative
|
|
109
|
+
* publicly readable?" gate for the anonymous variant route (file-derivatives).
|
|
110
|
+
*
|
|
111
|
+
* Apps register via
|
|
112
|
+
* `r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "<entityType>", { isPublic:
|
|
113
|
+
* (args, ctx) => boolean | Promise<boolean> })`, where `<entityType>` is the
|
|
114
|
+
* `entityType` string a FileRef carries from upload (e.g. "vehicle", "event").
|
|
115
|
+
* No registration for a given entityType is default-deny — the public route
|
|
116
|
+
* serves nothing for that entityType, not even a 403 (404, so existence isn't
|
|
117
|
+
* confirmed to an unauthorised caller).
|
|
118
|
+
*
|
|
119
|
+
* Registered/consumed by: `file-derivatives`' public variant route (#1951).
|
|
120
|
+
*/
|
|
121
|
+
export const EXT_DERIVATIVE_PUBLIC_PREDICATE = "derivativePublicPredicate" as const;
|
|
122
|
+
|
|
107
123
|
/**
|
|
108
124
|
* `searchAdapter` — Search-Adapter-Forget-Hooks (Meilisearch-Index-Cleanup
|
|
109
125
|
* bei User-Forget oder Tenant-Destroy).
|
package/src/engine/index.ts
CHANGED
|
@@ -77,6 +77,7 @@ export type { EmitCtx } from "./event-helpers";
|
|
|
77
77
|
export { emitEvent, typedPayload } from "./event-helpers";
|
|
78
78
|
export type { KumikoExtensionName } from "./extension-names";
|
|
79
79
|
export {
|
|
80
|
+
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
80
81
|
EXT_DERIVATIVE_RENDERER,
|
|
81
82
|
EXT_EXTERNAL_RESOURCE,
|
|
82
83
|
EXT_FILE_PROVIDER,
|
package/src/files/file-routes.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { getUser } from "../api/auth-middleware";
|
|
|
4
4
|
import type { DbConnection } from "../db/connection";
|
|
5
5
|
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
6
6
|
import { createTenantDb } from "../db/tenant-db";
|
|
7
|
+
import { createDerivativesContext, resolveFieldVariant } from "../derivatives";
|
|
7
8
|
import { isFileField, type Registry, type SessionUser, type TenantId } from "../engine/types";
|
|
8
9
|
import { generateId } from "../utils";
|
|
9
10
|
import { buildContentDispositionHeader } from "./content-disposition";
|
|
11
|
+
import { createFileContext } from "./file-handle";
|
|
10
12
|
import { fileRefEntity } from "./file-ref-entity";
|
|
11
13
|
import { fileRefsTable } from "./file-ref-table";
|
|
12
14
|
import type { FileProviderResolver } from "./provider-resolver";
|
|
@@ -235,6 +237,48 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
235
237
|
});
|
|
236
238
|
});
|
|
237
239
|
|
|
240
|
+
// GET /files/:id/variant/:name — a named derived version of the file.
|
|
241
|
+
// Same tenant + guard gate as the download above; the original stays
|
|
242
|
+
// reachable only through that route. The public, anonymous counterpart is
|
|
243
|
+
// a separate cut (#1951) and does not go through createFileRoutes.
|
|
244
|
+
api.get("/files/:id/variant/:name", async (c) => {
|
|
245
|
+
const user = getUser(c);
|
|
246
|
+
const id = c.req.param("id");
|
|
247
|
+
const name = c.req.param("name");
|
|
248
|
+
const fileRef = await loadFileForTenant(id, user.tenantId);
|
|
249
|
+
if (!fileRef) return c.json({ error: "not_found" }, 404);
|
|
250
|
+
|
|
251
|
+
const decision = await guard({ fileRef, user, operation: "read" });
|
|
252
|
+
if (decision === "deny") return c.json({ error: "not_found" }, 404);
|
|
253
|
+
|
|
254
|
+
// An unknown name and a denied file answer alike, so the route never
|
|
255
|
+
// confirms what exists. A missing renderer is NOT folded in here — that
|
|
256
|
+
// throws out of variant() as a 500, because a mount gap is a config
|
|
257
|
+
// error, not a missing variant.
|
|
258
|
+
const registry = options.registry;
|
|
259
|
+
const spec = registry
|
|
260
|
+
? resolveFieldVariant(registry, fileRef.entityType, fileRef.fieldName, name)
|
|
261
|
+
: undefined;
|
|
262
|
+
if (!registry || !spec) return c.json({ error: "not_found" }, 404);
|
|
263
|
+
|
|
264
|
+
// Built per request: createFileContext caches the resolved provider, so
|
|
265
|
+
// one shared across requests would serve tenant A's store to tenant B.
|
|
266
|
+
const files = createFileContext(() => options.resolveProvider(user.tenantId));
|
|
267
|
+
const derivatives = createDerivativesContext({
|
|
268
|
+
files,
|
|
269
|
+
registry,
|
|
270
|
+
db,
|
|
271
|
+
tenantId: user.tenantId,
|
|
272
|
+
});
|
|
273
|
+
const result = await derivatives.variant(id, spec, name);
|
|
274
|
+
const data = await files.ref(result.storageKey).read();
|
|
275
|
+
// No Content-Length/Content-Disposition: fileRef.size is the ORIGINAL's
|
|
276
|
+
// size, and a variant is rendered for display, not for download.
|
|
277
|
+
return new Response(Buffer.from(data), {
|
|
278
|
+
headers: { "Content-Type": result.mimeType },
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
238
282
|
// DELETE /files/:id — same guard, "delete" operation. Apps can differentiate
|
|
239
283
|
// read vs delete in their custom guard (e.g. only uploaders delete).
|
|
240
284
|
api.delete("/files/:id", async (c) => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Simulates an unreachable Redis: waitUntilReady() never resolves. Everything
|
|
4
|
+
// else start() touches (Queue.on/close/add/upsertJobScheduler) is a no-op —
|
|
5
|
+
// with an empty registry those code paths aren't exercised anyway.
|
|
6
|
+
mock.module("bullmq", () => {
|
|
7
|
+
class FakeQueue {
|
|
8
|
+
on() {}
|
|
9
|
+
close() {
|
|
10
|
+
return Promise.resolve();
|
|
11
|
+
}
|
|
12
|
+
getJobCounts() {
|
|
13
|
+
return Promise.resolve({});
|
|
14
|
+
}
|
|
15
|
+
removeJobScheduler() {
|
|
16
|
+
return Promise.resolve();
|
|
17
|
+
}
|
|
18
|
+
upsertJobScheduler() {
|
|
19
|
+
return Promise.resolve();
|
|
20
|
+
}
|
|
21
|
+
add() {
|
|
22
|
+
return Promise.resolve();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
class FakeWorker {
|
|
26
|
+
on() {}
|
|
27
|
+
waitUntilReady() {
|
|
28
|
+
return new Promise(() => {});
|
|
29
|
+
}
|
|
30
|
+
close() {
|
|
31
|
+
return Promise.resolve();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { Queue: FakeQueue, Worker: FakeWorker };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
import { createRegistry } from "../../engine";
|
|
38
|
+
import type { AppContext } from "../../engine/types";
|
|
39
|
+
import { createJobRunner } from "../job-runner";
|
|
40
|
+
|
|
41
|
+
describe("createJobRunner start() boot timeout", () => {
|
|
42
|
+
test("rejects instead of hanging forever when the worker's Redis connection never becomes ready", async () => {
|
|
43
|
+
const registry = createRegistry([]);
|
|
44
|
+
const context: AppContext = {};
|
|
45
|
+
const runner = createJobRunner({
|
|
46
|
+
registry,
|
|
47
|
+
context,
|
|
48
|
+
redisUrl: "redis://localhost:6379",
|
|
49
|
+
consumerLane: "worker",
|
|
50
|
+
bootRedisTimeoutMs: 50,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await expect(runner.start()).rejects.toThrow(/Redis not reachable within 50ms \(lane=worker\)/);
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -132,6 +132,10 @@ export type JobRunnerOptions = {
|
|
|
132
132
|
// Tests set a unique prefix (e.g. `"test-${Date.now()}"`) for isolation —
|
|
133
133
|
// two parallel test-runners never see each other's jobs.
|
|
134
134
|
queueNamePrefix?: string | undefined;
|
|
135
|
+
// Override how long start() waits for the worker's Redis connection
|
|
136
|
+
// before failing boot. Defaults to BOOT_REDIS_TIMEOUT_MS; tests shrink it
|
|
137
|
+
// to keep an unreachable-Redis assertion fast.
|
|
138
|
+
bootRedisTimeoutMs?: number | undefined;
|
|
135
139
|
getActiveTenantIds?: () => Promise<TenantId[]>;
|
|
136
140
|
onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
|
|
137
141
|
onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[]) => void;
|
|
@@ -192,9 +196,20 @@ function parseRedisOpts(url: string): { host: string; port: number; db?: number
|
|
|
192
196
|
return result;
|
|
193
197
|
}
|
|
194
198
|
|
|
199
|
+
// redisOpts carries no connectTimeout/retry cap, so an unreachable Redis
|
|
200
|
+
// would otherwise hang start() forever with no health endpoint to notice.
|
|
201
|
+
const BOOT_REDIS_TIMEOUT_MS = 10_000;
|
|
202
|
+
|
|
203
|
+
function timeoutReject(ms: number, message: string): Promise<never> {
|
|
204
|
+
return new Promise((_, reject) => {
|
|
205
|
+
setTimeout(() => reject(new Error(message)), ms);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
195
209
|
export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
196
210
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
197
211
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
212
|
+
const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
|
|
198
213
|
const redisOpts = parseRedisOpts(redisUrl);
|
|
199
214
|
// Use the context's tracer when present (observability-provider injected at
|
|
200
215
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
@@ -531,7 +546,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
531
546
|
// worker's connections (main + blocking) are ready (fw#1805). This
|
|
532
547
|
// mirrors the wait BullMQ already does internally for
|
|
533
548
|
// upsertJobScheduler()/add() below when the lane has a cron/boot job.
|
|
534
|
-
|
|
549
|
+
// Racing a timeout against it keeps an unreachable Redis from hanging
|
|
550
|
+
// start() forever — there's no worker health endpoint to notice.
|
|
551
|
+
await Promise.race([
|
|
552
|
+
worker.waitUntilReady(),
|
|
553
|
+
timeoutReject(
|
|
554
|
+
bootRedisTimeoutMs,
|
|
555
|
+
`job-runner: Redis not reachable within ${bootRedisTimeoutMs}ms (lane=${consumerLane})`,
|
|
556
|
+
),
|
|
557
|
+
]);
|
|
535
558
|
|
|
536
559
|
// Only schedule cron + boot for jobs that belong to this lane. Jobs
|
|
537
560
|
// assigned to the other lane get their cron/boot wiring from the
|