@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.
- package/dist/api/anonymous-cookie.js +9 -12
- package/dist/api/api-constants.d.ts +0 -1
- package/dist/api/api-constants.js +0 -1
- package/dist/api/auth-middleware.d.ts +0 -2
- package/dist/api/auth-middleware.js +16 -9
- package/dist/api/auth-routes.d.ts +2 -0
- package/dist/api/auth-routes.js +28 -40
- package/dist/api/csrf-middleware.js +3 -2
- package/dist/api/index.d.ts +4 -1
- package/dist/api/index.js +3 -1
- package/dist/api/origin-middleware.d.ts +11 -1
- package/dist/api/origin-middleware.js +40 -7
- package/dist/api/server.js +30 -4
- package/dist/api/session-cookies.d.ts +23 -0
- package/dist/api/session-cookies.js +85 -0
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/owner-transition-rekey.d.ts +2 -0
- package/dist/crypto/owner-transition-rekey.js +30 -0
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +5 -0
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +14 -1
- package/dist/db/apply-entity-event.js +0 -5
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-field-encryption.js +3 -2
- package/dist/db/entity-table-meta.js +3 -3
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +9 -2
- package/dist/db/event-store-executor-write.js +31 -5
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/event-store-executor.js +1 -0
- package/dist/db/index.d.ts +3 -3
- package/dist/db/index.js +2 -2
- package/dist/db/migrate-generator.d.ts +1 -0
- package/dist/db/migrate-generator.js +12 -0
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +120 -15
- package/dist/db/table-builder.d.ts +3 -1
- package/dist/db/table-builder.js +3 -6
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +38 -1
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/query-output-columns.js +3 -0
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +89 -14
- package/dist/engine/build-app-schema.js +1 -1
- package/dist/engine/define-handler.js +1 -3
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +8 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/schema-builder.js +2 -2
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +9 -0
- package/dist/engine/soft-delete-cleanup.js +12 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/classes.d.ts +7 -0
- package/dist/errors/classes.js +12 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
- package/dist/files/bind-pending-file-refs.d.ts +9 -0
- package/dist/files/bind-pending-file-refs.js +54 -0
- package/dist/files/create-file-ref-from-bytes.d.ts +22 -0
- package/dist/files/create-file-ref-from-bytes.js +39 -0
- package/dist/files/create-file-ref.d.ts +50 -0
- package/dist/files/create-file-ref.js +135 -0
- package/dist/files/dispose-removed-file-refs.d.ts +8 -0
- package/dist/files/dispose-removed-file-refs.js +51 -0
- package/dist/files/feature.js +2 -2
- package/dist/files/file-ref-entity.d.ts +1 -0
- package/dist/files/file-ref-entity.js +1 -0
- package/dist/files/file-ref-table.js +2 -2
- package/dist/files/file-routes.d.ts +4 -0
- package/dist/files/file-routes.js +60 -126
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.js +1 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
- package/dist/files/purge-expired-file-ref-bytes.js +34 -0
- package/dist/files/record-read-check.d.ts +9 -0
- package/dist/files/record-read-check.js +49 -0
- package/dist/files/record-write-check.d.ts +21 -0
- package/dist/files/record-write-check.js +64 -0
- package/dist/files/types.d.ts +1 -0
- package/dist/files/types.js +3 -0
- package/dist/i18n/required-surface-keys.js +10 -0
- package/dist/jobs/cron-repeat-options.d.ts +9 -0
- package/dist/jobs/cron-repeat-options.js +5 -0
- package/dist/jobs/job-runner.js +2 -1
- package/dist/migrations/pii-event-backfill.d.ts +1 -0
- package/dist/migrations/pii-event-backfill.js +16 -2
- package/dist/pipeline/cascade-handler.js +17 -2
- package/dist/pipeline/dispatch-write.js +7 -0
- package/dist/schema-cli.js +12 -6
- package/dist/search/reindex-entity.js +3 -4
- package/dist/stack/test-stack.d.ts +3 -1
- package/dist/stack/test-stack.js +1 -0
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/package.json +4 -4
- 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
|
+
}
|
package/dist/files/feature.js
CHANGED
|
@@ -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(
|
|
23
|
+
r.entity(FILE_REF_ENTITY_NAME, fileRefEntity);
|
|
24
24
|
return { routeOptions: options };
|
|
25
25
|
});
|
|
26
26
|
}
|
|
@@ -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(
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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);
|
package/dist/files/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/files/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/files/types.d.ts
CHANGED
package/dist/files/types.js
CHANGED
|
@@ -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;
|