@cosmicdrift/kumiko-framework 0.360.0 → 0.362.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 (85) hide show
  1. package/dist/api/server.js +14 -1
  2. package/dist/crypto/index.d.ts +1 -1
  3. package/dist/crypto/index.js +1 -1
  4. package/dist/crypto/owner-transition-rekey.d.ts +2 -0
  5. package/dist/crypto/owner-transition-rekey.js +30 -0
  6. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  7. package/dist/crypto/pii-field-encryption.js +5 -0
  8. package/dist/crypto/subject-resolver.d.ts +2 -0
  9. package/dist/crypto/subject-resolver.js +14 -1
  10. package/dist/db/apply-entity-event.js +0 -5
  11. package/dist/db/eagerload.js +2 -2
  12. package/dist/db/entity-field-encryption.js +3 -2
  13. package/dist/db/entity-table-meta.js +3 -3
  14. package/dist/db/event-store-executor-context.d.ts +1 -0
  15. package/dist/db/event-store-executor-context.js +9 -2
  16. package/dist/db/event-store-executor-write.js +31 -5
  17. package/dist/db/event-store-executor.d.ts +2 -1
  18. package/dist/db/event-store-executor.js +1 -0
  19. package/dist/db/index.d.ts +3 -3
  20. package/dist/db/index.js +2 -2
  21. package/dist/db/migrate-generator.d.ts +1 -0
  22. package/dist/db/migrate-generator.js +12 -0
  23. package/dist/db/queries/backfill-pii.d.ts +2 -0
  24. package/dist/db/queries/backfill-pii.js +120 -15
  25. package/dist/db/table-builder.d.ts +3 -1
  26. package/dist/db/table-builder.js +3 -6
  27. package/dist/engine/boot-validator/access-declarations.js +29 -18
  28. package/dist/engine/boot-validator/card-meta-overflow.js +5 -2
  29. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  30. package/dist/engine/boot-validator/entity-handler.js +38 -1
  31. package/dist/engine/boot-validator/index.js +2 -1
  32. package/dist/engine/boot-validator/query-output-columns.js +3 -0
  33. package/dist/engine/boot-validator/screens.d.ts +1 -1
  34. package/dist/engine/boot-validator/screens.js +89 -14
  35. package/dist/engine/build-app-schema.js +1 -0
  36. package/dist/engine/define-handler.js +1 -3
  37. package/dist/engine/factories.d.ts +2 -0
  38. package/dist/engine/feature-ast/extractors/round3.d.ts +2 -2
  39. package/dist/engine/feature-ast/extractors/round3.js +4 -4
  40. package/dist/engine/index.d.ts +1 -1
  41. package/dist/engine/schema-builder.js +2 -2
  42. package/dist/engine/screen-helpers.d.ts +1 -0
  43. package/dist/engine/screen-helpers.js +9 -0
  44. package/dist/engine/soft-delete-cleanup.js +12 -1
  45. package/dist/engine/types/index.d.ts +1 -1
  46. package/dist/errors/classes.d.ts +7 -0
  47. package/dist/errors/classes.js +12 -0
  48. package/dist/errors/index.d.ts +1 -1
  49. package/dist/errors/index.js +1 -1
  50. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
  51. package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
  52. package/dist/files/bind-pending-file-refs.d.ts +1 -0
  53. package/dist/files/bind-pending-file-refs.js +13 -13
  54. package/dist/files/bind-referenced-files.write.d.ts +16 -0
  55. package/dist/files/bind-referenced-files.write.js +109 -0
  56. package/dist/files/dispose-removed-file-refs.d.ts +8 -0
  57. package/dist/files/dispose-removed-file-refs.js +51 -0
  58. package/dist/files/feature.d.ts +1 -0
  59. package/dist/files/feature.js +2 -0
  60. package/dist/files/file-ref-entity.d.ts +1 -1
  61. package/dist/files/file-ref-entity.js +1 -1
  62. package/dist/files/file-routes.d.ts +4 -1
  63. package/dist/files/file-routes.js +37 -6
  64. package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
  65. package/dist/files/purge-expired-file-ref-bytes.js +34 -0
  66. package/dist/files/record-write-check.d.ts +22 -0
  67. package/dist/files/record-write-check.js +64 -0
  68. package/dist/files/types.d.ts +1 -0
  69. package/dist/files/types.js +3 -0
  70. package/dist/i18n/required-surface-keys.js +10 -0
  71. package/dist/migrations/pii-event-backfill.d.ts +1 -0
  72. package/dist/migrations/pii-event-backfill.js +16 -2
  73. package/dist/pipeline/cascade-handler.js +17 -2
  74. package/dist/pipeline/dispatch-write.js +6 -6
  75. package/dist/pipeline/write-origin.js +2 -2
  76. package/dist/schema-cli.js +12 -6
  77. package/dist/search/reindex-entity.js +3 -4
  78. package/dist/stack/test-stack.d.ts +3 -1
  79. package/dist/stack/test-stack.js +1 -0
  80. package/dist/ui-types/card-column-roles.d.ts +5 -0
  81. package/dist/ui-types/card-column-roles.js +22 -4
  82. package/dist/ui-types/index.d.ts +3 -3
  83. package/dist/ui-types/index.js +2 -2
  84. package/package.json +4 -4
  85. package/src/changes.json +134 -0
@@ -0,0 +1,109 @@
1
+ import * as z from "zod";
2
+ import { defineWriteHandler, SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/index.js";
3
+ import { isFileField } from "../engine/types/index.js";
4
+ import { ValidationError } from "../errors/index.js";
5
+ import { fileRefIdsOf } from "./bind-pending-file-refs.js";
6
+ import { getFileRefExecutor } from "./create-file-ref.js";
7
+ import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
8
+ import { findImplicitEntityTable } from "./record-write-check.js";
9
+ const MAX_PAGE_SIZE = 500;
10
+ const DEFAULT_PAGE_SIZE = 200;
11
+ const payloadSchema = z.object({
12
+ entityType: z.string().min(1),
13
+ entityIds: z.array(z.uuid()).min(1).max(MAX_PAGE_SIZE).optional(),
14
+ afterId: z.uuid().optional(),
15
+ limit: z.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
16
+ });
17
+ function invalidEntityType() {
18
+ return new ValidationError({
19
+ fields: [
20
+ {
21
+ path: "entityType",
22
+ code: "invalid_value",
23
+ i18nKey: "errors.validation.invalid_value",
24
+ },
25
+ ],
26
+ });
27
+ }
28
+ // The entity must have an implicit table and declared file fields, and the files
29
+ // feature must be mounted; anything else is a caller error, not an empty page.
30
+ function resolveFileBearingEntity(registry, entityType) {
31
+ const entity = registry.getEntity(entityType);
32
+ const table = findImplicitEntityTable(registry, entityType);
33
+ if (entity === undefined || table === undefined)
34
+ throw invalidEntityType();
35
+ if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
36
+ throw invalidEntityType();
37
+ const fileFieldNames = Object.entries(entity.fields)
38
+ .filter(([, fieldDef]) => isFileField(fieldDef))
39
+ .map(([fieldName]) => fieldName);
40
+ if (fileFieldNames.length === 0)
41
+ throw invalidEntityType();
42
+ return { entity, table, fileFieldNames };
43
+ }
44
+ // Either the named records or one id-ordered page after afterId.
45
+ function loadRecordPage(db, { entity, table }, { entityIds, afterId, limit }) {
46
+ return db.selectMany(table, {
47
+ ...(entity.softDelete === true && { isDeleted: false }),
48
+ ...(entityIds !== undefined
49
+ ? { id: entityIds }
50
+ : afterId !== undefined && { id: { gt: afterId } }),
51
+ }, { orderBy: { col: "id", direction: "asc" }, ...(entityIds === undefined && { limit }) });
52
+ }
53
+ // Unbound, same tenant, uploaded by the record's creator (or the system), and either
54
+ // unclaimed or pending for exactly this entity field.
55
+ function isBindableToRecord(fileRef, target) {
56
+ const uploader = fileRef["insertedById"];
57
+ return (typeof uploader === "string" &&
58
+ (uploader === target.record["insertedById"] || uploader === SYSTEM_USER_ID) &&
59
+ fileRef["tenantId"] === target.tenantId &&
60
+ fileRef["entityId"] === null &&
61
+ (fileRef["entityType"] === null ||
62
+ (fileRef["entityType"] === target.entityType && fileRef["fieldName"] === target.fieldName)));
63
+ }
64
+ // The binding follows only the record's declared file fields; the caller names
65
+ // no file ids. The record's creator must have uploaded the file themselves (or the
66
+ // system generated it), otherwise a foreign reference would pull someone else's
67
+ // private upload into the referencing user's record and make it readable there.
68
+ export const bindReferencedFilesWrite = defineWriteHandler({
69
+ name: "bindReferencedFiles",
70
+ schema: payloadSchema,
71
+ access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
72
+ // Maintenance backfill run by ops or system jobs, not a task an assistant performs.
73
+ agent: { expose: false },
74
+ description: "Binds the files a record references through its declared file fields to that record, so readers of the record see them through record access. Covers files uploaded before pending-upload binding existed (entityId null) and records created without a save lifecycle (custom handlers, system jobs). Binds only files uploaded by the record's creator or generated by the system; skips files already bound elsewhere, uploaded by someone else, of another tenant or missing; never rebinds.",
75
+ handler: async (event, ctx) => {
76
+ const { entityType, entityIds, limit } = event.payload;
77
+ const { fileFieldNames, ...source } = resolveFileBearingEntity(ctx.registry, entityType);
78
+ const records = await loadRecordPage(ctx.db, source, event.payload);
79
+ const executor = getFileRefExecutor();
80
+ let bound = 0;
81
+ let skipped = 0;
82
+ for (const record of records) {
83
+ for (const fieldName of fileFieldNames) {
84
+ for (const fileRefId of fileRefIdsOf(record[fieldName])) {
85
+ const row = await executor.detail({ id: fileRefId }, event.user, ctx.db);
86
+ if (row === null ||
87
+ !isBindableToRecord(row, {
88
+ record,
89
+ entityType,
90
+ fieldName,
91
+ tenantId: event.user.tenantId,
92
+ })) {
93
+ skipped += 1;
94
+ continue;
95
+ }
96
+ const version = typeof row["version"] === "number" ? row["version"] : undefined;
97
+ const result = await executor.update({ id: fileRefId, version, changes: { entityType, entityId: record.id, fieldName } }, event.user, ctx.db);
98
+ if (!result.isSuccess)
99
+ return result;
100
+ bound += 1;
101
+ }
102
+ }
103
+ }
104
+ const lastRecord = records[records.length - 1];
105
+ const pageIsFull = entityIds === undefined && records.length === limit;
106
+ const nextAfterId = pageIsFull ? (lastRecord?.id ?? null) : null;
107
+ return { isSuccess: true, data: { bound, skipped, nextAfterId } };
108
+ },
109
+ });
@@ -0,0 +1,8 @@
1
+ import type { TenantDb } from "../db/tenant-db.js";
2
+ import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
3
+ export declare function disposeRemovedFileRefs(args: {
4
+ readonly registry: Registry;
5
+ readonly result: SaveContext;
6
+ readonly user: SessionUser;
7
+ readonly db: TenantDb;
8
+ }): Promise<void>;
@@ -0,0 +1,51 @@
1
+ import { isFileField, } from "../engine/types/index.js";
2
+ import { reraiseAsKumikoError } from "../errors/index.js";
3
+ import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
+ import { getFileRefExecutor } from "./create-file-ref.js";
5
+ import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
+ function isBoundTo(row, entityName, entityId, fieldName) {
7
+ return (row["entityType"] === entityName &&
8
+ row["entityId"] === entityId &&
9
+ row["fieldName"] === fieldName);
10
+ }
11
+ // An update that drops or replaces a file id leaves the fileRef readable to
12
+ // everyone who may read the record. Deleting it goes through the same executor
13
+ // path as DELETE /files/:id (soft delete, bytes kept until retention), so
14
+ // record readers get 404 afterwards. Only fileRefs bound to exactly this
15
+ // record and field are touched: an id that is pending, shared or bound
16
+ // elsewhere is not this save's to dispose. The authority is the caller's write
17
+ // right on the record (the fileRef entity declares no write access of its own).
18
+ export async function disposeRemovedFileRefs(args) {
19
+ const { registry, result, user, db } = args;
20
+ // skip: a create has no earlier files that could be removed
21
+ if (result.isNew)
22
+ return;
23
+ // skip: saves of unnamed aggregates declare no file fields
24
+ if (result.entityName === undefined)
25
+ return;
26
+ const entityName = result.entityName;
27
+ const entity = registry.getEntity(entityName);
28
+ // skip: saves of unregistered aggregates (custom executors) declare no file fields
29
+ if (entity === undefined)
30
+ return;
31
+ // skip: without the files feature the file_refs table is not part of the schema
32
+ if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
33
+ return;
34
+ const entityId = String(result.id);
35
+ const executor = getFileRefExecutor();
36
+ for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
37
+ if (!isFileField(fieldDef) || !(fieldName in result.changes))
38
+ continue;
39
+ const kept = new Set(fileRefIdsOf(result.data[fieldName]));
40
+ for (const fileRefId of fileRefIdsOf(result.previous[fieldName])) {
41
+ if (kept.has(fileRefId))
42
+ continue;
43
+ const row = await executor.detail({ id: fileRefId }, user, db);
44
+ if (row === null || !isBoundTo(row, entityName, entityId, fieldName))
45
+ continue;
46
+ const removed = await executor.delete({ id: fileRefId }, user, db);
47
+ if (!removed.isSuccess)
48
+ throw reraiseAsKumikoError(removed.error);
49
+ }
50
+ }
51
+ }
@@ -4,6 +4,7 @@ export { fileRefEntity } from "./file-ref-entity.js";
4
4
  export type FilesFeatureOptions = {
5
5
  readonly accessGuard?: FileAccessGuard;
6
6
  readonly privilegedRoles?: readonly string[];
7
+ readonly unclaimedUploadReaders?: readonly string[];
7
8
  readonly maxUploadSize?: string;
8
9
  };
9
10
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -1,4 +1,5 @@
1
1
  import { defineFeature } from "../engine/index.js";
2
+ import { bindReferencedFilesWrite } from "./bind-referenced-files.write.js";
2
3
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
3
4
  export { fileRefEntity } from "./file-ref-entity.js";
4
5
  // files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
@@ -21,6 +22,7 @@ export function createFilesFeature(options = {}) {
21
22
  recommended: false,
22
23
  });
23
24
  r.entity(FILE_REF_ENTITY_NAME, fileRefEntity);
25
+ r.writeHandler(bindReferencedFilesWrite);
24
26
  return { routeOptions: options };
25
27
  });
26
28
  }
@@ -1,4 +1,4 @@
1
- export declare const FILE_REF_ENTITY_NAME = "fileRef";
1
+ export { FILE_REF_ENTITY_NAME } from "./types.js";
2
2
  export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
3
3
  storageKey: {
4
4
  readonly type: "text";
@@ -17,7 +17,7 @@ import { createBigIntField, createEntity, createTextField } from "../engine/inde
17
17
  // reference: "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf"). The other
18
18
  // fields (storageKey, mimeType, entityType, entityId, fieldName) are
19
19
  // technical references or system metadata, not subject content.
20
- export const FILE_REF_ENTITY_NAME = "fileRef";
20
+ export { FILE_REF_ENTITY_NAME } from "./types.js";
21
21
  export const fileRefEntity = createEntity({
22
22
  table: "file_refs",
23
23
  softDelete: true,
@@ -3,6 +3,7 @@ import type { DbConnection } from "../db/connection.js";
3
3
  import { type Registry, type SessionUser, type TenantId } from "../engine/types/index.js";
4
4
  import type { FileProviderResolver } from "./provider-resolver.js";
5
5
  import type { RecordReadCheck } from "./record-read-check.js";
6
+ import { type RecordWriteCheck } from "./record-write-check.js";
6
7
  export type FileAccessDecision = "allow" | "deny";
7
8
  export type FileRef = {
8
9
  id: string;
@@ -27,9 +28,11 @@ export type FileRoutesOptions = {
27
28
  readonly registry?: Registry;
28
29
  readonly maxUploadSize?: string;
29
30
  readonly privilegedRoles?: readonly string[];
31
+ readonly unclaimedUploadReaders?: readonly string[];
30
32
  readonly accessGuard?: FileAccessGuard;
31
33
  readonly canReadRecord?: RecordReadCheck;
34
+ readonly canWriteRecord?: RecordWriteCheck;
32
35
  };
33
36
  export declare function resolveMaxUploadBodyBytes(options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">): number;
34
- export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
37
+ export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "unclaimedUploadReaders" | "maxUploadSize">;
35
38
  export declare function createFileRoutes(options: FileRoutesOptions): Hono;
@@ -12,6 +12,7 @@ import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from
12
12
  import { createFileContext } from "./file-handle.js";
13
13
  import { fileRefEntity } from "./file-ref-entity.js";
14
14
  import { fileRefsTable } from "./file-ref-table.js";
15
+ import { RecordWriteDecisions } from "./record-write-check.js";
15
16
  import { neutralFileNameForMimeType } from "./resolve-content-type.js";
16
17
  import { parseMaxSize, resolveServedContentType } from "./types.js";
17
18
  const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
@@ -22,16 +23,23 @@ const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
22
23
  // Default guard: on attached files, allow the uploader or a privileged role,
23
24
  // and for reads also everyone who may read the record. Delete stays with the
24
25
  // uploader and privileged roles: reading a record is no right to remove its
25
- // files. Unattached files are tenant-wide (the tenant boundary is already
26
- // enforced by the query).
27
- function createDefaultGuard(privilegedRoles, canReadRecord) {
26
+ // files. Pending uploads (entityType set, record not yet created) are the
27
+ // uploader's own until bound; so are uploads without an entityType, which no
28
+ // record can ever claim.
29
+ function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReaders) {
28
30
  return async ({ fileRef, user, operation }) => {
29
- if (fileRef.entityType === null || fileRef.entityId === null)
30
- return "allow";
31
31
  if (fileRef.insertedById === user.id)
32
32
  return "allow";
33
33
  if (privilegedRoles.some((role) => user.roles.includes(role)))
34
34
  return "allow";
35
+ const isUnclaimed = fileRef.entityType === null && fileRef.entityId === null;
36
+ if (isUnclaimed &&
37
+ operation === "read" &&
38
+ unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
39
+ return "allow";
40
+ }
41
+ if (fileRef.entityType === null || fileRef.entityId === null)
42
+ return "deny";
35
43
  if (operation !== "read" || canReadRecord === undefined)
36
44
  return "deny";
37
45
  const recordReadable = await canReadRecord({
@@ -75,7 +83,8 @@ export function readFilesRouteOptions(registry) {
75
83
  export function createFileRoutes(options) {
76
84
  const { db } = options;
77
85
  const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
78
- const guard = options.accessGuard ?? createDefaultGuard(privilegedRoles, options.canReadRecord);
86
+ const guard = options.accessGuard ??
87
+ createDefaultGuard(privilegedRoles, options.canReadRecord, options.unclaimedUploadReaders ?? []);
79
88
  const executor = getFileRefExecutor();
80
89
  const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
81
90
  const api = new Hono();
@@ -135,6 +144,28 @@ export function createFileRoutes(options) {
135
144
  // store it in the text entityId column.
136
145
  const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
137
146
  const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
147
+ if (entityId !== undefined) {
148
+ if (entityType === undefined) {
149
+ return c.json({ error: "missing_entity_type: entityId requires entityType" }, 400);
150
+ }
151
+ // Before any bytes are written: a rejected attach must leave no file behind. An
152
+ // unregistered entityType attaches to nothing; storeFileAsFileRef rejects it with 400.
153
+ const decision = options.registry !== undefined && options.registry.getEntity(entityType) === undefined
154
+ ? RecordWriteDecisions.writable
155
+ : options.canWriteRecord
156
+ ? await options.canWriteRecord({ entityType, entityId, fieldName, user })
157
+ : RecordWriteDecisions.forbidden;
158
+ switch (decision) {
159
+ case RecordWriteDecisions.writable:
160
+ break;
161
+ case RecordWriteDecisions.notFound:
162
+ return c.json({ error: "not_found" }, 404);
163
+ case RecordWriteDecisions.forbidden:
164
+ return c.json({ error: "forbidden: no write access to this record" }, 403);
165
+ case RecordWriteDecisions.locked:
166
+ return c.json({ error: "record_locked: the record is locked" }, 409);
167
+ }
168
+ }
138
169
  const data = new Uint8Array(await file.arrayBuffer());
139
170
  const storageProvider = await options.resolveProvider(user.tenantId);
140
171
  const outcome = await storeFileAsFileRef({
@@ -0,0 +1,14 @@
1
+ import type { FileContext } from "@cosmicdrift/kumiko-types/file-handle-types";
2
+ import type { Logger } from "@cosmicdrift/kumiko-types/logger";
3
+ import { deleteMany, type WhereObject } from "../db/query.js";
4
+ import type { TenantDb } from "../db/tenant-db.js";
5
+ export declare const EXPIRED_FILE_REF_PURGE_BATCH = 500;
6
+ type PurgeLog = Pick<Logger, "warn"> | undefined;
7
+ export declare function purgeExpiredFileRefsWithBytes(args: {
8
+ readonly db: TenantDb;
9
+ readonly table: Parameters<typeof deleteMany>[1];
10
+ readonly where: WhereObject;
11
+ readonly files: FileContext | undefined;
12
+ readonly log: PurgeLog;
13
+ }): Promise<void>;
14
+ export {};
@@ -0,0 +1,34 @@
1
+ import { deleteMany, selectMany } from "../db/query.js";
2
+ import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
3
+ export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
4
+ // Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
5
+ // rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
6
+ // row whose bytes could not be removed stays and the next run retries it.
7
+ export async function purgeExpiredFileRefsWithBytes(args) {
8
+ const { db, table, where, files, log } = args;
9
+ if (!files) {
10
+ log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
11
+ return;
12
+ }
13
+ const rows = await selectMany(db, table, where, {
14
+ orderBy: [{ col: "deletedAt", direction: "asc" }],
15
+ limit: EXPIRED_FILE_REF_PURGE_BATCH,
16
+ });
17
+ const purgedIds = [];
18
+ const store = { list: files.list, delete: (key) => files.ref(key).delete() };
19
+ for (const row of rows) {
20
+ try {
21
+ const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
22
+ if (failedKeys.length === 0) {
23
+ purgedIds.push(row.id);
24
+ continue;
25
+ }
26
+ log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
27
+ }
28
+ catch (err) {
29
+ log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
30
+ }
31
+ }
32
+ if (purgedIds.length > 0)
33
+ await deleteMany(db, table, { ...where, id: { in: purgedIds } });
34
+ }
@@ -0,0 +1,22 @@
1
+ import type { TenantDb } from "../db/tenant-db.js";
2
+ import type { Registry, SessionUser, TenantId } from "../engine/types/index.js";
3
+ import type { RecordReadCheck } from "./record-read-check.js";
4
+ export declare const RecordWriteDecisions: {
5
+ readonly writable: "writable";
6
+ readonly notFound: "not_found";
7
+ readonly forbidden: "forbidden";
8
+ readonly locked: "locked";
9
+ };
10
+ export type RecordWriteDecision = (typeof RecordWriteDecisions)[keyof typeof RecordWriteDecisions];
11
+ export type RecordWriteCheck = (args: {
12
+ readonly entityType: string;
13
+ readonly entityId: string;
14
+ readonly fieldName: string | undefined;
15
+ readonly user: SessionUser;
16
+ }) => Promise<RecordWriteDecision>;
17
+ export declare function findImplicitEntityTable(registry: Registry, entityType: string): import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable | undefined;
18
+ export declare function createRecordWriteCheck(args: {
19
+ readonly registry: Registry;
20
+ readonly canReadRecord: RecordReadCheck;
21
+ readonly tenantDbFor: (tenantId: TenantId) => TenantDb;
22
+ }): RecordWriteCheck;
@@ -0,0 +1,64 @@
1
+ import { hasAccess } from "../engine/access.js";
2
+ import { normalizeAccessEntry, userCanWriteFieldRow } from "../engine/ownership.js";
3
+ import { toKebab } from "../engine/qualified-name.js";
4
+ import { isRecordLocked } from "../engine/screen-helpers.js";
5
+ export const RecordWriteDecisions = {
6
+ writable: "writable",
7
+ notFound: "not_found",
8
+ forbidden: "forbidden",
9
+ locked: "locked",
10
+ };
11
+ export function findImplicitEntityTable(registry, entityType) {
12
+ for (const projection of registry.getAllProjections().values()) {
13
+ if (projection.isImplicit === true && projection.source === entityType) {
14
+ return projection.table;
15
+ }
16
+ }
17
+ return undefined;
18
+ }
19
+ function isEntityUpdateWrite(qn, entityName) {
20
+ return qn.endsWith(`:write:${toKebab(entityName)}:update`);
21
+ }
22
+ // "May this user attach a file to the record?" asks what saving the record would ask: the
23
+ // entity's update handler, the row-level and field-level write rules against the stored row,
24
+ // and the lock. Not-readable, foreign-tenant and missing records answer alike (not_found) so
25
+ // the upload never confirms what exists.
26
+ export function createRecordWriteCheck(args) {
27
+ const { registry, canReadRecord, tenantDbFor } = args;
28
+ function findUpdateHandlerAccess(entityType) {
29
+ for (const [qn, handler] of registry.getAllWriteHandlers()) {
30
+ if (registry.getHandlerEntity(qn) === entityType && isEntityUpdateWrite(qn, entityType)) {
31
+ return handler.access;
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+ return async ({ entityType, entityId, fieldName, user }) => {
37
+ if (!(await canReadRecord({ entityType, entityId, user }))) {
38
+ return RecordWriteDecisions.notFound;
39
+ }
40
+ const entity = registry.getEntity(entityType);
41
+ const table = findImplicitEntityTable(registry, entityType);
42
+ if (!entity || !table)
43
+ return RecordWriteDecisions.notFound;
44
+ // The stored row, not the detail output: the detail query can hide ownership fields.
45
+ const row = await tenantDbFor(user.tenantId).fetchOne(table, {
46
+ id: entityId,
47
+ ...(entity.softDelete === true && { isDeleted: false }),
48
+ });
49
+ if (!row)
50
+ return RecordWriteDecisions.notFound;
51
+ if (!hasAccess(user, findUpdateHandlerAccess(entityType)))
52
+ return RecordWriteDecisions.forbidden;
53
+ if (!userCanWriteFieldRow(user, entity.access?.write, row, row)) {
54
+ return RecordWriteDecisions.forbidden;
55
+ }
56
+ const fieldWriteRules = fieldName === undefined ? undefined : entity.fields[fieldName]?.access?.write;
57
+ if (!userCanWriteFieldRow(user, normalizeAccessEntry(fieldWriteRules), row, row)) {
58
+ return RecordWriteDecisions.forbidden;
59
+ }
60
+ if (isRecordLocked(entity.lockedWhen, row))
61
+ return RecordWriteDecisions.locked;
62
+ return RecordWriteDecisions.writable;
63
+ };
64
+ }
@@ -1,4 +1,5 @@
1
1
  import type { TenantId } from "../engine/types/identifiers.js";
2
+ export declare const FILE_REF_ENTITY_NAME = "fileRef";
2
3
  export type FileMetadata = {
3
4
  readonly fileName: string;
4
5
  readonly mimeType: string;
@@ -1,3 +1,6 @@
1
+ // Lives here (not in file-ref-entity.ts) so engine code can name the entity without importing
2
+ // a module that calls createEntity at load time.
3
+ export const FILE_REF_ENTITY_NAME = "fileRef";
1
4
  export function parseMaxSize(maxSize) {
2
5
  const match = maxSize.match(/^(\d+)(kb|mb|gb)$/i);
3
6
  if (!match)
@@ -162,6 +162,12 @@ function pushRelatedListSectionKeys(out, section) {
162
162
  for (const label of Object.values(section.groupBy?.labels ?? {}))
163
163
  pushKey(out, label);
164
164
  }
165
+ function pushListEmptyStateKeys(out, emptyState) {
166
+ pushKey(out, emptyState?.title);
167
+ pushKey(out, emptyState?.description);
168
+ if (emptyState?.action !== undefined)
169
+ pushRowActionKeys(out, emptyState.action);
170
+ }
165
171
  function pushExpandableRowKeys(out, section) {
166
172
  pushRelatedListSectionKeys(out, section);
167
173
  for (const action of section.actions ?? [])
@@ -192,6 +198,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
192
198
  }
193
199
  pushKey(out, list.createLabel);
194
200
  pushKey(out, list.searchPlaceholder);
201
+ pushKey(out, list.description);
202
+ pushListEmptyStateKeys(out, list.emptyState);
195
203
  for (const action of list.rowActions ?? [])
196
204
  pushRowActionKeys(out, action);
197
205
  for (const action of list.toolbarActions ?? [])
@@ -210,6 +218,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
210
218
  }
211
219
  pushKey(out, list.createLabel);
212
220
  pushKey(out, list.searchPlaceholder);
221
+ pushKey(out, list.description);
222
+ pushListEmptyStateKeys(out, list.emptyState);
213
223
  for (const action of list.rowActions ?? [])
214
224
  pushRowActionKeys(out, action);
215
225
  for (const action of list.toolbarActions ?? [])
@@ -33,6 +33,7 @@ export type PiiEventBackfillResult = {
33
33
  readonly updatedEvents: number;
34
34
  readonly encryptedFields: number;
35
35
  readonly erasedFields: number;
36
+ readonly rekeyedFields: number;
36
37
  readonly failures: readonly PiiBackfillFailure[];
37
38
  readonly queuedTables: readonly string[];
38
39
  readonly rebuild: PiiRebuildRun | null;
@@ -68,8 +68,14 @@ export function piiAnnotationEntries(registry) {
68
68
  }
69
69
  return entries.sort();
70
70
  }
71
+ // Bump when the backfill gains a transform stage: completed states keyed by the
72
+ // old fingerprint must run one more full pass. Kept out of piiAnnotationEntries,
73
+ // whose emptiness gates no_pii.
74
+ const PII_BACKFILL_TRANSFORM_VERSION = "owner-rekey-v1";
71
75
  export function piiAnnotationFingerprint(registry) {
72
- return createHash("sha256").update(piiAnnotationEntries(registry).join("\n")).digest("hex");
76
+ return createHash("sha256")
77
+ .update([...piiAnnotationEntries(registry), PII_BACKFILL_TRANSFORM_VERSION].join("\n"))
78
+ .digest("hex");
73
79
  }
74
80
  async function queueRebuildTables(db, tableNames, migrationId) {
75
81
  for (const tableName of tableNames) {
@@ -125,6 +131,7 @@ async function runOneBatch(ctx, progress, retryEventIds) {
125
131
  ...(retryEventIds && { eventIds: retryEventIds }),
126
132
  batchSize: ctx.batchSize,
127
133
  resolveOwnerFromProjection: true,
134
+ rekeyTenantKeyedOwnerFields: true,
128
135
  scanCache: progress.scanCache,
129
136
  });
130
137
  const batchTouchedEventTypes = new Set(batch.touchedEventTypes);
@@ -151,6 +158,7 @@ function recordBatch(progress, step) {
151
158
  progress.totals.updatedEvents += batch.updatedEvents;
152
159
  progress.totals.encryptedFields += batch.encryptedFields;
153
160
  progress.totals.erasedFields += batch.erasedFields;
161
+ progress.totals.rekeyedFields += batch.rekeyedFields;
154
162
  for (const failure of batch.failures) {
155
163
  // The catch-up overlap and the per-event retry can hit the same failed event twice in one run.
156
164
  if (progress.failedIdsSeen.has(failure.eventId))
@@ -218,7 +226,13 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
218
226
  const progress = {
219
227
  scanCursor,
220
228
  scanCache: {},
221
- totals: { scannedEvents: 0, updatedEvents: 0, encryptedFields: 0, erasedFields: 0 },
229
+ totals: {
230
+ scannedEvents: 0,
231
+ updatedEvents: 0,
232
+ encryptedFields: 0,
233
+ erasedFields: 0,
234
+ rekeyedFields: 0,
235
+ },
222
236
  failures: [],
223
237
  queuedTables: new Set(),
224
238
  failedIdsSeen: new Set(),
@@ -1,6 +1,7 @@
1
- import { deleteMany, fetchOne, updateMany } from "../db/query.js";
1
+ import { deleteMany, fetchOne, selectMany, updateMany } from "../db/query.js";
2
2
  import { OnDeleteStrategies, SystemHookNames, SystemHookPriorities } from "../engine/constants.js";
3
- import { ConflictError, FrameworkReasons } from "../errors/index.js";
3
+ import { isRecordLocked } from "../engine/screen-helpers.js";
4
+ import { ConflictError, FrameworkReasons, RecordLockedError } from "../errors/index.js";
4
5
  export function createCascadeDeleteHook(registry, tables) {
5
6
  return {
6
7
  name: SystemHookNames.cascadeDelete,
@@ -47,6 +48,20 @@ export function createCascadeDeleteHook(registry, tables) {
47
48
  });
48
49
  }
49
50
  }
51
+ // The raw delete/update below bypasses the executor, so a locked child must
52
+ // block the parent delete here instead of changing with it. The error names
53
+ // the parent only: the caller may delete the parent without reading the child.
54
+ if (strategy === OnDeleteStrategies.cascade || strategy === OnDeleteStrategies.setNull) {
55
+ const childLock = registry.getEntity(relation.target)?.lockedWhen;
56
+ if (childLock !== undefined) {
57
+ const children = await selectMany(db, targetTable, {
58
+ [relation.foreignKey]: payload.id,
59
+ });
60
+ if (children.some((child) => isRecordLocked(childLock, child))) {
61
+ throw new RecordLockedError({ entityName, entityId: payload.id });
62
+ }
63
+ }
64
+ }
50
65
  if (strategy === OnDeleteStrategies.cascade) {
51
66
  await deleteMany(db, targetTable, { [relation.foreignKey]: payload.id });
52
67
  }
@@ -8,7 +8,9 @@ import { checkWriteFieldOwnership, maskWriteOnlyFields } from "../engine/field-a
8
8
  import { defineTransitions, guardTransition } from "../engine/state-machine.js";
9
9
  import { HookPhases } from "../engine/types/index.js";
10
10
  import { AccessDeniedError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, NotFoundError, ValidationError, writeFailure, } from "../errors/index.js";
11
+ import { assertFileRefsBelongToTenant } from "../files/assert-file-refs-belong-to-tenant.js";
11
12
  import { bindPendingFileRefs } from "../files/bind-pending-file-refs.js";
13
+ import { disposeRemovedFileRefs } from "../files/dispose-removed-file-refs.js";
12
14
  import { CACHE_SYNC_TOPICS } from "../redis/cache-sync-topics.js";
13
15
  import { assertNoSecretLeak } from "../secrets/index.js";
14
16
  import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
@@ -112,12 +114,10 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
112
114
  // keeps projection state consistent with what the hooks observe.
113
115
  await runProjections(result, handlerContext.registry, runner);
114
116
  if (result.kind === "save") {
115
- await bindPendingFileRefs({
116
- registry: handlerContext.registry,
117
- result,
118
- user,
119
- db: handlerContext.db,
120
- });
117
+ const fileRefArgs = { registry: handlerContext.registry, result, user, db: handlerContext.db };
118
+ await assertFileRefsBelongToTenant(fileRefArgs);
119
+ await bindPendingFileRefs(fileRefArgs);
120
+ await disposeRemovedFileRefs(fileRefArgs);
121
121
  await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
122
122
  afterCommitHooks.push(async () => {
123
123
  const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, true);
@@ -39,11 +39,11 @@ function declaresPublicIntake(access) {
39
39
  // Only the public dispatcher entry points compute a root; every nested call inherits
40
40
  // it, so switching identity via ctx.writeAs(SYSTEM, ...) cannot shed an anonymous root.
41
41
  export function rootWriteOrigin(registry, type, user) {
42
- const writeHandler = registry.getWriteHandler(type);
42
+ const rootHandler = registry.getWriteHandler(type) ?? registry.getQueryHandler(type);
43
43
  return {
44
44
  rootHandler: type,
45
45
  anonymousRoot: user.roles.includes(ANONYMOUS_ROLE),
46
- publicIntake: writeHandler !== undefined && declaresPublicIntake(writeHandler.access),
46
+ publicIntake: rootHandler !== undefined && declaresPublicIntake(rootHandler.access),
47
47
  };
48
48
  }
49
49
  // Owner binding is ignored (honorOwnerBinding=false): all anonymous callers share one