@cosmicdrift/kumiko-framework 0.98.0 → 0.100.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 +2 -2
- package/src/api/__tests__/jwt.test.ts +58 -0
- package/src/api/jwt.ts +24 -2
- package/src/api/server.ts +71 -20
- package/src/engine/__tests__/boot-validator.test.ts +39 -0
- package/src/engine/boot-validator/screens-nav.ts +5 -1
- package/src/engine/extension-names.ts +21 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/types/handlers.ts +10 -4
- package/src/engine/types/index.ts +1 -1
- package/src/engine/types/screen.ts +10 -0
- package/src/entrypoint/index.ts +0 -3
- package/src/files/__tests__/file-handle.test.ts +5 -5
- package/src/files/__tests__/files.integration.test.ts +8 -8
- package/src/files/feature.ts +18 -3
- package/src/files/file-handle.ts +27 -10
- package/src/files/file-routes.ts +20 -11
- package/src/files/index.ts +11 -0
- package/src/files/provider-resolver.ts +158 -0
- package/src/pipeline/dispatcher.ts +10 -0
- package/src/stack/test-stack.ts +10 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.100.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.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.100.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
|
|
54
|
-
roles
|
|
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
|
|
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
|
-
//
|
|
215
|
-
// provider
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
221
|
-
"
|
|
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.
|
|
292
|
-
//
|
|
293
|
-
// `ctx.files
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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).
|
package/src/engine/index.ts
CHANGED
|
@@ -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,
|
|
234
|
-
//
|
|
235
|
-
//
|
|
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:
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -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" | "
|
|
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
|
-
|
|
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 {
|
package/src/files/feature.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
package/src/files/file-handle.ts
CHANGED
|
@@ -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
|
|
29
|
-
//
|
|
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
|
-
|
|
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: () =>
|
|
38
|
-
write: (data, mimeType) =>
|
|
39
|
-
delete: () =>
|
|
40
|
-
exists: () =>
|
|
41
|
-
derive: (suffix) => createFileHandle(deriveKey(key, suffix),
|
|
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
|
-
|
|
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,
|
|
64
|
+
ref: (key) => createFileHandle(key, getProvider),
|
|
48
65
|
};
|
|
49
66
|
}
|
|
50
67
|
|
package/src/files/file-routes.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
package/src/files/index.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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,
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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;
|