@cosmicdrift/kumiko-bundled-features 0.192.0 → 0.193.1

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.
Files changed (24) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +43 -1
  3. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +237 -0
  4. package/src/file-derivatives/feature.ts +142 -21
  5. package/src/file-derivatives/handlers/public-variant.query.ts +122 -0
  6. package/src/file-derivatives/index.ts +3 -2
  7. package/src/file-derivatives/presets.ts +7 -0
  8. package/src/form-draft/__tests__/cleanup.integration.test.ts +30 -0
  9. package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +26 -1
  10. package/src/form-draft/__tests__/feature-optional-config.integration.test.ts +90 -0
  11. package/src/form-draft/db/queries/cleanup.ts +15 -14
  12. package/src/form-draft/db/queries/owned-file-refs.ts +13 -2
  13. package/src/form-draft/feature.ts +7 -1
  14. package/src/form-draft/handlers/__tests__/cleanup.job.test.ts +55 -0
  15. package/src/form-draft/handlers/cleanup.job.ts +57 -11
  16. package/src/form-draft/handlers/discard.write.ts +1 -0
  17. package/src/form-draft/lookup.ts +2 -0
  18. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +60 -0
  19. package/src/form-draft-user-data/hooks.ts +12 -1
  20. package/src/template-resolver/handlers/by-slug.query.ts +1 -0
  21. package/src/template-resolver/handlers/collection-shared.ts +2 -0
  22. package/src/template-resolver/web/__tests__/editor-read-only.test.tsx +25 -0
  23. package/src/template-resolver/web/client-plugin.tsx +22 -0
  24. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +21 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.192.0",
3
+ "version": "0.193.1",
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.192.0",
129
- "@cosmicdrift/kumiko-framework": "0.192.0",
130
- "@cosmicdrift/kumiko-headless": "0.192.0",
131
- "@cosmicdrift/kumiko-renderer": "0.192.0",
132
- "@cosmicdrift/kumiko-renderer-web": "0.192.0",
133
- "@cosmicdrift/kumiko-types": "0.192.0",
128
+ "@cosmicdrift/kumiko-dispatcher-live": "0.193.1",
129
+ "@cosmicdrift/kumiko-framework": "0.193.1",
130
+ "@cosmicdrift/kumiko-headless": "0.193.1",
131
+ "@cosmicdrift/kumiko-renderer": "0.193.1",
132
+ "@cosmicdrift/kumiko-renderer-web": "0.193.1",
133
+ "@cosmicdrift/kumiko-types": "0.193.1",
134
134
  "@mollie/api-client": "^4.5.0",
135
135
  "@node-rs/argon2": "^2.0.2",
136
136
  "@types/mailparser": "^3.4.6",
@@ -33,7 +33,7 @@ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery
33
33
  import { notificationPreferencesTable } from "../../delivery/tables";
34
34
  import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
35
35
  import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
36
- import { hashPassword } from "../../shared";
36
+ import { decryptStoredPii, hashPassword } from "../../shared";
37
37
  import { createTemplateResolverFeature } from "../../template-resolver/feature";
38
38
  import { createTenantFeature } from "../../tenant";
39
39
  import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
@@ -42,6 +42,7 @@ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
42
42
  import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
43
43
  import { createUserFeature } from "../../user/feature";
44
44
  import { userEntity, userTable } from "../../user/schema/user";
45
+ import { tenantInvitationDeleteHook } from "../../user-data-rights-defaults";
45
46
  import { AuthHandlers } from "../constants";
46
47
  import { createAuthEmailPasswordFeature } from "../feature";
47
48
  import { seedUser } from "../seeding";
@@ -298,4 +299,45 @@ describe("auth flows with active KMS + blind index", () => {
298
299
  expect(isPiiCiphertext(list[0]?.invitedBy)).toBe(false);
299
300
  expect(list[0]?.invitedBy).toBe(aliceId);
300
301
  });
302
+
303
+ // fw-review #7: the inviter-forget arm compared a plaintext userId against
304
+ // the encrypted `invitedBy` column via selectMany's equality filter — with
305
+ // no lookupable/blind-index column on `invitedBy`, that filter matched
306
+ // zero rows under active KMS, so Alice's Art.17 forget never anonymized
307
+ // rows for invitations she sent. Fixed by loading the tenant's invitations
308
+ // and decrypting `invitedBy` per row for comparison instead.
309
+ test("Art.17 forget anonymizes invitedBy for an invite the requester sent, even under active KMS", async () => {
310
+ await inviteEmail(CAROL_EMAIL, "Editor");
311
+
312
+ const [rawBefore] = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
313
+ `SELECT * FROM "${tenantInvitationsTable.tableName}" WHERE tenant_id = $1`,
314
+ [TENANT_A_ID],
315
+ );
316
+ if (!rawBefore) throw new Error("no invitation row seeded");
317
+ expect(isPiiCiphertext(rawBefore["invited_by"])).toBe(true);
318
+
319
+ await tenantInvitationDeleteHook(
320
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A_ID, userId: aliceId },
321
+ "delete",
322
+ );
323
+
324
+ const [rawAfter] = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
325
+ `SELECT * FROM "${tenantInvitationsTable.tableName}" WHERE tenant_id = $1`,
326
+ [TENANT_A_ID],
327
+ );
328
+ if (!rawAfter)
329
+ throw new Error("invitation row disappeared — inviter-forget only severs the link");
330
+ // The invitee link is untouched — this row belongs to Carol, not Alice.
331
+ expect(isPiiCiphertext(rawAfter["email"])).toBe(true);
332
+ // invitedBy is re-encrypted on write, so assert on the decrypted value —
333
+ // the sentinel itself lives as INVITED_BY_ANONYMIZED in the hook file.
334
+ expect(isPiiCiphertext(rawAfter["invited_by"])).toBe(true);
335
+ const decryptedInvitedBy = await decryptStoredPii(
336
+ String(rawAfter["invited_by"]),
337
+ "invitedBy",
338
+ "test",
339
+ );
340
+ expect(decryptedInvitedBy).toBe("anonymized");
341
+ expect(decryptedInvitedBy).not.toBe(aliceId);
342
+ });
301
343
  });
@@ -0,0 +1,237 @@
1
+ // Proves GET {basePath}/:fileRefId/:variant end-to-end: anonymous, no
2
+ // Authorization header, tenant resolved ONLY from Host (never the payload),
3
+ // default-deny when no `derivativePublicPredicate` is registered for the
4
+ // FileRef's entityType (or it returns false), the fixed preset-name
5
+ // pre-check running BEFORE any DB/systemQuery work, and the Step-1
6
+ // requestContext fix that makes `rateLimit: {per: "ip"}` actually gate an
7
+ // r.httpRoute handler invoked via systemQuery (#1951).
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import {
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([7, 7, 7]);
36
+
37
+ let renderCalls = 0;
38
+ const fakeRender: DerivativeRendererPlugin["render"] = async () => {
39
+ renderCalls++;
40
+ return VARIANT_BYTES;
41
+ };
42
+
43
+ const PUBLIC_WIDGET_ID = "widget-public";
44
+ const OTHER_WIDGET_ID = "widget-other";
45
+
46
+ let predicateCalls = 0;
47
+ const widgetPredicateFeature = defineFeature("publicvariantroutetest", (r) => {
48
+ r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "widget", {
49
+ isPublic: (args: DerivativePublicPredicateArgs) => {
50
+ predicateCalls++;
51
+ return args.entityId === PUBLIC_WIDGET_ID;
52
+ },
53
+ });
54
+ // EXT_DERIVATIVE_RENDERER itself is already declared by
55
+ // createFileDerivativesFeature — only register a plugin under it here.
56
+ r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", { render: fakeRender });
57
+ });
58
+
59
+ const TENANT_A = testTenantId(1);
60
+ const TENANT_B = testTenantId(2);
61
+ const HOST_A = "tenant-a.example.com";
62
+ const HOST_B = "tenant-b.example.com";
63
+
64
+ const userA = createTestUser({ id: 1, tenantId: TENANT_A, roles: ["Admin"] });
65
+
66
+ let stack: TestStack;
67
+
68
+ beforeAll(async () => {
69
+ patchFileInstanceofForBunTest();
70
+ stack = await setupTestStack({
71
+ features: [
72
+ createConfigFeature(),
73
+ fileFoundationFeature,
74
+ createFilesFeature(),
75
+ createFileDerivativesFeature({
76
+ resolveApexTenant: (host) => {
77
+ if (host === HOST_A) return TENANT_A;
78
+ if (host === HOST_B) return TENANT_B;
79
+ return null;
80
+ },
81
+ }),
82
+ widgetPredicateFeature,
83
+ ],
84
+ files: { storageProvider: createInMemoryFileProvider() },
85
+ });
86
+ });
87
+
88
+ afterAll(async () => {
89
+ await stack.cleanup();
90
+ });
91
+
92
+ beforeEach(async () => {
93
+ renderCalls = 0;
94
+ predicateCalls = 0;
95
+ // Fresh rate-limit bucket per test — no carry-over.
96
+ await stack.redis.flushNamespace();
97
+ });
98
+
99
+ async function uploadFile(
100
+ asUser: typeof userA,
101
+ attach: { entityType: string; entityId: string },
102
+ ): Promise<string> {
103
+ const token = await stack.jwt.sign(asUser);
104
+ const fd = new FormData();
105
+ fd.append("file", new File([Buffer.from([1, 2, 3])], "img.jpg", { type: "image/jpeg" }));
106
+ fd.append("entityType", attach.entityType);
107
+ fd.append("entityId", attach.entityId);
108
+ fd.append("fieldName", "img");
109
+ const { body, contentType } = await buildMultipartBody(fd);
110
+ const res = await stack.app.request("/api/files", {
111
+ method: "POST",
112
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
113
+ body,
114
+ });
115
+ expect(res.status).toBe(201);
116
+ const json = (await res.json()) as { id: string };
117
+ return json.id;
118
+ }
119
+
120
+ describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
121
+ test("no predicate registered for the FileRef's entityType → 404", async () => {
122
+ const fileId = await uploadFile(userA, { entityType: "unregistered-type", entityId: "x" });
123
+
124
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
125
+
126
+ expect(res.status).toBe(404);
127
+ expect(renderCalls).toBe(0);
128
+ });
129
+
130
+ test("predicate registered but returns false → 404", async () => {
131
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: OTHER_WIDGET_ID });
132
+
133
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
134
+
135
+ expect(res.status).toBe(404);
136
+ expect(predicateCalls).toBeGreaterThan(0);
137
+ expect(renderCalls).toBe(0);
138
+ });
139
+
140
+ test("predicate returns true → 200 with rendered bytes, mimeType, Vary: Host", async () => {
141
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
142
+
143
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
144
+
145
+ expect(res.status).toBe(200);
146
+ expect(res.headers.get("Content-Type")).toBe("image/webp");
147
+ expect(res.headers.get("Vary")).toBe("Host");
148
+ expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
149
+ });
150
+
151
+ test("a second call on the same (fileRefId, variant) hits the cache — renderer runs once", async () => {
152
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
153
+
154
+ const first = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
155
+ const second = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
156
+
157
+ expect(first.status).toBe(200);
158
+ expect(second.status).toBe(200);
159
+ expect(renderCalls).toBe(1);
160
+ });
161
+
162
+ test("If-None-Match with a matching ETag → 304, no body", async () => {
163
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
164
+
165
+ const first = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
166
+ const etag = first.headers.get("ETag");
167
+ expect(etag).toBeTruthy();
168
+
169
+ const revalidate = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`, {
170
+ headers: { "if-none-match": etag ?? "" },
171
+ });
172
+
173
+ expect(revalidate.status).toBe(304);
174
+ expect(await revalidate.arrayBuffer()).toEqual(new ArrayBuffer(0));
175
+ });
176
+
177
+ test("invalid variant name → 404, pre-check runs before any DB/systemQuery work", async () => {
178
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
179
+
180
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/not-a-preset`);
181
+
182
+ expect(res.status).toBe(404);
183
+ expect(predicateCalls).toBe(0);
184
+ expect(renderCalls).toBe(0);
185
+ });
186
+
187
+ test("unknown fileRefId → 404", async () => {
188
+ // Valid UUID shape (the id column's type) but no matching row — a
189
+ // malformed/non-UUID id is a separate concern shared with the
190
+ // already-merged #1950 route (same fetchOne-by-id pattern), out of
191
+ // scope here.
192
+ const res = await stack.app.request(
193
+ `http://${HOST_A}/media/00000000-0000-4000-8000-000000000000/thumb`,
194
+ );
195
+
196
+ expect(res.status).toBe(404);
197
+ });
198
+
199
+ test("cross-tenant: FileRef under tenant A, request resolves to tenant B → 404", async () => {
200
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
201
+
202
+ const res = await stack.app.request(`http://${HOST_B}/media/${fileId}/thumb`);
203
+
204
+ expect(res.status).toBe(404);
205
+ });
206
+
207
+ test("resolveApexTenant returns null for an unknown host → 404", async () => {
208
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
209
+
210
+ const res = await stack.app.request(`http://unknown.example.com/media/${fileId}/thumb`);
211
+
212
+ expect(res.status).toBe(404);
213
+ });
214
+
215
+ test("more than `limit` requests from the same IP within the window → 429 (Step-1 regression)", async () => {
216
+ const fileId = await uploadFile(userA, { entityType: "widget", entityId: PUBLIC_WIDGET_ID });
217
+ const xff = "203.0.113.9";
218
+
219
+ // publicVariantQuery's rateLimit is {per: "ip", limit: 60, windowSeconds: 60}
220
+ // — 60 calls must succeed, the 61st must be blocked. Without the Step-1
221
+ // fix (requestContext.run wrapping systemQuery in server.ts) `ip` is
222
+ // never populated for an r.httpRoute-invoked handler, enforceRateLimit's
223
+ // bucket resolves to "skip", and this test would fail — every call
224
+ // would return 200.
225
+ for (let i = 0; i < 60; i++) {
226
+ const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`, {
227
+ headers: { "x-forwarded-for": xff },
228
+ });
229
+ expect(res.status).toBe(200);
230
+ }
231
+
232
+ const blocked = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`, {
233
+ headers: { "x-forwarded-for": xff },
234
+ });
235
+ expect(blocked.status).toBe(429);
236
+ }, 20000);
237
+ });
@@ -6,28 +6,149 @@
6
6
  // configurable, so this feature has no `r.config` and no
7
7
  // `r.extensionSelector`.
8
8
 
9
- import { defineFeature, EXT_DERIVATIVE_RENDERER } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { cachedResponse, computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
10
+ import {
11
+ defineFeature,
12
+ EXT_DERIVATIVE_PUBLIC_PREDICATE,
13
+ EXT_DERIVATIVE_RENDERER,
14
+ type FeatureDefinition,
15
+ type TenantId,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
18
+ import { PUBLIC_VARIANT_QN, publicVariantQuery } from "./handlers/public-variant.query";
19
+ import { PRESET_VARIANT_NAMES } from "./presets";
10
20
 
11
21
  const FEATURE_NAME = "file-derivatives";
12
22
 
13
- export const fileDerivativesFeature = defineFeature(FEATURE_NAME, (r) => {
14
- r.describe(
15
- "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.",
16
- );
17
- r.uiHints({
18
- displayLabel: "File Derivatives",
19
- category: "storage",
20
- recommended: false,
21
- });
22
- // Needs a storage provider to read the original + write the variant
23
- // without file-foundation there's nothing to derive from or write to.
24
- r.requires("file-foundation");
25
-
26
- r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
27
- onRegister: () => {
28
- // No side-effects at register-time — resolution (exact MIME match,
29
- // then `<type>/*` wildcard) happens at request-time in
30
- // resolveRenderer, mirrors file-foundation's fileProvider point.
31
- },
23
+ // Widened from the readonly-tuple type of PRESET_VARIANT_NAMES so
24
+ // `.includes(variant)` accepts a plain `string` param without an `as` cast.
25
+ const VALID_VARIANT_NAMES: readonly string[] = PRESET_VARIANT_NAMES;
26
+
27
+ export type PublicVariantResolveApexTenant = (
28
+ host: string,
29
+ ) => Promise<TenantId | null> | TenantId | null;
30
+
31
+ export type FileDerivativesOptions = {
32
+ /** Host tenantId for the anonymous `/media/:fileRefId/:variant` route.
33
+ * Without this option the route is NOT mounted existing consumers that
34
+ * only need `ctx.derivatives` are unaffected. */
35
+ readonly resolveApexTenant?: PublicVariantResolveApexTenant;
36
+ /** Base path of the public variant route. Default "/media". */
37
+ readonly basePath?: string;
38
+ };
39
+
40
+ // Raw handler-return of publicVariantQuery — systemQuery dispatches
41
+ // directly against the handler, no `{data}` wire envelope.
42
+ type PublicVariantQueryResult = {
43
+ readonly dataBase64: string;
44
+ readonly mimeType: string;
45
+ readonly storageKey: string;
46
+ } | null;
47
+
48
+ // file-derivatives — derive-on-first-use file variants (thumbnails,
49
+ // resized/reformatted images) plus an opt-in ANONYMOUS route that serves
50
+ // one of 4 fixed presets (thumb/card/hero/full) for a FileRef whose
51
+ // entityType has a registered, default-deny `derivativePublicPredicate`.
52
+ //
53
+ // The route never serves the original and never accepts a free VariantSpec
54
+ // — only a preset name from the fixed list, and only after the app's
55
+ // registered `isPublic` predicate for the FileRef's entityType says yes.
56
+ // tenantId is resolved from the request Host via `resolveApexTenant`, NEVER
57
+ // read from the request payload.
58
+ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}): FeatureDefinition {
59
+ const basePath = opts.basePath ?? "/media";
60
+
61
+ return defineFeature(FEATURE_NAME, (r) => {
62
+ r.describe(
63
+ "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 ONE of the 4 fixed presets (thumb/card/hero/full) for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef.",
64
+ );
65
+ r.uiHints({
66
+ displayLabel: "File Derivatives",
67
+ category: "storage",
68
+ recommended: false,
69
+ });
70
+ // Needs a storage provider to read the original + write the variant —
71
+ // without file-foundation there's nothing to derive from or write to.
72
+ r.requires("file-foundation");
73
+
74
+ r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
75
+ onRegister: () => {
76
+ // No side-effects at register-time — resolution (exact MIME match,
77
+ // then `<type>/*` wildcard) happens at request-time in
78
+ // resolveRenderer, mirrors file-foundation's fileProvider point.
79
+ },
80
+ });
81
+ r.extendsRegistrar(EXT_DERIVATIVE_PUBLIC_PREDICATE, {
82
+ onRegister: () => {
83
+ // No side-effects at register-time — resolution happens at
84
+ // request-time in publicVariantQuery, keyed by the FileRef's
85
+ // entityType.
86
+ },
87
+ });
88
+
89
+ const queries = { publicVariant: r.queryHandler(publicVariantQuery) };
90
+
91
+ if (opts.resolveApexTenant) {
92
+ const resolveApexTenant = opts.resolveApexTenant;
93
+
94
+ r.httpRoute({
95
+ method: "GET",
96
+ path: `${basePath}/:fileRefId/:variant`,
97
+ anonymous: true,
98
+ handler: async (c, { systemQuery }) => {
99
+ // `param(...)` is string|undefined here — `path` is a computed
100
+ // template, so Hono can't infer the param type from a literal.
101
+ const fileRefId = c.req.param("fileRefId");
102
+ const variant = c.req.param("variant");
103
+ // Pre-check against the FIXED preset list before systemQuery runs
104
+ // at all — stops a caller from burning rate-limit budget/DB
105
+ // lookups on arbitrary variant names (thumb-a, thumb-b, ...).
106
+ // 404, not 400 — an invalid name and "doesn't exist" must answer
107
+ // identically, no name-oracle.
108
+ if (!fileRefId || !variant || !VALID_VARIANT_NAMES.includes(variant)) {
109
+ return c.text("not found", 404);
110
+ }
111
+
112
+ const host = c.req.header("host") ?? new URL(c.req.url).host;
113
+ const tenantId = await resolveApexTenant(host);
114
+ if (!tenantId) return c.text("not found", 404);
115
+
116
+ let result: PublicVariantQueryResult;
117
+ try {
118
+ // @cast-boundary engine-payload — shape comes from
119
+ // publicVariantQuery's return type.
120
+ result = (await systemQuery(
121
+ PUBLIC_VARIANT_QN,
122
+ { fileRefId, variant },
123
+ tenantId,
124
+ )) as PublicVariantQueryResult;
125
+ } catch (err) {
126
+ if (err instanceof RateLimitError) {
127
+ return c.text("rate limited", 429);
128
+ }
129
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback, no logger wired into HttpRouteHandlerDeps
130
+ console.error(
131
+ `file-derivatives: public-variant query failed for fileRefId="${fileRefId}" variant="${variant}"`,
132
+ err,
133
+ );
134
+ return c.text("variant unavailable", 503);
135
+ }
136
+ if (!result) return c.text("not found", 404);
137
+
138
+ const bytes = Buffer.from(result.dataBase64, "base64");
139
+ const etag = computeRevisionEtag([tenantId, fileRefId, variant, result.storageKey]);
140
+ return cachedResponse(c.req.raw, {
141
+ body: bytes,
142
+ etag,
143
+ cache: { kind: "revalidate", maxAgeSeconds: 60 },
144
+ headers: { "content-type": result.mimeType, vary: "Host" },
145
+ });
146
+ },
147
+ });
148
+ }
149
+
150
+ return { queries };
32
151
  });
33
- });
152
+ }
153
+
154
+ export const fileDerivativesFeature = createFileDerivativesFeature();
@@ -0,0 +1,122 @@
1
+ // Public-Read of a derived FileRef variant (thumbnail/card/hero/full) — the
2
+ // anonymous counterpart to GET /files/:id/variant/:name (#1950, auth-gated,
3
+ // field-declared variants). This route never serves the original, never
4
+ // accepts a free VariantSpec, and only serves one of the 4 fixed presets.
5
+ // Default-deny: an entityType with no `EXT_DERIVATIVE_PUBLIC_PREDICATE`
6
+ // registration, or a predicate that returns false, both answer `null` —
7
+ // the httpRoute wrapper in feature.ts turns that into a 404, same as a
8
+ // FileRef that doesn't exist (no existence-leak via distinct status codes).
9
+ //
10
+ // tenantId comes from `query.user.tenantId` ONLY (host-resolved by the
11
+ // httpRoute wrapper's `resolveApexTenant`), never from the payload.
12
+
13
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import {
15
+ defineQueryHandler,
16
+ EXT_DERIVATIVE_PUBLIC_PREDICATE,
17
+ type HandlerContext,
18
+ type TenantId,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
21
+ import { z } from "zod";
22
+ import { card, full, hero, PRESET_VARIANT_NAMES, thumb } from "../presets";
23
+
24
+ const VARIANT_SPECS = { thumb, card, hero, full } as const;
25
+
26
+ type FileRefRow = {
27
+ readonly entityType: string | null;
28
+ readonly entityId: string | null;
29
+ };
30
+
31
+ export type DerivativePublicPredicateArgs = {
32
+ readonly entityId: string;
33
+ readonly tenantId: TenantId;
34
+ };
35
+
36
+ export type DerivativePublicPredicatePlugin = {
37
+ readonly isPublic: (
38
+ args: DerivativePublicPredicateArgs,
39
+ ctx: HandlerContext,
40
+ ) => boolean | Promise<boolean>;
41
+ };
42
+
43
+ // extension-usage `options` is engine-payload (unknown) — structurally
44
+ // validate instead of casting blind, same pattern as
45
+ // isDerivativeRendererPlugin in derivatives-context.ts.
46
+ function isDerivativePublicPredicatePlugin(o: unknown): o is DerivativePublicPredicatePlugin {
47
+ return typeof o === "object" && o !== null && "isPublic" in o && typeof o.isPublic === "function";
48
+ }
49
+
50
+ // Full QN this handler is registered under once mounted into the
51
+ // "file-derivatives" feature (short name below + registry qualification —
52
+ // see qualifyEntityName). Kept as a separate literal, not derived, so
53
+ // feature.ts's systemQuery call doesn't need a code import of the handler
54
+ // def itself — mirrors managed-pages' BY_SLUG_QN.
55
+ export const PUBLIC_VARIANT_QN = "file-derivatives:query:public-variant";
56
+
57
+ export const publicVariantQuery = defineQueryHandler({
58
+ name: "public-variant",
59
+ schema: z.object({
60
+ fileRefId: z.string(),
61
+ variant: z.enum(PRESET_VARIANT_NAMES),
62
+ }),
63
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
64
+ // ponytail: "ip" trusts the first x-forwarded-for hop (buildRequestContextData
65
+ // in request-id-middleware.ts) — this is the ONLY throttle on an anonymous,
66
+ // internet-facing render/storage-cost route, so it assumes the deployment's
67
+ // ingress overwrites (not appends to) x-forwarded-for. A direct-to-origin
68
+ // deployment lets a caller rotate the header per request and bypass this.
69
+ // Upgrade path if that assumption doesn't hold: trusted-proxy-count config
70
+ // on buildRequestContextData, same fix point as every other /api/* route.
71
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
72
+ handler: async (query, ctx) => {
73
+ const row = await fetchOne<FileRefRow>(ctx.db, fileRefsTable, {
74
+ id: query.payload.fileRefId,
75
+ tenantId: ctx.user.tenantId,
76
+ isDeleted: false,
77
+ });
78
+ if (!row) return null;
79
+ const { entityType, entityId } = row;
80
+ if (entityType === null || entityId === null) return null;
81
+
82
+ const usage = ctx.registry
83
+ .getExtensionUsages(EXT_DERIVATIVE_PUBLIC_PREDICATE)
84
+ .find((u) => u.entityName === entityType);
85
+ if (!usage) return null;
86
+ if (!isDerivativePublicPredicatePlugin(usage.options)) {
87
+ throw new Error(
88
+ `file-derivatives: "${usage.entityName}" registered ${EXT_DERIVATIVE_PUBLIC_PREDICATE} without an isPublic(args, ctx) — extension options must be a DerivativePublicPredicatePlugin.`,
89
+ );
90
+ }
91
+
92
+ const isPublic = await usage.options.isPublic({ entityId, tenantId: ctx.user.tenantId }, ctx);
93
+ if (!isPublic) return null;
94
+
95
+ // ctx.files/ctx.derivatives are optional on HandlerContext (only wired
96
+ // when file-foundation resolved a provider) — narrow via a plain guard,
97
+ // never `!`/`as`, and surface a real config error (not a silent deny)
98
+ // when the feature isn't actually wired.
99
+ const { files, derivatives } = ctx;
100
+ if (!files || !derivatives) {
101
+ throw new Error(
102
+ "file-derivatives requires ctx.files/ctx.derivatives — is file-foundation mounted?",
103
+ );
104
+ }
105
+
106
+ const spec = VARIANT_SPECS[query.payload.variant];
107
+ const result = await derivatives.variant(query.payload.fileRefId, spec, query.payload.variant);
108
+ const bytes = await files.ref(result.storageKey).read();
109
+
110
+ // ponytail: fully buffered (no streaming), +33% over the wire from
111
+ // base64 — HttpRouteHandlerDeps only exposes {app, systemQuery} (no
112
+ // direct ctx.files access from the Hono handler itself), and the local
113
+ // dev storage provider has no getSignedUrl (a redirect design wouldn't
114
+ // work in dev). Upgrade path: a binary-passthrough dispatcher primitive
115
+ // would be its own, larger issue.
116
+ return {
117
+ dataBase64: Buffer.from(bytes).toString("base64"),
118
+ mimeType: result.mimeType,
119
+ storageKey: result.storageKey,
120
+ };
121
+ },
122
+ });
@@ -1,2 +1,3 @@
1
- export { fileDerivativesFeature } from "./feature";
2
- export { card, full, hero, thumb } from "./presets";
1
+ export type { FileDerivativesOptions, PublicVariantResolveApexTenant } from "./feature";
2
+ export { createFileDerivativesFeature, fileDerivativesFeature } from "./feature";
3
+ export { card, full, hero, PRESET_VARIANT_NAMES, thumb } from "./presets";
@@ -4,3 +4,10 @@ export const thumb = { maxEdge: 160, fit: "cover", format: "webp" } as const sat
4
4
  export const card = { maxEdge: 640, fit: "inside", format: "webp" } as const satisfies VariantSpec;
5
5
  export const hero = { maxEdge: 1600, fit: "inside", format: "webp" } as const satisfies VariantSpec;
6
6
  export const full = { maxEdge: 2560, fit: "inside", format: "webp" } as const satisfies VariantSpec;
7
+
8
+ // Single source of truth for the 4 preset variant names — the public
9
+ // variant route (#1951) validates its `:variant` path param against this
10
+ // list BEFORE any DB lookup or systemQuery dispatch, and the query
11
+ // handler's Zod schema enums against it too. Keep in sync with the
12
+ // preset exports above.
13
+ export const PRESET_VARIANT_NAMES = ["thumb", "card", "hero", "full"] as const;