@cosmicdrift/kumiko-framework 0.361.0 → 0.362.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -704,6 +704,9 @@ export function buildServer(options) {
704
704
  }),
705
705
  ...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
706
706
  ...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
707
+ ...(routeOptions.unclaimedUploadReaders
708
+ ? { unclaimedUploadReaders: routeOptions.unclaimedUploadReaders }
709
+ : {}),
707
710
  ...(routeOptions.maxUploadSize ? { maxUploadSize: routeOptions.maxUploadSize } : {}),
708
711
  }));
709
712
  }
@@ -67,16 +67,25 @@ function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
67
67
  }
68
68
  }
69
69
  }
70
- function validatePersonalDataOnlyOnWrite(feature, kind, handlerName, access) {
71
- // skip: write handlers may declare personalData, or it wasn't declared here
72
- if (kind === "write" || declaredPersonalData(access) === undefined)
70
+ function validatePersonalDataSupportedForKind(feature, kind, handlerName, access) {
71
+ // skip: nothing declared
72
+ if (declaredPersonalData(access) === undefined)
73
73
  return;
74
- const property = "openToAll" in access ? "openToAll.personalData" : "access.personalData";
75
- throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
76
- `${property} — it only applies to write handlers, whose input is ` +
77
- "checked for personal-data fields.");
74
+ // skip: write handlers accept every personalData declaration
75
+ if (kind === "write")
76
+ return;
77
+ const isOpenToAll = "openToAll" in access;
78
+ // skip: query handlers may declare access.personalData, its value is validated separately
79
+ if (kind === "query" && !isOpenToAll)
80
+ return;
81
+ const property = isOpenToAll ? "openToAll.personalData" : "access.personalData";
82
+ const onlyAllowed = kind === "query"
83
+ ? 'only access.personalData: "public-intake" applies to query handlers, which write ' +
84
+ "personal data through nested writes of an anonymous request."
85
+ : "it only applies to write handlers, whose input is checked for personal-data fields.";
86
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ${property} — ${onlyAllowed}`);
78
87
  }
79
- function validatePersonalDataValue(feature, handlerName, access) {
88
+ function validatePersonalDataValue(feature, kind, handlerName, access) {
80
89
  const declared = declaredPersonalData(access);
81
90
  // skip: nothing declared
82
91
  if (declared === undefined)
@@ -85,17 +94,17 @@ function validatePersonalDataValue(feature, handlerName, access) {
85
94
  // skip: the one supported value on openToAll
86
95
  if (declared === "tenant-members")
87
96
  return;
88
- throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
97
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an unknown ` +
89
98
  `openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`);
90
99
  }
91
100
  if (declared !== "public-intake") {
92
- throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
101
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an unknown ` +
93
102
  `access.personalData ${JSON.stringify(declared)} — the only supported value is "public-intake".`);
94
103
  }
95
104
  // skip: roles include "anonymous", which personalData: "public-intake" requires
96
105
  if (accessAllowsAnonymous(access))
97
106
  return;
98
- throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
107
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
99
108
  'access.personalData: "public-intake" but its roles do not include ' +
100
109
  `"${ANONYMOUS_ROLE}" — personalData: "public-intake" only applies to handlers ` +
101
110
  "that allow anonymous callers.");
@@ -150,14 +159,14 @@ function validateAnonymousPersonalData(feature, handlerName, handler) {
150
159
  }
151
160
  // The rateLimit is the only protection of a public-intake handler, so the
152
161
  // generic anonymous opt-out (rateLimit: { disabled: true }) must not apply.
153
- function validatePublicIntakeRateLimit(feature, handlerName, handler) {
154
- // skip: not a public-intake handler, or its rateLimit is active
162
+ function validatePublicIntakeRateLimit(feature, kind, handlerName, handler) {
163
+ // skip: not a public-intake handler
155
164
  if (declaredPersonalData(handler.access) !== "public-intake")
156
165
  return;
157
166
  // skip: rateLimit is active
158
167
  if (!isRateLimitDisabled(handler.rateLimit))
159
168
  return;
160
- throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
169
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
161
170
  'access.personalData: "public-intake" with rateLimit: { disabled: true } — the rateLimit ' +
162
171
  'is the only protection for anonymous personal-data writes. Declare rateLimit: { per: "ip" ' +
163
172
  'or "ip+handler", ... } instead.');
@@ -166,19 +175,21 @@ export function validateAccessDeclarations(feature) {
166
175
  for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
167
176
  validateOpenToAllReason(feature, "write", handlerName, handler.access);
168
177
  validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
169
- validatePersonalDataValue(feature, handlerName, handler.access);
178
+ validatePersonalDataValue(feature, "write", handlerName, handler.access);
170
179
  validateOpenToAllPersonalData(feature, handlerName, handler);
171
180
  validateAnonymousPersonalData(feature, handlerName, handler);
172
- validatePublicIntakeRateLimit(feature, handlerName, handler);
181
+ validatePublicIntakeRateLimit(feature, "write", handlerName, handler);
173
182
  }
174
183
  for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
175
184
  validateOpenToAllReason(feature, "query", handlerName, handler.access);
176
185
  validateEscapeHatchReason(feature, "query", handlerName, handler.escapeHatch);
177
- validatePersonalDataOnlyOnWrite(feature, "query", handlerName, handler.access);
186
+ validatePersonalDataSupportedForKind(feature, "query", handlerName, handler.access);
187
+ validatePersonalDataValue(feature, "query", handlerName, handler.access);
188
+ validatePublicIntakeRateLimit(feature, "query", handlerName, handler);
178
189
  }
179
190
  for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
180
191
  validateOpenToAllReason(feature, "stream", handlerName, handler.access);
181
192
  validateEscapeHatchReason(feature, "stream", handlerName, handler.escapeHatch);
182
- validatePersonalDataOnlyOnWrite(feature, "stream", handlerName, handler.access);
193
+ validatePersonalDataSupportedForKind(feature, "stream", handlerName, handler.access);
183
194
  }
184
195
  }
@@ -1,4 +1,4 @@
1
- import { CARD_META_MAX, cardColumnRoles } from "../../ui-types/card-column-roles.js";
1
+ import { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind, } from "../../ui-types/card-column-roles.js";
2
2
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
3
3
  import { parseRefTarget } from "../parse-ref-target.js";
4
4
  import { normalizeListColumn } from "../screen-helpers.js";
@@ -19,11 +19,14 @@ function resolveColumnType(spec, storedType, entity) {
19
19
  function countOverflow(feature, screen, location, columns, storedTypeOf, entity) {
20
20
  const cardColumns = columns.map((spec) => {
21
21
  const col = normalizeListColumn(spec);
22
+ const type = resolveColumnType(spec, storedTypeOf(col.field), entity);
23
+ const fileKind = cardFileDisplayKind(type, col.renderer);
22
24
  return {
23
25
  field: col.field,
24
- type: resolveColumnType(spec, storedTypeOf(col.field), entity),
26
+ type,
25
27
  renderer: col.renderer,
26
28
  hideOnNarrow: col.hideOnNarrow,
29
+ ...(fileKind !== undefined && { fileDisplay: { kind: fileKind } }),
27
30
  };
28
31
  });
29
32
  const { meta } = cardColumnRoles(cardColumns);
@@ -409,6 +409,7 @@ function projectEntity(entityName, entity) {
409
409
  ...(typeof entity.defaultCurrency === "string" && {
410
410
  defaultCurrency: entity.defaultCurrency,
411
411
  }),
412
+ ...(entity.lockedWhen !== undefined && { lockedWhen: entity.lockedWhen }),
412
413
  };
413
414
  }
414
415
  // Nur valueType durch — die derive-fn ist Server-only und NICHT JSON-safe
@@ -2,7 +2,7 @@ import type { CallExpression, SourceFile } from "ts-morph";
2
2
  import type { ClaimKeyType } from "../../types/handlers.js";
3
3
  import type { ParseError } from "../parse.js";
4
4
  import type { ClaimKeyPattern, ConfigPattern, MetricPattern, ReferenceDataPattern, SecretNamespacePattern, SecretPattern, TranslationsPattern, UseExtensionPattern } from "../patterns.js";
5
- import { type ExtractOutput } from "./shared.js";
5
+ import { type ExtractOutput, type ReadDataLiteralOptions } from "./shared.js";
6
6
  export type NamedOptionsResult = {
7
7
  readonly kind: "ok";
8
8
  readonly name: string;
@@ -11,7 +11,7 @@ export type NamedOptionsResult = {
11
11
  readonly kind: "error";
12
12
  readonly error: ParseError;
13
13
  };
14
- export declare function readNamedOptions(call: CallExpression, sourceFile: SourceFile, methodName: string): NamedOptionsResult;
14
+ export declare function readNamedOptions(call: CallExpression, sourceFile: SourceFile, methodName: string, readOptions?: ReadDataLiteralOptions): NamedOptionsResult;
15
15
  export declare function extractConfig(call: CallExpression, sourceFile: SourceFile): ExtractOutput<ConfigPattern>;
16
16
  export declare function extractTranslations(call: CallExpression, sourceFile: SourceFile): ExtractOutput<TranslationsPattern>;
17
17
  export declare function extractMetric(call: CallExpression, sourceFile: SourceFile): ExtractOutput<MetricPattern>;
@@ -1,7 +1,7 @@
1
1
  import { SyntaxKind } from "ts-morph";
2
2
  import { sourceLocationFromNode } from "../source-location.js";
3
3
  import { fail, isPlainObject, ok, readDataLiteralNode, readNameLiteral, readNameLiteralRef, readNameOrRef, } from "./shared.js";
4
- export function readNamedOptions(call, sourceFile, methodName) {
4
+ export function readNamedOptions(call, sourceFile, methodName, readOptions = {}) {
5
5
  const args = call.getArguments();
6
6
  const first = args[0];
7
7
  if (!first) {
@@ -17,7 +17,7 @@ export function readNamedOptions(call, sourceFile, methodName) {
17
17
  if (!nameInit) {
18
18
  return fail(methodName, sourceLocationFromNode(call, sourceFile), "object form requires a string-literal `name` property");
19
19
  }
20
- const data = readDataLiteralNode(obj);
20
+ const data = readDataLiteralNode(obj, readOptions);
21
21
  if (!isPlainObject(data)) {
22
22
  return fail(methodName, sourceLocationFromNode(call, sourceFile), "argument could not be read as a plain object");
23
23
  }
@@ -32,7 +32,7 @@ export function readNamedOptions(call, sourceFile, methodName) {
32
32
  if (!optionsArg) {
33
33
  return fail(methodName, sourceLocationFromNode(call, sourceFile), "expected an options object as second argument");
34
34
  }
35
- const options = readDataLiteralNode(optionsArg);
35
+ const options = readDataLiteralNode(optionsArg, readOptions);
36
36
  if (!isPlainObject(options)) {
37
37
  return fail(methodName, sourceLocationFromNode(call, sourceFile), "options could not be read as a plain object");
38
38
  }
@@ -88,7 +88,7 @@ export function extractMetric(call, sourceFile) {
88
88
  });
89
89
  }
90
90
  export function extractSecret(call, sourceFile) {
91
- const parsed = readNamedOptions(call, sourceFile, "secret");
91
+ const parsed = readNamedOptions(call, sourceFile, "secret", { functionValuesAsRaw: true });
92
92
  if (parsed.kind === "error")
93
93
  return parsed;
94
94
  return ok({
@@ -0,0 +1,16 @@
1
+ import * as z from "zod";
2
+ export type BindReferencedFilesResult = {
3
+ readonly bound: number;
4
+ readonly skipped: number;
5
+ readonly nextAfterId: string | null;
6
+ };
7
+ export declare const bindReferencedFilesWrite: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"bindReferencedFiles", z.ZodObject<{
8
+ entityType: z.ZodString;
9
+ entityIds: z.ZodOptional<z.ZodArray<z.ZodUUID>>;
10
+ afterId: z.ZodOptional<z.ZodUUID>;
11
+ limit: z.ZodDefault<z.ZodNumber>;
12
+ }, z.core.$strip>, {
13
+ bound: number;
14
+ skipped: number;
15
+ nextAfterId: string | null;
16
+ }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -0,0 +1,109 @@
1
+ import * as z from "zod";
2
+ import { defineWriteHandler, SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/index.js";
3
+ import { isFileField } from "../engine/types/index.js";
4
+ import { ValidationError } from "../errors/index.js";
5
+ import { fileRefIdsOf } from "./bind-pending-file-refs.js";
6
+ import { getFileRefExecutor } from "./create-file-ref.js";
7
+ import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
8
+ import { findImplicitEntityTable } from "./record-write-check.js";
9
+ const MAX_PAGE_SIZE = 500;
10
+ const DEFAULT_PAGE_SIZE = 200;
11
+ const payloadSchema = z.object({
12
+ entityType: z.string().min(1),
13
+ entityIds: z.array(z.uuid()).min(1).max(MAX_PAGE_SIZE).optional(),
14
+ afterId: z.uuid().optional(),
15
+ limit: z.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
16
+ });
17
+ function invalidEntityType() {
18
+ return new ValidationError({
19
+ fields: [
20
+ {
21
+ path: "entityType",
22
+ code: "invalid_value",
23
+ i18nKey: "errors.validation.invalid_value",
24
+ },
25
+ ],
26
+ });
27
+ }
28
+ // The entity must have an implicit table and declared file fields, and the files
29
+ // feature must be mounted; anything else is a caller error, not an empty page.
30
+ function resolveFileBearingEntity(registry, entityType) {
31
+ const entity = registry.getEntity(entityType);
32
+ const table = findImplicitEntityTable(registry, entityType);
33
+ if (entity === undefined || table === undefined)
34
+ throw invalidEntityType();
35
+ if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
36
+ throw invalidEntityType();
37
+ const fileFieldNames = Object.entries(entity.fields)
38
+ .filter(([, fieldDef]) => isFileField(fieldDef))
39
+ .map(([fieldName]) => fieldName);
40
+ if (fileFieldNames.length === 0)
41
+ throw invalidEntityType();
42
+ return { entity, table, fileFieldNames };
43
+ }
44
+ // Either the named records or one id-ordered page after afterId.
45
+ function loadRecordPage(db, { entity, table }, { entityIds, afterId, limit }) {
46
+ return db.selectMany(table, {
47
+ ...(entity.softDelete === true && { isDeleted: false }),
48
+ ...(entityIds !== undefined
49
+ ? { id: entityIds }
50
+ : afterId !== undefined && { id: { gt: afterId } }),
51
+ }, { orderBy: { col: "id", direction: "asc" }, ...(entityIds === undefined && { limit }) });
52
+ }
53
+ // Unbound, same tenant, uploaded by the record's creator (or the system), and either
54
+ // unclaimed or pending for exactly this entity field.
55
+ function isBindableToRecord(fileRef, target) {
56
+ const uploader = fileRef["insertedById"];
57
+ return (typeof uploader === "string" &&
58
+ (uploader === target.record["insertedById"] || uploader === SYSTEM_USER_ID) &&
59
+ fileRef["tenantId"] === target.tenantId &&
60
+ fileRef["entityId"] === null &&
61
+ (fileRef["entityType"] === null ||
62
+ (fileRef["entityType"] === target.entityType && fileRef["fieldName"] === target.fieldName)));
63
+ }
64
+ // The binding follows only the record's declared file fields; the caller names
65
+ // no file ids. The record's creator must have uploaded the file themselves (or the
66
+ // system generated it), otherwise a foreign reference would pull someone else's
67
+ // private upload into the referencing user's record and make it readable there.
68
+ export const bindReferencedFilesWrite = defineWriteHandler({
69
+ name: "bindReferencedFiles",
70
+ schema: payloadSchema,
71
+ access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
72
+ // Maintenance backfill run by ops or system jobs, not a task an assistant performs.
73
+ agent: { expose: false },
74
+ description: "Binds the files a record references through its declared file fields to that record, so readers of the record see them through record access. Covers files uploaded before pending-upload binding existed (entityId null) and records created without a save lifecycle (custom handlers, system jobs). Binds only files uploaded by the record's creator or generated by the system; skips files already bound elsewhere, uploaded by someone else, of another tenant or missing; never rebinds.",
75
+ handler: async (event, ctx) => {
76
+ const { entityType, entityIds, limit } = event.payload;
77
+ const { fileFieldNames, ...source } = resolveFileBearingEntity(ctx.registry, entityType);
78
+ const records = await loadRecordPage(ctx.db, source, event.payload);
79
+ const executor = getFileRefExecutor();
80
+ let bound = 0;
81
+ let skipped = 0;
82
+ for (const record of records) {
83
+ for (const fieldName of fileFieldNames) {
84
+ for (const fileRefId of fileRefIdsOf(record[fieldName])) {
85
+ const row = await executor.detail({ id: fileRefId }, event.user, ctx.db);
86
+ if (row === null ||
87
+ !isBindableToRecord(row, {
88
+ record,
89
+ entityType,
90
+ fieldName,
91
+ tenantId: event.user.tenantId,
92
+ })) {
93
+ skipped += 1;
94
+ continue;
95
+ }
96
+ const version = typeof row["version"] === "number" ? row["version"] : undefined;
97
+ const result = await executor.update({ id: fileRefId, version, changes: { entityType, entityId: record.id, fieldName } }, event.user, ctx.db);
98
+ if (!result.isSuccess)
99
+ return result;
100
+ bound += 1;
101
+ }
102
+ }
103
+ }
104
+ const lastRecord = records[records.length - 1];
105
+ const pageIsFull = entityIds === undefined && records.length === limit;
106
+ const nextAfterId = pageIsFull ? (lastRecord?.id ?? null) : null;
107
+ return { isSuccess: true, data: { bound, skipped, nextAfterId } };
108
+ },
109
+ });
@@ -4,6 +4,7 @@ export { fileRefEntity } from "./file-ref-entity.js";
4
4
  export type FilesFeatureOptions = {
5
5
  readonly accessGuard?: FileAccessGuard;
6
6
  readonly privilegedRoles?: readonly string[];
7
+ readonly unclaimedUploadReaders?: readonly string[];
7
8
  readonly maxUploadSize?: string;
8
9
  };
9
10
  export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
@@ -1,4 +1,5 @@
1
1
  import { defineFeature } from "../engine/index.js";
2
+ import { bindReferencedFilesWrite } from "./bind-referenced-files.write.js";
2
3
  import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
3
4
  export { fileRefEntity } from "./file-ref-entity.js";
4
5
  // files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
@@ -21,6 +22,7 @@ export function createFilesFeature(options = {}) {
21
22
  recommended: false,
22
23
  });
23
24
  r.entity(FILE_REF_ENTITY_NAME, fileRefEntity);
25
+ r.writeHandler(bindReferencedFilesWrite);
24
26
  return { routeOptions: options };
25
27
  });
26
28
  }
@@ -28,10 +28,11 @@ export type FileRoutesOptions = {
28
28
  readonly registry?: Registry;
29
29
  readonly maxUploadSize?: string;
30
30
  readonly privilegedRoles?: readonly string[];
31
+ readonly unclaimedUploadReaders?: readonly string[];
31
32
  readonly accessGuard?: FileAccessGuard;
32
33
  readonly canReadRecord?: RecordReadCheck;
33
34
  readonly canWriteRecord?: RecordWriteCheck;
34
35
  };
35
36
  export declare function resolveMaxUploadBodyBytes(options: Pick<FileRoutesOptions, "registry" | "maxUploadSize">): number;
36
- export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
37
+ export declare function readFilesRouteOptions(registry: Registry): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "unclaimedUploadReaders" | "maxUploadSize">;
37
38
  export declare function createFileRoutes(options: FileRoutesOptions): Hono;
@@ -26,12 +26,18 @@ const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
26
26
  // files. Pending uploads (entityType set, record not yet created) are the
27
27
  // uploader's own until bound; so are uploads without an entityType, which no
28
28
  // record can ever claim.
29
- function createDefaultGuard(privilegedRoles, canReadRecord) {
29
+ function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReaders) {
30
30
  return async ({ fileRef, user, operation }) => {
31
31
  if (fileRef.insertedById === user.id)
32
32
  return "allow";
33
33
  if (privilegedRoles.some((role) => user.roles.includes(role)))
34
34
  return "allow";
35
+ const isUnclaimed = fileRef.entityType === null && fileRef.entityId === null;
36
+ if (isUnclaimed &&
37
+ operation === "read" &&
38
+ unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
39
+ return "allow";
40
+ }
35
41
  if (fileRef.entityType === null || fileRef.entityId === null)
36
42
  return "deny";
37
43
  if (operation !== "read" || canReadRecord === undefined)
@@ -77,7 +83,8 @@ export function readFilesRouteOptions(registry) {
77
83
  export function createFileRoutes(options) {
78
84
  const { db } = options;
79
85
  const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
80
- const guard = options.accessGuard ?? createDefaultGuard(privilegedRoles, options.canReadRecord);
86
+ const guard = options.accessGuard ??
87
+ createDefaultGuard(privilegedRoles, options.canReadRecord, options.unclaimedUploadReaders ?? []);
81
88
  const executor = getFileRefExecutor();
82
89
  const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
83
90
  const api = new Hono();
@@ -14,6 +14,7 @@ export type RecordWriteCheck = (args: {
14
14
  readonly fieldName: string | undefined;
15
15
  readonly user: SessionUser;
16
16
  }) => Promise<RecordWriteDecision>;
17
+ export declare function findImplicitEntityTable(registry: Registry, entityType: string): import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable | undefined;
17
18
  export declare function createRecordWriteCheck(args: {
18
19
  readonly registry: Registry;
19
20
  readonly canReadRecord: RecordReadCheck;
@@ -8,6 +8,14 @@ export const RecordWriteDecisions = {
8
8
  forbidden: "forbidden",
9
9
  locked: "locked",
10
10
  };
11
+ export function findImplicitEntityTable(registry, entityType) {
12
+ for (const projection of registry.getAllProjections().values()) {
13
+ if (projection.isImplicit === true && projection.source === entityType) {
14
+ return projection.table;
15
+ }
16
+ }
17
+ return undefined;
18
+ }
11
19
  function isEntityUpdateWrite(qn, entityName) {
12
20
  return qn.endsWith(`:write:${toKebab(entityName)}:update`);
13
21
  }
@@ -25,20 +33,12 @@ export function createRecordWriteCheck(args) {
25
33
  }
26
34
  return undefined;
27
35
  }
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
36
  return async ({ entityType, entityId, fieldName, user }) => {
37
37
  if (!(await canReadRecord({ entityType, entityId, user }))) {
38
38
  return RecordWriteDecisions.notFound;
39
39
  }
40
40
  const entity = registry.getEntity(entityType);
41
- const table = findEntityTable(entityType);
41
+ const table = findImplicitEntityTable(registry, entityType);
42
42
  if (!entity || !table)
43
43
  return RecordWriteDecisions.notFound;
44
44
  // The stored row, not the detail output: the detail query can hide ownership fields.
@@ -39,11 +39,11 @@ function declaresPublicIntake(access) {
39
39
  // Only the public dispatcher entry points compute a root; every nested call inherits
40
40
  // it, so switching identity via ctx.writeAs(SYSTEM, ...) cannot shed an anonymous root.
41
41
  export function rootWriteOrigin(registry, type, user) {
42
- const writeHandler = registry.getWriteHandler(type);
42
+ const rootHandler = registry.getWriteHandler(type) ?? registry.getQueryHandler(type);
43
43
  return {
44
44
  rootHandler: type,
45
45
  anonymousRoot: user.roles.includes(ANONYMOUS_ROLE),
46
- publicIntake: writeHandler !== undefined && declaresPublicIntake(writeHandler.access),
46
+ publicIntake: rootHandler !== undefined && declaresPublicIntake(rootHandler.access),
47
47
  };
48
48
  }
49
49
  // Owner binding is ignored (honorOwnerBinding=false): all anonymous callers share one
@@ -5,10 +5,15 @@ export type CardRoleColumn = {
5
5
  readonly type?: string;
6
6
  readonly renderer?: unknown;
7
7
  readonly hideOnNarrow?: boolean;
8
+ readonly fileDisplay?: {
9
+ readonly kind: string;
10
+ };
8
11
  };
9
12
  export type CardColumnRoles<C extends CardRoleColumn> = {
13
+ readonly image: C | undefined;
10
14
  readonly title: C | undefined;
11
15
  readonly status: C | undefined;
12
16
  readonly meta: readonly C[];
13
17
  };
18
+ export declare function cardFileDisplayKind(type: string | undefined, renderer: unknown): "image" | "file" | undefined;
14
19
  export declare function cardColumnRoles<C extends CardRoleColumn>(columns: readonly C[]): CardColumnRoles<C>;
@@ -1,9 +1,27 @@
1
1
  export const CARD_META_MAX = 3;
2
+ // An explicit `{ format: "image" | "file" }` renderer wins over the field type, the
3
+ // same precedence as the list view model's file display.
4
+ export function cardFileDisplayKind(type, renderer) {
5
+ if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
6
+ if (renderer.format === "image")
7
+ return "image";
8
+ if (renderer.format === "file")
9
+ return "file";
10
+ }
11
+ if (type === "image" || type === "images")
12
+ return "image";
13
+ if (type === "file" || type === "files")
14
+ return "file";
15
+ return undefined;
16
+ }
2
17
  // One rule for the narrow-layout list card, shared by the renderer and the
3
18
  // boot validator so the validator counts exactly what the card would draw.
19
+ // The first image column is the card's leading preview, neither title nor meta.
4
20
  export function cardColumnRoles(columns) {
5
- const title = columns.find((col) => col.highlighted === true) ?? columns[0];
6
- const status = columns.find((col) => col !== title && col.type === "select" && col.renderer === undefined);
7
- const meta = columns.filter((col) => col !== title && col !== status && col.hideOnNarrow !== true);
8
- return { title, status, meta };
21
+ const image = columns.find((col) => col.fileDisplay?.kind === "image");
22
+ const textColumns = columns.filter((col) => col !== image);
23
+ const title = textColumns.find((col) => col.highlighted === true) ?? textColumns[0];
24
+ const status = textColumns.find((col) => col !== title && col.type === "select" && col.renderer === undefined);
25
+ const meta = textColumns.filter((col) => col !== title && col !== status && col.hideOnNarrow !== true);
26
+ return { image, title, status, meta };
9
27
  }
@@ -17,7 +17,7 @@ export type { WorkspaceDefinition } from "../engine/types/workspace.js";
17
17
  export { ACTION_FORM_ENTITY, handlerFieldLabelKey, handlerTitleKey, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
18
18
  export type { AppSchema, FeatureSchema, QualifiedContentCollection, WorkspaceSchema, } from "./app-schema.js";
19
19
  export type { CardColumnRoles, CardRoleColumn } from "./card-column-roles.js";
20
- export { CARD_META_MAX, cardColumnRoles } from "./card-column-roles.js";
20
+ export { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind } from "./card-column-roles.js";
21
21
  export { type Formality, formalLocaleTag } from "./formality.js";
22
22
  export type { ListRowMetaColumnType, ListRowMetaReference, ReferenceLookupSource, SystemReferenceLabel, } from "./list-row-meta.js";
23
23
  export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "./list-row-meta.js";
@@ -26,7 +26,7 @@ export { availableSelectOptions, evalFieldCondition, explicitListScreenId, findU
26
26
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
27
27
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
28
28
  export { ACTION_FORM_ENTITY, handlerFieldLabelKey, handlerTitleKey, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
29
- export { CARD_META_MAX, cardColumnRoles } from "./card-column-roles.js";
29
+ export { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind } from "./card-column-roles.js";
30
30
  export { formalLocaleTag } from "./formality.js";
31
31
  export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "./list-row-meta.js";
32
32
  export { resolveTranslationValue, translationValueOtherText, } from "./plural.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.361.0",
3
+ "version": "0.362.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.361.0",
214
- "@cosmicdrift/kumiko-types": "0.361.0",
213
+ "@cosmicdrift/kumiko-http": "0.362.0",
214
+ "@cosmicdrift/kumiko-types": "0.362.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.361.0",
230
+ "@cosmicdrift/kumiko-dispatcher-live": "0.362.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,48 @@
1
1
  [
2
+ {
3
+ "version": "0.362.0",
4
+ "type": "fix",
5
+ "title": "Client schema carries entity lockedWhen",
6
+ "detail": "buildAppSchema projects lockedWhen, so an entityEdit screen shows a locked record read-only without a screen-level readOnly."
7
+ },
8
+ {
9
+ "version": "0.362.0",
10
+ "type": "fix",
11
+ "title": "r.secret options with function values (redact) no longer fail to parse",
12
+ "detail": "r.secret(...) options that contain arrow or function expressions (for example redact: () => \"••••••••\") were reported as \"options could not be read as a plain object\", which turned the whole feature into a legacy corpus entry. The functions are now kept verbatim as raw references, option keys stay visible and a parse to render round-trip keeps them (multi-line function bodies may be re-indented). Function values in other patterns (entity, nav, metric, job) are still rejected on purpose.",
13
+ "migration": "Consumers that ship a checked-in few-shot corpus (Enterprise) should regenerate it on the next framework bump; its drift test enforces that."
14
+ },
15
+ {
16
+ "version": "0.362.0",
17
+ "type": "improvement",
18
+ "title": "Binding of referenced files only for the record creator's own uploads",
19
+ "detail": "files:write:bind-referenced-files now binds a file only if the record creator uploaded it (or the system generated it), so foreign references cannot expose private uploads."
20
+ },
21
+ {
22
+ "version": "0.362.0",
23
+ "type": "improvement",
24
+ "title": "files:write:bind-referenced-files binds a record's referenced files",
25
+ "detail": "System-only handler that binds the files a record references through its declared file fields (legacy uploads with entityId null, records created without a save lifecycle) so record readers can open them. Skips files bound elsewhere, of another tenant or missing. Callable from es-ops seeds via ctx.systemWriteAs; pages with limit and nextAfterId."
26
+ },
27
+ {
28
+ "version": "0.362.0",
29
+ "type": "breaking",
30
+ "title": "Optional number fields in entity forms start empty instead of 0",
31
+ "migration": "An optional number field without default no longer starts at 0 in entityEdit create/edit, and an untouched one is not sent. If a screen relied on the implicit 0, declare default: 0 on the field."
32
+ },
33
+ {
34
+ "version": "0.362.0",
35
+ "type": "improvement",
36
+ "title": "unclaimedUploadReaders option for createFilesFeature",
37
+ "detail": "Roles listed in unclaimedUploadReaders may read (not delete) uploads that have no entityType and entityId, for documents referenced without a file field."
38
+ },
39
+ {
40
+ "version": "0.362.0",
41
+ "type": "fix",
42
+ "title": "Query handlers may declare personalData public-intake",
43
+ "detail": "The personal-data write gate computed the public-intake exemption only for write handlers, so an anonymous query handler that wrote personal data (directly or through a nested write) was always rejected, and the boot validator refused access.personalData on query handlers. Query handlers now follow the same rules as write handlers: access.personalData \"public-intake\" requires the anonymous role and an active rateLimit (not disabled). openToAll.personalData stays write-only, stream handlers still cannot declare personalData.",
44
+ "migration": "No consumer change; anonymous query handlers that write personal data now declare access.personalData: \"public-intake\" instead of failing at the write gate."
45
+ },
2
46
  {
3
47
  "version": "0.361.0",
4
48
  "type": "improvement",