@cosmicdrift/kumiko-framework 0.98.0 → 0.102.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.98.0",
3
+ "version": "0.102.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>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.98.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.102.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as jose from "jose";
3
+ import type { SessionUser } from "../../engine/types";
4
+ import { createJwtHelper } from "../jwt";
5
+
6
+ const SECRET = "test-secret-at-least-32-characters-long-aa";
7
+ const TENANT = "11111111-1111-4111-8111-111111111111";
8
+ const ISSUER = "kumiko";
9
+
10
+ const user: SessionUser = {
11
+ id: "22222222-2222-4222-8222-222222222222",
12
+ tenantId: TENANT,
13
+ roles: ["TenantAdmin"],
14
+ };
15
+
16
+ // Sign a token with arbitrary claims using the SAME secret (valid signature, fully
17
+ // controlled payload). This is how a leaked-secret / hand-crafted token looks, and it is
18
+ // exactly what verify() must reject on claim shape — not just on a bad signature.
19
+ function forge(claims: Record<string, unknown>): Promise<string> {
20
+ return new jose.SignJWT(claims)
21
+ .setProtectedHeader({ alg: "HS256" })
22
+ .setSubject(user.id)
23
+ .setIssuer(ISSUER)
24
+ .setIssuedAt()
25
+ .setExpirationTime("1h")
26
+ .sign(new TextEncoder().encode(SECRET));
27
+ }
28
+
29
+ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
30
+ const jwt = createJwtHelper(SECRET);
31
+
32
+ it("round-trips a well-formed token", async () => {
33
+ const payload = await jwt.verify(await jwt.sign(user));
34
+ expect(payload.sub).toBe(user.id);
35
+ expect(payload.tenantId).toBe(TENANT);
36
+ expect(payload.roles).toEqual(["TenantAdmin"]);
37
+ });
38
+
39
+ it("rejects a validly-signed token whose tenantId is malformed", async () => {
40
+ const token = await forge({ tenantId: "not-a-uuid", roles: ["TenantAdmin"] });
41
+ await expect(jwt.verify(token)).rejects.toThrow(/tenantId/);
42
+ });
43
+
44
+ it("rejects a token with no tenantId claim", async () => {
45
+ const token = await forge({ roles: ["TenantAdmin"] });
46
+ await expect(jwt.verify(token)).rejects.toThrow(/tenantId/);
47
+ });
48
+
49
+ it("rejects a token whose roles claim is not an array", async () => {
50
+ const token = await forge({ tenantId: TENANT, roles: "TenantAdmin" });
51
+ await expect(jwt.verify(token)).rejects.toThrow(/roles/);
52
+ });
53
+
54
+ it("rejects a token whose roles array contains a non-string", async () => {
55
+ const token = await forge({ tenantId: TENANT, roles: ["ok", 7] });
56
+ await expect(jwt.verify(token)).rejects.toThrow(/roles/);
57
+ });
58
+ });
package/src/api/jwt.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as jose from "jose";
2
2
  import type { DbRow } from "../db/connection";
3
3
  import type { SessionUser, TenantId } from "../engine/types";
4
+ import { parseTenantId } from "../engine/types";
4
5
 
5
6
  export type JwtPayload = {
6
7
  // JWT `sub` is a string per RFC 7519. Matches SessionUser.id — a UUID-string
@@ -48,10 +49,31 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
48
49
 
49
50
  async verify(token) {
50
51
  const { payload } = await jose.jwtVerify(token, encodedSecret, { issuer });
52
+
53
+ // Defence in depth: a valid signature does not guarantee well-formed claims. A
54
+ // leaked secret, key confusion, or a hand-crafted token can still carry junk —
55
+ // validate the claim shape and reject (verify() throws → 401 in auth-middleware)
56
+ // instead of casting blindly.
57
+ const tenantId = parseTenantId(payload["tenantId"]);
58
+ if (tenantId === null) {
59
+ throw new Error("JWT payload validation failed: tenantId claim is missing or malformed");
60
+ }
61
+ const rawRoles = payload["roles"];
62
+ if (!Array.isArray(rawRoles)) {
63
+ throw new Error("JWT payload validation failed: roles claim must be an array");
64
+ }
65
+ const roles: string[] = [];
66
+ for (const role of rawRoles) {
67
+ if (typeof role !== "string") {
68
+ throw new Error("JWT payload validation failed: roles must contain only strings");
69
+ }
70
+ roles.push(role);
71
+ }
72
+
51
73
  const result: JwtPayload = {
52
74
  sub: String(payload.sub),
53
- tenantId: payload["tenantId"] as string, // @cast-boundary dynamic-key
54
- roles: payload["roles"] as string[], // @cast-boundary dynamic-key
75
+ tenantId,
76
+ roles,
55
77
  };
56
78
  const claims = payload["claims"];
57
79
  if (claims && typeof claims === "object") {
package/src/api/server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import type { DbConnection, PgClient } from "../db/connection";
3
3
  import { createTenantDb } from "../db/tenant-db";
4
+ import { EXT_FILE_PROVIDER } from "../engine/extension-names";
4
5
  import { runsInLane } from "../engine/run-in";
5
6
  import {
6
7
  type AppContext,
@@ -12,6 +13,7 @@ import {
12
13
  import { createFileContext } from "../files/file-handle";
13
14
  import type { FileRoutesOptions } from "../files/file-routes";
14
15
  import { createFileRoutes } from "../files/file-routes";
16
+ import { makeFileProviderResolver } from "../files/provider-resolver";
15
17
  import type { Lifecycle } from "../lifecycle";
16
18
  import {
17
19
  createNoopProvider,
@@ -72,7 +74,11 @@ export type ServerOptions = {
72
74
  eventDedup?: EventDedup;
73
75
  sseBroker?: SseBroker;
74
76
  auth?: AuthRoutesConfig;
75
- files?: Omit<FileRoutesOptions, "db"> & { db?: FileRoutesOptions["db"] };
77
+ // No `files` option: file-storage is wired by mounting `file-foundation` +
78
+ // a `file-provider-*` feature. Upload routes, ctx.files and the GDPR jobs
79
+ // resolve the provider per-tenant through that single source (issue #608).
80
+ // Upload-route policy (accessGuard/privilegedRoles/maxUploadSize) lives on
81
+ // `createFilesFeature(opts?)`.
76
82
  // Async event-dispatcher config. The dispatcher is created automatically
77
83
  // when (a) context.db is a DbConnection AND (b) at least one consumer is
78
84
  // wired — SSE (iff sseBroker), Search (iff context.searchAdapter), or
@@ -211,14 +217,22 @@ export type KumikoServer = {
211
217
  };
212
218
 
213
219
  export function buildServer(options: ServerOptions): KumikoServer {
214
- // Hard-fail when the registry declares file/image fields but no storage
215
- // provider is wired. Boot-validator checks the env shape; here we prove the
216
- // runtime actually has somewhere to put the bytes. Without this, uploads
217
- // would fail at the first request instead of at boot.
218
- if (!options.files?.storageProvider && registryDeclaresFileFields(options.registry)) {
220
+ // File-storage is resolved per-tenant through file-foundation: a mounted
221
+ // `file-provider-*` plugin (inmemory/s3/s3-env) is the single source for
222
+ // uploads, ctx.files and the GDPR jobs.
223
+ const hasFileProvider = options.registry.getExtensionUsages(EXT_FILE_PROVIDER).length > 0;
224
+ // A test/advanced caller may inject the resolver directly on the context
225
+ // (e.g. a static in-memory provider) instead of mounting a provider plugin.
226
+ const hasInjectedResolver = options.context._fileProviderResolver !== undefined;
227
+ // Hard-fail when the registry declares file/image fields but no provider
228
+ // plugin is mounted — uploads would otherwise fail at the first request
229
+ // instead of at boot. Proves a plugin is mounted, not that each tenant
230
+ // selected one; per-tenant misconfig surfaces via createFileProviderForTenant.
231
+ if (registryDeclaresFileFields(options.registry) && !hasFileProvider && !hasInjectedResolver) {
219
232
  throw new Error(
220
- "Features declare file/image fields but no storageProvider was registered — " +
221
- "pass `files: { storageProvider, db }` to buildServer().",
233
+ "Features declare file/image fields but no file-storage provider is mounted — " +
234
+ "mount `file-foundation` + a `file-provider-*` feature (inmemory/s3/s3-env) and " +
235
+ "select one per tenant via the 'file-foundation:config:provider' config-key.",
222
236
  );
223
237
  }
224
238
 
@@ -288,12 +302,20 @@ export function buildServer(options: ServerOptions): KumikoServer {
288
302
  shouldWrapRedis && redisCtx ? wrapRedisClient(redisCtx, observability.tracer) : redisCtx;
289
303
 
290
304
  // Inject tracer + meter into the AppContext so the dispatcher can propagate
291
- // them into every HandlerContext it builds. If a file storage provider was
292
- // registered, wrap it in a FileContext so handlers/hooks can resolve
293
- // `ctx.files.ref(key)` without reaching for the raw provider.
294
- const fileCtx = options.files?.storageProvider
295
- ? createFileContext(options.files.storageProvider)
296
- : undefined;
305
+ // them into every HandlerContext it builds. Build the per-tenant file-provider
306
+ // resolver once (when a provider plugin is mounted) — the dispatcher uses it
307
+ // to materialise `ctx.files`, the upload routes + MSP-applies share it. The
308
+ // resolver reads config + the s3.secretAccessKey secret under SYSTEM identity.
309
+ const fileProviderResolver =
310
+ options.context._fileProviderResolver ??
311
+ (hasFileProvider
312
+ ? makeFileProviderResolver({
313
+ registry: options.registry,
314
+ _configAccessorFactory: options.context._configAccessorFactory,
315
+ secrets: options.context.secrets,
316
+ db: options.context.db,
317
+ })
318
+ : undefined);
297
319
  // Auto-wire the rate-limit resolver, but ONLY when at least one
298
320
  // handler actually declared a rateLimit option. Apps that don't use
299
321
  // L3 pay zero cost: no resolver instance, no Lua-script registration
@@ -313,7 +335,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
313
335
  const contextWithObservability: AppContext = {
314
336
  ...options.context,
315
337
  ...(wrappedRedis ? { redis: wrappedRedis } : {}),
316
- ...(fileCtx ? { files: fileCtx } : {}),
338
+ ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
317
339
  ...(rateLimitResolver ? { rateLimit: rateLimitResolver } : {}),
318
340
  // Propagate the feature-toggle resolver to the context so the event-
319
341
  // dispatcher (and any future context-reading consumer) sees the same
@@ -429,7 +451,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
429
451
  tenantId: event.tenantId,
430
452
  userId: event.metadata.userId,
431
453
  ...(mspOwner && { callerFeature: mspOwner }),
432
- ...(fileCtx && { files: fileCtx }),
454
+ ...(fileProviderResolver && {
455
+ files: createFileContext(() => fileProviderResolver(event.tenantId)),
456
+ }),
433
457
  });
434
458
  await applyFn(event, rawRunner, applyCtx);
435
459
  // Keep ctx reachable to satisfy the EventConsumerHandler signature.
@@ -583,15 +607,25 @@ export function buildServer(options: ServerOptions): KumikoServer {
583
607
  app.route("/api", createApiRoutes(dispatcher));
584
608
  app.route("/api", createSseRoute(sseBroker));
585
609
 
586
- if (options.files) {
587
- const fileDb = options.files.db ?? (options.context.db as FileRoutesOptions["db"]); // @cast-boundary engine-bridge
588
- if (!fileDb) throw new Error("files option requires db in context or files.db");
610
+ // Mount upload/download routes whenever a file provider is resolvable (a
611
+ // file-provider plugin is mounted, or a resolver was injected). They resolve
612
+ // the provider per-tenant through file-foundation no `files` option; route
613
+ // policy comes from createFilesFeature(opts?). Gating on the resolver (not on
614
+ // declared file fields) keeps unattached uploads working and matches the old
615
+ // "files option present → routes" behavior.
616
+ if (fileProviderResolver) {
617
+ const fileDb = options.context.db as FileRoutesOptions["db"]; // @cast-boundary engine-bridge
618
+ if (!fileDb) throw new Error("file routes require db in context");
619
+ const routeOptions = readFilesRouteOptions(options.registry);
589
620
  app.route(
590
621
  "/api",
591
622
  createFileRoutes({
592
- ...options.files,
593
623
  db: fileDb,
594
624
  registry: options.registry,
625
+ resolveProvider: fileProviderResolver,
626
+ ...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
627
+ ...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
628
+ ...(routeOptions.maxUploadSize ? { maxUploadSize: routeOptions.maxUploadSize } : {}),
595
629
  }),
596
630
  );
597
631
  }
@@ -663,3 +697,20 @@ function registryDeclaresFileFields(registry: Registry): boolean {
663
697
  }
664
698
  return false;
665
699
  }
700
+
701
+ // Upload-route policy carried by createFilesFeature(opts?) — read from the
702
+ // feature's exports so buildServer applies it without a parallel ServerOptions
703
+ // surface. Absent feature / opts → defaults in createFileRoutes.
704
+ function readFilesRouteOptions(
705
+ registry: Registry,
706
+ ): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize"> {
707
+ const exp = registry.features.get("files")?.exports;
708
+ if (exp && typeof exp === "object" && "routeOptions" in exp) {
709
+ const ro = (exp as { routeOptions?: unknown }).routeOptions;
710
+ if (ro && typeof ro === "object") {
711
+ // @cast-boundary feature-exports: engine-payload (unknown) → known shape
712
+ return ro as Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
713
+ }
714
+ }
715
+ return {};
716
+ }
@@ -1158,6 +1158,45 @@ describe("boot-validator", () => {
1158
1158
  });
1159
1159
  });
1160
1160
 
1161
+ // --- entityList virtual (labeled) columns ---
1162
+ // A column whose `field` is not an entity field is allowed IF it carries a
1163
+ // `label` — a presentational column drawn by a columnRenderer component (e.g.
1164
+ // tag chips). Without a label an unknown field stays a boot error (typo guard).
1165
+ describe("entityList virtual labeled column", () => {
1166
+ function noteFeature(column: unknown) {
1167
+ return defineFeature("notes", (r) => {
1168
+ r.entity("note", createEntity({ fields: { title: createTextField() } }));
1169
+ r.screen({
1170
+ id: "note-list",
1171
+ type: "entityList",
1172
+ entity: "note",
1173
+ // kumiko-lint-ignore as-cast test fixture pins valid + invalid column forms
1174
+ columns: ["title", column as never],
1175
+ });
1176
+ });
1177
+ }
1178
+
1179
+ test("labeled component column on a non-field → kein Throw", () => {
1180
+ expect(() =>
1181
+ validateBoot([
1182
+ noteFeature({
1183
+ field: "tags",
1184
+ label: "Tags",
1185
+ renderer: { react: { __component: "TagsCell" } },
1186
+ }),
1187
+ ]),
1188
+ ).not.toThrow();
1189
+ });
1190
+
1191
+ test("unlabeled column on a non-field → Throw (unknown field)", () => {
1192
+ expect(() =>
1193
+ validateBoot([
1194
+ noteFeature({ field: "tags", renderer: { react: { __component: "TagsCell" } } }),
1195
+ ]),
1196
+ ).toThrow(/tags/);
1197
+ });
1198
+ });
1199
+
1161
1200
  // --- entityList: pagination + sort validation ---
1162
1201
  // Author-Fehler vor Production fangen, damit "Screen lädt nichts /
1163
1202
  // sortiert falsch / crasht beim Pager-Klick" nicht erst zur Laufzeit
@@ -338,7 +338,11 @@ export function validateScreens(
338
338
  }
339
339
  for (const col of screen.columns) {
340
340
  const normalized = normalizeListColumn(col);
341
- if (!columnFieldNames.has(normalized.field)) {
341
+ // A labeled column whose field is not an entity field is a *virtual*
342
+ // presentational column (drawn by a columnRenderer component from the
343
+ // row, e.g. tag chips) — its `field` is just a column key. Only an
344
+ // unlabeled unknown field is an author typo worth failing the boot.
345
+ if (!columnFieldNames.has(normalized.field) && normalized.label === undefined) {
342
346
  throw new Error(
343
347
  buildUnknownFieldMessage(
344
348
  feature.name,
@@ -66,6 +66,27 @@ export const EXT_TENANT_DATA = "tenantData" as const;
66
66
  */
67
67
  export const EXT_STORAGE_PROVIDER = "storageProvider" as const;
68
68
 
69
+ /**
70
+ * `fileProvider` — File-Storage-Provider-Plugin-Selection (file-foundation).
71
+ *
72
+ * Provider-Features (file-provider-s3, -inmemory, -s3-env) registrieren eine
73
+ * Implementierung via `r.useExtension(EXT_FILE_PROVIDER, "<name>", { build })`;
74
+ * der per-Tenant Config-Key `provider` waehlt zur Runtime eine aus. Der
75
+ * Framework-Resolver (`createFileProviderForTenant`) liest das, damit Upload-
76
+ * Routes, `ctx.files` UND die DSGVO-Jobs denselben Store treffen.
77
+ *
78
+ * Framework-seitig besessen, damit kein String-Drift entsteht — file-
79
+ * foundation MUSS Extension-Point + Config-Key unter genau diesen Namen
80
+ * registrieren.
81
+ */
82
+ export const EXT_FILE_PROVIDER = "fileProvider" as const;
83
+
84
+ // Qualifizierter Config-Key, den das file-foundation-Feature registriert
85
+ // (Format `<feature>:config:<key>`). Akzeptierte Kopplung: der Framework-
86
+ // Resolver liegt UNTER dem Feature und liest den Key per String. Umbenennen
87
+ // des file-foundation-Features MUSS diese Konstante mitziehen.
88
+ export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as const;
89
+
69
90
  /**
70
91
  * `searchAdapter` — Search-Adapter-Forget-Hooks (Meilisearch-Index-Cleanup
71
92
  * bei User-Forget oder Tenant-Destroy).
@@ -73,12 +73,14 @@ export { emitEvent, typedPayload } from "./event-helpers";
73
73
  export type { KumikoExtensionName } from "./extension-names";
74
74
  export {
75
75
  EXT_EXTERNAL_RESOURCE,
76
+ EXT_FILE_PROVIDER,
76
77
  EXT_INFRA_RESOURCE,
77
78
  EXT_SEARCH_ADAPTER,
78
79
  EXT_STORAGE_PROVIDER,
79
80
  EXT_TENANT_DATA,
80
81
  EXT_USER_DATA,
81
82
  EXT_USER_DATA_ORDER,
83
+ FILE_PROVIDER_CONFIG_KEY,
82
84
  } from "./extension-names";
83
85
  export type {
84
86
  TenantUserModel,
@@ -3,6 +3,7 @@ import type { ZodType } from "zod";
3
3
  import type { DbConnection } from "../../db/connection";
4
4
  import type { TenantDb } from "../../db/tenant-db";
5
5
  import type { FileContext } from "../../files/file-handle";
6
+ import type { FileProviderResolver } from "../../files/provider-resolver";
6
7
  import type { Logger } from "../../logging/types";
7
8
  import type { Meter, MetricsHandle, Tracer } from "../../observability/types";
8
9
  import type { EntityCache } from "../../pipeline/entity-cache";
@@ -230,11 +231,16 @@ type SharedContextFields = {
230
231
  // rejected at boot to surface the misconfig early.
231
232
  readonly rateLimit?: import("../../rate-limit").RateLimitResolver;
232
233
  readonly searchAdapter?: SearchAdapter;
233
- // Binary storage, wrapped around the registered FileStorageProvider.
234
- // Optional at the AppContext level present when the app booted with
235
- // `files.storageProvider`. Hooks/handlers use ctx.files.ref(key) instead
236
- // of receiving binaries in payloads.
234
+ // Binary storage. The dispatcher builds this per-call, bound to the caller's
235
+ // tenant, from `_fileProviderResolver` (below)so uploads, ctx.files and the
236
+ // GDPR jobs all resolve through the same file-foundation provider. Hooks/
237
+ // handlers use ctx.files.ref(key) instead of receiving binaries in payloads.
237
238
  readonly files?: FileContext;
239
+ // Boot-built, per-tenant file-provider resolver. Set by buildServer when a
240
+ // `file-provider-*` plugin is mounted; the dispatcher reads it to materialise
241
+ // ctx.files (and the upload routes + MSP-applies use the same resolver).
242
+ // Resolution runs under system identity for the s3.secretAccessKey read.
243
+ readonly _fileProviderResolver?: FileProviderResolver;
238
244
  readonly entityCache?: EntityCache;
239
245
  readonly notify?: NotifyFn;
240
246
  readonly _notifyFactory?: NotifyFactory;
@@ -192,7 +192,7 @@ export type {
192
192
  } from "./http-route";
193
193
  // Domain-identifier type aliases — see identifiers.ts for rationale.
194
194
  export type { EntityId, TenantId } from "./identifiers";
195
- export { isSystemTenant, SYSTEM_TENANT_ID } from "./identifiers";
195
+ export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./identifiers";
196
196
  export type { NavDefinition } from "./nav";
197
197
  export type {
198
198
  MspErrorMode,
@@ -78,6 +78,16 @@ export type ListColumnSpec =
78
78
  | {
79
79
  readonly field: string;
80
80
  readonly renderer?: FieldRenderer;
81
+ /** Optional header, overriding the default
82
+ * `<feature>:entity:<entity>:field:<field>` i18n convention. Resolved
83
+ * through `translate` like any header — an i18n key, or a plain literal
84
+ * shown verbatim if it isn't a key. Also the way to declare a *virtual*
85
+ * column whose `field` is NOT an entity field — a presentational column
86
+ * drawn entirely by a `columnRenderer` component that reads the `row`
87
+ * (e.g. a tag-chips cell), not a stored value. Such a column needs a
88
+ * label; without one it is rejected at boot as an unknown field. `field`
89
+ * is then just a stable column key (pick any unique slug). */
90
+ readonly label?: string;
81
91
  };
82
92
 
83
93
  // Pagination-Modi für entityList:
@@ -38,7 +38,6 @@ import { buildServer } from "../api/server";
38
38
  import type { SseBroker } from "../api/sse-broker";
39
39
  import type { PgClient } from "../db/connection";
40
40
  import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
41
- import type { FileRoutesOptions } from "../files/file-routes";
42
41
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
43
42
  import { createJobRunner } from "../jobs/job-runner";
44
43
  import type { Lifecycle } from "../lifecycle";
@@ -81,7 +80,6 @@ export type ApiEntrypointOptions = BaseEntrypointOptions & {
81
80
  readonly auth?: AuthRoutesConfig;
82
81
  readonly anonymousAccess?: ServerOptions["anonymousAccess"];
83
82
  readonly sseBroker?: SseBroker;
84
- readonly files?: Omit<FileRoutesOptions, "db"> & { db?: FileRoutesOptions["db"] };
85
83
  readonly rateLimit?: ServerOptions["rateLimit"];
86
84
  readonly maxRequestBytes?: ServerOptions["maxRequestBytes"];
87
85
  readonly readiness?: ServerOptions["readiness"];
@@ -201,7 +199,6 @@ function buildApiServer(
201
199
  jwtIssuer: opts.jwtIssuer,
202
200
  auth: opts.auth,
203
201
  anonymousAccess: opts.anonymousAccess,
204
- files: opts.files,
205
202
  sseBroker: opts.sseBroker,
206
203
  rateLimit: opts.rateLimit,
207
204
  maxRequestBytes: opts.maxRequestBytes,
@@ -27,7 +27,7 @@ describe("deriveKey", () => {
27
27
  describe("FileHandle", () => {
28
28
  test("read/write round-trip through the provider", async () => {
29
29
  const provider = createInMemoryFileProvider();
30
- const handle = createFileHandle("tenant/x.jpg", provider);
30
+ const handle = createFileHandle("tenant/x.jpg", () => Promise.resolve(provider));
31
31
 
32
32
  const payload = new Uint8Array([1, 2, 3, 4]);
33
33
  await handle.write(payload, "image/jpeg");
@@ -38,7 +38,7 @@ describe("FileHandle", () => {
38
38
 
39
39
  test("exists reflects write/delete state", async () => {
40
40
  const provider = createInMemoryFileProvider();
41
- const handle = createFileHandle("tenant/x.jpg", provider);
41
+ const handle = createFileHandle("tenant/x.jpg", () => Promise.resolve(provider));
42
42
 
43
43
  expect(await handle.exists()).toBe(false);
44
44
  await handle.write(new Uint8Array([9]));
@@ -49,7 +49,7 @@ describe("FileHandle", () => {
49
49
 
50
50
  test("derive produces an independent handle at the derived key", async () => {
51
51
  const provider = createInMemoryFileProvider();
52
- const original = createFileHandle("tenant/photo.jpg", provider);
52
+ const original = createFileHandle("tenant/photo.jpg", () => Promise.resolve(provider));
53
53
  const thumb = original.derive("thumb");
54
54
 
55
55
  expect(thumb.key).toBe("tenant/photo.thumb.jpg");
@@ -66,7 +66,7 @@ describe("FileHandle", () => {
66
66
 
67
67
  test("writes copy the buffer — caller mutations don't corrupt stored data", async () => {
68
68
  const provider = createInMemoryFileProvider();
69
- const handle = createFileHandle("tenant/x.bin", provider);
69
+ const handle = createFileHandle("tenant/x.bin", () => Promise.resolve(provider));
70
70
 
71
71
  const buf = new Uint8Array([1, 2, 3]);
72
72
  await handle.write(buf);
@@ -80,7 +80,7 @@ describe("FileHandle", () => {
80
80
  describe("createFileContext", () => {
81
81
  test("ref returns a handle bound to the given key", async () => {
82
82
  const provider = createInMemoryFileProvider();
83
- const files = createFileContext(provider);
83
+ const files = createFileContext(() => Promise.resolve(provider));
84
84
 
85
85
  const h = files.ref("tenant/foo.pdf");
86
86
  expect(h.key).toBe("tenant/foo.pdf");
@@ -27,6 +27,7 @@ import {
27
27
  expectErrorIncludes,
28
28
  patchFileInstanceofForBunTest,
29
29
  } from "../../testing";
30
+ import { createFilesFeature } from "../feature";
30
31
  import { fileRefsTable } from "../file-ref-table";
31
32
  import type { FileRoutesOptions } from "../file-routes";
32
33
  import { createInMemoryFileProvider } from "../in-memory-provider";
@@ -83,9 +84,8 @@ beforeAll(async () => {
83
84
 
84
85
  const server = buildServer({
85
86
  registry,
86
- context: { db: testDb.db },
87
+ context: { db: testDb.db, _fileProviderResolver: () => Promise.resolve(storageProvider) },
87
88
  jwtSecret: JWT_SECRET,
88
- files: { db: testDb.db, storageProvider },
89
89
  });
90
90
  app = server.app;
91
91
  jwt = server.jwt;
@@ -397,7 +397,7 @@ describe("custom file access guard", () => {
397
397
  // body inside try/finally so the DB and tmpdir are cleaned up even if
398
398
  // assertions fail.
399
399
  async function withIsolatedFileServer(
400
- options: Omit<FileRoutesOptions, "db" | "storageProvider"> & {
400
+ options: Omit<FileRoutesOptions, "db" | "resolveProvider"> & {
401
401
  // Overrides the default local-filesystem provider. Needed for tests
402
402
  // that exercise optional provider methods (e.g. getSignedUrl) which
403
403
  // the local provider deliberately doesn't implement.
@@ -416,12 +416,13 @@ describe("custom file access guard", () => {
416
416
  await unsafeCreateEntityTable(isolatedDb.db, testTenantEntity);
417
417
  const storagePath = await mkdtemp(join(tmpdir(), "kumiko-files-custom-"));
418
418
  const provider = providerOverride ?? createLocalProvider(storagePath);
419
- const isolatedRegistry = createRegistry([tenantFeature]);
419
+ // Route policy (accessGuard/privilegedRoles/maxUploadSize) now rides on the
420
+ // files feature; the provider is injected as the per-tenant resolver.
421
+ const isolatedRegistry = createRegistry([tenantFeature, createFilesFeature(routeOptions)]);
420
422
  const isolatedServer = buildServer({
421
423
  registry: isolatedRegistry,
422
- context: { db: isolatedDb.db },
424
+ context: { db: isolatedDb.db, _fileProviderResolver: () => Promise.resolve(provider) },
423
425
  jwtSecret: JWT_SECRET,
424
- files: { db: isolatedDb.db, storageProvider: provider, ...routeOptions },
425
426
  });
426
427
 
427
428
  try {
@@ -736,9 +737,8 @@ describe("download-url endpoint", () => {
736
737
  const isolatedRegistry = createRegistry([tenantFeature]);
737
738
  const isolatedServer = buildServer({
738
739
  registry: isolatedRegistry,
739
- context: { db: isolatedDb.db },
740
+ context: { db: isolatedDb.db, _fileProviderResolver: () => Promise.resolve(storageProvider) },
740
741
  jwtSecret: JWT_SECRET,
741
- files: { db: isolatedDb.db, storageProvider },
742
742
  });
743
743
 
744
744
  try {
@@ -1,8 +1,22 @@
1
1
  import { defineFeature, type FeatureDefinition } from "../engine";
2
2
  import { fileRefEntity } from "./file-ref-entity";
3
+ import type { FileAccessGuard } from "./file-routes";
3
4
 
4
5
  export { fileRefEntity } from "./file-ref-entity";
5
6
 
7
+ // Upload-route policy. buildServer reads these off the `files` feature's
8
+ // exports (no parallel ServerOptions surface) and applies them to the
9
+ // /api/files routes. Storage itself is wired by mounting a `file-provider-*`
10
+ // feature — these options only tune authorization + size limits.
11
+ export type FilesFeatureOptions = {
12
+ // Replaces the default owner-or-privileged guard entirely.
13
+ readonly accessGuard?: FileAccessGuard;
14
+ // Roles that bypass the default owner-check on entity-attached files.
15
+ readonly privilegedRoles?: readonly string[];
16
+ // Global upload size default (per-field `maxSize` still wins). e.g. "10mb".
17
+ readonly maxUploadSize?: string;
18
+ };
19
+
6
20
  // files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
7
21
  // den Standard-Entity-Executor (file-routes.ts: executor.create/delete →
8
22
  // `fileRef.created`/`fileRef.deleted` → applyEntityEvent materialisiert
@@ -14,16 +28,17 @@ export { fileRefEntity } from "./file-ref-entity";
14
28
  // userData/tenantData-Extensions sichtbar und registriert die implizite
15
29
  // Entity-Projektion (für rebuildProjection). Liegt im Framework neben
16
30
  // file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
17
- export function createFilesFeature(): FeatureDefinition {
31
+ export function createFilesFeature(options: FilesFeatureOptions = {}): FeatureDefinition {
18
32
  return defineFeature("files", (r) => {
19
33
  r.describe(
20
- "Exposes the `fileRef` entity and `createFilesFeature` from the framework core so that uploaded files \u2014 tracked in the `file_refs` table by `createFileRoutes` \u2014 participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and future tenant-lifecycle cleanup will delete all refs on tenant destroy. This feature does not add upload or download routes; those remain in the server bootstrap via the `options.files` parameter.",
34
+ "Exposes the `fileRef` entity from the framework core so that uploaded files tracked in the `file_refs` table by `createFileRoutes` participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and tenant-lifecycle cleanup deletes all refs on tenant destroy. Upload/download routes are registered by the server bootstrap when a `file-provider-*` feature is mounted; this feature carries their access/size policy via `createFilesFeature(opts?)`.",
21
35
  );
22
36
  r.uiHints({
23
- displayLabel: "Files \u00b7 Metadata",
37
+ displayLabel: "Files · Metadata",
24
38
  category: "storage",
25
39
  recommended: false,
26
40
  });
27
41
  r.entity("fileRef", fileRefEntity);
42
+ return { routeOptions: options };
28
43
  });
29
44
  }
@@ -25,26 +25,43 @@ export type FileHandle = {
25
25
  };
26
26
 
27
27
  // The `ctx.files` service — a factory that materialises a FileHandle for a
28
- // storage key. One per app, wrapped around whichever FileStorageProvider the
29
- // app boot registered.
28
+ // storage key. One per request/event, bound to a single tenant: the provider
29
+ // is resolved per-tenant through file-foundation, so uploads, ctx.files and the
30
+ // GDPR jobs all hit the same store by construction.
30
31
  export type FileContext = {
31
32
  ref(key: string): FileHandle;
32
33
  };
33
34
 
34
- export function createFileHandle(key: string, provider: FileStorageProvider): FileHandle {
35
+ // `getProvider` is a lazily-resolved, memoized accessor the provider is
36
+ // resolved (config + s3.secretAccessKey secret read) only when a handle method
37
+ // actually does I/O, never on every request that merely builds a ctx.
38
+ export function createFileHandle(
39
+ key: string,
40
+ getProvider: () => Promise<FileStorageProvider>,
41
+ ): FileHandle {
35
42
  return {
36
43
  key,
37
- read: () => provider.read(key),
38
- write: (data, mimeType) => provider.write(key, data, mimeType),
39
- delete: () => provider.delete(key),
40
- exists: () => provider.exists(key),
41
- derive: (suffix) => createFileHandle(deriveKey(key, suffix), provider),
44
+ read: async () => (await getProvider()).read(key),
45
+ write: async (data, mimeType) => (await getProvider()).write(key, data, mimeType),
46
+ delete: async () => (await getProvider()).delete(key),
47
+ exists: async () => (await getProvider()).exists(key),
48
+ derive: (suffix) => createFileHandle(deriveKey(key, suffix), getProvider),
42
49
  };
43
50
  }
44
51
 
45
- export function createFileContext(provider: FileStorageProvider): FileContext {
52
+ // `resolve` is a tenant-bound thunk (the caller binds the tenant). The result
53
+ // is memoized for this FileContext's lifetime and shared across every handle it
54
+ // produces — never a process-global cache, since each FileContext is bound to a
55
+ // single request/event tenant; sharing would leak one tenant's provider (and
56
+ // its bucket/credentials) to another.
57
+ export function createFileContext(resolve: () => Promise<FileStorageProvider>): FileContext {
58
+ let cached: Promise<FileStorageProvider> | undefined;
59
+ const getProvider = () => {
60
+ cached ??= resolve();
61
+ return cached;
62
+ };
46
63
  return {
47
- ref: (key) => createFileHandle(key, provider), // @wrapper-known semantic-alias
64
+ ref: (key) => createFileHandle(key, getProvider),
48
65
  };
49
66
  }
50
67
 
@@ -9,7 +9,7 @@ import { generateId } from "../utils";
9
9
  import { buildContentDispositionHeader } from "./content-disposition";
10
10
  import { fileRefEntity } from "./file-ref-entity";
11
11
  import { fileRefsTable } from "./file-ref-table";
12
- import type { FileStorageProvider } from "./types";
12
+ import type { FileProviderResolver } from "./provider-resolver";
13
13
  import { buildStorageKey, validateFile } from "./types";
14
14
 
15
15
  // Decision returned by a FileAccessGuard — distinct from boolean so callers
@@ -47,7 +47,11 @@ export type FileAccessGuard = (args: {
47
47
 
48
48
  export type FileRoutesOptions = {
49
49
  readonly db: DbConnection;
50
- readonly storageProvider: FileStorageProvider;
50
+ // Per-tenant provider resolver — resolves through file-foundation so uploads,
51
+ // ctx.files and the GDPR jobs all hit the same store. Resolution runs under
52
+ // system identity for the secret-read; request-user authorization is the
53
+ // accessGuard's job below.
54
+ readonly resolveProvider: FileProviderResolver;
51
55
  readonly registry?: Registry;
52
56
  readonly maxUploadSize?: string; // global default, e.g. "10mb"
53
57
  // Roles that bypass the default owner-check on entity-attached files.
@@ -80,7 +84,7 @@ function createDefaultGuard(privilegedRoles: readonly string[]): FileAccessGuard
80
84
  }
81
85
 
82
86
  export function createFileRoutes(options: FileRoutesOptions): Hono {
83
- const { db, storageProvider } = options;
87
+ const { db } = options;
84
88
  const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
85
89
  const guard: FileAccessGuard = options.accessGuard ?? createDefaultGuard(privilegedRoles);
86
90
  // Standard entity executor for fileRef — self-contained (table + entity),
@@ -147,6 +151,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
147
151
  // row on append-failure is acceptable; corrupting a committed row with a
148
152
  // missing binary is not.
149
153
  const data = new Uint8Array(await file.arrayBuffer());
154
+ const storageProvider = await options.resolveProvider(user.tenantId);
150
155
  await storageProvider.write(storageKey, data, file.type);
151
156
 
152
157
  // Create via the standard entity executor: emits fileRef.created +
@@ -206,6 +211,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
206
211
  return c.json({ error: "not_found" }, 404);
207
212
  }
208
213
 
214
+ const storageProvider = await options.resolveProvider(user.tenantId);
209
215
  const data = await storageProvider.read(fileRef.storageKey);
210
216
  return new Response(Buffer.from(data), {
211
217
  headers: {
@@ -261,6 +267,17 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
261
267
  // 501 when the wired provider doesn't support signed URLs (filesystem
262
268
  // dev providers). Clients should fall back to the streaming endpoint.
263
269
  api.get("/files/:id/download-url", async (c) => {
270
+ const user = getUser(c);
271
+ const id = c.req.param("id");
272
+ const fileRef = await loadFileForTenant(id, user.tenantId);
273
+ if (!fileRef) return c.json({ error: "not_found" }, 404);
274
+
275
+ const decision = await guard({ fileRef, user, operation: "read" });
276
+ if (decision === "deny") return c.json({ error: "not_found" }, 404);
277
+
278
+ // Resolve after access-check: tenant-scoped provider, and capability
279
+ // disclosure (501) stays behind the auth+guard gate.
280
+ const storageProvider = await options.resolveProvider(user.tenantId);
264
281
  if (!storageProvider.getSignedUrl) {
265
282
  return c.json(
266
283
  {
@@ -271,14 +288,6 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
271
288
  );
272
289
  }
273
290
 
274
- const user = getUser(c);
275
- const id = c.req.param("id");
276
- const fileRef = await loadFileForTenant(id, user.tenantId);
277
- if (!fileRef) return c.json({ error: "not_found" }, 404);
278
-
279
- const decision = await guard({ fileRef, user, operation: "read" });
280
- if (decision === "deny") return c.json({ error: "not_found" }, 404);
281
-
282
291
  const expiresInSeconds = SIGNED_URL_DEFAULT_EXPIRY_SECONDS;
283
292
  const url = await storageProvider.getSignedUrl(fileRef.storageKey, expiresInSeconds, {
284
293
  // Hint the provider to set Content-Disposition so the browser prompts
@@ -15,6 +15,17 @@ export { createFileRoutes } from "./file-routes";
15
15
  export type { InMemoryFileProvider } from "./in-memory-provider";
16
16
  export { createInMemoryFileProvider } from "./in-memory-provider";
17
17
  export { createLocalProvider } from "./local-provider";
18
+ export type {
19
+ FileProviderContext,
20
+ FileProviderPlugin,
21
+ FileProviderResolver,
22
+ FileProviderResolverDeps,
23
+ } from "./provider-resolver";
24
+ export {
25
+ createFileProviderForTenant,
26
+ isFileProviderPlugin,
27
+ makeFileProviderResolver,
28
+ } from "./provider-resolver";
18
29
  export { filesStorageTrackingFeature, tenantStorageUsageTable } from "./storage-tracking";
19
30
  export type {
20
31
  FileMetadata,
@@ -0,0 +1,158 @@
1
+ // File-provider resolution — lives in the framework so the upload routes,
2
+ // `ctx.files`, AND the GDPR export/forget jobs resolve their FileStorageProvider
3
+ // through ONE path. Divergence (uploads writing one store, erasure deleting
4
+ // another) is structurally impossible: there is a single resolver.
5
+ //
6
+ // The framework owns the resolution but NOT the storage backends. The
7
+ // `fileProvider` extension point + the per-tenant `provider` config key are
8
+ // declared by the `file-foundation` bundled feature; the concrete stores live
9
+ // in `file-provider-*` plugins. The framework only consumes what features
10
+ // registered in the registry — it never imports bundled-features upward.
11
+ //
12
+ // file-foundation re-exports `createFileProviderForTenant` + the plugin types
13
+ // (moved here from there) so existing imports keep working.
14
+
15
+ import type { DbConnection } from "../db/connection";
16
+ import type { TenantDb } from "../db/tenant-db";
17
+ import { EXT_FILE_PROVIDER, FILE_PROVIDER_CONFIG_KEY } from "../engine/extension-names";
18
+ import { SYSTEM_USER_ID } from "../engine/system-user";
19
+ import type { ConfigAccessor, ConfigAccessorFactory, Registry, TenantId } from "../engine/types";
20
+ import type { SecretsContext } from "../secrets";
21
+ import type { FileStorageProvider } from "./types";
22
+
23
+ const FEATURE_NAME = "file-foundation";
24
+
25
+ /**
26
+ * Schmaler Surface-Type fuer Provider-Plugins. HandlerContext ist zu fett
27
+ * (haelt tx, actor, signal etc.) — Provider sollen sich auf die read-Felder
28
+ * beschraenken die fuer Tenant-Config + Secret-Lookup gebraucht werden.
29
+ *
30
+ * **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job) gibt es keinen
31
+ * request-bezogenen `tx`/`actor`/`signal`. Wenn ein Provider `ctx.tx` lesen
32
+ * wuerde, wuerde der ganze Worker-Pfad zur Runtime brechen — und das wuerde NUR
33
+ * mit S3 und nur in production auffallen. Die schmale Surface zwingt Provider
34
+ * zur expliziten Erweiterung statt silent ctx-feld-ausnutzen.
35
+ *
36
+ * **Felder:**
37
+ * config — fuer tenant-config-reads (bucket/region/endpoint/...)
38
+ * registry — fuer extension-Lookup in der Factory (nicht Plugin-intern)
39
+ * secrets — fuer tenant-secret-reads (s3.secretAccessKey)
40
+ * _userId — Audit-/Authority-Identity fuer secret-reads. Der Framework-
41
+ * Resolver setzt das auf SYSTEM_USER_ID — der s3-Provider liest
42
+ * `s3.secretAccessKey`, was Nicht-Admin-Request-User nicht duerfen;
43
+ * die Request-User-Autorisierung bleibt am Route-accessGuard.
44
+ */
45
+ export type FileProviderContext = {
46
+ readonly config?: ConfigAccessor;
47
+ readonly registry?: Registry;
48
+ readonly secrets?: SecretsContext;
49
+ readonly _userId?: string | undefined;
50
+ };
51
+
52
+ /**
53
+ * File-Storage-Plugin contract. Each provider-feature (file-provider-s3,
54
+ * file-provider-inmemory, ...) registers an implementation via
55
+ * `r.useExtension(EXT_FILE_PROVIDER, "<name>", { build })`.
56
+ *
57
+ * **Plugin-Author-Warnung:** `ctx` ist EXPLIZIT ein FileProviderContext, nicht
58
+ * ein voller HandlerContext. Felder ausserhalb der schmalen Surface (z.B.
59
+ * `ctx.tx`, `ctx.actor`, `ctx.signal`) sind im Worker-Pfad NICHT vorhanden.
60
+ * Cast `ctx as unknown as HandlerContext` fliegt zur Runtime im Worker — der
61
+ * Crash kommt erst in production mit dem ersten S3-Tenant. Braucht ein Plugin
62
+ * Felder ausserhalb FileProviderContext: lieber FileProviderContext explizit
63
+ * erweitern (sichtbarer breaking change) als ctx-cast.
64
+ */
65
+ export type FileProviderPlugin = {
66
+ readonly build: (ctx: FileProviderContext, tenantId: string) => Promise<FileStorageProvider>;
67
+ };
68
+
69
+ // extension-usage `options` is engine-payload (unknown) — structurally validate
70
+ // instead of casting blind.
71
+ export function isFileProviderPlugin(o: unknown): o is FileProviderPlugin {
72
+ return typeof o === "object" && o !== null && "build" in o && typeof o.build === "function";
73
+ }
74
+
75
+ // Looks up the per-tenant selected provider plugin + delegates to its build().
76
+ export async function createFileProviderForTenant(
77
+ ctx: FileProviderContext,
78
+ tenantId: string,
79
+ handlerName = "file-foundation:provider-factory",
80
+ ): Promise<FileStorageProvider> {
81
+ const ctxConfig = ctx.config;
82
+ if (!ctxConfig) {
83
+ throw new Error(
84
+ `${handlerName}: ctx.config is missing — feature requires the config-feature mounted in the registry`,
85
+ );
86
+ }
87
+ if (!ctx.registry) {
88
+ throw new Error(
89
+ `${handlerName}: ctx.registry is missing — required to look up registered file-provider plugins`,
90
+ );
91
+ }
92
+
93
+ const raw = await ctxConfig(FILE_PROVIDER_CONFIG_KEY);
94
+ const provider = typeof raw === "string" ? raw : raw == null ? "" : String(raw);
95
+ if (provider.length === 0) {
96
+ const usages = ctx.registry.getExtensionUsages(EXT_FILE_PROVIDER);
97
+ const known = usages.map((u) => u.entityName).join(", ") || "<none>";
98
+ throw new Error(
99
+ `${FEATURE_NAME}: no provider selected — set the '${FILE_PROVIDER_CONFIG_KEY}' config-key to one of: ${known}. ` +
100
+ `Mount a file-provider-* feature first if no plugins are registered.`,
101
+ );
102
+ }
103
+
104
+ const usages = ctx.registry.getExtensionUsages(EXT_FILE_PROVIDER);
105
+ const usage = usages.find((u) => u.entityName === provider);
106
+ if (!usage) {
107
+ const known = usages.map((u) => u.entityName).join(", ") || "<none>";
108
+ throw new Error(
109
+ `${FEATURE_NAME}: provider "${provider}" not registered. Known: ${known}. ` +
110
+ `Mount the matching file-provider-${provider} feature.`,
111
+ );
112
+ }
113
+
114
+ if (!isFileProviderPlugin(usage.options)) {
115
+ throw new Error(
116
+ `${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
117
+ `extension options must be a FileProviderPlugin.`,
118
+ );
119
+ }
120
+ return usage.options.build(ctx, tenantId);
121
+ }
122
+
123
+ // A bound, per-tenant provider resolver. One instance serves all tenants
124
+ // (tenantId is the call argument) — the single spine shared by upload routes,
125
+ // ctx.files and the GDPR jobs.
126
+ export type FileProviderResolver = (tenantId: TenantId) => Promise<FileStorageProvider>;
127
+
128
+ export type FileProviderResolverDeps = {
129
+ readonly registry?: Registry;
130
+ readonly _configAccessorFactory?: ConfigAccessorFactory;
131
+ readonly secrets?: SecretsContext;
132
+ readonly db?: DbConnection | TenantDb;
133
+ };
134
+
135
+ // Builds the resolver from the ambient AppContext fields — the framework-side
136
+ // equivalent of the GDPR `makeTenantStorageProviderResolver`. The config
137
+ // accessor (and therefore the s3.secretAccessKey secret-read) runs under
138
+ // SYSTEM identity: provider construction is an infra read, distinct from the
139
+ // request user's authorization which stays at the route accessGuard.
140
+ export function makeFileProviderResolver(deps: FileProviderResolverDeps): FileProviderResolver {
141
+ return async (tenantId) => {
142
+ if (!deps.registry || !deps._configAccessorFactory || !deps.db) {
143
+ throw new Error(
144
+ "makeFileProviderResolver: registry/_configAccessorFactory/db missing — " +
145
+ "mount the config + file-foundation features and wire context.db",
146
+ );
147
+ }
148
+ const config = deps._configAccessorFactory({
149
+ user: { id: SYSTEM_USER_ID, tenantId },
150
+ db: deps.db,
151
+ secrets: deps.secrets,
152
+ });
153
+ return createFileProviderForTenant(
154
+ { config, registry: deps.registry, secrets: deps.secrets, _userId: SYSTEM_USER_ID },
155
+ tenantId,
156
+ );
157
+ };
158
+ }
@@ -61,6 +61,10 @@ export type JobMeta = {
61
61
  // the run-started event so audit queries can distinguish "fresh run" vs.
62
62
  // "nth retry" without joining back to BullMQ-internals.
63
63
  attempt?: number | undefined;
64
+ // BullMQ job priority (lower = processed first). Set per-dispatch so the same
65
+ // job definition can be enqueued at different urgencies (e.g. delivery maps
66
+ // critical/normal/low onto it).
67
+ priority?: number | undefined;
64
68
  };
65
69
 
66
70
  export type JobRunner = {
@@ -177,6 +181,11 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
177
181
  worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
178
182
  };
179
183
  let worker: Worker | null = null;
184
+ // Forward reference to the runner's own API, exposed on the job-handler ctx
185
+ // so a handler can dispatch a follow-up job (job→job chaining, e.g.
186
+ // delivery.render → delivery.send). Assigned just before return; reads happen
187
+ // at job-execution time (after start()), so it is always defined by then.
188
+ let selfRunner: JobRunner | undefined;
180
189
 
181
190
  // Counts active + waiting jobs with this name for this tenant across
182
191
  // BOTH lane queues. Jobs with the same name should only live in one
@@ -303,6 +312,9 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
303
312
  // workers can reach projections/jobs without the app author duplicating
304
313
  // it into `context` (the JobContext contract guarantees `registry`).
305
314
  registry,
315
+ // Expose the runner so handlers can chain a follow-up job. Symmetric with
316
+ // how the command-dispatcher hands write-handlers their jobRunner.
317
+ ...(selfRunner !== undefined && { jobRunner: selfRunner }),
306
318
  systemUser: createSystemUser(tenantId),
307
319
  triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
308
320
  log: createJobLogger(logs),
@@ -379,7 +391,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
379
391
  }
380
392
  }
381
393
 
382
- return {
394
+ const runnerApi: JobRunner = {
383
395
  async start(): Promise<void> {
384
396
  // skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
385
397
  // no boot jobs. The API-process (runLocalJobs=false) lands here; it
@@ -510,6 +522,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
510
522
  if (jobDef.retries !== undefined) bullOpts["attempts"] = jobDef.retries + 1;
511
523
  if (jobDef.backoff) bullOpts["backoff"] = { type: jobDef.backoff };
512
524
  if (jobDef.timeout) bullOpts["timeout"] = jobDef.timeout;
525
+ if (meta?.priority !== undefined) bullOpts["priority"] = meta.priority;
513
526
 
514
527
  // Pack meta into job data with _ prefix
515
528
  const data: Record<string, unknown> = { ...payload };
@@ -575,4 +588,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
575
588
  }
576
589
  },
577
590
  };
591
+
592
+ selfRunner = runnerApi;
593
+ return runnerApi;
578
594
  }
@@ -25,6 +25,7 @@ import type {
25
25
  } from "../engine/types";
26
26
  import { HookPhases } from "../engine/types";
27
27
  import type { TenantId } from "../engine/types/identifiers";
28
+ import { createFileContext } from "../files/file-handle";
28
29
  import { createFallbackLogger } from "../logging/utils";
29
30
 
30
31
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
@@ -285,6 +286,14 @@ export function createDispatcher(
285
286
  secrets: context.secrets,
286
287
  })
287
288
  : undefined;
289
+ // ctx.files resolved per-tenant through file-foundation (lazy — the
290
+ // provider is only resolved when a handle actually does I/O). Boot wires
291
+ // _fileProviderResolver when a file-provider plugin is mounted; falls back
292
+ // to a statically-injected context.files (tests).
293
+ const fileResolver = context._fileProviderResolver;
294
+ const files = fileResolver
295
+ ? createFileContext(() => fileResolver(user.tenantId))
296
+ : context.files;
288
297
 
289
298
  // Observability — feature-bound metrics handle, so ctx.metrics.inc("foo")
290
299
  // resolves to kumiko_<feature>_foo. Unknown feature falls back to noop
@@ -558,6 +567,7 @@ export function createDispatcher(
558
567
  log,
559
568
  notify,
560
569
  ...(config && { config }),
570
+ ...(files && { files }),
561
571
  tracer,
562
572
  metrics,
563
573
  tz,
@@ -258,6 +258,15 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
258
258
  const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
259
259
  const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
260
260
 
261
+ // A static `files.storageProvider` is wired as the per-tenant resolver — the
262
+ // framework test seam that doesn't require mounting config + file-foundation.
263
+ // (Bundled GDPR tests mount the real provider features instead.)
264
+ let fileProviderResolver: import("../files").FileProviderResolver | undefined;
265
+ if (options.files) {
266
+ const provider = options.files.storageProvider;
267
+ fileProviderResolver = () => Promise.resolve(provider);
268
+ }
269
+
261
270
  const server = buildServer({
262
271
  registry,
263
272
  context: {
@@ -267,6 +276,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
267
276
  entityCache,
268
277
  registry,
269
278
  ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
279
+ ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
270
280
  ...(typeof options.extraContext === "function"
271
281
  ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
272
282
  : options.extraContext),
@@ -318,11 +328,6 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
318
328
  : options.anonymousAccess,
319
329
  }
320
330
  : {}),
321
- // Wire the upload routes + ctx.files only when the caller registered a
322
- // provider. Tests that don't touch files skip both without extra setup.
323
- ...(options.files
324
- ? { files: { db: testDb.db, storageProvider: options.files.storageProvider } }
325
- : {}),
326
331
  });
327
332
 
328
333
  const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;