@cosmicdrift/kumiko-framework 0.359.0 → 0.361.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 (104) hide show
  1. package/dist/api/anonymous-cookie.js +9 -12
  2. package/dist/api/api-constants.d.ts +0 -1
  3. package/dist/api/api-constants.js +0 -1
  4. package/dist/api/auth-middleware.d.ts +0 -2
  5. package/dist/api/auth-middleware.js +16 -9
  6. package/dist/api/auth-routes.d.ts +2 -0
  7. package/dist/api/auth-routes.js +28 -40
  8. package/dist/api/csrf-middleware.js +3 -2
  9. package/dist/api/index.d.ts +4 -1
  10. package/dist/api/index.js +3 -1
  11. package/dist/api/origin-middleware.d.ts +11 -1
  12. package/dist/api/origin-middleware.js +40 -7
  13. package/dist/api/server.js +30 -4
  14. package/dist/api/session-cookies.d.ts +23 -0
  15. package/dist/api/session-cookies.js +85 -0
  16. package/dist/crypto/index.d.ts +1 -1
  17. package/dist/crypto/index.js +1 -1
  18. package/dist/crypto/owner-transition-rekey.d.ts +2 -0
  19. package/dist/crypto/owner-transition-rekey.js +30 -0
  20. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  21. package/dist/crypto/pii-field-encryption.js +5 -0
  22. package/dist/crypto/subject-resolver.d.ts +2 -0
  23. package/dist/crypto/subject-resolver.js +14 -1
  24. package/dist/db/apply-entity-event.js +0 -5
  25. package/dist/db/eagerload.js +2 -2
  26. package/dist/db/entity-field-encryption.js +3 -2
  27. package/dist/db/entity-table-meta.js +3 -3
  28. package/dist/db/event-store-executor-context.d.ts +1 -0
  29. package/dist/db/event-store-executor-context.js +9 -2
  30. package/dist/db/event-store-executor-write.js +31 -5
  31. package/dist/db/event-store-executor.d.ts +2 -1
  32. package/dist/db/event-store-executor.js +1 -0
  33. package/dist/db/index.d.ts +3 -3
  34. package/dist/db/index.js +2 -2
  35. package/dist/db/migrate-generator.d.ts +1 -0
  36. package/dist/db/migrate-generator.js +12 -0
  37. package/dist/db/queries/backfill-pii.d.ts +2 -0
  38. package/dist/db/queries/backfill-pii.js +120 -15
  39. package/dist/db/table-builder.d.ts +3 -1
  40. package/dist/db/table-builder.js +3 -6
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +38 -1
  43. package/dist/engine/boot-validator/index.js +2 -1
  44. package/dist/engine/boot-validator/query-output-columns.js +3 -0
  45. package/dist/engine/boot-validator/screens.d.ts +1 -1
  46. package/dist/engine/boot-validator/screens.js +89 -14
  47. package/dist/engine/build-app-schema.js +1 -1
  48. package/dist/engine/define-handler.js +1 -3
  49. package/dist/engine/factories.d.ts +2 -0
  50. package/dist/engine/feature-config-events-jobs.js +8 -0
  51. package/dist/engine/index.d.ts +1 -1
  52. package/dist/engine/schema-builder.js +2 -2
  53. package/dist/engine/screen-helpers.d.ts +1 -0
  54. package/dist/engine/screen-helpers.js +9 -0
  55. package/dist/engine/soft-delete-cleanup.js +12 -1
  56. package/dist/engine/types/index.d.ts +1 -1
  57. package/dist/errors/classes.d.ts +7 -0
  58. package/dist/errors/classes.js +12 -0
  59. package/dist/errors/index.d.ts +1 -1
  60. package/dist/errors/index.js +1 -1
  61. package/dist/errors/reasons.d.ts +1 -0
  62. package/dist/errors/reasons.js +3 -0
  63. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
  64. package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
  65. package/dist/files/bind-pending-file-refs.d.ts +9 -0
  66. package/dist/files/bind-pending-file-refs.js +54 -0
  67. package/dist/files/create-file-ref-from-bytes.d.ts +22 -0
  68. package/dist/files/create-file-ref-from-bytes.js +39 -0
  69. package/dist/files/create-file-ref.d.ts +50 -0
  70. package/dist/files/create-file-ref.js +135 -0
  71. package/dist/files/dispose-removed-file-refs.d.ts +8 -0
  72. package/dist/files/dispose-removed-file-refs.js +51 -0
  73. package/dist/files/feature.js +2 -2
  74. package/dist/files/file-ref-entity.d.ts +1 -0
  75. package/dist/files/file-ref-entity.js +1 -0
  76. package/dist/files/file-ref-table.js +2 -2
  77. package/dist/files/file-routes.d.ts +4 -0
  78. package/dist/files/file-routes.js +60 -126
  79. package/dist/files/index.d.ts +2 -0
  80. package/dist/files/index.js +1 -0
  81. package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
  82. package/dist/files/purge-expired-file-ref-bytes.js +34 -0
  83. package/dist/files/record-read-check.d.ts +9 -0
  84. package/dist/files/record-read-check.js +49 -0
  85. package/dist/files/record-write-check.d.ts +21 -0
  86. package/dist/files/record-write-check.js +64 -0
  87. package/dist/files/types.d.ts +1 -0
  88. package/dist/files/types.js +3 -0
  89. package/dist/i18n/required-surface-keys.js +10 -0
  90. package/dist/jobs/cron-repeat-options.d.ts +9 -0
  91. package/dist/jobs/cron-repeat-options.js +5 -0
  92. package/dist/jobs/job-runner.js +2 -1
  93. package/dist/migrations/pii-event-backfill.d.ts +1 -0
  94. package/dist/migrations/pii-event-backfill.js +16 -2
  95. package/dist/pipeline/cascade-handler.js +17 -2
  96. package/dist/pipeline/dispatch-write.js +7 -0
  97. package/dist/schema-cli.js +12 -6
  98. package/dist/search/reindex-entity.js +3 -4
  99. package/dist/stack/test-stack.d.ts +3 -1
  100. package/dist/stack/test-stack.js +1 -0
  101. package/dist/ui-types/index.d.ts +2 -2
  102. package/dist/ui-types/index.js +1 -1
  103. package/package.json +4 -4
  104. package/src/changes.json +135 -0
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { defineFeature } from "../engine/index.js";
2
- import { fileRefEntity } from "./file-ref-entity.js";
2
+ import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
3
3
  export { fileRefEntity } from "./file-ref-entity.js";
4
4
  // files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
5
5
  // den Standard-Entity-Executor (file-routes.ts: executor.create/delete →
@@ -20,7 +20,7 @@ export function createFilesFeature(options = {}) {
20
20
  category: "storage",
21
21
  recommended: false,
22
22
  });
23
- r.entity("fileRef", fileRefEntity);
23
+ r.entity(FILE_REF_ENTITY_NAME, fileRefEntity);
24
24
  return { routeOptions: options };
25
25
  });
26
26
  }
@@ -1,3 +1,4 @@
1
+ export { FILE_REF_ENTITY_NAME } from "./types.js";
1
2
  export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
2
3
  storageKey: {
3
4
  readonly type: "text";
@@ -17,6 +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 { FILE_REF_ENTITY_NAME } from "./types.js";
20
21
  export const fileRefEntity = createEntity({
21
22
  table: "file_refs",
22
23
  softDelete: true,
@@ -1,5 +1,5 @@
1
1
  import { buildEntityTable } from "../db/table-builder.js";
2
- import { fileRefEntity } from "./file-ref-entity.js";
2
+ import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
3
3
  // `file_refs` ist die read-table des `fileRef`-Entity. Aus der Entity-
4
4
  // Definition gebaut (kein hand-gepflegtes pgTable mehr), damit die implizite
5
5
  // Entity-Projektion (Rebuild) und der Live-Executor-Write spaltengleich auf
@@ -9,4 +9,4 @@ import { fileRefEntity } from "./file-ref-entity.js";
9
9
  // jeder Upload appended `fileRef.created`, jeder Delete `fileRef.deleted`
10
10
  // (Standard-Entity-Auto-Verben). UUIDs schließen die Enumeration-Attacke
11
11
  // auf /files/:id.
12
- export const fileRefsTable = buildEntityTable("fileRef", fileRefEntity);
12
+ export const fileRefsTable = buildEntityTable(FILE_REF_ENTITY_NAME, fileRefEntity);
@@ -2,6 +2,8 @@ import { Hono } from "hono";
2
2
  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
+ import type { RecordReadCheck } from "./record-read-check.js";
6
+ import { type RecordWriteCheck } from "./record-write-check.js";
5
7
  export type FileAccessDecision = "allow" | "deny";
6
8
  export type FileRef = {
7
9
  id: string;
@@ -27,6 +29,8 @@ export type FileRoutesOptions = {
27
29
  readonly maxUploadSize?: string;
28
30
  readonly privilegedRoles?: readonly string[];
29
31
  readonly accessGuard?: FileAccessGuard;
32
+ readonly canReadRecord?: RecordReadCheck;
33
+ readonly canWriteRecord?: RecordWriteCheck;
30
34
  };
31
35
  export declare function resolveMaxUploadBodyBytes(options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">): number;
32
36
  export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
@@ -4,54 +4,46 @@ import { getUser } from "../api/auth-middleware.js";
4
4
  import { requestContext } from "../api/request-context.js";
5
5
  import { selectMany } from "../bun-db/index.js";
6
6
  import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
7
- import { createEventStoreExecutor } from "../db/event-store-executor.js";
8
7
  import { createTenantDb } from "../db/tenant-db.js";
9
8
  import { createDerivativesContext, resolveFieldVariant, resolveRenderer, } from "../derivatives/index.js";
10
9
  import { isFileField, isUuid, } from "../engine/types/index.js";
11
- import { generateId } from "../utils/index.js";
12
10
  import { buildContentDispositionHeader } from "./content-disposition.js";
11
+ import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
13
12
  import { createFileContext } from "./file-handle.js";
14
13
  import { fileRefEntity } from "./file-ref-entity.js";
15
14
  import { fileRefsTable } from "./file-ref-table.js";
16
- import { neutralFileNameForMimeType, resolveContentType } from "./resolve-content-type.js";
17
- import { buildStorageKey, parseMaxSize, resolveServedContentType, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
15
+ import { RecordWriteDecisions } from "./record-write-check.js";
16
+ import { neutralFileNameForMimeType } from "./resolve-content-type.js";
17
+ import { parseMaxSize, resolveServedContentType } from "./types.js";
18
18
  const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
19
19
  // 15 minutes — long enough for a download to start, short enough that a
20
20
  // leaked URL (e.g. from a browser history screenshot) isn't a long-lived
21
21
  // credential (see "Signed-URL default expiry" in core-files.md).
22
22
  const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
23
- // Default guard: on attached files, allow the uploader or a privileged role.
24
- // Unattached files are tenant-wide (the tenant boundary is already enforced
25
- // by the query).
26
- function createDefaultGuard(privilegedRoles) {
27
- return ({ fileRef, user }) => {
28
- if (fileRef.entityType === null || fileRef.entityId === null)
29
- return "allow";
23
+ // Default guard: on attached files, allow the uploader or a privileged role,
24
+ // and for reads also everyone who may read the record. Delete stays with the
25
+ // uploader and privileged roles: reading a record is no right to remove its
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) {
30
+ return async ({ fileRef, user, operation }) => {
30
31
  if (fileRef.insertedById === user.id)
31
32
  return "allow";
32
- for (const role of privilegedRoles) {
33
- if (user.roles.includes(role))
34
- return "allow";
35
- }
36
- return "deny";
33
+ if (privilegedRoles.some((role) => user.roles.includes(role)))
34
+ return "allow";
35
+ if (fileRef.entityType === null || fileRef.entityId === null)
36
+ return "deny";
37
+ if (operation !== "read" || canReadRecord === undefined)
38
+ return "deny";
39
+ const recordReadable = await canReadRecord({
40
+ entityType: fileRef.entityType,
41
+ entityId: fileRef.entityId,
42
+ user,
43
+ });
44
+ return recordReadable ? "allow" : "deny";
37
45
  };
38
46
  }
39
- // entityType/fieldName drive the GDPR-forget decision downstream
40
- // (fileRefDeleteHook resolves the field's PII annotation from exactly this
41
- // pairing) — attaching to a field nobody can resolve would leave that
42
- // decision hanging on an unverified client string. Either both are omitted
43
- // (unattached upload, allowed) or both must resolve to a real registered
44
- // field.
45
- function resolveAttachedField(registry, entityType, fieldName) {
46
- if (entityType === undefined && fieldName === undefined)
47
- return { kind: "unattached" };
48
- const entity = entityType !== undefined ? registry?.getEntity(entityType) : undefined;
49
- const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
50
- if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
51
- return { kind: "unresolvable" };
52
- return { kind: "resolved", fieldDef };
53
- }
54
- const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
55
47
  // Multipart framing plus the small entityType/entityId/fieldName parts
56
48
  // riding alongside the file.
57
49
  const MULTIPART_OVERHEAD_MARGIN_BYTES = 64 * 1024;
@@ -85,14 +77,8 @@ export function readFilesRouteOptions(registry) {
85
77
  export function createFileRoutes(options) {
86
78
  const { db } = options;
87
79
  const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
88
- const guard = options.accessGuard ?? createDefaultGuard(privilegedRoles);
89
- // Standard entity executor for fileRef — self-contained (table + entity).
90
- // create/delete emit fileRef.created/deleted and write file_refs via
91
- // applyEntityEvent in one tx (read-your-own-write), exactly like any other
92
- // entity's lifecycle. The registry only feeds custom projections on those events.
93
- const executor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
94
- entityName: "fileRef",
95
- });
80
+ const guard = options.accessGuard ?? createDefaultGuard(privilegedRoles, options.canReadRecord);
81
+ const executor = getFileRefExecutor();
96
82
  const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
97
83
  const api = new Hono();
98
84
  const projectionGrants = options.registry ? { projectionRegistry: options.registry } : undefined;
@@ -151,103 +137,51 @@ export function createFileRoutes(options) {
151
137
  // store it in the text entityId column.
152
138
  const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
153
139
  const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
154
- // These interpolate directly into the storage key (buildStorageKey below) —
155
- // reject anything but a safe identifier so a client can't path-traverse
156
- // out of its tenant's storage prefix (../.. segments, absolute paths).
157
- for (const [field, value] of [
158
- ["entityType", entityType],
159
- ["entityId", entityId],
160
- ["fieldName", fieldName],
161
- ]) {
162
- if (value !== undefined && !/^[A-Za-z0-9_-]+$/.test(value)) {
163
- return c.json({ error: `invalid_${field}: must match /^[A-Za-z0-9_-]+$/` }, 400);
140
+ if (entityId !== undefined) {
141
+ if (entityType === undefined) {
142
+ return c.json({ error: "missing_entity_type: entityId requires entityType" }, 400);
164
143
  }
165
- }
166
- // Validate against entity field definition if available.
167
- let maxSize = options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
168
- let accept;
169
- const attachedField = resolveAttachedField(options.registry, entityType, fieldName);
170
- if (attachedField.kind === "unresolvable") {
171
- return c.json({
172
- error: "unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload",
173
- }, 400);
174
- }
175
- if (attachedField.kind === "resolved") {
176
- if (attachedField.fieldDef.maxSize)
177
- maxSize = attachedField.fieldDef.maxSize;
178
- if (attachedField.fieldDef.accept)
179
- accept = attachedField.fieldDef.accept;
180
- }
181
- const validationError = validateFile({ fileName: file.name, mimeType: file.type, size: file.size }, { maxSize, accept });
182
- if (validationError) {
183
- return c.json({ error: validationError }, 400);
184
- }
185
- const data = new Uint8Array(await file.arrayBuffer());
186
- const contentValidation = validateFileContent(file.name, data, accept);
187
- if (contentValidation.kind === "rejected") {
188
- return c.json({ error: contentValidation.error }, 400);
189
- }
190
- // A normalized upload's real type is already known from the sniffed
191
- // bytes — resolveUploadMimeType's alias/rewrite step only applies to the
192
- // declared mimeType of an unnormalized upload.
193
- let mimeType;
194
- if (contentValidation.kind === "normalized") {
195
- mimeType = contentValidation.mimeType;
196
- }
197
- else {
198
- const mimeTypeResolution = resolveUploadMimeType(file.name, file.type, data);
199
- if (mimeTypeResolution.kind === "rejected") {
200
- return c.json({ error: mimeTypeResolution.error }, 400);
144
+ // Before any bytes are written: a rejected attach must leave no file behind. An
145
+ // unregistered entityType attaches to nothing; storeFileAsFileRef rejects it with 400.
146
+ const decision = options.registry !== undefined && options.registry.getEntity(entityType) === undefined
147
+ ? RecordWriteDecisions.writable
148
+ : options.canWriteRecord
149
+ ? await options.canWriteRecord({ entityType, entityId, fieldName, user })
150
+ : RecordWriteDecisions.forbidden;
151
+ switch (decision) {
152
+ case RecordWriteDecisions.writable:
153
+ break;
154
+ case RecordWriteDecisions.notFound:
155
+ return c.json({ error: "not_found" }, 404);
156
+ case RecordWriteDecisions.forbidden:
157
+ return c.json({ error: "forbidden: no write access to this record" }, 403);
158
+ case RecordWriteDecisions.locked:
159
+ return c.json({ error: "record_locked: the record is locked" }, 409);
201
160
  }
202
- mimeType = resolveContentType({
203
- declared: mimeTypeResolution.mimeType,
204
- bytes: data,
205
- filename: file.name,
206
- });
207
161
  }
208
- const fileRefId = generateId();
209
- const storageKey = buildStorageKey(user.tenantId, entityType ?? "unattached", entityId, fieldName ?? "file", file.name, generateId(), contentValidation.kind === "normalized" ? contentValidation.extension : undefined);
210
- // Write binary FIRST (outside the tx — network/disk I/O doesn't belong
211
- // inside a PG connection's tx window). On DB-tx rollback below the bytes
212
- // are orphaned in the provider; cleanup-jobs sweep those later. Losing a
213
- // row on append-failure is acceptable; corrupting a committed row with a
214
- // missing binary is not.
162
+ const data = new Uint8Array(await file.arrayBuffer());
215
163
  const storageProvider = await options.resolveProvider(user.tenantId);
216
- await storageProvider.write(storageKey, data, mimeType);
217
- // Create via the standard entity executor: emits fileRef.created +
218
- // materialises the file_refs row in one tx (read-your-own-write). id is
219
- // explicit so the response + storageKey stay consistent; tenantId,
220
- // insertedAt and insertedById are set by applyEntityEvent from the event
221
- // metadata. The binary was written above, outside the tx — a create
222
- // failure orphans bytes (cleanup-job sweeps) rather than committing a row
223
- // whose binary is missing.
224
- const result = await executor.create({
225
- id: fileRefId,
226
- storageKey,
227
- fileName: file.name,
228
- mimeType,
229
- size: file.size,
230
- entityType: entityType ?? null,
231
- entityId: entityId ?? null,
232
- fieldName: fieldName ?? null,
233
- }, user, tenantDbFor(user.tenantId));
234
- if (!result.isSuccess) {
164
+ const outcome = await storeFileAsFileRef({
165
+ user,
166
+ tenantDb: tenantDbFor(user.tenantId),
167
+ registry: options.registry,
168
+ defaultMaxUploadSize: options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
169
+ writeBytes: (key, bytes, mimeType) => storageProvider.write(key, bytes, mimeType),
170
+ deleteBytes: (key) => storageProvider.delete(key),
171
+ }, { fileName: file.name, declaredMimeType: file.type, data, entityType, entityId, fieldName });
172
+ if (outcome.kind === "rejected")
173
+ return c.json({ error: outcome.error }, 400);
174
+ if (outcome.kind === "failed")
235
175
  return c.json({ error: "upload_failed" }, 500);
236
- }
237
- return c.json({
238
- id: fileRefId,
239
- fileName: file.name,
240
- mimeType,
241
- size: file.size,
242
- storageKey,
243
- }, 201);
176
+ return c.json(outcome.fileRef, 201);
244
177
  });
245
178
  // GET /files/:id — download.
246
179
  //
247
180
  // Authorization stack:
248
181
  // 1. tenantId must match (hard isolation, never crossable).
249
182
  // 2. The configured FileAccessGuard decides read access. Default:
250
- // unattached → allow; attached → uploader or privileged role.
183
+ // unattached → allow; attached → uploader, privileged role or a
184
+ // reader of the record.
251
185
  // Apps override via options.accessGuard to layer entity-level rules.
252
186
  api.get("/files/:id", async (c) => {
253
187
  const user = getUser(c);
@@ -1,3 +1,5 @@
1
+ export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, } from "./create-file-ref-from-bytes.js";
2
+ export { createFileRefFromBytes } from "./create-file-ref-from-bytes.js";
1
3
  export { createFilesFeature } from "./feature.js";
2
4
  export type { FileContext, FileHandle } from "./file-handle.js";
3
5
  export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
@@ -1,3 +1,4 @@
1
+ export { createFileRefFromBytes } from "./create-file-ref-from-bytes.js";
1
2
  export { createFilesFeature } from "./feature.js";
2
3
  // `createFileHandle` is an implementation detail — construct handles via
3
4
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
@@ -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,9 @@
1
+ import type { Registry, SessionUser } from "../engine/types/index.js";
2
+ export type RecordReadCheck = (args: {
3
+ readonly entityType: string;
4
+ readonly entityId: string;
5
+ readonly user: SessionUser;
6
+ }) => Promise<boolean>;
7
+ type QueryAsUser = (type: string, payload: unknown, user: SessionUser) => Promise<unknown>;
8
+ export declare function createRecordReadCheck(registry: Registry, query: QueryAsUser): RecordReadCheck;
9
+ export {};
@@ -0,0 +1,49 @@
1
+ import { toKebab } from "../engine/qualified-name.js";
2
+ import { isKumikoError } from "../errors/index.js";
3
+ import { createFallbackLogger } from "../logging/index.js";
4
+ const log = createFallbackLogger("files");
5
+ // Only the "<entity>:detail" handler counts: other "<entity>:...:detail"
6
+ // queries map to the same entity but may return a different shape or scope.
7
+ // Entity names are globally unique and only the owning feature maps handlers
8
+ // to its entity, so at most one QN matches per entity.
9
+ function isEntityDetailQuery(qn, entityName) {
10
+ return qn.endsWith(`:query:${toKebab(entityName)}:detail`);
11
+ }
12
+ function buildDetailQueryIndex(registry) {
13
+ const detailQueryByEntity = new Map();
14
+ for (const qn of registry.getAllQueryHandlers().keys()) {
15
+ const entityName = registry.getHandlerEntity(qn);
16
+ if (entityName !== undefined && isEntityDetailQuery(qn, entityName)) {
17
+ detailQueryByEntity.set(entityName, qn);
18
+ }
19
+ }
20
+ return detailQueryByEntity;
21
+ }
22
+ // "May this user read the record?" is answered by the entity's own detail
23
+ // query run as that user: handler access, row ownership and parent visibility
24
+ // all apply, exactly as when the record is opened. null (not found or not
25
+ // visible) and access errors mean no. Entities without a detail query never
26
+ // grant access this way.
27
+ export function createRecordReadCheck(registry, query) {
28
+ let detailQueryByEntity;
29
+ return async ({ entityType, entityId, user }) => {
30
+ detailQueryByEntity ??= buildDetailQueryIndex(registry);
31
+ const detailQuery = detailQueryByEntity.get(entityType);
32
+ if (detailQuery === undefined)
33
+ return false;
34
+ try {
35
+ const record = await query(detailQuery, { id: entityId }, user);
36
+ return record !== null && record !== undefined;
37
+ }
38
+ catch (error) {
39
+ if (!isKumikoError(error) || error.httpStatus >= 500) {
40
+ log.error("record read check failed; file access denied", {
41
+ entityType,
42
+ detailQuery,
43
+ error: error instanceof Error ? error.message : String(error),
44
+ });
45
+ }
46
+ return false;
47
+ }
48
+ };
49
+ }
@@ -0,0 +1,21 @@
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 createRecordWriteCheck(args: {
18
+ readonly registry: Registry;
19
+ readonly canReadRecord: RecordReadCheck;
20
+ readonly tenantDbFor: (tenantId: TenantId) => TenantDb;
21
+ }): 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
+ function isEntityUpdateWrite(qn, entityName) {
12
+ return qn.endsWith(`:write:${toKebab(entityName)}:update`);
13
+ }
14
+ // "May this user attach a file to the record?" asks what saving the record would ask: the
15
+ // entity's update handler, the row-level and field-level write rules against the stored row,
16
+ // and the lock. Not-readable, foreign-tenant and missing records answer alike (not_found) so
17
+ // the upload never confirms what exists.
18
+ export function createRecordWriteCheck(args) {
19
+ const { registry, canReadRecord, tenantDbFor } = args;
20
+ function findUpdateHandlerAccess(entityType) {
21
+ for (const [qn, handler] of registry.getAllWriteHandlers()) {
22
+ if (registry.getHandlerEntity(qn) === entityType && isEntityUpdateWrite(qn, entityType)) {
23
+ return handler.access;
24
+ }
25
+ }
26
+ return undefined;
27
+ }
28
+ function findEntityTable(entityType) {
29
+ for (const projection of registry.getAllProjections().values()) {
30
+ if (projection.isImplicit === true && projection.source === entityType) {
31
+ return projection.table;
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 = findEntityTable(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 ?? [])
@@ -0,0 +1,9 @@
1
+ export type CronTrigger = {
2
+ readonly cron: string;
3
+ readonly tz?: string | undefined;
4
+ };
5
+ export type CronRepeatOptions = {
6
+ readonly pattern: string;
7
+ readonly tz?: string;
8
+ };
9
+ export declare function cronRepeatOptions(trigger: CronTrigger): CronRepeatOptions;
@@ -0,0 +1,5 @@
1
+ export function cronRepeatOptions(trigger) {
2
+ return trigger.tz === undefined
3
+ ? { pattern: trigger.cron }
4
+ : { pattern: trigger.cron, tz: trigger.tz };
5
+ }