@cosmicdrift/kumiko-framework 0.286.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.286.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.286.0",
202
- "@cosmicdrift/kumiko-types": "0.286.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.286.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/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 { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
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 { createDerivativesContext, resolveRenderer } from "../derivatives-context";
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: { storageKey: string; mimeType: string }): unknown {
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
- ...row,
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 { EXT_DERIVATIVE_RENDERER } from "../engine/extension-names";
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 typeof row["storageKey"] === "string" && typeof row["mimeType"] === "string";
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, spec));
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(spec, row.mimeType);
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, spec, row.mimeType);
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-Properties sind type-level optional. Auf Field-
60
- // Defs die nicht via "& ResolvedPiiFlags" erweitert sind (Boolean,
61
- // Money, Reference, Embedded, Tz, LocatedTimestamp, File*, Image*)
62
- // liefert property-access undefined zur Runtime. Die TS-Compile-
63
- // Time-Validation hat dort schon abgelehnt → Cast ist safe.
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}", …) — without the hook the V3 boot guard throws. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
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).
@@ -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(overrides?: Partial<Omit<FileFieldDef, "type">>): FileFieldDef {
423
- return { type: "file", ...overrides };
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(overrides?: Partial<Omit<ImageFieldDef, "type">>): ImageFieldDef {
427
- return { type: "image", ...overrides };
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(overrides?: Partial<Omit<FilesFieldDef, "type">>): FilesFieldDef {
431
- return { type: "files", ...overrides };
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
@@ -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" }));
@@ -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
- if (options.registry && entityType && fieldName) {
175
- const entity = options.registry.getEntity(entityType);
176
- if (entity) {
177
- const fieldDef = entity.fields[fieldName];
178
- if (isFileField(fieldDef)) {
179
- if (fieldDef.maxSize) maxSize = fieldDef.maxSize;
180
- if (fieldDef.accept) accept = fieldDef.accept;
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() {},