@cosmicdrift/kumiko-framework 0.358.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 +11 -2
- 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 +52 -0
|
@@ -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.
|
|
@@ -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,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;
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -17,6 +17,7 @@ import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
|
17
17
|
import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
|
|
18
18
|
import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
|
|
19
19
|
import { bridgeStub } from "../testing/handler-context.js";
|
|
20
|
+
import { cronRepeatOptions } from "./cron-repeat-options.js";
|
|
20
21
|
// Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
|
|
21
22
|
// (a security-relevant escape hatch that must fail the run closed), a
|
|
22
23
|
// malformed `_triggerEvent` only costs an idempotency-key convenience — the
|
|
@@ -1154,7 +1155,7 @@ export function createJobRunner(options) {
|
|
|
1154
1155
|
if ("cron" in jobDef.trigger) {
|
|
1155
1156
|
const schedulerId = schedulerIdForJobName(name);
|
|
1156
1157
|
expectedSchedulerIds.add(schedulerId);
|
|
1157
|
-
await consumerQueue.upsertJobScheduler(schedulerId,
|
|
1158
|
+
await consumerQueue.upsertJobScheduler(schedulerId, cronRepeatOptions(jobDef.trigger), {
|
|
1158
1159
|
name: jobDef.perTenant ? `_perTenant:${name}` : name,
|
|
1159
1160
|
data: {},
|
|
1160
1161
|
// Queue-level defaultJobOptions (jobRetentionOpts) covers
|
|
@@ -8,6 +8,7 @@ import { checkWriteFieldOwnership, maskWriteOnlyFields } from "../engine/field-a
|
|
|
8
8
|
import { defineTransitions, guardTransition } from "../engine/state-machine.js";
|
|
9
9
|
import { HookPhases } from "../engine/types/index.js";
|
|
10
10
|
import { AccessDeniedError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, NotFoundError, ValidationError, writeFailure, } from "../errors/index.js";
|
|
11
|
+
import { bindPendingFileRefs } from "../files/bind-pending-file-refs.js";
|
|
11
12
|
import { CACHE_SYNC_TOPICS } from "../redis/cache-sync-topics.js";
|
|
12
13
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
13
14
|
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
@@ -111,6 +112,12 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
|
|
|
111
112
|
// keeps projection state consistent with what the hooks observe.
|
|
112
113
|
await runProjections(result, handlerContext.registry, runner);
|
|
113
114
|
if (result.kind === "save") {
|
|
115
|
+
await bindPendingFileRefs({
|
|
116
|
+
registry: handlerContext.registry,
|
|
117
|
+
result,
|
|
118
|
+
user,
|
|
119
|
+
db: handlerContext.db,
|
|
120
|
+
});
|
|
114
121
|
await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
|
|
115
122
|
afterCommitHooks.push(async () => {
|
|
116
123
|
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, true);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.360.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -210,8 +210,8 @@
|
|
|
210
210
|
"./package.json": "./package.json"
|
|
211
211
|
},
|
|
212
212
|
"dependencies": {
|
|
213
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
214
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
213
|
+
"@cosmicdrift/kumiko-http": "0.360.0",
|
|
214
|
+
"@cosmicdrift/kumiko-types": "0.360.0",
|
|
215
215
|
"bullmq": "^5.76.7",
|
|
216
216
|
"bun-types": "^1.3.13",
|
|
217
217
|
"hono": "^4.13.1",
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
"zod": "^4.4.3"
|
|
228
228
|
},
|
|
229
229
|
"devDependencies": {
|
|
230
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
230
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.360.0",
|
|
231
231
|
"bun-types": "^1.3.13",
|
|
232
232
|
"pino-pretty": "^13.1.3"
|
|
233
233
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.360.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Secure host-only session cookies use the __Host- prefix",
|
|
6
|
+
"detail": "Auth, csrf and tenant cookies are named __Host-kumiko_auth, __Host-kumiko_csrf and __Host-kumiko_tenant when they are written Secure and without a Domain (the tenant cookie is always host-only). With auth.cookieDomain or without Secure the auth and csrf names stay kumiko_auth and kumiko_csrf. New auth option secureCookies forces Secure (default NODE_ENV === \"production\"). Login deletes the legacy unprefixed host-only cookies, logout and deleteTenantCookie delete the prefixed and the legacy names. The server reads only the resolved names, so a request that carries only the legacy kumiko_auth is anonymous. AUTH_COOKIE_NAME, CSRF_COOKIE_NAME and TENANT_COOKIE_NAME are removed from the framework and dispatcher-live; the base names and sessionCookieName() are exported from @cosmicdrift/kumiko-types/session-cookies and @cosmicdrift/kumiko-framework/api, getSessionCookies(c) returns the resolved names of a request. The testing package's CSRF_COOKIE_NAME stays and resolves to the unprefixed name (e2e servers run without Secure).",
|
|
7
|
+
"migration": "Every user logs in once after the deploy, because the old kumiko_auth cookie is no longer read. Custom clients, proxies or scripts that read or forward the cookies by literal name break: read the names through sessionCookieName() / resolveSessionCookieNames() (types package) or getSessionCookies(c) on the server, and handle both the prefixed and the plain name in browser code (readCsrfToken() already does). Replace imports of AUTH_COOKIE_NAME, CSRF_COOKIE_NAME and TENANT_COOKIE_NAME with AUTH_COOKIE_BASE, CSRF_COOKIE_BASE and TENANT_COOKIE_BASE plus sessionCookieName(); e2e helpers that imported CSRF_COOKIE_NAME from @cosmicdrift/kumiko-dispatcher-live import it from @cosmicdrift/kumiko-testing/e2e instead (known: solon e2e/_helpers/api.ts and e2e/session-ended.spec.ts, kumiko-enterprise ai-agent-live-transcription.integration.test.ts uses AUTH_COOKIE_NAME). A reverse proxy that strips or rewrites cookie names must pass the __Host- names unchanged. Staging or preview deployments over plain http need no change (Secure is off there); set auth.secureCookies (also accepted by runProdApp) explicitly when TLS terminates before the app and NODE_ENV is not \"production\". A standalone createAuthRoutes mount outside buildServer must also mount sessionCookiesMiddleware with the same resolved names, otherwise the readers fall back to the domain-less default."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.360.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Duplicate session cookie names end the cookie session",
|
|
13
|
+
"detail": "When the Cookie header carries the resolved auth or csrf cookie name more than once, authMiddleware ignores the cookie transport and expires every variant (prefixed host-only, legacy host-only, the cookieDomain variant and retired domains). With anonymousAccess the request continues as anonymous, otherwise it gets 401 missing_token; a Bearer token on the same request still authenticates. A prefixed and a legacy name side by side are not a duplicate. The cookie clearing moved from the auth routes into clearSessionCookies in session-cookies.ts; ResolvedSessionCookies carries domain and retiredDomains."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.360.0",
|
|
17
|
+
"type": "breaking",
|
|
18
|
+
"title": "Origin guard defaults on when the public app URL is known",
|
|
19
|
+
"detail": "New auth option publicOrigin. Without allowedOrigins, cookieDomain and unsafeSkipOriginCheck, buildServer mounts the Origin guard for cookie-authenticated state-changing /api requests and admits publicOrigin plus the request's own host (Host header). runProdApp sets publicOrigin from auth.publicOrigin, then auth.mail.baseUrl, then the APP_BASE_URL env (origin only, path dropped); with none of them in production it logs a warning that the Origin guard is off and boots anyway. allowedOrigins keeps its strict meaning, and the WebSocket check and the anonymous downgrade still use only allowedOrigins.",
|
|
20
|
+
"migration": "Cookie-authenticated POST, PUT, PATCH or DELETE requests from a page on another origin than the public app URL and than the API host itself now get 403 origin_not_allowed in runProdApp deployments that set auth.mail.baseUrl or APP_BASE_URL. A web client served from a different host than the API (for example a sibling subdomain) must list its origin in auth.allowedOrigins. Same-host setups, custom domains served by the same server and Bearer clients need no change."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.360.0",
|
|
24
|
+
"type": "improvement",
|
|
25
|
+
"title": "createFileRefFromBytes creates FileRefs from handler- or job-produced bytes",
|
|
26
|
+
"detail": "createFileRefFromBytes({ db, files, registry, user }, { fileName, mimeType, data, attachTo? }) shares one pipeline with POST /api/files, so identifier checks, field-level maxSize/accept, content sniffing, MIME resolution, the tenant-prefixed storage key and the fileRef.created event are identical. The tenant always comes from ctx.user. A rejected file returns a 422 write failure with reason file_rejected and the upload error string in details.error. The FileRef's insertedById is the passed user, so the default access guard lets only that user and privileged roles read files attached to an entity."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.360.0",
|
|
30
|
+
"type": "improvement",
|
|
31
|
+
"title": "Cron jobs with an IANA time zone, images fields with capture",
|
|
32
|
+
"detail": "A cron trigger accepts tz (IANA name; without it the server process zone applies) and is scheduled in that zone, DST included. r.job() throws at boot for an invalid zone. createImagesField accepts capture (\"environment\" | \"user\") like createImageField, and the edit view model carries it for images fields."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.360.0",
|
|
36
|
+
"type": "breaking",
|
|
37
|
+
"title": "Uploads from an update form are attached to the record",
|
|
38
|
+
"detail": "computeEditViewModel accepts entityId (the record being edited, update mode only) and sets it on the hints of file, image, files and images fields; RenderEdit passes the id it was mounted with and the file input sends it as the entityId multipart part of POST /api/files. Create forms send no entityId; after a successful create the write pipeline binds the caller's own pending uploads named in the record's file fields (same entityType and fieldName, entityId null, insertedById = caller) to the new record via a fileRef.updated event in the same transaction, for single and plural file fields alike, when the files feature (fileRef entity) is mounted. Behaviour change: such files are now attached to the record. The default file access guard allows read on an attached file to the uploader, privilegedRoles (default Admin/SystemAdmin) and everyone whose run of the entity's <entity>:detail query returns the record; delete stays uploader and privilegedRoles only. A qr-overlay variant of an image field no longer throws for a file uploaded from an update or create form. Write-form sections send no entityId. EmbeddedListInput has a new required actionsLabel prop.",
|
|
39
|
+
"migration": "Files attached through forms now follow the record's read access instead of being tenant-wide: anyone who cannot read the record no longer reads the file, anyone who can read it does. Check image, images, file and files fields whose files must be visible beyond the record's readers (for example an avatar shown in lists to users without detail access) and add those roles to privilegedRoles or set accessGuard in the files feature options. An entity without a detail query falls back to uploader plus privilegedRoles. Files uploaded before the update keep their old attachment. Custom primitive sets that implement EmbeddedListInput accept the new actionsLabel prop and render it as the actions column header; custom file/image Input primitives forward entityId into the upload and offer a remove control that calls onChange(null)."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"version": "0.360.0",
|
|
43
|
+
"type": "improvement",
|
|
44
|
+
"title": "images and files fields get an edit widget; required is allowed",
|
|
45
|
+
"detail": "NO_WIDGET_FIELD_TYPES now holds only jsonb and embedded. required true on an images or files field passes boot validation and the client-side check treats an empty array as missing. The edit view model carries maxCount for files and images fields."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"version": "0.359.0",
|
|
49
|
+
"type": "improvement",
|
|
50
|
+
"title": "actionForm screens honour the called handler's excludedFields",
|
|
51
|
+
"detail": "buildAppSchema projects the handler's excludedFields onto actionForm screens as writeExcludedFields (a flat list, unlike entityEdit's per-verb object). The form shows these fields read-only, not required and never in the payload.",
|
|
52
|
+
"migration": "Drop a hand-written readOnly lock on an actionForm field once the handler it calls declares excludedFields for it."
|
|
53
|
+
},
|
|
2
54
|
{
|
|
3
55
|
"version": "0.358.0",
|
|
4
56
|
"type": "fix",
|