@cosmicdrift/kumiko-samples 0.371.0 → 0.372.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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/auth-mfa/db/queries.ts +5 -7
  4. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +2 -1
  5. package/packages/bundled-features/src/config/web/index.ts +2 -0
  6. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  7. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +75 -34
  8. package/packages/bundled-features/src/crypto-shredding/resolve-file-bytes-deleter.ts +29 -0
  9. package/packages/bundled-features/src/data-retention/changes.json +6 -0
  10. package/packages/bundled-features/src/data-retention/index.ts +6 -0
  11. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +37 -213
  12. package/packages/bundled-features/src/delivery/changes.json +13 -0
  13. package/packages/bundled-features/src/delivery/delivery-service.ts +7 -2
  14. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +2 -2
  15. package/packages/bundled-features/src/delivery/index.ts +2 -0
  16. package/packages/bundled-features/src/delivery/jobs.ts +42 -23
  17. package/packages/bundled-features/src/delivery/recipient-address-seal.ts +72 -0
  18. package/packages/bundled-features/src/delivery/testing.ts +4 -1
  19. package/packages/bundled-features/src/delivery/unsubscribe.ts +23 -2
  20. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +2 -2
  21. package/packages/bundled-features/src/files/changes.json +7 -0
  22. package/packages/bundled-features/src/personal-access-tokens/changes.json +12 -0
  23. package/packages/bundled-features/src/personal-access-tokens/screens.ts +15 -9
  24. package/packages/bundled-features/src/shared/index.ts +14 -0
  25. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +206 -0
  26. package/packages/bundled-features/src/shared/purge-row-file-refs.ts +214 -0
  27. package/packages/bundled-features/src/template-resolver/changes.json +6 -0
  28. package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -4
  29. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +2 -1
  30. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +1 -9
  31. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
  32. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  33. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +46 -1
  34. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  35. package/samples/apps/styleguide/src/features/demo/feature.ts +1 -1
  36. package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +5 -0
  37. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +2 -0
  38. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +9 -0
  39. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +6 -0
  40. package/samples/apps/ui-walkthrough/src/features/rental/entities.ts +1 -0
  41. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +27 -1
  42. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +9 -0
  43. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +1 -0
  44. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +22 -1
  45. package/samples/apps/use-all-bundled/package.json +3 -1
  46. package/samples/recipes/custom-handlers/src/feature.ts +3 -5
  47. package/samples/recipes/embedded-entity-form/src/feature.ts +1 -1
  48. package/samples/recipes/pdf-audit-export/README.md +1 -1
  49. package/samples/recipes/user-data-rights/README.md +22 -0
  50. package/samples/recipes/user-data-rights/src/retained-docs.ts +35 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.371.0",
3
+ "version": "0.372.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.371.0",
3
+ "version": "0.372.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>",
@@ -5,6 +5,7 @@ import {
5
5
  } from "@cosmicdrift/kumiko-framework/db";
6
6
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
8
+ import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
8
9
  import { userMfaEntity, userMfaTable } from "../schema/user-mfa.ts";
9
10
 
10
11
  export type UserMfaRow = {
@@ -13,8 +14,8 @@ export type UserMfaRow = {
13
14
  readonly userId: string;
14
15
  readonly totpSecret: string;
15
16
  readonly recoveryCodes: { readonly hashes: readonly string[] };
16
- readonly enabledAt: string;
17
- readonly lastUsedAt: string | null;
17
+ readonly enabledAt: Temporal.Instant;
18
+ readonly lastUsedAt: Temporal.Instant | null;
18
19
  };
19
20
 
20
21
  const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
@@ -37,12 +38,9 @@ export async function findUserMfaRow(db: TenantDb, user: SessionUser): Promise<U
37
38
  if (!idLookup) return null;
38
39
  const detail = await executor.detail({ id: idLookup.id }, user, db);
39
40
  if (!detail) return null;
40
- // @cast-boundary engine-payload — executor.detail returns a decrypted,
41
- // untyped Record; the userMfa entity's own field shape narrows it.
42
- const row = detail as Omit<UserMfaRow, "recoveryCodes"> & { recoveryCodes: string };
43
41
  const recoveryCodes = parseJsonOrThrow<{ hashes: readonly string[] }>(
44
- row.recoveryCodes,
42
+ detail.recoveryCodes,
45
43
  "user-mfa.recoveryCodes",
46
44
  );
47
- return { ...row, recoveryCodes };
45
+ return { ...detail, recoveryCodes };
48
46
  }
@@ -1,3 +1,4 @@
1
+ import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-headless";
1
2
  // @runtime client
2
3
  import type { ExtensionSectionProps } from "@cosmicdrift/kumiko-renderer";
3
4
  import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
@@ -102,7 +103,7 @@ export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNod
102
103
  label={t(cap.label)}
103
104
  value={
104
105
  !isMeasured(cap)
105
- ? "—"
106
+ ? EMPTY_VALUE_PLACEHOLDER
106
107
  : cap.limit === null
107
108
  ? String(cap.used)
108
109
  : `${cap.used} / ${cap.limit}`
@@ -1,4 +1,6 @@
1
1
  // @runtime client
2
+
3
+ export { ConfigQueries } from "../constants.ts";
2
4
  export {
3
5
  type ConfigClientFeature,
4
6
  type ConfigClientOptions,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.372.0",
4
+ "type": "fix",
5
+ "title": "forget-subject removes files bound to the forgotten subject",
6
+ "detail": "Forgetting a record or user subject now deletes the bytes, derivatives and file_refs rows behind that subject's personal file fields (fail closed with missing_file_storage or file_delete_failed in cleanupError), so a retry can finish the job; the entity row stays."
7
+ },
2
8
  {
3
9
  "version": "0.370.0",
4
10
  "type": "improvement",
@@ -27,6 +27,7 @@ import {
27
27
  type EntityDefinition,
28
28
  type FeatureDefinition,
29
29
  type HandlerContext,
30
+ parseTenantId,
30
31
  type SessionUser,
31
32
  type TenantId,
32
33
  type WriteEvent,
@@ -47,6 +48,7 @@ import * as z from "zod";
47
48
  import { resolveRetentionPolicyForTenant } from "../../data-retention/index.ts";
48
49
  import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.ts";
49
50
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.ts";
51
+ import { purgeFilesReachingSubject } from "../../shared/index.ts";
50
52
  import { USER_STATUS, userTable } from "../../user/index.ts";
51
53
  import {
52
54
  denyIfTargetOutsideAdminTenant,
@@ -63,6 +65,7 @@ import {
63
65
  TARGET_RECORD_RETENTION_BLOCK_DELETE,
64
66
  TARGET_TENANT_NOT_ADMIN_TENANT,
65
67
  } from "../constants.ts";
68
+ import { resolveFileBytesDeleter } from "../resolve-file-bytes-deleter.ts";
66
69
 
67
70
  export { subjectIdSchema };
68
71
 
@@ -216,6 +219,17 @@ async function resolveRetentionDenial(
216
219
  return effective.policy?.strategy === "blockDelete" ? deny() : undefined;
217
220
  }
218
221
 
222
+ // The tenant that owns a record subject's row; the file purge is scoped to it.
223
+ async function owningTenantOfRecordSubject(
224
+ runner: DbRunner,
225
+ features: ReadonlyMap<string, FeatureDefinition>,
226
+ raw: SubjectIdInput,
227
+ ): Promise<TenantId | undefined> {
228
+ if (raw.kind !== "record") return undefined;
229
+ const owner = await recordRowOwningTenantId(runner, features, raw.entity, raw.id);
230
+ return parseTenantId(owner) ?? undefined;
231
+ }
232
+
219
233
  // AccessDeniedError.code is the generic "access_denied" for every branch;
220
234
  // the specific branch lives in details.reason.
221
235
  function denialReasonOf(failure: WriteFailure): string {
@@ -300,46 +314,72 @@ function sweepErrorLabel(err: unknown): string {
300
314
 
301
315
  // The key is already erased when this runs, so a failing sweep must not skip the
302
316
  // audit event: the failure is returned as a label for the event instead of thrown.
303
- // The savepoint keeps a database error from aborting the handler transaction.
317
+ // Each step runs in its own savepoint: a database error must not abort the handler
318
+ // transaction, and one failing step must not skip or roll back the others.
304
319
  async function sweepDerivedSubjectData(
305
320
  ctx: HandlerContext,
306
321
  subject: SubjectId,
307
322
  subjectKey: string,
308
323
  crossTenantRunner: DbRunner,
309
324
  audit: { readonly requestId: string; readonly erasedBy: string },
325
+ rawSubject: SubjectIdInput,
310
326
  ): Promise<string | undefined> {
311
- try {
312
- await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
313
- // Blind-index sweep (#818): nulls bidx columns now so the deterministic
314
- // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
315
- await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
316
-
317
- // Derived search index still holds plaintext (#1610) — purge next to the
318
- // blind-index sweep. No adapter → no-op (apps without search).
319
- if (ctx.searchAdapter) {
320
- await purgeSearchDocumentsForSubject(
321
- crossTenantSubjectRunner,
322
- ctx.registry.features,
323
- ctx.searchAdapter,
324
- subjectKey,
325
- subject,
326
- );
327
- }
328
-
329
- // Note bodies are keyed per note, so the subject's own key never reaches them.
330
- if (ctx.registry.features.has("notes-history")) {
331
- await eraseNoteBodiesReachingSubject({
332
- db: crossTenantSubjectRunner,
333
- subject,
334
- erasedBy: audit.erasedBy,
335
- requestId: audit.requestId,
336
- });
337
- }
338
- });
339
- return undefined;
340
- } catch (err) {
341
- return sweepErrorLabel(err);
342
- }
327
+ const recordTenantId = await owningTenantOfRecordSubject(
328
+ crossTenantRunner,
329
+ ctx.registry.features,
330
+ rawSubject,
331
+ );
332
+ const failureLabels: string[] = [];
333
+ const runStep = async (step: (runner: DbRunner) => Promise<void>): Promise<void> => {
334
+ try {
335
+ await runInSavepointIfSupported(crossTenantRunner, step);
336
+ } catch (err) {
337
+ failureLabels.push(sweepErrorLabel(err));
338
+ }
339
+ };
340
+
341
+ await runStep(async (crossTenantSubjectRunner) => {
342
+ // Blind-index sweep (#818): nulls bidx columns now so the deterministic
343
+ // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
344
+ await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
345
+
346
+ // Derived search index still holds plaintext (#1610) — purge next to the
347
+ // blind-index sweep. No adapter → no-op (apps without search).
348
+ if (ctx.searchAdapter) {
349
+ await purgeSearchDocumentsForSubject(
350
+ crossTenantSubjectRunner,
351
+ ctx.registry.features,
352
+ ctx.searchAdapter,
353
+ subjectKey,
354
+ subject,
355
+ );
356
+ }
357
+
358
+ // Note bodies are keyed per note, so the subject's own key never reaches them.
359
+ if (ctx.registry.features.has("notes-history")) {
360
+ await eraseNoteBodiesReachingSubject({
361
+ db: crossTenantSubjectRunner,
362
+ subject,
363
+ erasedBy: audit.erasedBy,
364
+ requestId: audit.requestId,
365
+ });
366
+ }
367
+ });
368
+
369
+ // File columns hold only the fileRef id, so the key erase never reaches the bytes.
370
+ await runStep((crossTenantSubjectRunner) =>
371
+ purgeFilesReachingSubject({
372
+ registry: ctx.registry,
373
+ db: crossTenantSubjectRunner,
374
+ subject,
375
+ subjectKey,
376
+ ...(recordTenantId !== undefined && { recordTenantId }),
377
+ resolveDeleteBytes: resolveFileBytesDeleter(ctx),
378
+ missingStorage: { mode: "fail" },
379
+ }),
380
+ );
381
+
382
+ return failureLabels[0];
343
383
  }
344
384
 
345
385
  // Email subscribers and other non-user entities use user-style subject keys
@@ -441,7 +481,7 @@ export const forgetSubjectWrite = defineWriteHandler({
441
481
  reason:
442
482
  "the denial and SUBJECT_FORGOTTEN audit appends name the caller's own tenant stream on the outside-transaction db; " +
443
483
  "the tenant-scope and retention checks run against the subject's tenant, not necessarily the caller's; " +
444
- "the blind-index sweep and search purge address the subject across tenants; the user " +
484
+ "the blind-index sweep, search purge and file purge address the subject across tenants; the user " +
445
485
  "lifecycle update and PAT revoke run on the SYSTEM user stream.",
446
486
  },
447
487
  handler: async (event, ctx) => {
@@ -544,6 +584,7 @@ export const forgetSubjectWrite = defineWriteHandler({
544
584
  subjectKey,
545
585
  ctx.db.unsafeRaw(),
546
586
  { requestId, erasedBy: event.user.id },
587
+ raw,
547
588
  );
548
589
 
549
590
  await appendSubjectForgotten(
@@ -0,0 +1,29 @@
1
+ import { deleteFileRefBytes } from "@cosmicdrift/kumiko-framework/derivatives";
2
+ import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createFileContext } from "@cosmicdrift/kumiko-framework/files";
4
+ import type { DeleteFileBytes } from "../shared/index.ts";
5
+
6
+ const ERROR_CONTEXT = "crypto-shredding:forget-subject";
7
+
8
+ // The upload resolver, not ctx.files: ctx.files is bound to the acting user's tenant, while a
9
+ // subject's rows can live in any tenant. Resolved eagerly so a missing provider surfaces as
10
+ // missing_file_storage instead of a failed delete.
11
+ export function resolveFileBytesDeleter(
12
+ ctx: HandlerContext,
13
+ ): (tenantId: TenantId) => Promise<DeleteFileBytes | undefined> {
14
+ return async (tenantId) => {
15
+ const resolver = ctx._fileProviderResolver;
16
+ if (resolver === undefined) {
17
+ const own = tenantId === ctx.user.tenantId ? ctx.files : undefined;
18
+ return own && ((key) => deleteFileRefBytes(own, { storageKey: key }, ERROR_CONTEXT));
19
+ }
20
+ try {
21
+ const provider = await resolver(tenantId);
22
+ const files = createFileContext(() => Promise.resolve(provider), tenantId);
23
+ return (key) => deleteFileRefBytes(files, { storageKey: key }, ERROR_CONTEXT);
24
+ } catch {
25
+ // skip: no provider resolvable for this tenant; surfaces as missing_file_storage when files exist
26
+ return undefined;
27
+ }
28
+ };
29
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.372.0",
4
+ "type": "improvement",
5
+ "title": "runRetentionCleanup is exported for app tests",
6
+ "detail": "The data-retention entry point now exports runRetentionCleanup with its argument, result and skip types, so an app test can drive a retention run with its own now and file context and check that bytes, file_refs rows and the entity row are gone. The user-data-rights recipe shows the pattern."
7
+ },
2
8
  {
3
9
  "version": "0.363.0",
4
10
  "type": "improvement",
@@ -19,3 +19,9 @@ export {
19
19
  tenantRetentionOverrideTable,
20
20
  } from "./feature.ts";
21
21
  export { computeCutoff, type Instant } from "./keep-for.ts";
22
+ export {
23
+ type RetentionCleanupSkip,
24
+ type RunRetentionCleanupArgs,
25
+ type RunRetentionCleanupResult,
26
+ runRetentionCleanup,
27
+ } from "./run-retention-cleanup.ts";
@@ -74,7 +74,13 @@ import {
74
74
  fileRefsTable,
75
75
  } from "@cosmicdrift/kumiko-framework/files";
76
76
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
77
- import { runInSubTransaction } from "../shared/index.ts";
77
+ import {
78
+ collectSingleFileHolders,
79
+ planFileFields,
80
+ purgeRowFileRefs,
81
+ runInSubTransaction,
82
+ type SingleFileHolder,
83
+ } from "../shared/index.ts";
78
84
  import { computeCutoff, type Instant } from "./keep-for.ts";
79
85
  import type { RetentionPresetKey } from "./presets.ts";
80
86
  import { resolveRetentionPolicyForTenant } from "./resolve-for-tenant.ts";
@@ -264,130 +270,6 @@ async function forgetRowAndEraseKeys(args: {
264
270
 
265
271
  // --- hardDelete + file-fields (kumiko-framework#3089) ---
266
272
 
267
- type FileFieldPlan = {
268
- /** type file|image — stores the fileRef-id directly in the entity column. */
269
- readonly singleFields: readonly string[];
270
- /** type files|images — resolved via file_refs (bound by entityType/entityId/fieldName). */
271
- readonly multiFields: readonly string[];
272
- };
273
-
274
- const NO_FILE_FIELDS: FileFieldPlan = { singleFields: [], multiFields: [] };
275
-
276
- // file/image = single-column fields (fileRef-id lives directly in the entity
277
- // row); files/images = plural, resolved via file_refs. Same set
278
- // as boot-validator/entity-handler.ts FILE_FIELD_TYPES and engine's
279
- // isFileField — inlined here rather than importing across the package
280
- // barrel since this module already needs the single/multi split, not just
281
- // membership.
282
- function planFileFields(fields: Readonly<Record<string, unknown>>): FileFieldPlan {
283
- const singleFields: string[] = [];
284
- const multiFields: string[] = [];
285
- for (const [name, field] of Object.entries(fields)) {
286
- const type = (field as { readonly type?: unknown }).type; // @cast-boundary schema-walk
287
- if (type === "file" || type === "image") singleFields.push(name);
288
- else if (type === "files" || type === "images") multiFields.push(name);
289
- }
290
- return singleFields.length > 0 || multiFields.length > 0
291
- ? { singleFields, multiFields }
292
- : NO_FILE_FIELDS;
293
- }
294
-
295
- // Every fileRef-id a row's file/image/files/images-fields reference: single
296
- // fields hold the id in their own column, plural fields resolve
297
- // via file_refs (tenantId, entityType, entityId, fieldName).
298
- async function collectFileRefIds(
299
- db: DbRunner,
300
- tenantId: TenantId,
301
- entityName: string,
302
- row: Record<string, unknown>,
303
- plan: FileFieldPlan,
304
- ): Promise<readonly string[]> {
305
- const ids = new Set<string>();
306
- for (const field of plan.singleFields) {
307
- const value = row[field];
308
- if (typeof value === "string" && value.length > 0) ids.add(value);
309
- }
310
- if (plan.multiFields.length > 0) {
311
- const refRows = await selectMany<{ id: string }>(db, fileRefsTable, {
312
- tenantId,
313
- entityType: entityName,
314
- entityId: String(row["id"]),
315
- fieldName: { in: plan.multiFields },
316
- });
317
- for (const r of refRows) ids.add(r.id);
318
- }
319
- return Array.from(ids);
320
- }
321
-
322
- // A fileRef is bound to a DIFFERENT entity/record than the row currently
323
- // being purged — its binding (entityType/entityId), not the row's own
324
- // single-field column, decides ownership. A single-field value can point at
325
- // a fileRef uploaded/attached elsewhere; that fileRef's bytes belong to
326
- // whatever it's actually bound to, not to this row.
327
- function isFileRefBoundElsewhere(
328
- fileRef: Record<string, unknown>,
329
- entityName: string,
330
- rowId: string,
331
- ): boolean {
332
- const entityType = fileRef["entityType"];
333
- const entityId = fileRef["entityId"];
334
- if (typeof entityType === "string" && entityType !== entityName) return true;
335
- if (typeof entityId === "string" && entityId !== rowId) return true;
336
- return false;
337
- }
338
-
339
- type SingleFileHolder = {
340
- readonly table: Parameters<typeof selectMany>[1];
341
- readonly tableHasTenantId: boolean;
342
- readonly singleFields: readonly string[];
343
- };
344
-
345
- // Every implicit entity table with file/image fields: an unbound fileRef
346
- // (POST /files without entityType/entityId) can sit in single fields of
347
- // several entity types, so the sharing check must look beyond the purged
348
- // row's own table.
349
- function collectSingleFileHolders(registry: Registry): readonly SingleFileHolder[] {
350
- const holders: SingleFileHolder[] = [];
351
- for (const proj of registry.getAllProjections().values()) {
352
- if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table) continue;
353
- const entity = registry.getEntity(proj.source);
354
- if (!entity) continue;
355
- const { singleFields } = planFileFields(entity.fields);
356
- if (singleFields.length === 0) continue;
357
- holders.push({
358
- table: proj.table,
359
- tableHasTenantId: propertyOf(proj.table, "tenantId") !== undefined,
360
- singleFields,
361
- });
362
- }
363
- return holders;
364
- }
365
-
366
- // A single-field fileRef-id can be shared by more than one row, in the SAME
367
- // table (e.g. a stock-photo re-picked across records) or in another entity's
368
- // table. Purging its bytes would corrupt the row that isn't being deleted.
369
- // Bounded by batchLimit rows per run and holderFields queries per fileRef —
370
- // the same per-row event-cost tradeoff as the rest of this cron (see header).
371
- async function isFileRefSharedByOtherRow(
372
- db: DbRunner,
373
- holders: readonly SingleFileHolder[],
374
- ownTable: Parameters<typeof selectMany>[1],
375
- tenantId: TenantId,
376
- rowId: string,
377
- fileRefId: string,
378
- ): Promise<boolean> {
379
- for (const holder of holders) {
380
- for (const field of holder.singleFields) {
381
- const where: WhereObject = { [field]: fileRefId };
382
- if (holder.table === ownTable) where["id"] = { ne: rowId };
383
- if (holder.tableHasTenantId) where["tenantId"] = tenantId;
384
- const others = await selectMany(db, holder.table, where, { limit: 1 });
385
- if (others.length > 0) return true;
386
- }
387
- }
388
- return false;
389
- }
390
-
391
273
  // Throws (unsafe key, missing list permission) must fail this row closed,
392
274
  // not abort the whole tenant run.
393
275
  async function deleteFileFailingClosed(
@@ -407,11 +289,8 @@ async function deleteFileFailingClosed(
407
289
  }
408
290
 
409
291
  // hardDelete for ONE row of an entity that has file/image/files/images
410
- // fields: delete the un-shared fileRefs' bytes (+ derivatives) first, then
411
- // their fileRef rows, then the entity row itself — bytes before rows so a
412
- // crash mid-way leaves an orphaned fileRef pointing at already-gone bytes
413
- // (safe: a later run's provider.delete is idempotent) rather than a gone
414
- // fileRef whose bytes leak forever.
292
+ // fields: purge the un-shared fileRefs (bytes + derivatives, then the fileRef
293
+ // rows), then the entity row itself.
415
294
  async function purgeHardDeleteRowWithFiles(args: {
416
295
  readonly db: DbRunner;
417
296
  readonly table: Parameters<typeof selectMany>[1];
@@ -420,7 +299,6 @@ async function purgeHardDeleteRowWithFiles(args: {
420
299
  readonly entityName: string;
421
300
  readonly entity: EntityDefinition;
422
301
  readonly row: Record<string, unknown>;
423
- readonly plan: FileFieldPlan;
424
302
  readonly files: FileContext | undefined;
425
303
  readonly entityExecutor: ReturnType<typeof createEventStoreExecutor>;
426
304
  readonly fileRefExecutor: ReturnType<typeof createEventStoreExecutor>;
@@ -431,8 +309,32 @@ async function purgeHardDeleteRowWithFiles(args: {
431
309
  readonly onSkip: (reason: "missing_file_storage" | "file_delete_failed") => void;
432
310
  }): Promise<boolean> {
433
311
  const rowId = String(args.row["id"]);
434
- const forgetEntityRow = () =>
435
- forgetRowAndEraseKeys({
312
+ const logContext = `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`;
313
+ const files = args.files;
314
+ const outcome = await purgeRowFileRefs({
315
+ db: args.db,
316
+ tenantId: args.tenantId,
317
+ entityName: args.entityName,
318
+ entity: args.entity,
319
+ table: args.table,
320
+ fileHolders: args.fileHolders,
321
+ row: args.row,
322
+ deleteBytes: files ? (key) => deleteFileFailingClosed(key, files, logContext) : undefined,
323
+ fileRefForget: async (fileRefId) =>
324
+ (await args.fileRefExecutor.forget({ id: fileRefId }, args.systemUser, args.tdb)).isSuccess,
325
+ onBytesDeleteFailed: (failedKeys) => {
326
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
327
+ console.warn(
328
+ `[data-retention:hardDelete] ${logContext} storage delete failed keys=${failedKeys.join(",")}`,
329
+ );
330
+ },
331
+ });
332
+ if (outcome !== "purged") {
333
+ args.onSkip(outcome);
334
+ return false;
335
+ }
336
+ return (
337
+ await forgetRowAndEraseKeys({
436
338
  db: args.db,
437
339
  executor: args.entityExecutor,
438
340
  entity: args.entity,
@@ -442,83 +344,8 @@ async function purgeHardDeleteRowWithFiles(args: {
442
344
  row: args.row,
443
345
  kms: args.kms,
444
346
  registry: args.registry,
445
- });
446
- const fileRefIds = await collectFileRefIds(
447
- args.db,
448
- args.tenantId,
449
- args.entityName,
450
- args.row,
451
- args.plan,
452
- );
453
- if (fileRefIds.length === 0) {
454
- return (await forgetEntityRow()).isSuccess;
455
- }
456
-
457
- const fileRefRows = await selectMany<Record<string, unknown>>(args.db, fileRefsTable, {
458
- id: { in: fileRefIds },
459
- tenantId: args.tenantId,
460
- });
461
-
462
- const toDelete: Record<string, unknown>[] = [];
463
- for (const fileRef of fileRefRows) {
464
- if (isFileRefBoundElsewhere(fileRef, args.entityName, rowId)) continue;
465
- const shared = await isFileRefSharedByOtherRow(
466
- args.db,
467
- args.fileHolders,
468
- args.table,
469
- args.tenantId,
470
- rowId,
471
- String(fileRef["id"]),
472
- );
473
- if (shared) continue;
474
- toDelete.push(fileRef);
475
- }
476
-
477
- if (toDelete.length === 0) {
478
- return (await forgetEntityRow()).isSuccess;
479
- }
480
-
481
- if (!args.files) {
482
- args.onSkip("missing_file_storage");
483
- return false;
484
- }
485
- const files = args.files;
486
-
487
- let anyFailed = false;
488
- for (const fileRef of toDelete) {
489
- const key = fileRef["storageKey"];
490
- if (typeof key !== "string" || key.length === 0) continue;
491
- const failedKeys = await deleteFileFailingClosed(
492
- key,
493
- files,
494
- `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`,
495
- );
496
- if (failedKeys.length > 0) {
497
- anyFailed = true;
498
- // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
499
- console.warn(
500
- `[data-retention:hardDelete] tenant=${args.tenantId} entity=${args.entityName} row=${rowId} storage delete failed keys=${failedKeys.join(",")}`,
501
- );
502
- }
503
- }
504
- if (anyFailed) {
505
- args.onSkip("file_delete_failed");
506
- return false;
507
- }
508
-
509
- for (const fileRef of toDelete) {
510
- const res = await args.fileRefExecutor.forget(
511
- { id: fileRef["id"] as EntityId },
512
- args.systemUser,
513
- args.tdb,
514
- );
515
- if (!res.isSuccess) {
516
- args.onSkip("file_delete_failed");
517
- return false;
518
- }
519
- }
520
-
521
- return (await forgetEntityRow()).isSuccess;
347
+ })
348
+ ).isSuccess;
522
349
  }
523
350
 
524
351
  // hardDelete page for an entity WITH file/image/files/images fields — loads
@@ -534,7 +361,6 @@ async function purgeHardDeleteRowsWithFiles(args: {
534
361
  readonly tenantId: TenantId;
535
362
  readonly entityName: string;
536
363
  readonly entity: EntityDefinition;
537
- readonly plan: FileFieldPlan;
538
364
  readonly files: FileContext | undefined;
539
365
  readonly entityExecutor: ReturnType<typeof createEventStoreExecutor>;
540
366
  readonly fileRefExecutor: ReturnType<typeof createEventStoreExecutor>;
@@ -575,7 +401,6 @@ async function purgeHardDeleteRowsWithFiles(args: {
575
401
  entityName: args.entityName,
576
402
  entity: args.entity,
577
403
  row,
578
- plan: args.plan,
579
404
  files: args.files,
580
405
  entityExecutor: args.entityExecutor,
581
406
  fileRefExecutor: args.fileRefExecutor,
@@ -753,7 +578,6 @@ export async function runRetentionCleanup(
753
578
  tenantId,
754
579
  entityName,
755
580
  entity,
756
- plan: filePlan,
757
581
  files: args.files,
758
582
  entityExecutor: executor,
759
583
  fileRefExecutor,
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.372.0",
4
+ "type": "breaking",
5
+ "title": "Queued delivery jobs carry the recipient address sealed",
6
+ "detail": "createDeliveryService takes an addressCipher; the boot wiring passes the master-key cipher, so delivery.render and delivery.send payloads hold sealedAddress instead of address. Workers open it through ctx.configEncryption.",
7
+ "migration": "Roll out worker and web together with the same KUMIKO_SECRETS_MASTER_KEY_V<n>; a worker without the master key fails sealed jobs with send_failed. Code that calls createDeliveryService itself passes addressCipher to get sealed payloads."
8
+ },
9
+ {
10
+ "version": "0.372.0",
11
+ "type": "improvement",
12
+ "title": "createUnsubscribePageChromeReader gives app routes the tenant branding for unsubscribe pages",
13
+ "detail": "The JSDoc of renderUnsubscribeResultPage pointed to a chrome read that only works for SystemAdmin, so app routes for legacy unsubscribe links rendered without branding. The new `createUnsubscribePageChromeReader({ dispatchSystemQuery })` returns `(tenantId, locale) => MailChrome | null`, failing soft to null. Build it once in the `wire` hook, whose deps now also carry `dispatchSystemQuery` (runProdApp, runWorkerApp.wireComponents, createKumikoServer), and call it with a tenant the route resolved itself, for example from the Host, never with a tenantId from the query string or body. The chrome query itself stays SystemAdmin-only; anonymous `/api/query` callers are still rejected for every tenant."
14
+ },
2
15
  {
3
16
  "version": "0.371.0",
4
17
  "type": "improvement",