@cosmicdrift/kumiko-framework 0.285.2 → 0.287.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 +4 -4
- package/src/api/index.ts +4 -1
- package/src/api/request-id-middleware.ts +42 -21
- package/src/changes.json +6 -0
- package/src/derivatives/__tests__/derivatives-context.test.ts +157 -4
- package/src/derivatives/derivatives-context.ts +146 -5
- package/src/engine/boot-validator/__tests__/nav.test.ts +290 -0
- package/src/engine/boot-validator/index.ts +21 -0
- package/src/engine/boot-validator/nav.ts +44 -0
- package/src/engine/boot-validator/pii-retention.ts +6 -6
- package/src/engine/boot-validator/workspaces.ts +38 -0
- package/src/engine/extension-names.ts +17 -0
- package/src/engine/factories.ts +18 -8
- package/src/engine/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +39 -0
- package/src/files/file-routes.ts +37 -9
- package/src/http/index.ts +1 -1
- package/src/observability/escape-hatch-report.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.287.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>",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.287.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.287.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.287.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
package/src/api/index.ts
CHANGED
|
@@ -44,7 +44,10 @@ export { patAllows, qnMatches } from "./pat-scope";
|
|
|
44
44
|
export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
|
|
45
45
|
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
|
|
46
46
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
47
|
-
export {
|
|
47
|
+
export {
|
|
48
|
+
buildRequestContextDataFromRequest,
|
|
49
|
+
requestIdMiddleware,
|
|
50
|
+
} from "./request-id-middleware";
|
|
48
51
|
export { createApiRoutes } from "./routes";
|
|
49
52
|
export type { KumikoServer, ServerOptions } from "./server";
|
|
50
53
|
export { buildServer } from "./server";
|
|
@@ -16,43 +16,47 @@ function sanitizeClientId(value: string | undefined): string | undefined {
|
|
|
16
16
|
return value !== undefined && SAFE_ID_RE.test(value) ? value : undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Request.headers.get() returns `string | null` (Fetch API); Hono's
|
|
20
|
+
// c.req.header() normalizes that to `string | undefined`. Match Hono's
|
|
21
|
+
// contract here so both builders return the exact same RequestContextData
|
|
22
|
+
// shape regardless of which one a call-site uses.
|
|
23
|
+
function header(req: Request, name: string): string | undefined {
|
|
24
|
+
return req.headers.get(name) ?? undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
/**
|
|
20
|
-
* Builds the RequestContextData record for a
|
|
28
|
+
* Builds the RequestContextData record for a raw Fetch Request — requestId
|
|
21
29
|
* (client-supplied + sanitized, or generated), correlationId (mirrors
|
|
22
30
|
* requestId unless the client set its own), the underlying abort signal,
|
|
23
|
-
* and the client IP/User-Agent. Extracted out of `
|
|
24
|
-
* call-sites that
|
|
25
|
-
*
|
|
26
|
-
* the same AsyncLocalStorage
|
|
31
|
+
* and the client IP/User-Agent. Extracted out of `buildRequestContextData`
|
|
32
|
+
* so call-sites that only have a `Request` (no Hono `Context`) — e.g.
|
|
33
|
+
* server-runtime's static-fallback page-head resolver, which runs outside
|
|
34
|
+
* Hono's router entirely — can still populate the same AsyncLocalStorage
|
|
35
|
+
* record via `requestContext.run(...)`.
|
|
27
36
|
*/
|
|
28
|
-
export function
|
|
29
|
-
const requestId =
|
|
30
|
-
|
|
31
|
-
const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId;
|
|
37
|
+
export function buildRequestContextDataFromRequest(req: Request): RequestContextData {
|
|
38
|
+
const requestId = sanitizeClientId(header(req, REQUEST_ID_HEADER)) ?? requestContext.generateId();
|
|
39
|
+
const correlationId = sanitizeClientId(header(req, CORRELATION_ID_HEADER)) ?? requestId;
|
|
32
40
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
// adapter combos may not populate `c.req.raw.signal`; conditional
|
|
38
|
-
// spread keeps `signal: undefined` out of the stored record so
|
|
39
|
-
// downstream `signal?` checks behave as if no signal exists.
|
|
40
|
-
const signal = c.req.raw?.signal;
|
|
41
|
+
// The Fetch Request's `signal` aborts when the client disconnects (mobile
|
|
42
|
+
// back-press, tab close). We propagate it through requestContext so
|
|
43
|
+
// framework internals can honour cancellation at long-running checkpoints.
|
|
44
|
+
const signal = req.signal;
|
|
41
45
|
// Client IP for per-IP rate limiting. Trust `x-forwarded-for` when
|
|
42
46
|
// present (proxy/CDN) — first hop is the originating client. Adapter-
|
|
43
47
|
// specific socket-address fallback (bun, node) is not standardized
|
|
44
48
|
// in Hono; deployments behind a proxy should always set xff. Without
|
|
45
49
|
// either we leave `ip` undefined and skip ip-bucketed checks rather
|
|
46
50
|
// than fabricate one.
|
|
47
|
-
const xff =
|
|
51
|
+
const xff = header(req, "x-forwarded-for");
|
|
48
52
|
const ip = xff?.split(",")[0]?.trim();
|
|
49
|
-
const userAgent =
|
|
53
|
+
const userAgent = header(req, "user-agent");
|
|
50
54
|
// Runs before auth-middleware, so this reaches public routes too (e.g.
|
|
51
55
|
// signup-request) — that's the whole point: the active UI locale must
|
|
52
56
|
// survive to anonymous callers, not just authenticated ones.
|
|
53
57
|
const locale = resolveHeaderLocale({
|
|
54
|
-
headerLocale:
|
|
55
|
-
acceptLanguage:
|
|
58
|
+
headerLocale: header(req, LOCALE_HEADER_NAME),
|
|
59
|
+
acceptLanguage: header(req, "accept-language"),
|
|
56
60
|
});
|
|
57
61
|
|
|
58
62
|
return {
|
|
@@ -65,6 +69,23 @@ export function buildRequestContextData(c: Context): RequestContextData {
|
|
|
65
69
|
};
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Builds the RequestContextData record for a Hono request. Thin wrapper
|
|
74
|
+
* around `buildRequestContextDataFromRequest(c.req.raw)` — kept as its own
|
|
75
|
+
* export because most call-sites (server.ts's httpRoute→systemQuery mount,
|
|
76
|
+
* `requestIdMiddleware` below) already hold a Hono `Context`.
|
|
77
|
+
*/
|
|
78
|
+
export function buildRequestContextData(c: Context): RequestContextData {
|
|
79
|
+
// Older Hono / adapter combos may leave c.req.raw unset even though it's
|
|
80
|
+
// typed as Request — degrade to a bare id pair (no signal/ip/ua/locale)
|
|
81
|
+
// instead of letting req.headers.get() throw on every request.
|
|
82
|
+
if (!c.req.raw) {
|
|
83
|
+
const requestId = requestContext.generateId();
|
|
84
|
+
return { requestId, correlationId: requestId };
|
|
85
|
+
}
|
|
86
|
+
return buildRequestContextDataFromRequest(c.req.raw);
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
/**
|
|
69
90
|
* Assigns a requestId + correlationId to every request and wraps execution
|
|
70
91
|
* in AsyncLocalStorage. Runs BEFORE auth — both ids are available even for
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.287.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "boot-time reachability warning for nav entries outside the app's sidebar allowlist, filter helper moved into the framework",
|
|
6
|
+
"detail": "Adds `warnOnUnreachableNavScreens(allNavQns, allowedNavQns, navAllowlistExempt?)`\nto packages/framework/src/engine/boot-validator/nav.ts, in the same\nnon-fatal console.warn style as the existing warnOnNavAccessInversion.\nThe rule checks screen reachability, not nav-QN membership: it builds\nthe set of screens reached by an allowlisted nav, then warns for every\nunallowlisted nav whose screen isn't in that set. A naive \"nav QN not\nin allowlist\" rule was tried first and measured against the real\nofflot-app schema (58 navs, 30 allowlisted) — it fired 28 warnings per\nboot, mostly app-shell leaves that intentionally re-target a screen an\nallowlisted nav already reaches, which would have buried the one real\nbug (offlot's VIN screen) in noise. The reachability rule fires 8 times\non the same schema, all of them either the real bug or exemptable.\nValidateBootOptions gains `navAllowlist?: ReadonlySet<string>` and\n`navAllowlistExempt?: ReadonlySet<string>`; when navAllowlist is set,\nvalidateBoot calls the new warning right after warnOnNavAccessInversion,\npassing navAllowlistExempt through so an app can mark navs whose screen\nis reachable outside the sidebar (e.g. as a sub-page of a generated\nsettings hub) without regenerating the noise.\nSeparately, `filterAppSchemaNavsByAllowlist` and `NavReparentOverride`\nmove from offlot-app's local copy into\npackages/renderer-web/src/layout/filter-app-schema-navs.ts and are now\nexported from @cosmicdrift/kumiko-renderer-web, reusing nav-tree.tsx's\nexisting (now exported) qualifyNavId instead of keeping a second copy\nof that qualification logic in sync across apps.\nfw#3019 covered offlot's app-local allowlist but not solon#113, which\nassigns navs to workspaces via `r.workspace({ nav: [...] })` and never\npasses navAllowlist — the warning stayed silent for that shape.\npackages/framework/src/engine/boot-validator/workspaces.ts gains\n`deriveNavAllowlistFromWorkspaces(allNavQns, allWorkspaceQns)`, which\nunions every `WorkspaceDefinition.nav` entry with every nav QN whose\n`NavDefinition.workspaces` self-assigns to at least one workspace — both\nfields already hold fully-qualified QNs (same as validateWorkspaces /\nvalidateNavs compare them), so no re-qualification step is needed.\n`resolveNavAllowlist(explicitAllowlist, allNavQns, allWorkspaceQns)`\nwraps it: an explicit `navAllowlist` always wins, otherwise the derived\nset is used only when the app has at least one workspace (an app with\nnone must produce no warnings), else `undefined` (no check runs).\nvalidateBoot now calls `resolveNavAllowlist` before\nwarnOnUnreachableNavScreens instead of gating on `options.navAllowlist`\ndirectly."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.285.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
DerivativeRendererPlugin,
|
|
4
|
+
VariantSpec,
|
|
5
|
+
} from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
3
6
|
import type { Registry } from "../../engine/types";
|
|
4
7
|
import { InternalError } from "../../errors";
|
|
5
8
|
import { createFileContext } from "../../files/file-handle";
|
|
6
9
|
import { createInMemoryFileProvider } from "../../files/in-memory-provider";
|
|
7
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createDerivativesContext,
|
|
12
|
+
type OverlayResolverPlugin,
|
|
13
|
+
resolveRenderer,
|
|
14
|
+
} from "../derivatives-context";
|
|
8
15
|
|
|
9
16
|
const FILE_REF_ID = "11111111-1111-4111-8111-111111111111";
|
|
10
17
|
const TENANT_ID = "22222222-2222-4222-8222-222222222222";
|
|
@@ -39,9 +46,19 @@ function countingRenderer(): {
|
|
|
39
46
|
// it: variant()'s two mandatory filters (tenantId, isDeleted) are real
|
|
40
47
|
// integration-test territory, but a fake that returns the row unconditionally
|
|
41
48
|
// would let those filters be deleted here without a single red test.
|
|
42
|
-
function fakeDbWithFileRef(row: {
|
|
49
|
+
function fakeDbWithFileRef(row: {
|
|
50
|
+
storageKey: string;
|
|
51
|
+
mimeType: string;
|
|
52
|
+
entityType?: string | null;
|
|
53
|
+
entityId?: string | null;
|
|
54
|
+
fieldName?: string | null;
|
|
55
|
+
}): unknown {
|
|
43
56
|
const canned: Record<string, unknown> = {
|
|
44
|
-
|
|
57
|
+
storageKey: row.storageKey,
|
|
58
|
+
mimeType: row.mimeType,
|
|
59
|
+
entityType: row.entityType ?? null,
|
|
60
|
+
entityId: row.entityId ?? null,
|
|
61
|
+
fieldName: row.fieldName ?? null,
|
|
45
62
|
id: FILE_REF_ID,
|
|
46
63
|
tenantId: TENANT_ID,
|
|
47
64
|
isDeleted: false,
|
|
@@ -214,3 +231,139 @@ describe("createDerivativesContext — variant()", () => {
|
|
|
214
231
|
expect(result.mimeType).toBe("image/jpeg");
|
|
215
232
|
});
|
|
216
233
|
});
|
|
234
|
+
|
|
235
|
+
describe("createDerivativesContext — variant() overlay token resolution", () => {
|
|
236
|
+
const QR_LAYER = {
|
|
237
|
+
kind: "qr",
|
|
238
|
+
dataToken: "public-url",
|
|
239
|
+
widthPct: 0.2,
|
|
240
|
+
gravity: "center",
|
|
241
|
+
} as const;
|
|
242
|
+
|
|
243
|
+
function specForwardingRenderer(): {
|
|
244
|
+
plugin: DerivativeRendererPlugin;
|
|
245
|
+
specs: () => VariantSpec[];
|
|
246
|
+
} {
|
|
247
|
+
const specs: VariantSpec[] = [];
|
|
248
|
+
const plugin: DerivativeRendererPlugin = {
|
|
249
|
+
render: async (_input, spec) => {
|
|
250
|
+
specs.push(spec);
|
|
251
|
+
return new Uint8Array([1]);
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
return { plugin, specs: () => specs };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function setupWithOverlayResolver(resolve: OverlayResolverPlugin["resolve"]) {
|
|
258
|
+
const provider = createInMemoryFileProvider();
|
|
259
|
+
await provider.write("tenant/photo.jpg", new Uint8Array([1, 2, 3]), "image/jpeg");
|
|
260
|
+
const files = createFileContext(() => Promise.resolve(provider));
|
|
261
|
+
const { plugin: renderPlugin, specs } = specForwardingRenderer();
|
|
262
|
+
const registry = fakeRegistry([
|
|
263
|
+
{ entityName: "image/*", options: renderPlugin },
|
|
264
|
+
{ entityName: "vehicle", options: { resolve } },
|
|
265
|
+
]);
|
|
266
|
+
const db = fakeDbWithFileRef({
|
|
267
|
+
storageKey: "tenant/photo.jpg",
|
|
268
|
+
mimeType: "image/jpeg",
|
|
269
|
+
entityType: "vehicle",
|
|
270
|
+
entityId: "vehicle-1",
|
|
271
|
+
fieldName: "publicImage",
|
|
272
|
+
});
|
|
273
|
+
const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
|
|
274
|
+
return { ctx, specs };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
test("a registered resolver's value lands in resolvedOverlays, never in overlays, before the renderer sees it", async () => {
|
|
278
|
+
const { ctx, specs } = await setupWithOverlayResolver(async (args) => {
|
|
279
|
+
expect(args).toEqual({
|
|
280
|
+
entityId: "vehicle-1",
|
|
281
|
+
tenantId: TENANT_ID,
|
|
282
|
+
fieldName: "publicImage",
|
|
283
|
+
dataToken: "public-url",
|
|
284
|
+
});
|
|
285
|
+
return "https://example.com/v/vehicle-1";
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await ctx.variant(FILE_REF_ID, { overlays: [QR_LAYER] }, "card");
|
|
289
|
+
|
|
290
|
+
expect(specs()).toHaveLength(1);
|
|
291
|
+
expect(specs()[0]?.overlays).toBeUndefined();
|
|
292
|
+
expect(specs()[0]?.resolvedOverlays).toEqual([
|
|
293
|
+
{ kind: "qr", data: "https://example.com/v/vehicle-1", widthPct: 0.2, gravity: "center" },
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("no resolver registered for the entityType throws InternalError instead of rendering without the QR", async () => {
|
|
298
|
+
const provider = createInMemoryFileProvider();
|
|
299
|
+
await provider.write("tenant/photo.jpg", new Uint8Array([1, 2, 3]), "image/jpeg");
|
|
300
|
+
const files = createFileContext(() => Promise.resolve(provider));
|
|
301
|
+
const { plugin: renderPlugin } = specForwardingRenderer();
|
|
302
|
+
const registry = fakeRegistry([{ entityName: "image/*", options: renderPlugin }]);
|
|
303
|
+
const db = fakeDbWithFileRef({
|
|
304
|
+
storageKey: "tenant/photo.jpg",
|
|
305
|
+
mimeType: "image/jpeg",
|
|
306
|
+
entityType: "vehicle",
|
|
307
|
+
entityId: "vehicle-1",
|
|
308
|
+
fieldName: "publicImage",
|
|
309
|
+
});
|
|
310
|
+
const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
|
|
311
|
+
|
|
312
|
+
const err = await ctx.variant(FILE_REF_ID, { overlays: [QR_LAYER] }, "card").catch((e) => e);
|
|
313
|
+
expect(err).toBeInstanceOf(InternalError);
|
|
314
|
+
expect((err as InternalError).httpStatus).toBe(500);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("a resolver returning an empty string throws instead of silently dropping the QR", async () => {
|
|
318
|
+
const { ctx } = await setupWithOverlayResolver(async () => "");
|
|
319
|
+
|
|
320
|
+
const err = await ctx.variant(FILE_REF_ID, { overlays: [QR_LAYER] }, "card").catch((e) => e);
|
|
321
|
+
expect(err).toBeInstanceOf(InternalError);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a qr overlay on a FileRef missing entityType/entityId/fieldName throws instead of skipping resolution", async () => {
|
|
325
|
+
const provider = createInMemoryFileProvider();
|
|
326
|
+
await provider.write("tenant/photo.jpg", new Uint8Array([1, 2, 3]), "image/jpeg");
|
|
327
|
+
const files = createFileContext(() => Promise.resolve(provider));
|
|
328
|
+
const { plugin: renderPlugin } = specForwardingRenderer();
|
|
329
|
+
const registry = fakeRegistry([{ entityName: "image/*", options: renderPlugin }]);
|
|
330
|
+
const db = fakeDbWithFileRef({ storageKey: "tenant/photo.jpg", mimeType: "image/jpeg" });
|
|
331
|
+
const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
|
|
332
|
+
|
|
333
|
+
const err = await ctx.variant(FILE_REF_ID, { overlays: [QR_LAYER] }, "card").catch((e) => e);
|
|
334
|
+
expect(err).toBeInstanceOf(InternalError);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("an image-only overlay list resolves without any entityType/entityId/fieldName", async () => {
|
|
338
|
+
const provider = createInMemoryFileProvider();
|
|
339
|
+
await provider.write("tenant/photo.jpg", new Uint8Array([1, 2, 3]), "image/jpeg");
|
|
340
|
+
const files = createFileContext(() => Promise.resolve(provider));
|
|
341
|
+
const { plugin: renderPlugin, specs } = specForwardingRenderer();
|
|
342
|
+
const registry = fakeRegistry([{ entityName: "image/*", options: renderPlugin }]);
|
|
343
|
+
const db = fakeDbWithFileRef({ storageKey: "tenant/photo.jpg", mimeType: "image/jpeg" });
|
|
344
|
+
const ctx = createDerivativesContext({ files, registry, db, tenantId: TENANT_ID });
|
|
345
|
+
const imageLayer = {
|
|
346
|
+
kind: "image",
|
|
347
|
+
imageBase64: "aGVsbG8=",
|
|
348
|
+
widthPct: 0.3,
|
|
349
|
+
gravity: "south-east",
|
|
350
|
+
} as const;
|
|
351
|
+
|
|
352
|
+
await ctx.variant(FILE_REF_ID, { overlays: [imageLayer] }, "card");
|
|
353
|
+
|
|
354
|
+
expect(specs()[0]?.resolvedOverlays).toEqual([imageLayer]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("overlays change the variant suffix without touching the source storage key", async () => {
|
|
358
|
+
const { ctx } = await setupWithOverlayResolver(async () => "https://example.com/v/vehicle-1");
|
|
359
|
+
const provider = createInMemoryFileProvider();
|
|
360
|
+
await provider.write("tenant/photo.jpg", new Uint8Array([1, 2, 3]), "image/jpeg");
|
|
361
|
+
|
|
362
|
+
const withOverlay = await ctx.variant(FILE_REF_ID, { overlays: [QR_LAYER] }, "card");
|
|
363
|
+
const withoutOverlay = await ctx.variant(FILE_REF_ID, {}, "card");
|
|
364
|
+
|
|
365
|
+
expect(withOverlay.storageKey).not.toBe(withoutOverlay.storageKey);
|
|
366
|
+
expect(withOverlay.storageKey.startsWith("tenant/photo.card-")).toBe(true);
|
|
367
|
+
expect(withoutOverlay.storageKey.startsWith("tenant/photo.card-")).toBe(true);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DerivativeRendererPlugin,
|
|
3
3
|
DerivativesContext,
|
|
4
|
+
OverlayLayer,
|
|
5
|
+
ResolvedOverlayLayer,
|
|
4
6
|
VariantSpec,
|
|
5
7
|
} from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
6
8
|
import { type AnyDb, fetchOne } from "../bun-db/query";
|
|
7
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
EXT_DERIVATIVE_OVERLAY_RESOLVER,
|
|
11
|
+
EXT_DERIVATIVE_RENDERER,
|
|
12
|
+
} from "../engine/extension-names";
|
|
8
13
|
import type { Registry, TenantId } from "../engine/types";
|
|
9
14
|
import { InternalError, NotFoundError } from "../errors";
|
|
10
15
|
import type { FileContext } from "../files/file-handle";
|
|
@@ -60,13 +65,145 @@ export function resolveRenderer(
|
|
|
60
65
|
return undefined;
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
export type OverlayResolverArgs = {
|
|
69
|
+
readonly entityId: string;
|
|
70
|
+
readonly tenantId: TenantId;
|
|
71
|
+
readonly fieldName: string;
|
|
72
|
+
readonly dataToken: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type OverlayResolverPlugin = {
|
|
76
|
+
readonly resolve: (args: OverlayResolverArgs) => string | Promise<string>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// extension-usage `options` is engine-payload (unknown) — structurally validate
|
|
80
|
+
// instead of casting blind, same pattern as isDerivativeRendererPlugin.
|
|
81
|
+
function isOverlayResolverPlugin(o: unknown): o is OverlayResolverPlugin {
|
|
82
|
+
return typeof o === "object" && o !== null && "resolve" in o && typeof o.resolve === "function";
|
|
83
|
+
}
|
|
84
|
+
|
|
63
85
|
type FileRefRow = {
|
|
64
86
|
readonly storageKey: string;
|
|
65
87
|
readonly mimeType: string;
|
|
88
|
+
readonly entityType: string | null;
|
|
89
|
+
readonly entityId: string | null;
|
|
90
|
+
readonly fieldName: string | null;
|
|
66
91
|
};
|
|
67
92
|
|
|
68
93
|
function isFileRefRow(row: Record<string, unknown>): row is FileRefRow {
|
|
69
|
-
return
|
|
94
|
+
return (
|
|
95
|
+
typeof row["storageKey"] === "string" &&
|
|
96
|
+
typeof row["mimeType"] === "string" &&
|
|
97
|
+
(row["entityType"] === null || typeof row["entityType"] === "string") &&
|
|
98
|
+
(row["entityId"] === null || typeof row["entityId"] === "string") &&
|
|
99
|
+
(row["fieldName"] === null || typeof row["fieldName"] === "string")
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A qr overlay needs the FileRef's field identity to pick a resolver and to
|
|
104
|
+
// hand the resolver something to look up — narrows the 3 nullable columns
|
|
105
|
+
// together so the throw below can name whichever one is actually missing.
|
|
106
|
+
type FieldIdentifiedFileRef = FileRefRow & {
|
|
107
|
+
readonly entityType: string;
|
|
108
|
+
readonly entityId: string;
|
|
109
|
+
readonly fieldName: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function hasFieldIdentity(row: FileRefRow): row is FieldIdentifiedFileRef {
|
|
113
|
+
return row.entityType !== null && row.entityId !== null && row.fieldName !== null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type QrOverlayContext = {
|
|
117
|
+
readonly row: FieldIdentifiedFileRef;
|
|
118
|
+
readonly plugin: OverlayResolverPlugin;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Looks up the entityType's resolver once per variant() call (not once per
|
|
122
|
+
// qr layer) and fails before any layer is touched — a spec with 3 qr layers
|
|
123
|
+
// and no registration throws once, not on the first layer only.
|
|
124
|
+
function resolveQrOverlayContext(row: FileRefRow, registry: Registry): QrOverlayContext {
|
|
125
|
+
if (!hasFieldIdentity(row)) {
|
|
126
|
+
throw new InternalError({
|
|
127
|
+
message:
|
|
128
|
+
"derivatives.variant: a qr overlay requires the FileRef's entityType/entityId/fieldName, but at least one is missing.",
|
|
129
|
+
details: { entityType: row.entityType, entityId: row.entityId, fieldName: row.fieldName },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const usage = registry
|
|
133
|
+
.getExtensionUsages(EXT_DERIVATIVE_OVERLAY_RESOLVER)
|
|
134
|
+
.find((u) => u.entityName === row.entityType);
|
|
135
|
+
if (!usage) {
|
|
136
|
+
throw new InternalError({
|
|
137
|
+
message: `derivatives.variant: no ${EXT_DERIVATIVE_OVERLAY_RESOLVER} registered for entityType "${row.entityType}".`,
|
|
138
|
+
details: { entityType: row.entityType },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (!isOverlayResolverPlugin(usage.options)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`derivatives.variant: "${usage.entityName}" registered ${EXT_DERIVATIVE_OVERLAY_RESOLVER} without a resolve(args) — extension options must be an OverlayResolverPlugin.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return { row, plugin: usage.options };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function resolveQrLayer(
|
|
150
|
+
layer: Extract<OverlayLayer, { kind: "qr" }>,
|
|
151
|
+
context: QrOverlayContext,
|
|
152
|
+
tenantId: TenantId,
|
|
153
|
+
): Promise<ResolvedOverlayLayer> {
|
|
154
|
+
const { dataToken, ...placement } = layer;
|
|
155
|
+
const data = await context.plugin.resolve({
|
|
156
|
+
entityId: context.row.entityId,
|
|
157
|
+
tenantId,
|
|
158
|
+
fieldName: context.row.fieldName,
|
|
159
|
+
dataToken,
|
|
160
|
+
});
|
|
161
|
+
if (!data) {
|
|
162
|
+
throw new InternalError({
|
|
163
|
+
message: `derivatives.variant: ${EXT_DERIVATIVE_OVERLAY_RESOLVER} resolved dataToken "${dataToken}" to an empty value for entityType "${context.row.entityType}".`,
|
|
164
|
+
details: { entityType: context.row.entityType, dataToken },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return { ...placement, data };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A non-qr layer is already ResolvedOverlayLayer-shaped (see
|
|
171
|
+
// ResolvedOverlayLayer's `image` member) — this only exists because TS can't
|
|
172
|
+
// derive that from `layer.kind !== "qr"` across a Promise.all/map boundary.
|
|
173
|
+
function resolveOverlayLayer(
|
|
174
|
+
layer: OverlayLayer,
|
|
175
|
+
qrContext: QrOverlayContext | undefined,
|
|
176
|
+
tenantId: TenantId,
|
|
177
|
+
): Promise<ResolvedOverlayLayer> | ResolvedOverlayLayer {
|
|
178
|
+
if (layer.kind !== "qr") return layer;
|
|
179
|
+
if (!qrContext) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"derivatives.variant: unreachable — resolveOverlaySpec only omits a qr context when the spec has no qr layer.",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return resolveQrLayer(layer, qrContext, tenantId);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Resolves every `qr` layer's dataToken to a concrete value and moves the
|
|
188
|
+
// whole overlay list to `resolvedOverlays` — BEFORE variantSuffix() hashes
|
|
189
|
+
// the spec, so a resolver whose target changes (e.g. a base-URL rotation)
|
|
190
|
+
// invalidates the cache instead of serving a stale target under the old key.
|
|
191
|
+
async function resolveOverlaySpec(
|
|
192
|
+
spec: VariantSpec,
|
|
193
|
+
row: FileRefRow,
|
|
194
|
+
registry: Registry,
|
|
195
|
+
tenantId: TenantId,
|
|
196
|
+
): Promise<VariantSpec> {
|
|
197
|
+
if (!spec.overlays || spec.overlays.length === 0) return spec;
|
|
198
|
+
|
|
199
|
+
const qrContext = spec.overlays.some((layer) => layer.kind === "qr")
|
|
200
|
+
? resolveQrOverlayContext(row, registry)
|
|
201
|
+
: undefined;
|
|
202
|
+
|
|
203
|
+
const resolvedOverlays = await Promise.all(
|
|
204
|
+
spec.overlays.map((layer) => resolveOverlayLayer(layer, qrContext, tenantId)),
|
|
205
|
+
);
|
|
206
|
+
return { ...spec, overlays: undefined, resolvedOverlays };
|
|
70
207
|
}
|
|
71
208
|
|
|
72
209
|
// sourceMimeType is client-controlled (it's `file.type` off the upload — see
|
|
@@ -144,16 +281,20 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
|
|
|
144
281
|
});
|
|
145
282
|
}
|
|
146
283
|
|
|
284
|
+
// Resolves qr dataTokens to concrete values BEFORE the spec is hashed
|
|
285
|
+
// — see resolveOverlaySpec.
|
|
286
|
+
const resolvedSpec = await resolveOverlaySpec(spec, row, deps.registry, deps.tenantId);
|
|
287
|
+
|
|
147
288
|
const src = deps.files.ref(row.storageKey);
|
|
148
289
|
// ponytail: derived key keeps the source extension regardless of the
|
|
149
290
|
// spec's format — widen deriveKey if a storage backend ever routes on
|
|
150
291
|
// extension.
|
|
151
|
-
const target = src.derive(variantSuffix(name,
|
|
292
|
+
const target = src.derive(variantSuffix(name, resolvedSpec));
|
|
152
293
|
// Belt-and-suspenders: variantSuffix already rejects an unsafe name,
|
|
153
294
|
// this catches a traversal segment reaching the key through any other
|
|
154
295
|
// path (e.g. a future deriveKey change).
|
|
155
296
|
assertSafeStorageKey(target.key);
|
|
156
|
-
const mimeType = outputMimeType(
|
|
297
|
+
const mimeType = outputMimeType(resolvedSpec, row.mimeType);
|
|
157
298
|
|
|
158
299
|
// ponytail: exists→render→write is a TOCTOU window under concurrent
|
|
159
300
|
// requests for the same variant (duplicate render + write). Ceiling:
|
|
@@ -168,7 +309,7 @@ export function createDerivativesContext(deps: DerivativesContextDeps): Derivati
|
|
|
168
309
|
}
|
|
169
310
|
|
|
170
311
|
const original = await src.read();
|
|
171
|
-
const result = await renderer.render(original,
|
|
312
|
+
const result = await renderer.render(original, resolvedSpec, row.mimeType);
|
|
172
313
|
await target.write(result, mimeType);
|
|
173
314
|
return {
|
|
174
315
|
storageKey: target.key,
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import type { NavDefinition, WorkspaceDefinition } from "../../types";
|
|
3
|
+
import { warnOnUnreachableNavScreens } from "../nav";
|
|
4
|
+
import { deriveNavAllowlistFromWorkspaces, resolveNavAllowlist } from "../workspaces";
|
|
5
|
+
|
|
6
|
+
function navMap(
|
|
7
|
+
entries: ReadonlyArray<[string, NavDefinition & { readonly featureName: string }]>,
|
|
8
|
+
): Map<string, NavDefinition & { readonly featureName: string }> {
|
|
9
|
+
return new Map(entries);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function workspaceMap(
|
|
13
|
+
entries: ReadonlyArray<[string, WorkspaceDefinition & { readonly featureName: string }]>,
|
|
14
|
+
): Map<string, WorkspaceDefinition & { readonly featureName: string }> {
|
|
15
|
+
return new Map(entries);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("warnOnUnreachableNavScreens", () => {
|
|
19
|
+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
warnSpy.mockRestore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("does not warn for an unallowlisted nav whose screen is reached by an allowlisted nav", () => {
|
|
30
|
+
const allNavQns = navMap([
|
|
31
|
+
[
|
|
32
|
+
"vehicles:nav:a",
|
|
33
|
+
{
|
|
34
|
+
id: "a",
|
|
35
|
+
label: "A",
|
|
36
|
+
screen: "vehicles:screen:vin",
|
|
37
|
+
featureName: "vehicles",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
"vehicles:nav:b",
|
|
42
|
+
{
|
|
43
|
+
id: "b",
|
|
44
|
+
label: "B",
|
|
45
|
+
screen: "vehicles:screen:vin",
|
|
46
|
+
featureName: "vehicles",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
warnOnUnreachableNavScreens(allNavQns, new Set(["vehicles:nav:a"]));
|
|
52
|
+
|
|
53
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("warns only for the unallowlisted nav whose screen nobody else reaches", () => {
|
|
57
|
+
const allNavQns = navMap([
|
|
58
|
+
[
|
|
59
|
+
"vehicles:nav:a",
|
|
60
|
+
{
|
|
61
|
+
id: "a",
|
|
62
|
+
label: "A",
|
|
63
|
+
screen: "vehicles:screen:vin",
|
|
64
|
+
featureName: "vehicles",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
"vehicles:nav:b",
|
|
69
|
+
{
|
|
70
|
+
id: "b",
|
|
71
|
+
label: "B",
|
|
72
|
+
screen: "vehicles:screen:vin",
|
|
73
|
+
featureName: "vehicles",
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
"vehicles:nav:c",
|
|
78
|
+
{
|
|
79
|
+
id: "c",
|
|
80
|
+
label: "C",
|
|
81
|
+
screen: "vehicles:screen:title",
|
|
82
|
+
featureName: "vehicles",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
warnOnUnreachableNavScreens(allNavQns, new Set(["vehicles:nav:a"]));
|
|
88
|
+
|
|
89
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
90
|
+
const msg = warnSpy.mock.calls[0]![0] as string;
|
|
91
|
+
expect(msg).toContain("vehicles:nav:c");
|
|
92
|
+
expect(msg).toContain("vehicles");
|
|
93
|
+
expect(msg).toContain("vehicles:screen:title");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("navAllowlistExempt suppresses the warning for an exempted nav", () => {
|
|
97
|
+
const allNavQns = navMap([
|
|
98
|
+
[
|
|
99
|
+
"vehicles:nav:a",
|
|
100
|
+
{
|
|
101
|
+
id: "a",
|
|
102
|
+
label: "A",
|
|
103
|
+
screen: "vehicles:screen:vin",
|
|
104
|
+
featureName: "vehicles",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
"vehicles:nav:c",
|
|
109
|
+
{
|
|
110
|
+
id: "c",
|
|
111
|
+
label: "C",
|
|
112
|
+
screen: "vehicles:screen:title",
|
|
113
|
+
featureName: "vehicles",
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
warnOnUnreachableNavScreens(
|
|
119
|
+
allNavQns,
|
|
120
|
+
new Set(["vehicles:nav:a"]),
|
|
121
|
+
new Set(["vehicles:nav:c"]),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("never warns for a nav without a screen (pure grouping entry)", () => {
|
|
128
|
+
const allNavQns = navMap([
|
|
129
|
+
[
|
|
130
|
+
"vehicles:nav:group",
|
|
131
|
+
{
|
|
132
|
+
id: "group",
|
|
133
|
+
label: "Group",
|
|
134
|
+
featureName: "vehicles",
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
warnOnUnreachableNavScreens(allNavQns, new Set());
|
|
140
|
+
|
|
141
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("resolveNavAllowlist (fw#3019 solon#113: workspace-derived allowlist)", () => {
|
|
146
|
+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
warnSpy.mockRestore();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("does not warn for a nav listed in a workspace's nav array", () => {
|
|
157
|
+
const allNavQns = navMap([
|
|
158
|
+
[
|
|
159
|
+
"vehicles:nav:a",
|
|
160
|
+
{ id: "a", label: "A", screen: "vehicles:screen:vin", featureName: "vehicles" },
|
|
161
|
+
],
|
|
162
|
+
]);
|
|
163
|
+
const allWorkspaceQns = workspaceMap([
|
|
164
|
+
[
|
|
165
|
+
"vehicles:workspace:main",
|
|
166
|
+
{ id: "main", label: "Main", nav: ["vehicles:nav:a"], featureName: "vehicles" },
|
|
167
|
+
],
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
const allowlist = resolveNavAllowlist(undefined, allNavQns, allWorkspaceQns);
|
|
171
|
+
warnOnUnreachableNavScreens(allNavQns, allowlist ?? new Set());
|
|
172
|
+
|
|
173
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("does not warn for a nav that self-assigns via workspaces", () => {
|
|
177
|
+
const allNavQns = navMap([
|
|
178
|
+
[
|
|
179
|
+
"vehicles:nav:a",
|
|
180
|
+
{
|
|
181
|
+
id: "a",
|
|
182
|
+
label: "A",
|
|
183
|
+
screen: "vehicles:screen:vin",
|
|
184
|
+
workspaces: ["vehicles:workspace:main"],
|
|
185
|
+
featureName: "vehicles",
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
]);
|
|
189
|
+
const allWorkspaceQns = workspaceMap([
|
|
190
|
+
["vehicles:workspace:main", { id: "main", label: "Main", featureName: "vehicles" }],
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
const allowlist = resolveNavAllowlist(undefined, allNavQns, allWorkspaceQns);
|
|
194
|
+
warnOnUnreachableNavScreens(allNavQns, allowlist ?? new Set());
|
|
195
|
+
|
|
196
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("warns for a nav in no workspace whose screen nobody else reaches (solon#113)", () => {
|
|
200
|
+
const allNavQns = navMap([
|
|
201
|
+
[
|
|
202
|
+
"vehicles:nav:a",
|
|
203
|
+
{ id: "a", label: "A", screen: "vehicles:screen:vin", featureName: "vehicles" },
|
|
204
|
+
],
|
|
205
|
+
[
|
|
206
|
+
"vehicles:nav:orphan",
|
|
207
|
+
{ id: "orphan", label: "Orphan", screen: "vehicles:screen:title", featureName: "vehicles" },
|
|
208
|
+
],
|
|
209
|
+
]);
|
|
210
|
+
const allWorkspaceQns = workspaceMap([
|
|
211
|
+
[
|
|
212
|
+
"vehicles:workspace:main",
|
|
213
|
+
{ id: "main", label: "Main", nav: ["vehicles:nav:a"], featureName: "vehicles" },
|
|
214
|
+
],
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
const allowlist = resolveNavAllowlist(undefined, allNavQns, allWorkspaceQns);
|
|
218
|
+
warnOnUnreachableNavScreens(allNavQns, allowlist ?? new Set());
|
|
219
|
+
|
|
220
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
221
|
+
expect(warnSpy.mock.calls[0]![0] as string).toContain("vehicles:nav:orphan");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("does not warn when the app has no workspaces at all", () => {
|
|
225
|
+
const allNavQns = navMap([
|
|
226
|
+
[
|
|
227
|
+
"vehicles:nav:a",
|
|
228
|
+
{ id: "a", label: "A", screen: "vehicles:screen:vin", featureName: "vehicles" },
|
|
229
|
+
],
|
|
230
|
+
]);
|
|
231
|
+
const allWorkspaceQns = workspaceMap([]);
|
|
232
|
+
|
|
233
|
+
const allowlist = resolveNavAllowlist(undefined, allNavQns, allWorkspaceQns);
|
|
234
|
+
|
|
235
|
+
expect(allowlist).toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("an explicit navAllowlist wins over the workspace-derived one", () => {
|
|
239
|
+
const allNavQns = navMap([
|
|
240
|
+
[
|
|
241
|
+
"vehicles:nav:a",
|
|
242
|
+
{ id: "a", label: "A", screen: "vehicles:screen:vin", featureName: "vehicles" },
|
|
243
|
+
],
|
|
244
|
+
]);
|
|
245
|
+
const allWorkspaceQns = workspaceMap([
|
|
246
|
+
[
|
|
247
|
+
"vehicles:workspace:main",
|
|
248
|
+
{ id: "main", label: "Main", nav: ["vehicles:nav:a"], featureName: "vehicles" },
|
|
249
|
+
],
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
const allowlist = resolveNavAllowlist(new Set(), allNavQns, allWorkspaceQns);
|
|
253
|
+
warnOnUnreachableNavScreens(allNavQns, allowlist ?? new Set());
|
|
254
|
+
|
|
255
|
+
expect(allowlist).toEqual(new Set());
|
|
256
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
257
|
+
expect(warnSpy.mock.calls[0]![0] as string).toContain("vehicles:nav:a");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("deriveNavAllowlistFromWorkspaces", () => {
|
|
262
|
+
test("unions workspace.nav entries and self-assigning nav.workspaces entries", () => {
|
|
263
|
+
const allNavQns = navMap([
|
|
264
|
+
[
|
|
265
|
+
"vehicles:nav:a",
|
|
266
|
+
{ id: "a", label: "A", screen: "vehicles:screen:vin", featureName: "vehicles" },
|
|
267
|
+
],
|
|
268
|
+
[
|
|
269
|
+
"vehicles:nav:b",
|
|
270
|
+
{
|
|
271
|
+
id: "b",
|
|
272
|
+
label: "B",
|
|
273
|
+
screen: "vehicles:screen:title",
|
|
274
|
+
workspaces: ["vehicles:workspace:main"],
|
|
275
|
+
featureName: "vehicles",
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
]);
|
|
279
|
+
const allWorkspaceQns = workspaceMap([
|
|
280
|
+
[
|
|
281
|
+
"vehicles:workspace:main",
|
|
282
|
+
{ id: "main", label: "Main", nav: ["vehicles:nav:a"], featureName: "vehicles" },
|
|
283
|
+
],
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
const allowlist = deriveNavAllowlistFromWorkspaces(allNavQns, allWorkspaceQns);
|
|
287
|
+
|
|
288
|
+
expect(allowlist).toEqual(new Set(["vehicles:nav:a", "vehicles:nav:b"]));
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
validateNavCycles,
|
|
47
47
|
validateNavs,
|
|
48
48
|
warnOnNavAccessInversion,
|
|
49
|
+
warnOnUnreachableNavScreens,
|
|
49
50
|
} from "./nav";
|
|
50
51
|
import { collectClaimKeys, validateOwnershipRules } from "./ownership";
|
|
51
52
|
import { validateParentRefs } from "./parent-ref";
|
|
@@ -66,6 +67,7 @@ import {
|
|
|
66
67
|
import { warnOnMissingSecurityBaseline } from "./security-baseline";
|
|
67
68
|
import {
|
|
68
69
|
collectWorkspaceQns,
|
|
70
|
+
resolveNavAllowlist,
|
|
69
71
|
validateDefaultWorkspaceUniqueness,
|
|
70
72
|
validateWorkspaces,
|
|
71
73
|
} from "./workspaces";
|
|
@@ -85,6 +87,17 @@ export type ValidateBootOptions = {
|
|
|
85
87
|
* prod warning that nobody can silence per-role isn't worth the noise
|
|
86
88
|
* it generates on every boot. */
|
|
87
89
|
readonly warnOnUniqueAccessRoles?: boolean;
|
|
90
|
+
/** QNs the app's sidebar allowlist admits. When set, every declared nav
|
|
91
|
+
* whose screen isn't reachable from any allowlisted nav entry gets a
|
|
92
|
+
* boot-time warning — that screen would otherwise be silently
|
|
93
|
+
* unreachable via the sidebar (fw#3019). When omitted and the app has
|
|
94
|
+
* workspaces, the allowlist is derived from `r.workspace({ nav })` and
|
|
95
|
+
* `r.nav({ workspaces })` assignments instead. */
|
|
96
|
+
readonly navAllowlist?: ReadonlySet<string>;
|
|
97
|
+
/** Nav QNs deliberately left out of the sidebar allowlist because their
|
|
98
|
+
* screen is reachable another way (e.g. as a sub-page of a generated
|
|
99
|
+
* settings hub). Suppresses the navAllowlist warning for these QNs. */
|
|
100
|
+
readonly navAllowlistExempt?: ReadonlySet<string>;
|
|
88
101
|
};
|
|
89
102
|
|
|
90
103
|
/**
|
|
@@ -226,6 +239,14 @@ export function validateBoot(
|
|
|
226
239
|
|
|
227
240
|
validateNavCycles(allNavQns);
|
|
228
241
|
warnOnNavAccessInversion(allNavQns);
|
|
242
|
+
const effectiveNavAllowlist = resolveNavAllowlist(
|
|
243
|
+
options?.navAllowlist,
|
|
244
|
+
allNavQns,
|
|
245
|
+
allWorkspaceQns,
|
|
246
|
+
);
|
|
247
|
+
if (effectiveNavAllowlist !== undefined) {
|
|
248
|
+
warnOnUnreachableNavScreens(allNavQns, effectiveNavAllowlist, options?.navAllowlistExempt);
|
|
249
|
+
}
|
|
229
250
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
230
251
|
validateI18nSurfaceKeys(features);
|
|
231
252
|
validateEntityListScreens(features);
|
|
@@ -171,6 +171,50 @@ export function warnOnNavAccessInversion(
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// `NavDefinition.screen` is authored already-qualified ("<feature>:screen:
|
|
175
|
+
// <id>", see packages/types/src/nav.ts) — this only guards a short id
|
|
176
|
+
// slipping through, since a wrong qualification here would make every nav
|
|
177
|
+
// look orphaned (28 false positives on the real offlot-app schema instead
|
|
178
|
+
// of the intended 8, see fw#3019 measurement).
|
|
179
|
+
function qualifyNavScreenQn(featureName: string, screen: string): string {
|
|
180
|
+
return screen.includes(":screen:") ? screen : `${featureName}:screen:${screen}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// fw#3019: warn (never throw — a screen deliberately left out of the app's
|
|
184
|
+
// sidebar is legitimate) when a declared nav's screen isn't reachable from
|
|
185
|
+
// any allowlisted nav entry. Checking "nav QN not in allowlist" directly
|
|
186
|
+
// warned 28x on the real offlot-app schema (58 navs, 30 allowed) — most of
|
|
187
|
+
// them app-shell leaves that intentionally re-target a screen another,
|
|
188
|
+
// allowlisted nav already reaches. Reachability warns only for a screen no
|
|
189
|
+
// allowlisted nav points at, which is the actual "unreachable via sidebar"
|
|
190
|
+
// bug (solon#113, offlot's VIN screen in pilot).
|
|
191
|
+
export function warnOnUnreachableNavScreens(
|
|
192
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
193
|
+
allowedNavQns: ReadonlySet<string>,
|
|
194
|
+
navAllowlistExempt: ReadonlySet<string> = new Set(),
|
|
195
|
+
): void {
|
|
196
|
+
const reachableScreenQns = new Set<string>();
|
|
197
|
+
for (const [qn, navDef] of allNavQns) {
|
|
198
|
+
if (!allowedNavQns.has(qn) || navDef.screen === undefined) continue;
|
|
199
|
+
reachableScreenQns.add(qualifyNavScreenQn(navDef.featureName, navDef.screen));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const [qn, navDef] of allNavQns) {
|
|
203
|
+
if (allowedNavQns.has(qn) || navDef.screen === undefined || navAllowlistExempt.has(qn)) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const screenQn = qualifyNavScreenQn(navDef.featureName, navDef.screen);
|
|
207
|
+
if (reachableScreenQns.has(screenQn)) continue;
|
|
208
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
209
|
+
console.warn(
|
|
210
|
+
`[kumiko:boot] Nav entry "${qn}" declared by feature "${navDef.featureName}" points at ` +
|
|
211
|
+
`screen "${screenQn}", which no allowlisted nav entry reaches — it is unreachable via ` +
|
|
212
|
+
`the sidebar. If this is intentional (e.g. reachable through a generated hub), add ` +
|
|
213
|
+
`"${qn}" to navAllowlistExempt; otherwise add it to the allowlist.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
174
218
|
// Roles we recognise at boot time. The framework has no explicit
|
|
175
219
|
// role-registry (r.defineRoles is a type helper only), so we synthesise
|
|
176
220
|
// one from every handler-access rule plus the "all"/"system" built-ins.
|
|
@@ -56,11 +56,11 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
56
56
|
const fieldsByName = entity.fields;
|
|
57
57
|
|
|
58
58
|
for (const [fieldName, field] of Object.entries(fieldsByName)) {
|
|
59
|
-
// ResolvedPiiFlags
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
59
|
+
// ResolvedPiiFlags properties are type-level optional. On field defs
|
|
60
|
+
// not extended via "& ResolvedPiiFlags" (Boolean, Money, Reference,
|
|
61
|
+
// Embedded), property access returns undefined at runtime. TS
|
|
62
|
+
// compile-time validation already rejected that case elsewhere →
|
|
63
|
+
// the cast is safe.
|
|
64
64
|
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
65
65
|
|
|
66
66
|
const hasPii = Boolean(annot.pii);
|
|
@@ -195,7 +195,7 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
195
195
|
} else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) && !annot.subjectRef) {
|
|
196
196
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
197
197
|
console.warn(
|
|
198
|
-
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) —
|
|
198
|
+
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
|
|
199
199
|
);
|
|
200
200
|
}
|
|
201
201
|
}
|
|
@@ -48,6 +48,44 @@ const SETTINGS_HUB_AUDIENCE_NAV_QN_SET: ReadonlySet<string> = new Set(
|
|
|
48
48
|
SETTINGS_HUB_AUDIENCE_NAV_QNS,
|
|
49
49
|
);
|
|
50
50
|
|
|
51
|
+
// fw#3019: apps that assign navs to workspaces via `r.workspace({ nav })`
|
|
52
|
+
// or `r.nav({ workspaces })` never pass an explicit navAllowlist (solon#113)
|
|
53
|
+
// — without this, warnOnUnreachableNavScreens never runs for them. Both
|
|
54
|
+
// `WorkspaceDefinition.nav` and `NavDefinition.workspaces` already hold
|
|
55
|
+
// fully-qualified QNs (validateWorkspaces/validateNavs compare them
|
|
56
|
+
// directly against allNavQns/allWorkspaceQns), so no re-qualification is
|
|
57
|
+
// needed here.
|
|
58
|
+
export function deriveNavAllowlistFromWorkspaces(
|
|
59
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
60
|
+
allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
|
|
61
|
+
): ReadonlySet<string> {
|
|
62
|
+
const allowlist = new Set<string>();
|
|
63
|
+
for (const wsDef of allWorkspaceQns.values()) {
|
|
64
|
+
for (const navQn of wsDef.nav ?? []) {
|
|
65
|
+
allowlist.add(navQn);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const [navQn, navDef] of allNavQns) {
|
|
69
|
+
if (navDef.workspaces !== undefined && navDef.workspaces.length > 0) {
|
|
70
|
+
allowlist.add(navQn);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return allowlist;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// App-provided navAllowlist always wins (app knowledge beats derivation).
|
|
77
|
+
// Falling back to derivation only when the app has workspaces at all avoids
|
|
78
|
+
// flagging every nav as unreachable in a workspace-free app.
|
|
79
|
+
export function resolveNavAllowlist(
|
|
80
|
+
explicitAllowlist: ReadonlySet<string> | undefined,
|
|
81
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
82
|
+
allWorkspaceQns: ReadonlyMap<string, WorkspaceDefinition & { readonly featureName: string }>,
|
|
83
|
+
): ReadonlySet<string> | undefined {
|
|
84
|
+
if (explicitAllowlist !== undefined) return explicitAllowlist;
|
|
85
|
+
if (allWorkspaceQns.size === 0) return undefined;
|
|
86
|
+
return deriveNavAllowlistFromWorkspaces(allNavQns, allWorkspaceQns);
|
|
87
|
+
}
|
|
88
|
+
|
|
51
89
|
// Single-default rule across the entire app. Mirrors how createApp validates
|
|
52
90
|
// roles up front — a second `default: true` is a configuration error, not a
|
|
53
91
|
// runtime fallback. Apps without any default fall back to "first workspace
|
|
@@ -120,6 +120,23 @@ export const EXT_DERIVATIVE_RENDERER = "derivativeRenderer" as const;
|
|
|
120
120
|
*/
|
|
121
121
|
export const EXT_DERIVATIVE_PUBLIC_PREDICATE = "derivativePublicPredicate" as const;
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* `derivativeOverlayResolver` — per-entityType "resolve this overlay
|
|
125
|
+
* dataToken to its real value" contract for `file-derivatives`' overlay
|
|
126
|
+
* compositing (QR codes on a public variant).
|
|
127
|
+
*
|
|
128
|
+
* Apps register via `r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER,
|
|
129
|
+
* "<entityType>", { resolve: (args) => string | Promise<string> })`. Missing
|
|
130
|
+
* registration, or a resolve() returning an empty string, both throw —
|
|
131
|
+
* unlike `derivativePublicPredicate`, there is no default-deny answer here:
|
|
132
|
+
* an image silently rendered without its QR would look correct while being
|
|
133
|
+
* wrong.
|
|
134
|
+
*
|
|
135
|
+
* Registered/consumed by: `file-derivatives`'s `variant()`
|
|
136
|
+
* (derivatives-context.ts), before the variant's spec hash is computed.
|
|
137
|
+
*/
|
|
138
|
+
export const EXT_DERIVATIVE_OVERLAY_RESOLVER = "derivativeOverlayResolver" as const;
|
|
139
|
+
|
|
123
140
|
/**
|
|
124
141
|
* `searchAdapter` — Search-Adapter-Forget-Hooks (Meilisearch-Index-Cleanup
|
|
125
142
|
* bei User-Forget oder Tenant-Destroy).
|
package/src/engine/factories.ts
CHANGED
|
@@ -419,22 +419,32 @@ export function createLocatedTimestampField<R extends true | false = false>(
|
|
|
419
419
|
} as LocatedTimestampFieldDef & { required: R }; // @cast-boundary engine-payload
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
-
export function createFileField(
|
|
423
|
-
|
|
422
|
+
export function createFileField(
|
|
423
|
+
overrides?: Partial<Omit<FileFieldDef, "type" | keyof ResolvedPiiFlags>> &
|
|
424
|
+
PersonalAnnotationsNoFind,
|
|
425
|
+
): FileFieldDef {
|
|
426
|
+
return { type: "file", ...expandPersonalAnnotations(overrides) };
|
|
424
427
|
}
|
|
425
428
|
|
|
426
|
-
export function createImageField(
|
|
427
|
-
|
|
429
|
+
export function createImageField(
|
|
430
|
+
overrides?: Partial<Omit<ImageFieldDef, "type" | keyof ResolvedPiiFlags>> &
|
|
431
|
+
PersonalAnnotationsNoFind,
|
|
432
|
+
): ImageFieldDef {
|
|
433
|
+
return { type: "image", ...expandPersonalAnnotations(overrides) };
|
|
428
434
|
}
|
|
429
435
|
|
|
430
|
-
export function createFilesField(
|
|
431
|
-
|
|
436
|
+
export function createFilesField(
|
|
437
|
+
overrides?: Partial<Omit<FilesFieldDef, "type" | keyof ResolvedPiiFlags>> &
|
|
438
|
+
PersonalAnnotationsNoFind,
|
|
439
|
+
): FilesFieldDef {
|
|
440
|
+
return { type: "files", ...expandPersonalAnnotations(overrides) };
|
|
432
441
|
}
|
|
433
442
|
|
|
434
443
|
export function createImagesField(
|
|
435
|
-
overrides?: Partial<Omit<ImagesFieldDef, "type"
|
|
444
|
+
overrides?: Partial<Omit<ImagesFieldDef, "type" | keyof ResolvedPiiFlags>> &
|
|
445
|
+
PersonalAnnotationsNoFind,
|
|
436
446
|
): ImagesFieldDef {
|
|
437
|
-
return { type: "images", ...overrides };
|
|
447
|
+
return { type: "images", ...expandPersonalAnnotations(overrides) };
|
|
438
448
|
}
|
|
439
449
|
|
|
440
450
|
// `F` läuft OHNE Constraint im Generic-Param damit TS die literal-types
|
package/src/engine/index.ts
CHANGED
|
@@ -98,6 +98,7 @@ export type { EmitCtx } from "./event-helpers";
|
|
|
98
98
|
export { emitEvent, typedPayload } from "./event-helpers";
|
|
99
99
|
export type { KumikoExtensionName } from "./extension-names";
|
|
100
100
|
export {
|
|
101
|
+
EXT_DERIVATIVE_OVERLAY_RESOLVER,
|
|
101
102
|
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
102
103
|
EXT_DERIVATIVE_RENDERER,
|
|
103
104
|
EXT_EXTERNAL_RESOURCE,
|
|
@@ -731,6 +731,45 @@ describe("error handling", () => {
|
|
|
731
731
|
expect(body.error).toContain("invalid_fieldName");
|
|
732
732
|
});
|
|
733
733
|
|
|
734
|
+
// #3005: fileRefDeleteHook's GDPR-forget decision resolves entityType/
|
|
735
|
+
// fieldName against the registry — an attachment nobody can resolve must
|
|
736
|
+
// be rejected at upload, not silently accepted and left to the forget
|
|
737
|
+
// hook's conservative fallback.
|
|
738
|
+
test("upload with an entityType not registered in the registry is rejected", async () => {
|
|
739
|
+
const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
740
|
+
const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
|
|
741
|
+
entityType: "no-such-entity",
|
|
742
|
+
entityId: "1",
|
|
743
|
+
fieldName: "logo",
|
|
744
|
+
});
|
|
745
|
+
expect(res.status).toBe(400);
|
|
746
|
+
const body = await res.json();
|
|
747
|
+
expect(body.error).toContain("unresolvable_field");
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("upload with a fieldName not declared on the resolved entity is rejected", async () => {
|
|
751
|
+
const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
752
|
+
const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
|
|
753
|
+
entityType: "tenant",
|
|
754
|
+
entityId: "1",
|
|
755
|
+
fieldName: "no-such-field",
|
|
756
|
+
});
|
|
757
|
+
expect(res.status).toBe(400);
|
|
758
|
+
const body = await res.json();
|
|
759
|
+
expect(body.error).toContain("unresolvable_field");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test("upload with only entityType set (no fieldName) is rejected, not silently treated as unattached", async () => {
|
|
763
|
+
const pngContent = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
764
|
+
const res = await uploadFile(adminUser, "logo.png", pngContent, "image/png", {
|
|
765
|
+
entityType: "tenant",
|
|
766
|
+
entityId: "1",
|
|
767
|
+
});
|
|
768
|
+
expect(res.status).toBe(400);
|
|
769
|
+
const body = await res.json();
|
|
770
|
+
expect(body.error).toContain("unresolvable_field");
|
|
771
|
+
});
|
|
772
|
+
|
|
734
773
|
test("upload without auth returns 401", async () => {
|
|
735
774
|
const formData = new FormData();
|
|
736
775
|
formData.append("file", new File([new Uint8Array(10)], "test.png", { type: "image/png" }));
|
package/src/files/file-routes.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
|
14
14
|
import { createTenantDb } from "../db/tenant-db";
|
|
15
15
|
import { createDerivativesContext, resolveFieldVariant, resolveRenderer } from "../derivatives";
|
|
16
16
|
import {
|
|
17
|
+
type FieldDefinition,
|
|
17
18
|
isFileField,
|
|
18
19
|
isUuid,
|
|
19
20
|
type Registry,
|
|
@@ -100,6 +101,29 @@ function createDefaultGuard(privilegedRoles: readonly string[]): FileAccessGuard
|
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
type AttachedFieldResolution =
|
|
105
|
+
| { readonly kind: "unattached" }
|
|
106
|
+
| { readonly kind: "resolved"; readonly fieldDef: FieldDefinition }
|
|
107
|
+
| { readonly kind: "unresolvable" };
|
|
108
|
+
|
|
109
|
+
// entityType/fieldName drive the GDPR-forget decision downstream
|
|
110
|
+
// (fileRefDeleteHook resolves the field's PII annotation from exactly this
|
|
111
|
+
// pairing) — attaching to a field nobody can resolve would leave that
|
|
112
|
+
// decision hanging on an unverified client string. Either both are omitted
|
|
113
|
+
// (unattached upload, allowed) or both must resolve to a real registered
|
|
114
|
+
// field.
|
|
115
|
+
function resolveAttachedField(
|
|
116
|
+
registry: Registry | undefined,
|
|
117
|
+
entityType: string | undefined,
|
|
118
|
+
fieldName: string | undefined,
|
|
119
|
+
): AttachedFieldResolution {
|
|
120
|
+
if (entityType === undefined && fieldName === undefined) return { kind: "unattached" };
|
|
121
|
+
const entity = entityType !== undefined ? registry?.getEntity(entityType) : undefined;
|
|
122
|
+
const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
|
|
123
|
+
if (!entityType || !fieldName || !entity || !fieldDef) return { kind: "unresolvable" };
|
|
124
|
+
return { kind: "resolved", fieldDef };
|
|
125
|
+
}
|
|
126
|
+
|
|
103
127
|
export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
104
128
|
const { db } = options;
|
|
105
129
|
const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
|
|
@@ -171,15 +195,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
171
195
|
let maxSize = options.maxUploadSize ?? "10mb";
|
|
172
196
|
let accept: readonly string[] | undefined;
|
|
173
197
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
198
|
+
const attachedField = resolveAttachedField(options.registry, entityType, fieldName);
|
|
199
|
+
if (attachedField.kind === "unresolvable") {
|
|
200
|
+
return c.json(
|
|
201
|
+
{
|
|
202
|
+
error:
|
|
203
|
+
"unresolvable_field: entityType/fieldName must resolve to a registered entity field, or both must be omitted for an unattached upload",
|
|
204
|
+
},
|
|
205
|
+
400,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (attachedField.kind === "resolved" && isFileField(attachedField.fieldDef)) {
|
|
209
|
+
if (attachedField.fieldDef.maxSize) maxSize = attachedField.fieldDef.maxSize;
|
|
210
|
+
if (attachedField.fieldDef.accept) accept = attachedField.fieldDef.accept;
|
|
183
211
|
}
|
|
184
212
|
|
|
185
213
|
const validationError = validateFile(
|
package/src/http/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { EgressPolicy } from "@cosmicdrift/kumiko-http";
|
|
2
|
-
export { egress } from "@cosmicdrift/kumiko-http";
|
|
2
|
+
export { egress, isPublicHost } from "@cosmicdrift/kumiko-http";
|
|
@@ -49,9 +49,11 @@ const fallbackReportWindow = createEscapeHatchReportWindow();
|
|
|
49
49
|
const consoleLogger: Logger = {
|
|
50
50
|
info() {},
|
|
51
51
|
warn(msg, data) {
|
|
52
|
+
// biome-ignore lint/suspicious/noConsole: fallback for callers without a logger — dropping the call would lose the report silently.
|
|
52
53
|
console.warn(msg, data);
|
|
53
54
|
},
|
|
54
55
|
error(msg, data) {
|
|
56
|
+
// biome-ignore lint/suspicious/noConsole: same fallback, see warn above.
|
|
55
57
|
console.error(msg, data);
|
|
56
58
|
},
|
|
57
59
|
debug() {},
|