@cosmicdrift/kumiko-bundled-features 0.308.0 → 0.310.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.
@@ -18,6 +18,10 @@ import {
18
18
  } from "@cosmicdrift/kumiko-framework/engine";
19
19
  import { RateLimitError } from "@cosmicdrift/kumiko-framework/errors";
20
20
  import { PUBLIC_VARIANT_QN, publicVariantQuery } from "./handlers/public-variant.query";
21
+ import {
22
+ PUBLIC_VARIANT_BY_FILE_REF_QN,
23
+ publicVariantByFileRefQuery,
24
+ } from "./handlers/public-variant-by-file-ref.query";
21
25
 
22
26
  const FEATURE_NAME = "file-derivatives";
23
27
 
@@ -42,6 +46,8 @@ export type PublicVariantResolveApexTenant = (
42
46
  host: string,
43
47
  ) => Promise<TenantId | null> | TenantId | null;
44
48
 
49
+ export type PublicVariantTenantResolution = "host" | "fileRef";
50
+
45
51
  export type FileDerivativesOptions = {
46
52
  /** Host → tenantId for the anonymous `/media/:fileRefId/:variant` route.
47
53
  * Without this option, neither the httpRoute NOR the `publicVariant`
@@ -52,6 +58,14 @@ export type FileDerivativesOptions = {
52
58
  readonly resolveApexTenant?: PublicVariantResolveApexTenant;
53
59
  /** Base path of the public variant route. Default "/media". */
54
60
  readonly basePath?: string;
61
+ /** How the route picks the tenant a variant is read from. "host" (default):
62
+ * tenant = resolveApexTenant(host). "fileRef": resolveApexTenant still
63
+ * gates the host (an unknown host still 404s), but the variant itself is
64
+ * read from the FileRef row's own tenant — a single shared platform host
65
+ * then serves every tenant's public variants. The FileRef-tenant's own
66
+ * `isPublic` predicate remains the default-deny gate either way; requires
67
+ * `resolveApexTenant`. */
68
+ readonly publicTenantResolution?: PublicVariantTenantResolution;
55
69
  };
56
70
 
57
71
  // Raw handler-return of publicVariantQuery — systemQuery dispatches
@@ -73,13 +87,22 @@ type PublicVariantQueryResult = {
73
87
  // — only a name, resolved against the field's own `variants` declaration —
74
88
  // and only after the app's registered `isPublic` predicate for the FileRef's
75
89
  // entityType says yes. tenantId is resolved from the request Host via
76
- // `resolveApexTenant`, NEVER read from the request payload.
90
+ // `resolveApexTenant`, NEVER read from the request payload — in
91
+ // `publicTenantResolution: "fileRef"` mode the tenant instead comes from the
92
+ // FileRef row matching `fileRefId` (never from the payload either), and the
93
+ // by-file-ref handler is reachable over `/api` independently of any Host,
94
+ // but still only returns a variant its FileRef-tenant's `isPublic` allows.
77
95
  export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}): FeatureDefinition {
78
96
  const basePath = opts.basePath ?? "/media";
97
+ if (opts.publicTenantResolution !== undefined && !opts.resolveApexTenant) {
98
+ throw new Error(
99
+ "createFileDerivativesFeature: publicTenantResolution requires resolveApexTenant — it only changes which tenant a variant is read from, resolveApexTenant still gates the host.",
100
+ );
101
+ }
79
102
 
80
103
  return defineFeature(FEATURE_NAME, (r) => {
81
104
  r.describe(
82
- "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. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
105
+ "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. `publicTenantResolution: \"fileRef\"` keeps resolveApexTenant as the host gate but reads the variant from the FileRef row's own tenant instead of the host's, so one shared platform host serves every tenant's public variants — each FileRef-tenant's own `isPublic` predicate still default-denies. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
83
106
  );
84
107
  r.uiHints({
85
108
  displayLabel: "File Derivatives",
@@ -127,8 +150,18 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
127
150
  // `resolveApexTenant` enforces.
128
151
  if (opts.resolveApexTenant) {
129
152
  const resolveApexTenant = opts.resolveApexTenant;
153
+ const byFileRef = opts.publicTenantResolution === "fileRef";
154
+ const routeQn = byFileRef ? PUBLIC_VARIANT_BY_FILE_REF_QN : PUBLIC_VARIANT_QN;
130
155
 
131
156
  r.queryHandler(publicVariantQuery);
157
+ // publicVariantByFileRefQuery is the ONLY way "fileRef" mode is
158
+ // reachable — registered ONLY in that mode for the same reason
159
+ // publicVariantQuery is gated above: registering it unconditionally
160
+ // would expose its unsafeRaw, cross-tenant FileRef lookup via the
161
+ // generic `/api` query dispatch even in default "host" mode.
162
+ if (byFileRef) {
163
+ r.queryHandler(publicVariantByFileRefQuery);
164
+ }
132
165
 
133
166
  r.httpRoute({
134
167
  method: "GET",
@@ -156,9 +189,10 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
156
189
  let result: PublicVariantQueryResult;
157
190
  try {
158
191
  // @cast-boundary engine-payload — shape comes from
159
- // publicVariantQuery's return type.
192
+ // publicVariantQuery's return type (publicVariantByFileRefQuery
193
+ // returns exactly that, or null, never its own shape).
160
194
  result = (await systemQuery(
161
- PUBLIC_VARIANT_QN,
195
+ routeQn,
162
196
  { fileRefId, variant },
163
197
  tenantId,
164
198
  )) as PublicVariantQueryResult;
@@ -0,0 +1,40 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import {
3
+ createAnonymousUser,
4
+ defineQueryHandler,
5
+ isUuid,
6
+ type TenantId,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
9
+ import { PUBLIC_VARIANT_QN, publicVariantPayloadSchema } from "./public-variant.query";
10
+
11
+ type FileRefTenantRow = {
12
+ readonly tenantId: TenantId;
13
+ };
14
+
15
+ const BY_FILE_REF_ESCAPE_HATCH_REASON =
16
+ "shared public host serves every tenant's public variants; the FileRef row names the tenant, and publicVariantQuery's isPublic gate still runs inside that tenant via queryAs";
17
+
18
+ // Full QN this handler is registered under, mirrors PUBLIC_VARIANT_QN.
19
+ export const PUBLIC_VARIANT_BY_FILE_REF_QN = "file-derivatives:query:public-variant-by-file-ref";
20
+
21
+ export const publicVariantByFileRefQuery = defineQueryHandler({
22
+ name: "public-variant-by-file-ref",
23
+ schema: publicVariantPayloadSchema,
24
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
25
+ agent: { expose: false },
26
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
27
+ escapeHatch: {
28
+ reason: BY_FILE_REF_ESCAPE_HATCH_REASON,
29
+ },
30
+ handler: async (query, ctx) => {
31
+ const row = await fetchOne<FileRefTenantRow>(
32
+ ctx.db.unsafeRaw(BY_FILE_REF_ESCAPE_HATCH_REASON),
33
+ fileRefsTable,
34
+ { id: query.payload.fileRefId, isDeleted: false },
35
+ );
36
+ if (!row || !isUuid(row.tenantId)) return null;
37
+
38
+ return ctx.queryAs(createAnonymousUser(row.tenantId), PUBLIC_VARIANT_QN, query.payload);
39
+ },
40
+ });
@@ -16,6 +16,11 @@
16
16
  // feature.ts), `ctx.user.tenantId` instead comes from that dispatcher's own
17
17
  // `anonymousAccess` resolution — tenant provenance there depends on the
18
18
  // consumer's `resolverTrust`/anonymousAccess setup, not the host.
19
+ //
20
+ // In `publicTenantResolution: "fileRef"` mode, this handler is also the
21
+ // `ctx.queryAs(createAnonymousUser(fileRefTenantId), ...)` target of
22
+ // publicVariantByFileRefQuery — tenantId there comes from the FileRef row,
23
+ // but this handler's own isPublic gate runs exactly the same either way.
19
24
 
20
25
  import { computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
21
26
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -72,15 +77,19 @@ declare module "@cosmicdrift/kumiko-framework/engine" {
72
77
  // def itself — mirrors managed-pages' BY_SLUG_QN.
73
78
  export const PUBLIC_VARIANT_QN = "file-derivatives:query:public-variant";
74
79
 
80
+ // Shared with publicVariantByFileRefQuery — same payload shape, both routes
81
+ // forward `{ fileRefId, variant }` unchanged.
82
+ export const publicVariantPayloadSchema = z.object({
83
+ // Loose, version-agnostic UUID shape (same as isUuid/TENANT_ID_REGEX in
84
+ // packages/types) — not zod's `.uuid()`, which is stricter than this
85
+ // repo's convention and would reject valid v7/nil ids the DB accepts.
86
+ fileRefId: z.string().refine(isUuid, "invalid fileRefId"),
87
+ variant: z.string().min(1).max(64),
88
+ });
89
+
75
90
  export const publicVariantQuery = defineQueryHandler({
76
91
  name: "public-variant",
77
- schema: z.object({
78
- // Loose, version-agnostic UUID shape (same as isUuid/TENANT_ID_REGEX in
79
- // packages/types) — not zod's `.uuid()`, which is stricter than this
80
- // repo's convention and would reject valid v7/nil ids the DB accepts.
81
- fileRefId: z.string().refine(isUuid, "invalid fileRefId"),
82
- variant: z.string().min(1).max(64),
83
- }),
92
+ schema: publicVariantPayloadSchema,
84
93
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
85
94
  agent: { expose: false },
86
95
  // ponytail: "ip" trusts the first x-forwarded-for hop (buildRequestContextData
@@ -1,4 +1,8 @@
1
- export type { FileDerivativesOptions, PublicVariantResolveApexTenant } from "./feature";
1
+ export type {
2
+ FileDerivativesOptions,
3
+ PublicVariantResolveApexTenant,
4
+ PublicVariantTenantResolution,
5
+ } from "./feature";
2
6
  export { createFileDerivativesFeature, fileDerivativesFeature } from "./feature";
3
7
  export type {
4
8
  DerivativePublicPredicateArgs,
@@ -16,8 +16,6 @@ import {
16
16
  type TestStack,
17
17
  TestUsers,
18
18
  testTenantId,
19
- unsafeCreateEntityTable,
20
- unsafePushTables,
21
19
  } from "@cosmicdrift/kumiko-framework/stack";
22
20
  import {
23
21
  createTestEnvelopeCipher,
@@ -27,18 +25,16 @@ import {
27
25
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
28
26
  import {
29
27
  createComplianceProfilesFeature,
30
- tenantComplianceProfileEntity,
31
28
  tenantComplianceProfileTable,
32
29
  } from "../../compliance-profiles";
33
30
  import { createConfigFeature } from "../../config";
34
31
  import { createConfigResolver } from "../../config/resolver";
35
- import { configValuesTable } from "../../config/table";
36
32
  import { createSessionsFeature } from "../../sessions";
37
- import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
38
- import { tenantMembershipEntity, tenantMembershipsTable } from "../../tenant";
33
+ import { userSessionTable } from "../../sessions/schema/user-session";
34
+ import { tenantMembershipsTable } from "../../tenant";
39
35
  import { TenantHandlers, TenantQueries } from "../../tenant/constants";
40
36
  import { createTenantFeature } from "../../tenant/feature";
41
- import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
37
+ import { tenantTable } from "../../tenant/schema/tenant";
42
38
  import { seedTenantMembership } from "../../tenant/testing";
43
39
  import { createUserFeature } from "../../user/feature";
44
40
  import {
@@ -85,12 +81,6 @@ beforeAll(async () => {
85
81
  } as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
86
82
  });
87
83
  db = stack.db;
88
-
89
- await unsafeCreateEntityTable(db, tenantEntity);
90
- await unsafeCreateEntityTable(db, userSessionEntity);
91
- await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
92
- await unsafeCreateEntityTable(db, tenantMembershipEntity);
93
- await unsafePushTables(db, { configValuesTable });
94
84
  });
95
85
 
96
86
  afterAll(async () => {
@@ -175,6 +165,11 @@ describe("tenant-lifecycle :: request / cancel / 410 gate", () => {
175
165
  const err = await stack.http.writeErr(REQUEST, {}, member);
176
166
  expect(err.httpStatus).toBe(403);
177
167
  });
168
+
169
+ test("authenticated read against an unseeded tenant doesn't 500 on a missing read_tenants table (fw#3102)", async () => {
170
+ const res = await stack.http.query(TenantQueries.me, {}, tenantAdmin);
171
+ expect(res.status).toBe(200);
172
+ });
178
173
  });
179
174
 
180
175
  function createPoisonTenantDataFeature() {
@@ -210,9 +205,6 @@ describe("tenant-lifecycle :: pipeline abandon / destroyFailed", () => {
210
205
  },
211
206
  } as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
212
207
  });
213
- await unsafeCreateEntityTable(poisonStack.db, tenantEntity);
214
- await unsafeCreateEntityTable(poisonStack.db, tenantComplianceProfileEntity);
215
- await unsafePushTables(poisonStack.db, { configValuesTable });
216
208
  });
217
209
 
218
210
  afterAll(async () => {
@@ -416,9 +408,6 @@ describe("tenant-lifecycle :: sweep isolates one tenant's failure from another's
416
408
  ],
417
409
  extraContext: { configResolver: resolver, configEncryption: encryption },
418
410
  });
419
- await unsafeCreateEntityTable(isolationStack.db, tenantEntity);
420
- await unsafeCreateEntityTable(isolationStack.db, tenantComplianceProfileEntity);
421
- await unsafePushTables(isolationStack.db, { configValuesTable });
422
411
  });
423
412
 
424
413
  afterAll(async () => {
@@ -483,11 +472,6 @@ describe("tenant-lifecycle :: 410 gate derived from the mounted provider", () =>
483
472
  ],
484
473
  extraContext: { configResolver: resolver, configEncryption: encryption },
485
474
  });
486
- await unsafeCreateEntityTable(derivedStack.db, tenantEntity);
487
- await unsafeCreateEntityTable(derivedStack.db, userSessionEntity);
488
- await unsafeCreateEntityTable(derivedStack.db, tenantComplianceProfileEntity);
489
- await unsafeCreateEntityTable(derivedStack.db, tenantMembershipEntity);
490
- await unsafePushTables(derivedStack.db, { configValuesTable });
491
475
  });
492
476
 
493
477
  afterAll(async () => {
@@ -71,26 +71,8 @@ export async function seedUser(
71
71
  // liefert die Projection-Row als Record<string, unknown>.
72
72
  if (existing) {
73
73
  const id = existing["id"] as string;
74
- // Reconcile emailVerified on an already-seeded row: a persistent dev DB
75
- // (or a re-run bootstrap) can carry a User seeded before this flag
76
- // existed or before it flipped to true — without this, "seed with
77
- // emailVerified: true" only ever takes effect on the very first insert
78
- // and the flag silently does nothing on every re-run after (#1687).
79
- // Goes through the executor (a real `.updated` event), never a direct
80
- // write.
81
- if (options.emailVerified === true && existing["emailVerified"] !== true) {
82
- const result = await userExecutor.update(
83
- { id, version: existing["version"] as number, changes: { emailVerified: true } },
84
- by,
85
- tdb,
86
- );
87
- // version_conflict = a concurrent write already changed the row —
88
- // fine for a seed helper, don't fail the whole seed run over it.
89
- if (!result.isSuccess && result.error.code !== "version_conflict") {
90
- throw new Error(
91
- `seedUser emailVerified reconcile failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
92
- );
93
- }
74
+ if (options.emailVerified === true) {
75
+ await reconcileSeededUserEmailVerified(db, existing, by);
94
76
  }
95
77
  return { id };
96
78
  }
@@ -116,6 +98,33 @@ export async function seedUser(
116
98
  return { id: extractId(result.data, "seedUser") };
117
99
  }
118
100
 
101
+ // A re-run seed onto a row created before emailVerified flipped to true must
102
+ // still set it (#1687) — via the executor, so it lands as a real `.updated` event.
103
+ export async function reconcileSeededUserEmailVerified(
104
+ db: DbConnection,
105
+ existingRow: Record<string, unknown>,
106
+ by: SessionUser,
107
+ ): Promise<void> {
108
+ // skip: already verified, nothing to reconcile
109
+ if (existingRow["emailVerified"] === true) return;
110
+ // @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
111
+ // liefert die Projection-Row als Record<string, unknown>.
112
+ const id = existingRow["id"] as string;
113
+ const tdb = createTenantDb(db, by.tenantId, "system");
114
+ const result = await userExecutor.update(
115
+ { id, version: existingRow["version"] as number, changes: { emailVerified: true } },
116
+ by,
117
+ tdb,
118
+ );
119
+ // version_conflict = a concurrent write already changed the row —
120
+ // fine for a seed helper, don't fail the whole seed run over it.
121
+ if (!result.isSuccess && result.error.code !== "version_conflict") {
122
+ throw new Error(
123
+ `reconcileSeededUserEmailVerified failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
124
+ );
125
+ }
126
+ }
127
+
119
128
  // Extrahiert die `id`-Spalte aus dem executor.create-Result. Der
120
129
  // Executor liefert ein Record<string, unknown> (die Projection-Row), in
121
130
  // das die DB die Aggregat-id reinschreibt — wir prüfen runtime statt
@@ -1,5 +1,5 @@
1
1
  import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import { derivativeListPrefix, isDerivativeKeyOf } from "@cosmicdrift/kumiko-framework/derivatives";
2
+ import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
3
3
  import {
4
4
  createSystemUser,
5
5
  type FieldDefinition,
@@ -10,13 +10,11 @@ import {
10
10
  type UserDataHookCtx,
11
11
  type UserDataStorageProvider,
12
12
  } from "@cosmicdrift/kumiko-framework/engine";
13
- import {
14
- assertSafeStorageKey,
15
- fileRefEntity,
16
- fileRefsTable,
17
- } from "@cosmicdrift/kumiko-framework/files";
13
+ import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
18
14
  import { assertErased } from "../../shared";
19
15
 
16
+ const ERROR_CONTEXT = "user-data-rights-defaults:fileRef";
17
+
20
18
  // Forget writes go through the executor (events), not deleteMany/updateMany:
21
19
  // a projection rebuild replays the events, so the erasure survives. Eventless
22
20
  // writes are wiped/resurrected on rebuild — the Art.17 hole this fixes. Bounded:
@@ -174,52 +172,12 @@ function isPersonalFileRow(registry: Registry, row: Record<string, unknown>): bo
174
172
  return isPersonalPiiField(fieldDef);
175
173
  }
176
174
 
177
- // Derivatives (thumbnails/resized variants — see derivatives-context.ts) are
178
- // never tracked anywhere but the storage layer: they're rendered under a
179
- // deterministic key (deriveKey + variantSuffix) computed from the original's
180
- // key + a render spec, on demand, and nothing writes that key back onto the
181
- // FileRef row. List the original's key-prefix and keep only candidates whose
182
- // suffix matches the derivative grammar, anchored to the original's own
183
- // basename AND extension — a same-directory sibling original (a different
184
- // file, possibly another user's) shares the same list prefix but fails the
185
- // extension/suffix check and is never touched.
186
- async function listDerivativeKeys(
187
- originalKey: string,
188
- provider: UserDataStorageProvider,
189
- ): Promise<readonly string[]> {
190
- let candidates: readonly string[];
191
- try {
192
- candidates = await provider.list(derivativeListPrefix(originalKey));
193
- } catch (err) {
194
- // Wrap rather than let a raw provider error (e.g. S3 AccessDenied) surface
195
- // unexplained — list() is a NEW requirement this hook added on top of
196
- // delete(); a bucket policy granting only s3:DeleteObject now fails forget
197
- // every run instead of just leaving derivatives behind. Name the likely
198
- // cause so an operator doesn't have to guess.
199
- throw new Error(
200
- `[user-data-rights-defaults:fileRef] provider.list() failed while looking up derivatives of key=${originalKey} — forget/erasure requires list permission on the storage bucket (e.g. s3:ListBucket), not just delete. Original error: ${err instanceof Error ? err.message : String(err)}`,
201
- );
202
- }
203
- const derivativeKeys: string[] = [];
204
- for (const candidate of candidates) {
205
- if (!isDerivativeKeyOf(originalKey, candidate)) continue;
206
- try {
207
- assertSafeStorageKey(candidate);
208
- } catch {
209
- // ponytail: a listing hit that fails the storage-key grammar can't be
210
- // a real derivative (every derivative is written via a validated
211
- // write()) — skip rather than pass a malformed key to delete().
212
- continue;
213
- }
214
- derivativeKeys.push(candidate);
215
- }
216
- return derivativeKeys;
217
- }
218
-
219
175
  // Delete every row's binary (original + any derivatives) via the provider.
220
176
  // Returns the keys whose delete threw — the caller fails closed on a
221
177
  // non-empty list so the sub-tx rolls back and the next forget run retries
222
- // (delete is idempotent → converges).
178
+ // (delete is idempotent → converges). Derivative-listing + per-key delete
179
+ // lives in the shared derivatives helper (also used by data-retention's
180
+ // hardDelete cleanup) so this hook no longer carries its own copy.
223
181
  async function deleteBinaries(
224
182
  rows: readonly Record<string, unknown>[],
225
183
  provider: UserDataStorageProvider,
@@ -228,18 +186,7 @@ async function deleteBinaries(
228
186
  for (const row of rows) {
229
187
  const key = row["storageKey"]; // @cast-boundary db-row
230
188
  if (typeof key !== "string" || key.length === 0) continue;
231
- const keysToDelete = [key, ...(await listDerivativeKeys(key, provider))];
232
- for (const k of keysToDelete) {
233
- try {
234
- await provider.delete(k);
235
- } catch (err) {
236
- // biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
237
- console.warn(
238
- `[user-data-rights-defaults:fileRef] storage delete failed key=${k} err=${err instanceof Error ? err.message : String(err)}`,
239
- );
240
- failedKeys.push(k);
241
- }
242
- }
189
+ failedKeys.push(...(await deleteStoredFileAndDerivatives(key, provider, ERROR_CONTEXT)));
243
190
  }
244
191
  return failedKeys;
245
192
  }