@cosmicdrift/kumiko-bundled-features 0.196.1 → 0.197.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 +7 -7
- package/src/file-derivatives/__tests__/public-variant-predicate-args.integration.test.ts +141 -0
- package/src/file-derivatives/__tests__/public-variant-query-validation.integration.test.ts +75 -0
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +10 -0
- package/src/file-derivatives/feature.ts +7 -2
- package/src/file-derivatives/handlers/public-variant.query.ts +17 -3
- package/src/file-derivatives/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.197.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -125,12 +125,12 @@
|
|
|
125
125
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
126
126
|
},
|
|
127
127
|
"dependencies": {
|
|
128
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.197.0",
|
|
129
|
+
"@cosmicdrift/kumiko-framework": "0.197.0",
|
|
130
|
+
"@cosmicdrift/kumiko-headless": "0.197.0",
|
|
131
|
+
"@cosmicdrift/kumiko-renderer": "0.197.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer-web": "0.197.0",
|
|
133
|
+
"@cosmicdrift/kumiko-types": "0.197.0",
|
|
134
134
|
"@mollie/api-client": "^4.5.0",
|
|
135
135
|
"@node-rs/argon2": "^2.0.2",
|
|
136
136
|
"@types/mailparser": "^3.4.6",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Proves #1989/2: DerivativePublicPredicateArgs carries `fieldName` and
|
|
2
|
+
// `variant` (not just entityId/tenantId) through to `isPublic`, so an app
|
|
3
|
+
// can opt individual variants out of public serving instead of every
|
|
4
|
+
// declared variant on an entityType being implicitly public once the
|
|
5
|
+
// entityType-level check passes.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
createEntity,
|
|
10
|
+
createImageField,
|
|
11
|
+
defineFeature,
|
|
12
|
+
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
13
|
+
EXT_DERIVATIVE_RENDERER,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import {
|
|
16
|
+
createFilesFeature,
|
|
17
|
+
createInMemoryFileProvider,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
19
|
+
import {
|
|
20
|
+
createTestUser,
|
|
21
|
+
setupTestStack,
|
|
22
|
+
type TestStack,
|
|
23
|
+
testTenantId,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
|
+
import {
|
|
26
|
+
buildMultipartBody,
|
|
27
|
+
patchFileInstanceofForBunTest,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
29
|
+
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
30
|
+
import { createConfigFeature } from "../../config";
|
|
31
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
32
|
+
import { createFileDerivativesFeature } from "../feature";
|
|
33
|
+
import type { DerivativePublicPredicateArgs } from "../handlers/public-variant.query";
|
|
34
|
+
|
|
35
|
+
const VARIANT_BYTES = new Uint8Array([9, 9, 9]);
|
|
36
|
+
const fakeRender: DerivativeRendererPlugin["render"] = async () => VARIANT_BYTES;
|
|
37
|
+
|
|
38
|
+
const ENTITY_ID = "gadget-1";
|
|
39
|
+
const HOST = "gadgets.example.com";
|
|
40
|
+
const TENANT = testTenantId(7);
|
|
41
|
+
|
|
42
|
+
// Deliberately gates on `variant`, not just entityId — "premium" stays
|
|
43
|
+
// denied even for an entityId the entityType-level check would otherwise
|
|
44
|
+
// allow, proving a predicate can opt a specific variant out.
|
|
45
|
+
let receivedArgs: DerivativePublicPredicateArgs[] = [];
|
|
46
|
+
const gadgetEntity = createEntity({
|
|
47
|
+
table: "public_variant_predicate_gadgets",
|
|
48
|
+
fields: {
|
|
49
|
+
img: createImageField({
|
|
50
|
+
variants: {
|
|
51
|
+
thumb: { maxEdge: 160, format: "webp" },
|
|
52
|
+
premium: { maxEdge: 4096, format: "webp" },
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const gadgetPredicateFeature = defineFeature("publicvariantpredicateargstest", (r) => {
|
|
59
|
+
r.entity("gadget", gadgetEntity);
|
|
60
|
+
r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "gadget", {
|
|
61
|
+
isPublic: (args: DerivativePublicPredicateArgs) => {
|
|
62
|
+
receivedArgs.push(args);
|
|
63
|
+
return args.variant !== "premium";
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", { render: fakeRender });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const user = createTestUser({ id: 1, tenantId: TENANT, roles: ["Admin"] });
|
|
70
|
+
|
|
71
|
+
let stack: TestStack;
|
|
72
|
+
|
|
73
|
+
beforeAll(async () => {
|
|
74
|
+
patchFileInstanceofForBunTest();
|
|
75
|
+
stack = await setupTestStack({
|
|
76
|
+
features: [
|
|
77
|
+
createConfigFeature(),
|
|
78
|
+
fileFoundationFeature,
|
|
79
|
+
createFilesFeature(),
|
|
80
|
+
createFileDerivativesFeature({
|
|
81
|
+
resolveApexTenant: (host) => (host === HOST ? TENANT : null),
|
|
82
|
+
}),
|
|
83
|
+
gadgetPredicateFeature,
|
|
84
|
+
],
|
|
85
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await stack.cleanup();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
receivedArgs = [];
|
|
95
|
+
await stack.redis.flushNamespace();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
async function uploadGadgetImage(): Promise<string> {
|
|
99
|
+
const token = await stack.jwt.sign(user);
|
|
100
|
+
const fd = new FormData();
|
|
101
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "img.jpg", { type: "image/jpeg" }));
|
|
102
|
+
fd.append("entityType", "gadget");
|
|
103
|
+
fd.append("entityId", ENTITY_ID);
|
|
104
|
+
fd.append("fieldName", "img");
|
|
105
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
106
|
+
const res = await stack.app.request("/api/files", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
109
|
+
body,
|
|
110
|
+
});
|
|
111
|
+
expect(res.status).toBe(201);
|
|
112
|
+
const json = (await res.json()) as { id: string };
|
|
113
|
+
return json.id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("file-derivatives :: isPublic receives fieldName + variant", () => {
|
|
117
|
+
test("isPublic is called with the FileRef's fieldName and the requested variant name", async () => {
|
|
118
|
+
const fileId = await uploadGadgetImage();
|
|
119
|
+
|
|
120
|
+
const res = await stack.app.request(`http://${HOST}/media/${fileId}/thumb`);
|
|
121
|
+
|
|
122
|
+
expect(res.status).toBe(200);
|
|
123
|
+
expect(receivedArgs).toHaveLength(1);
|
|
124
|
+
expect(receivedArgs[0]).toMatchObject({
|
|
125
|
+
entityId: ENTITY_ID,
|
|
126
|
+
fieldName: "img",
|
|
127
|
+
variant: "thumb",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a predicate that opts a specific variant out denies exactly that variant, not the whole entity", async () => {
|
|
132
|
+
const fileId = await uploadGadgetImage();
|
|
133
|
+
|
|
134
|
+
const thumbRes = await stack.app.request(`http://${HOST}/media/${fileId}/thumb`);
|
|
135
|
+
const premiumRes = await stack.app.request(`http://${HOST}/media/${fileId}/premium`);
|
|
136
|
+
|
|
137
|
+
expect(thumbRes.status).toBe(200);
|
|
138
|
+
expect(premiumRes.status).toBe(404);
|
|
139
|
+
expect(receivedArgs.map((a) => a.variant)).toEqual(["thumb", "premium"]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Proves #1989/3: publicVariantQuery's `fileRefId` is UUID-validated by the
|
|
2
|
+
// handler's own zod schema, not only by the httpRoute wrapper's
|
|
3
|
+
// FILE_REF_ID_RE pre-check. The generic `/api/query` dispatch path (reachable
|
|
4
|
+
// once resolveApexTenant is configured, see public-variant-query-gating.
|
|
5
|
+
// integration.test.ts for the "not configured" case) skips that httpRoute
|
|
6
|
+
// pre-check entirely and calls `handler.schema.safeParse` directly — before
|
|
7
|
+
// this fix, a non-UUID `fileRefId` sailed through the schema and hit
|
|
8
|
+
// fetchOne(), throwing a Postgres 22P02 (malformed uuid literal) that
|
|
9
|
+
// poisons the pooled Bun.SQL connection, an unauth DoS primitive.
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import {
|
|
14
|
+
createFilesFeature,
|
|
15
|
+
createInMemoryFileProvider,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
17
|
+
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
18
|
+
import { createConfigFeature } from "../../config";
|
|
19
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
20
|
+
import { createFileDerivativesFeature } from "../feature";
|
|
21
|
+
import { PUBLIC_VARIANT_QN } from "../handlers/public-variant.query";
|
|
22
|
+
|
|
23
|
+
describe("file-derivatives :: publicVariant query fileRefId validation", () => {
|
|
24
|
+
test("non-UUID fileRefId via /api/query → 400 validation_error, never reaches the DB", async () => {
|
|
25
|
+
const stack: TestStack = await setupTestStack({
|
|
26
|
+
features: [
|
|
27
|
+
createConfigFeature(),
|
|
28
|
+
fileFoundationFeature,
|
|
29
|
+
createFilesFeature(),
|
|
30
|
+
createFileDerivativesFeature({ resolveApexTenant: () => SYSTEM_TENANT_ID }),
|
|
31
|
+
],
|
|
32
|
+
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
33
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
38
|
+
type: PUBLIC_VARIANT_QN,
|
|
39
|
+
payload: { fileRefId: "not-a-uuid; DROP TABLE file_refs;--", variant: "thumb" },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(res.status).toBe(400);
|
|
43
|
+
const body = (await res.json()) as { error?: { code?: string } };
|
|
44
|
+
expect(body.error?.code).toBe("validation_error");
|
|
45
|
+
} finally {
|
|
46
|
+
await stack.cleanup();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("well-formed but unknown UUID fileRefId via /api/query → 200 with null (schema passes, DB lookup just finds nothing)", async () => {
|
|
51
|
+
const stack: TestStack = await setupTestStack({
|
|
52
|
+
features: [
|
|
53
|
+
createConfigFeature(),
|
|
54
|
+
fileFoundationFeature,
|
|
55
|
+
createFilesFeature(),
|
|
56
|
+
createFileDerivativesFeature({ resolveApexTenant: () => SYSTEM_TENANT_ID }),
|
|
57
|
+
],
|
|
58
|
+
anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
|
|
59
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const res = await stack.http.raw("POST", "/api/query", {
|
|
64
|
+
type: PUBLIC_VARIANT_QN,
|
|
65
|
+
payload: { fileRefId: "00000000-0000-4000-8000-000000000000", variant: "thumb" },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(res.status).toBe(200);
|
|
69
|
+
const body = (await res.json()) as { data: unknown };
|
|
70
|
+
expect(body.data).toBeNull();
|
|
71
|
+
} finally {
|
|
72
|
+
await stack.cleanup();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -182,6 +182,16 @@ describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
|
|
|
182
182
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
+
// setupTestStack doesn't wrap with the app-wide security-headers default, so a pass proves the route sets its own.
|
|
186
|
+
test("sets X-Content-Type-Options: nosniff on the response itself", async () => {
|
|
187
|
+
const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
|
|
188
|
+
|
|
189
|
+
const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
|
|
190
|
+
|
|
191
|
+
expect(res.status).toBe(200);
|
|
192
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
193
|
+
});
|
|
194
|
+
|
|
185
195
|
test("a second call on the same (fileRefId, variant) hits the cache — renderer runs once", async () => {
|
|
186
196
|
const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
|
|
187
197
|
|
|
@@ -78,7 +78,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
78
78
|
|
|
79
79
|
return defineFeature(FEATURE_NAME, (r) => {
|
|
80
80
|
r.describe(
|
|
81
|
-
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name.",
|
|
81
|
+
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it.",
|
|
82
82
|
);
|
|
83
83
|
r.uiHints({
|
|
84
84
|
displayLabel: "File Derivatives",
|
|
@@ -171,7 +171,12 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
171
171
|
body: bytes,
|
|
172
172
|
etag,
|
|
173
173
|
cache: { kind: "revalidate", maxAgeSeconds: 60 },
|
|
174
|
-
|
|
174
|
+
// Explicit here, not left to the app-wide security-headers default, so the route stays safe standalone.
|
|
175
|
+
headers: {
|
|
176
|
+
"content-type": result.mimeType,
|
|
177
|
+
vary: "Host",
|
|
178
|
+
"x-content-type-options": "nosniff",
|
|
179
|
+
},
|
|
175
180
|
});
|
|
176
181
|
},
|
|
177
182
|
});
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
// FileRef that doesn't exist (no existence-leak via distinct status codes).
|
|
11
11
|
//
|
|
12
12
|
// tenantId comes from `query.user.tenantId` ONLY (host-resolved by the
|
|
13
|
-
// httpRoute wrapper's `resolveApexTenant`), never from the payload
|
|
13
|
+
// httpRoute wrapper's `resolveApexTenant`), never from the payload — true
|
|
14
|
+
// for the httpRoute-wrapper path. Over the generic `/api/query` dispatch
|
|
15
|
+
// (only reachable when this feature is mounted WITH resolveApexTenant, see
|
|
16
|
+
// feature.ts), `ctx.user.tenantId` instead comes from that dispatcher's own
|
|
17
|
+
// `anonymousAccess` resolution — tenant provenance there depends on the
|
|
18
|
+
// consumer's `resolverTrust`/anonymousAccess setup, not the host.
|
|
14
19
|
|
|
15
20
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
21
|
import { resolveFieldVariant } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
@@ -29,9 +34,15 @@ type FileRefRow = {
|
|
|
29
34
|
readonly fieldName: string | null;
|
|
30
35
|
};
|
|
31
36
|
|
|
37
|
+
// A declared variant with no explicit gate in `isPublic` is implicitly
|
|
38
|
+
// public as soon as the entityType-level check returns `true` — `fieldName`
|
|
39
|
+
// and `variant` let a predicate opt individual fields/variants out (e.g. a
|
|
40
|
+
// cheap `thumb` served publicly while an expensive `full` stays gated).
|
|
32
41
|
export type DerivativePublicPredicateArgs = {
|
|
33
42
|
readonly entityId: string;
|
|
34
43
|
readonly tenantId: TenantId;
|
|
44
|
+
readonly fieldName: string;
|
|
45
|
+
readonly variant: string;
|
|
35
46
|
};
|
|
36
47
|
|
|
37
48
|
export type DerivativePublicPredicatePlugin = {
|
|
@@ -58,7 +69,7 @@ export const PUBLIC_VARIANT_QN = "file-derivatives:query:public-variant";
|
|
|
58
69
|
export const publicVariantQuery = defineQueryHandler({
|
|
59
70
|
name: "public-variant",
|
|
60
71
|
schema: z.object({
|
|
61
|
-
fileRefId: z.string(),
|
|
72
|
+
fileRefId: z.string().uuid(),
|
|
62
73
|
variant: z.string().min(1).max(64),
|
|
63
74
|
}),
|
|
64
75
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
@@ -90,7 +101,10 @@ export const publicVariantQuery = defineQueryHandler({
|
|
|
90
101
|
);
|
|
91
102
|
}
|
|
92
103
|
|
|
93
|
-
const isPublic = await usage.options.isPublic(
|
|
104
|
+
const isPublic = await usage.options.isPublic(
|
|
105
|
+
{ entityId, tenantId: ctx.user.tenantId, fieldName, variant: query.payload.variant },
|
|
106
|
+
ctx,
|
|
107
|
+
);
|
|
94
108
|
if (!isPublic) return null;
|
|
95
109
|
|
|
96
110
|
// ctx.files/ctx.derivatives are optional on HandlerContext (only wired
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export type { FileDerivativesOptions, PublicVariantResolveApexTenant } from "./feature";
|
|
2
2
|
export { createFileDerivativesFeature, fileDerivativesFeature } from "./feature";
|
|
3
|
+
export type {
|
|
4
|
+
DerivativePublicPredicateArgs,
|
|
5
|
+
DerivativePublicPredicatePlugin,
|
|
6
|
+
} from "./handlers/public-variant.query";
|
|
3
7
|
export { card, full, hero, thumb } from "./presets";
|