@cosmicdrift/kumiko-framework 0.359.0 → 0.360.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 +20 -4
- package/dist/api/session-cookies.d.ts +23 -0
- package/dist/api/session-cookies.js +85 -0
- package/dist/engine/build-app-schema.js +1 -1
- package/dist/engine/feature-config-events-jobs.js +8 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/files/bind-pending-file-refs.d.ts +8 -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/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 +2 -0
- package/dist/files/file-routes.js +36 -126
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.js +1 -0
- package/dist/files/record-read-check.d.ts +9 -0
- package/dist/files/record-read-check.js +49 -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/pipeline/dispatch-write.js +7 -0
- package/package.json +4 -4
- package/src/changes.json +45 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { escapeHatchGrantsProblem, normalizeEventPiiSubject, } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { ZodObject } from "zod";
|
|
3
|
+
import { isValidIanaTimeZone } from "../time/iana.js";
|
|
3
4
|
import { resolveName } from "./handler-helpers.js";
|
|
4
5
|
import { splitNamedDefinition, unwrapArrayForm } from "./object-form.js";
|
|
5
6
|
import { QnTypes, qn, toKebab } from "./qualified-name.js";
|
|
@@ -168,6 +169,13 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
168
169
|
if (jobGrantsProblem) {
|
|
169
170
|
throw new Error(`[Feature ${name}] r.job("${jobName}") declares an invalid escapeHatch — ${jobGrantsProblem}`);
|
|
170
171
|
}
|
|
172
|
+
if ("cron" in jobOptions.trigger && jobOptions.trigger.tz !== undefined) {
|
|
173
|
+
const tz = jobOptions.trigger.tz;
|
|
174
|
+
if (!isValidIanaTimeZone(tz)) {
|
|
175
|
+
throw new Error(`[Feature ${name}] r.job("${jobName}") declares trigger { cron, tz: ${JSON.stringify(tz)} } — ` +
|
|
176
|
+
'tz must be an IANA time zone name such as "Europe/Berlin".');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
171
179
|
// Resolve NameOrRef(s) in trigger.on. Multi-Trigger-Form: Array
|
|
172
180
|
// wird zu Array von resolved strings, Single bleibt single string —
|
|
173
181
|
// job-runner unterscheidet anhand Array.isArray.
|
package/dist/errors/reasons.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const FrameworkReasons: {
|
|
|
14
14
|
readonly irreversibleOperationRequiresHighRisk: "irreversible_operation_requires_high_risk";
|
|
15
15
|
readonly instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk";
|
|
16
16
|
readonly subjectErased: "subject_erased";
|
|
17
|
+
readonly fileRejected: "file_rejected";
|
|
17
18
|
};
|
|
18
19
|
export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
|
|
19
20
|
export declare const AgentReasons: {
|
package/dist/errors/reasons.js
CHANGED
|
@@ -66,6 +66,9 @@ export const FrameworkReasons = {
|
|
|
66
66
|
// ConflictError: a write touched a personal-data field of a data subject whose
|
|
67
67
|
// key was erased (crypto-shredding); the subject can no longer receive data.
|
|
68
68
|
subjectErased: "subject_erased",
|
|
69
|
+
// UnprocessableError: createFileRefFromBytes refused the file (size, type,
|
|
70
|
+
// content or attach target). `details.error` carries the upload-route error string.
|
|
71
|
+
fileRejected: "file_rejected",
|
|
69
72
|
};
|
|
70
73
|
// Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
|
|
71
74
|
// sister catalog to FrameworkReasons instead of extending it — see the
|
|
@@ -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 bindPendingFileRefs(args: {
|
|
4
|
+
readonly registry: Registry;
|
|
5
|
+
readonly result: SaveContext;
|
|
6
|
+
readonly user: SessionUser;
|
|
7
|
+
readonly db: TenantDb;
|
|
8
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isFileField, isUuid, } from "../engine/types/index.js";
|
|
2
|
+
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
3
|
+
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
4
|
+
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
5
|
+
function fileRefIdsOf(value) {
|
|
6
|
+
if (typeof value === "string")
|
|
7
|
+
return isUuid(value) ? [value] : [];
|
|
8
|
+
if (!Array.isArray(value))
|
|
9
|
+
return [];
|
|
10
|
+
return value.filter((item) => typeof item === "string" && isUuid(item));
|
|
11
|
+
}
|
|
12
|
+
function isPendingUploadFor(row, entityName, fieldName, user) {
|
|
13
|
+
return (row["entityId"] === null &&
|
|
14
|
+
row["entityType"] === entityName &&
|
|
15
|
+
row["fieldName"] === fieldName &&
|
|
16
|
+
row["insertedById"] === user.id);
|
|
17
|
+
}
|
|
18
|
+
// A create form uploads before the record exists, so its fileRefs carry
|
|
19
|
+
// entityType + fieldName but no entityId. Binding them in the create's own
|
|
20
|
+
// transaction makes record-bound variants (qr overlay) and record-read file
|
|
21
|
+
// access work for them. Only the caller's own pending uploads for exactly this
|
|
22
|
+
// entity field are bound: naming someone else's upload id in a create payload
|
|
23
|
+
// must not pull that file into a record the caller controls.
|
|
24
|
+
export async function bindPendingFileRefs(args) {
|
|
25
|
+
const { registry, result, user, db } = args;
|
|
26
|
+
// skip: only a create can carry still-pending uploads for its own record
|
|
27
|
+
if (!result.isNew || result.entityName === undefined)
|
|
28
|
+
return;
|
|
29
|
+
const entityName = result.entityName;
|
|
30
|
+
const entity = registry.getEntity(entityName);
|
|
31
|
+
// skip: saves of unregistered aggregates (custom executors) declare no file fields
|
|
32
|
+
if (entity === undefined)
|
|
33
|
+
return;
|
|
34
|
+
// skip: without the files feature the file_refs table is not part of the schema
|
|
35
|
+
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
36
|
+
return;
|
|
37
|
+
const executor = getFileRefExecutor();
|
|
38
|
+
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
39
|
+
if (!isFileField(fieldDef))
|
|
40
|
+
continue;
|
|
41
|
+
// files/images fields have no projection column, so their ids are only in
|
|
42
|
+
// the create's input (changes), not in the returned row (data).
|
|
43
|
+
const namedIds = result.data[fieldName] ?? result.changes[fieldName];
|
|
44
|
+
for (const fileRefId of fileRefIdsOf(namedIds)) {
|
|
45
|
+
const row = await executor.detail({ id: fileRefId }, user, db);
|
|
46
|
+
if (row === null || !isPendingUploadFor(row, entityName, fieldName, user))
|
|
47
|
+
continue;
|
|
48
|
+
const version = typeof row["version"] === "number" ? row["version"] : undefined;
|
|
49
|
+
const bound = await executor.update({ id: fileRefId, version, changes: { entityId: String(result.id) } }, user, db);
|
|
50
|
+
if (!bound.isSuccess)
|
|
51
|
+
throw reraiseAsKumikoError(bound.error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import type { Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
3
|
+
import { type CreatedFileRef } from "./create-file-ref.js";
|
|
4
|
+
import type { FileContext } from "./file-handle.js";
|
|
5
|
+
export type { CreatedFileRef };
|
|
6
|
+
export type CreateFileRefContext = {
|
|
7
|
+
readonly db: TenantDb;
|
|
8
|
+
readonly files?: FileContext | undefined;
|
|
9
|
+
readonly registry: Registry;
|
|
10
|
+
readonly user: SessionUser;
|
|
11
|
+
};
|
|
12
|
+
export type CreateFileRefInput = {
|
|
13
|
+
readonly fileName: string;
|
|
14
|
+
readonly mimeType: string;
|
|
15
|
+
readonly data: Uint8Array;
|
|
16
|
+
readonly attachTo?: {
|
|
17
|
+
readonly entityType: string;
|
|
18
|
+
readonly fieldName: string;
|
|
19
|
+
readonly entityId?: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export declare function createFileRefFromBytes(ctx: CreateFileRefContext, input: CreateFileRefInput): Promise<WriteResult<CreatedFileRef>>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { FrameworkReasons, InternalError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
2
|
+
import { DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, } from "./create-file-ref.js";
|
|
3
|
+
import { readFilesRouteOptions } from "./file-routes.js";
|
|
4
|
+
// Server-side counterpart of POST /files for handlers/jobs that produce bytes
|
|
5
|
+
// themselves (generated PDFs, exports). Same validation, storage layout and
|
|
6
|
+
// fileRef.created event as an upload; the tenant is always ctx.user.tenantId.
|
|
7
|
+
export async function createFileRefFromBytes(ctx, input) {
|
|
8
|
+
const { files } = ctx;
|
|
9
|
+
if (!files) {
|
|
10
|
+
return writeFailure(new InternalError({
|
|
11
|
+
message: "createFileRefFromBytes: ctx.files is unavailable — mount a file-provider feature to store files",
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
const outcome = await storeFileAsFileRef({
|
|
15
|
+
user: ctx.user,
|
|
16
|
+
tenantDb: ctx.db,
|
|
17
|
+
registry: ctx.registry,
|
|
18
|
+
defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
|
|
19
|
+
writeBytes: (key, data, mimeType) => files.ref(key).write(data, mimeType),
|
|
20
|
+
deleteBytes: (key) => files.ref(key).delete(),
|
|
21
|
+
}, {
|
|
22
|
+
fileName: input.fileName,
|
|
23
|
+
declaredMimeType: input.mimeType,
|
|
24
|
+
data: input.data,
|
|
25
|
+
entityType: input.attachTo?.entityType,
|
|
26
|
+
entityId: input.attachTo?.entityId,
|
|
27
|
+
fieldName: input.attachTo?.fieldName,
|
|
28
|
+
});
|
|
29
|
+
switch (outcome.kind) {
|
|
30
|
+
case "created":
|
|
31
|
+
return { isSuccess: true, data: outcome.fileRef };
|
|
32
|
+
case "rejected":
|
|
33
|
+
return writeFailure(new UnprocessableError(FrameworkReasons.fileRejected, {
|
|
34
|
+
details: { error: outcome.error },
|
|
35
|
+
}));
|
|
36
|
+
case "failed":
|
|
37
|
+
return outcome.failure;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
import type { WriteFailure } from "../errors/index.js";
|
|
4
|
+
export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
5
|
+
declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor;
|
|
6
|
+
export declare function getFileRefExecutor(): ReturnType<typeof buildFileRefExecutor>;
|
|
7
|
+
type AttachedFieldResolution = {
|
|
8
|
+
readonly kind: "unattached";
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: "resolved";
|
|
11
|
+
readonly fieldDef: AnyFileFieldDef;
|
|
12
|
+
} | {
|
|
13
|
+
readonly kind: "unresolvable";
|
|
14
|
+
};
|
|
15
|
+
export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined): AttachedFieldResolution;
|
|
16
|
+
export type StoreFileInput = {
|
|
17
|
+
readonly fileName: string;
|
|
18
|
+
readonly declaredMimeType: string;
|
|
19
|
+
readonly data: Uint8Array;
|
|
20
|
+
readonly entityType?: string | undefined;
|
|
21
|
+
readonly entityId?: string | undefined;
|
|
22
|
+
readonly fieldName?: string | undefined;
|
|
23
|
+
};
|
|
24
|
+
export type StoreFileDeps = {
|
|
25
|
+
readonly user: SessionUser;
|
|
26
|
+
readonly tenantDb: TenantDb;
|
|
27
|
+
readonly registry: Registry | undefined;
|
|
28
|
+
readonly defaultMaxUploadSize: string;
|
|
29
|
+
readonly writeBytes: (storageKey: string, data: Uint8Array, mimeType: string) => Promise<void>;
|
|
30
|
+
readonly deleteBytes: (storageKey: string) => Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
export type CreatedFileRef = {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly fileName: string;
|
|
35
|
+
readonly mimeType: string;
|
|
36
|
+
readonly size: number;
|
|
37
|
+
readonly storageKey: string;
|
|
38
|
+
};
|
|
39
|
+
export type StoreFileOutcome = {
|
|
40
|
+
readonly kind: "created";
|
|
41
|
+
readonly fileRef: CreatedFileRef;
|
|
42
|
+
} | {
|
|
43
|
+
readonly kind: "rejected";
|
|
44
|
+
readonly error: string;
|
|
45
|
+
} | {
|
|
46
|
+
readonly kind: "failed";
|
|
47
|
+
readonly failure: WriteFailure;
|
|
48
|
+
};
|
|
49
|
+
export declare function storeFileAsFileRef(deps: StoreFileDeps, input: StoreFileInput): Promise<StoreFileOutcome>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createEventStoreExecutor } from "../db/event-store-executor.js";
|
|
2
|
+
import { isFileField, } from "../engine/types/index.js";
|
|
3
|
+
import { generateId } from "../utils/index.js";
|
|
4
|
+
import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
5
|
+
import { fileRefsTable } from "./file-ref-table.js";
|
|
6
|
+
import { resolveContentType } from "./resolve-content-type.js";
|
|
7
|
+
import { buildStorageKey, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
|
|
8
|
+
export const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
9
|
+
// Standard entity executor for fileRef: create/delete emit fileRef.created /
|
|
10
|
+
// fileRef.deleted and write file_refs via applyEntityEvent in one tx
|
|
11
|
+
// (read-your-own-write), exactly like any other entity's lifecycle. Built on
|
|
12
|
+
// first use: the table builder's import graph reaches back into this module.
|
|
13
|
+
let fileRefExecutor;
|
|
14
|
+
function buildFileRefExecutor() {
|
|
15
|
+
return createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
16
|
+
entityName: FILE_REF_ENTITY_NAME,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function getFileRefExecutor() {
|
|
20
|
+
fileRefExecutor ??= buildFileRefExecutor();
|
|
21
|
+
return fileRefExecutor;
|
|
22
|
+
}
|
|
23
|
+
const SAFE_IDENTIFIER = /^[A-Za-z0-9_-]+$/;
|
|
24
|
+
// entityType/fieldName drive the GDPR-forget decision downstream
|
|
25
|
+
// (fileRefDeleteHook resolves the field's PII annotation from exactly this
|
|
26
|
+
// pairing) — attaching to a field nobody can resolve would leave that
|
|
27
|
+
// decision hanging on an unverified client string. Either both are omitted
|
|
28
|
+
// (unattached upload, allowed) or both must resolve to a real registered
|
|
29
|
+
// field.
|
|
30
|
+
export function resolveAttachedField(registry, entityType, fieldName) {
|
|
31
|
+
if (entityType === undefined && fieldName === undefined)
|
|
32
|
+
return { kind: "unattached" };
|
|
33
|
+
const entity = entityType !== undefined ? registry?.getEntity(entityType) : undefined;
|
|
34
|
+
const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
|
|
35
|
+
if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
|
|
36
|
+
return { kind: "unresolvable" };
|
|
37
|
+
return { kind: "resolved", fieldDef };
|
|
38
|
+
}
|
|
39
|
+
function acceptFile(deps, input) {
|
|
40
|
+
const { fileName, declaredMimeType, data, entityType, entityId, fieldName } = input;
|
|
41
|
+
const rejected = (error) => ({ kind: "rejected", error });
|
|
42
|
+
// These interpolate directly into the storage key (buildStorageKey) —
|
|
43
|
+
// reject anything but a safe identifier so a caller can't path-traverse
|
|
44
|
+
// out of its tenant's storage prefix (../.. segments, absolute paths).
|
|
45
|
+
for (const [field, value] of [
|
|
46
|
+
["entityType", entityType],
|
|
47
|
+
["entityId", entityId],
|
|
48
|
+
["fieldName", fieldName],
|
|
49
|
+
]) {
|
|
50
|
+
if (value !== undefined && !SAFE_IDENTIFIER.test(value)) {
|
|
51
|
+
return rejected(`invalid_${field}: must match /^[A-Za-z0-9_-]+$/`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let maxSize = deps.defaultMaxUploadSize;
|
|
55
|
+
let accept;
|
|
56
|
+
const attachedField = resolveAttachedField(deps.registry, entityType, fieldName);
|
|
57
|
+
if (attachedField.kind === "unresolvable") {
|
|
58
|
+
return rejected("unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload");
|
|
59
|
+
}
|
|
60
|
+
if (attachedField.kind === "resolved") {
|
|
61
|
+
if (attachedField.fieldDef.maxSize)
|
|
62
|
+
maxSize = attachedField.fieldDef.maxSize;
|
|
63
|
+
if (attachedField.fieldDef.accept)
|
|
64
|
+
accept = attachedField.fieldDef.accept;
|
|
65
|
+
}
|
|
66
|
+
const validationError = validateFile({ fileName, mimeType: declaredMimeType, size: data.byteLength }, { maxSize, accept });
|
|
67
|
+
if (validationError)
|
|
68
|
+
return rejected(validationError);
|
|
69
|
+
const contentValidation = validateFileContent(fileName, data, accept);
|
|
70
|
+
if (contentValidation.kind === "rejected")
|
|
71
|
+
return rejected(contentValidation.error);
|
|
72
|
+
// A normalized upload's real type is already known from the sniffed
|
|
73
|
+
// bytes — resolveUploadMimeType's alias/rewrite step only applies to the
|
|
74
|
+
// declared mimeType of an unnormalized upload.
|
|
75
|
+
if (contentValidation.kind === "normalized") {
|
|
76
|
+
return {
|
|
77
|
+
kind: "accepted",
|
|
78
|
+
mimeType: contentValidation.mimeType,
|
|
79
|
+
extension: contentValidation.extension,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const mimeTypeResolution = resolveUploadMimeType(fileName, declaredMimeType, data);
|
|
83
|
+
if (mimeTypeResolution.kind === "rejected")
|
|
84
|
+
return rejected(mimeTypeResolution.error);
|
|
85
|
+
return {
|
|
86
|
+
kind: "accepted",
|
|
87
|
+
mimeType: resolveContentType({
|
|
88
|
+
declared: mimeTypeResolution.mimeType,
|
|
89
|
+
bytes: data,
|
|
90
|
+
filename: fileName,
|
|
91
|
+
}),
|
|
92
|
+
extension: undefined,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// The one upload pipeline behind POST /files and createFileRefFromBytes.
|
|
96
|
+
export async function storeFileAsFileRef(deps, input) {
|
|
97
|
+
const { fileName, data, entityType, entityId, fieldName } = input;
|
|
98
|
+
const accepted = acceptFile(deps, input);
|
|
99
|
+
if (accepted.kind === "rejected")
|
|
100
|
+
return accepted;
|
|
101
|
+
const { mimeType, extension } = accepted;
|
|
102
|
+
const fileRefId = generateId();
|
|
103
|
+
const storageKey = buildStorageKey(deps.user.tenantId, entityType ?? "unattached", entityId, fieldName ?? "file", fileName, generateId(), extension);
|
|
104
|
+
// Write binary FIRST (outside the tx — network/disk I/O doesn't belong
|
|
105
|
+
// inside a PG connection's tx window). A failed create deletes the bytes
|
|
106
|
+
// again below; a later rollback of an enclosing transaction cannot, so
|
|
107
|
+
// those are orphaned for the cleanup-jobs to sweep. Losing a row on
|
|
108
|
+
// append-failure is acceptable; a committed row with a missing binary is not.
|
|
109
|
+
await deps.writeBytes(storageKey, data, mimeType);
|
|
110
|
+
// id is explicit so the response + storageKey stay consistent; tenantId,
|
|
111
|
+
// insertedAt and insertedById are set by applyEntityEvent from the event metadata.
|
|
112
|
+
const result = await getFileRefExecutor().create({
|
|
113
|
+
id: fileRefId,
|
|
114
|
+
storageKey,
|
|
115
|
+
fileName,
|
|
116
|
+
mimeType,
|
|
117
|
+
size: data.byteLength,
|
|
118
|
+
entityType: entityType ?? null,
|
|
119
|
+
entityId: entityId ?? null,
|
|
120
|
+
fieldName: fieldName ?? null,
|
|
121
|
+
}, deps.user, deps.tenantDb);
|
|
122
|
+
if (!result.isSuccess) {
|
|
123
|
+
try {
|
|
124
|
+
await deps.deleteBytes(storageKey);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// skip: the create failure is the error worth reporting; leftover bytes are swept by cleanup-jobs.
|
|
128
|
+
}
|
|
129
|
+
return { kind: "failed", failure: result };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
kind: "created",
|
|
133
|
+
fileRef: { id: fileRefId, fileName, mimeType, size: data.byteLength, storageKey },
|
|
134
|
+
};
|
|
135
|
+
}
|
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 const FILE_REF_ENTITY_NAME = "fileRef";
|
|
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,7 @@ 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";
|
|
5
6
|
export type FileAccessDecision = "allow" | "deny";
|
|
6
7
|
export type FileRef = {
|
|
7
8
|
id: string;
|
|
@@ -27,6 +28,7 @@ export type FileRoutesOptions = {
|
|
|
27
28
|
readonly maxUploadSize?: string;
|
|
28
29
|
readonly privilegedRoles?: readonly string[];
|
|
29
30
|
readonly accessGuard?: FileAccessGuard;
|
|
31
|
+
readonly canReadRecord?: RecordReadCheck;
|
|
30
32
|
};
|
|
31
33
|
export declare function resolveMaxUploadBodyBytes(options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">): number;
|
|
32
34
|
export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
|
|
@@ -4,54 +4,44 @@ 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
|
|
17
|
-
import {
|
|
15
|
+
import { neutralFileNameForMimeType } from "./resolve-content-type.js";
|
|
16
|
+
import { parseMaxSize, resolveServedContentType } from "./types.js";
|
|
18
17
|
const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
|
|
19
18
|
// 15 minutes — long enough for a download to start, short enough that a
|
|
20
19
|
// leaked URL (e.g. from a browser history screenshot) isn't a long-lived
|
|
21
20
|
// credential (see "Signed-URL default expiry" in core-files.md).
|
|
22
21
|
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
|
-
|
|
22
|
+
// Default guard: on attached files, allow the uploader or a privileged role,
|
|
23
|
+
// and for reads also everyone who may read the record. Delete stays with the
|
|
24
|
+
// 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) {
|
|
28
|
+
return async ({ fileRef, user, operation }) => {
|
|
28
29
|
if (fileRef.entityType === null || fileRef.entityId === null)
|
|
29
30
|
return "allow";
|
|
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 (operation !== "read" || canReadRecord === undefined)
|
|
36
|
+
return "deny";
|
|
37
|
+
const recordReadable = await canReadRecord({
|
|
38
|
+
entityType: fileRef.entityType,
|
|
39
|
+
entityId: fileRef.entityId,
|
|
40
|
+
user,
|
|
41
|
+
});
|
|
42
|
+
return recordReadable ? "allow" : "deny";
|
|
37
43
|
};
|
|
38
44
|
}
|
|
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
45
|
// Multipart framing plus the small entityType/entityId/fieldName parts
|
|
56
46
|
// riding alongside the file.
|
|
57
47
|
const MULTIPART_OVERHEAD_MARGIN_BYTES = 64 * 1024;
|
|
@@ -85,14 +75,8 @@ export function readFilesRouteOptions(registry) {
|
|
|
85
75
|
export function createFileRoutes(options) {
|
|
86
76
|
const { db } = options;
|
|
87
77
|
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
|
-
});
|
|
78
|
+
const guard = options.accessGuard ?? createDefaultGuard(privilegedRoles, options.canReadRecord);
|
|
79
|
+
const executor = getFileRefExecutor();
|
|
96
80
|
const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
|
|
97
81
|
const api = new Hono();
|
|
98
82
|
const projectionGrants = options.registry ? { projectionRegistry: options.registry } : undefined;
|
|
@@ -151,103 +135,29 @@ export function createFileRoutes(options) {
|
|
|
151
135
|
// store it in the text entityId column.
|
|
152
136
|
const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
|
|
153
137
|
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);
|
|
164
|
-
}
|
|
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
138
|
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);
|
|
201
|
-
}
|
|
202
|
-
mimeType = resolveContentType({
|
|
203
|
-
declared: mimeTypeResolution.mimeType,
|
|
204
|
-
bytes: data,
|
|
205
|
-
filename: file.name,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
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.
|
|
215
139
|
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) {
|
|
140
|
+
const outcome = await storeFileAsFileRef({
|
|
141
|
+
user,
|
|
142
|
+
tenantDb: tenantDbFor(user.tenantId),
|
|
143
|
+
registry: options.registry,
|
|
144
|
+
defaultMaxUploadSize: options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
|
|
145
|
+
writeBytes: (key, bytes, mimeType) => storageProvider.write(key, bytes, mimeType),
|
|
146
|
+
deleteBytes: (key) => storageProvider.delete(key),
|
|
147
|
+
}, { fileName: file.name, declaredMimeType: file.type, data, entityType, entityId, fieldName });
|
|
148
|
+
if (outcome.kind === "rejected")
|
|
149
|
+
return c.json({ error: outcome.error }, 400);
|
|
150
|
+
if (outcome.kind === "failed")
|
|
235
151
|
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);
|
|
152
|
+
return c.json(outcome.fileRef, 201);
|
|
244
153
|
});
|
|
245
154
|
// GET /files/:id — download.
|
|
246
155
|
//
|
|
247
156
|
// Authorization stack:
|
|
248
157
|
// 1. tenantId must match (hard isolation, never crossable).
|
|
249
158
|
// 2. The configured FileAccessGuard decides read access. Default:
|
|
250
|
-
// unattached → allow; attached → uploader
|
|
159
|
+
// unattached → allow; attached → uploader, privileged role or a
|
|
160
|
+
// reader of the record.
|
|
251
161
|
// Apps override via options.accessGuard to layer entity-level rules.
|
|
252
162
|
api.get("/files/:id", async (c) => {
|
|
253
163
|
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.
|