@cosmicdrift/kumiko-samples 0.371.1 → 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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/auth-mfa/db/queries.ts +5 -7
- package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +2 -1
- package/packages/bundled-features/src/config/web/index.ts +2 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +75 -34
- package/packages/bundled-features/src/crypto-shredding/resolve-file-bytes-deleter.ts +29 -0
- package/packages/bundled-features/src/data-retention/changes.json +6 -0
- package/packages/bundled-features/src/data-retention/index.ts +6 -0
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +37 -213
- package/packages/bundled-features/src/delivery/changes.json +13 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +7 -2
- package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +2 -2
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +42 -23
- package/packages/bundled-features/src/delivery/recipient-address-seal.ts +72 -0
- package/packages/bundled-features/src/delivery/testing.ts +4 -1
- package/packages/bundled-features/src/delivery/unsubscribe.ts +23 -2
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +2 -2
- package/packages/bundled-features/src/files/changes.json +7 -0
- package/packages/bundled-features/src/personal-access-tokens/changes.json +12 -0
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +15 -9
- package/packages/bundled-features/src/shared/index.ts +14 -0
- package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +206 -0
- package/packages/bundled-features/src/shared/purge-row-file-refs.ts +214 -0
- package/packages/bundled-features/src/template-resolver/changes.json +6 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +24 -4
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +2 -1
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +1 -9
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +46 -1
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
- package/samples/apps/styleguide/src/features/demo/feature.ts +1 -1
- package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +5 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +9 -0
- package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +6 -0
- package/samples/apps/ui-walkthrough/src/features/rental/entities.ts +1 -0
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +27 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +9 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +1 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +22 -1
- package/samples/apps/use-all-bundled/package.json +3 -1
- package/samples/recipes/custom-handlers/src/feature.ts +3 -5
- package/samples/recipes/embedded-entity-form/src/feature.ts +1 -1
- package/samples/recipes/pdf-audit-export/README.md +1 -1
- package/samples/recipes/user-data-rights/README.md +22 -0
- 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.
|
|
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.
|
|
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:
|
|
17
|
-
readonly lastUsedAt:
|
|
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
|
-
|
|
42
|
+
detail.recoveryCodes,
|
|
45
43
|
"user-mfa.recoveryCodes",
|
|
46
44
|
);
|
|
47
|
-
return { ...
|
|
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,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
|
-
//
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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 {
|
|
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:
|
|
411
|
-
//
|
|
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
|
|
435
|
-
|
|
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
|
-
|
|
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",
|