@cosmicdrift/kumiko-framework 0.398.0 → 0.400.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/AGENTS.md +33 -19
  2. package/agents/api/dev-server-testing.md +23 -0
  3. package/agents/api/framework-db.md +277 -0
  4. package/agents/api/framework-engine.md +1810 -0
  5. package/agents/api/framework-errors.md +119 -0
  6. package/agents/api/framework-event-store.md +99 -0
  7. package/agents/api/framework-seeding.md +26 -0
  8. package/agents/api/framework-ui-types.md +542 -0
  9. package/agents/api/renderer-web.md +1450 -0
  10. package/agents/api/testing-helpers.md +189 -0
  11. package/agents/api/testing-stack.md +144 -0
  12. package/agents/api/types.md +5124 -0
  13. package/agents/api.md +20 -0
  14. package/agents/examples/derived-fields-sql.md +251 -0
  15. package/agents/examples/documents.md +161 -0
  16. package/agents/examples/inspection-checklist.md +807 -0
  17. package/agents/examples.md +3 -0
  18. package/agents/features-index.md +178 -0
  19. package/agents/features.md +9 -1
  20. package/dist/api/entity-read-access.d.ts +2 -0
  21. package/dist/api/entity-read-access.js +46 -2
  22. package/dist/api/server-boot-checks.js +2 -2
  23. package/dist/api/server-routes.js +3 -1
  24. package/dist/db/derived-field-sql.d.ts +23 -0
  25. package/dist/db/derived-field-sql.js +115 -0
  26. package/dist/db/derived-related-sql.d.ts +31 -0
  27. package/dist/db/derived-related-sql.js +137 -0
  28. package/dist/db/driver-row-coercion.d.ts +1 -0
  29. package/dist/db/driver-row-coercion.js +13 -0
  30. package/dist/db/eagerload.d.ts +3 -1
  31. package/dist/db/eagerload.js +4 -6
  32. package/dist/db/event-store-executor-context.js +1 -1
  33. package/dist/db/event-store-executor-read-paging.js +1 -1
  34. package/dist/db/event-store-executor-read-search.d.ts +3 -0
  35. package/dist/db/event-store-executor-read-search.js +17 -9
  36. package/dist/db/event-store-executor-read-sort.d.ts +9 -2
  37. package/dist/db/event-store-executor-read-sort.js +27 -14
  38. package/dist/db/event-store-executor-read-where.d.ts +0 -5
  39. package/dist/db/event-store-executor-read-where.js +3 -9
  40. package/dist/db/event-store-executor-read.js +23 -4
  41. package/dist/db/event-store-executor-write-steps.d.ts +6 -1
  42. package/dist/db/event-store-executor-write-steps.js +20 -1
  43. package/dist/db/event-store-executor-write.js +8 -8
  44. package/dist/db/money.d.ts +4 -0
  45. package/dist/db/money.js +34 -29
  46. package/dist/db/parent-ref-clause.d.ts +1 -0
  47. package/dist/db/parent-ref-clause.js +18 -8
  48. package/dist/db/read-gate-clause.d.ts +7 -0
  49. package/dist/db/read-gate-clause.js +188 -0
  50. package/dist/db/read-ownership.d.ts +9 -0
  51. package/dist/db/read-ownership.js +12 -0
  52. package/dist/engine/boot-validator/boot-check.d.ts +3 -1
  53. package/dist/engine/boot-validator/boot-check.js +7 -11
  54. package/dist/engine/boot-validator/boot-context.d.ts +26 -0
  55. package/dist/engine/boot-validator/boot-context.js +57 -0
  56. package/dist/engine/boot-validator/boot-options.d.ts +33 -0
  57. package/dist/engine/boot-validator/boot-options.js +1 -0
  58. package/dist/engine/boot-validator/boot-phases-app.d.ts +5 -0
  59. package/dist/engine/boot-validator/boot-phases-app.js +103 -0
  60. package/dist/engine/boot-validator/boot-phases.d.ts +11 -0
  61. package/dist/engine/boot-validator/boot-phases.js +140 -0
  62. package/dist/engine/boot-validator/boot-report.d.ts +6 -0
  63. package/dist/engine/boot-validator/boot-report.js +47 -0
  64. package/dist/engine/boot-validator/boot-rule.d.ts +12 -0
  65. package/dist/engine/boot-validator/boot-rule.js +14 -0
  66. package/dist/engine/boot-validator/boot-warnings.d.ts +2 -0
  67. package/dist/engine/boot-validator/boot-warnings.js +20 -0
  68. package/dist/engine/boot-validator/derived-sql-fields.d.ts +4 -0
  69. package/dist/engine/boot-validator/derived-sql-fields.js +184 -0
  70. package/dist/engine/boot-validator/entity-handler.d.ts +3 -0
  71. package/dist/engine/boot-validator/entity-handler.js +6 -3
  72. package/dist/engine/boot-validator/entity-list-screens.js +3 -1
  73. package/dist/engine/boot-validator/field-conditions.d.ts +2 -0
  74. package/dist/engine/boot-validator/field-conditions.js +178 -0
  75. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -2
  76. package/dist/engine/boot-validator/i18n-keys.js +1 -3
  77. package/dist/engine/boot-validator/index.d.ts +5 -34
  78. package/dist/engine/boot-validator/index.js +24 -204
  79. package/dist/engine/boot-validator/pii-retention.js +2 -7
  80. package/dist/engine/boot-validator/projection-list-screens.js +19 -1
  81. package/dist/engine/boot-validator/read-gate.d.ts +2 -0
  82. package/dist/engine/boot-validator/read-gate.js +54 -0
  83. package/dist/engine/boot-validator/screens-forms.js +19 -6
  84. package/dist/engine/boot-validator/screens-kind-entity-list.js +8 -6
  85. package/dist/engine/boot-validator/screens-lists.js +8 -0
  86. package/dist/engine/boot-validator/zod-shape.js +4 -2
  87. package/dist/engine/build-app-schema.js +11 -5
  88. package/dist/engine/embedded-derived.d.ts +8 -6
  89. package/dist/engine/embedded-derived.js +25 -7
  90. package/dist/engine/entity-handlers.d.ts +1 -1
  91. package/dist/engine/entity-handlers.js +17 -18
  92. package/dist/engine/entity-list-runtime-options.d.ts +5 -1
  93. package/dist/engine/entity-list-runtime-options.js +103 -4
  94. package/dist/engine/factories.d.ts +5 -5
  95. package/dist/engine/factories.js +3 -5
  96. package/dist/engine/field-access.d.ts +2 -0
  97. package/dist/engine/field-access.js +48 -1
  98. package/dist/engine/field-state.d.ts +12 -0
  99. package/dist/engine/field-state.js +67 -0
  100. package/dist/engine/handler-helpers.d.ts +3 -0
  101. package/dist/engine/handler-helpers.js +7 -1
  102. package/dist/engine/index.d.ts +2 -1
  103. package/dist/engine/index.js +2 -1
  104. package/dist/engine/ownership.d.ts +1 -1
  105. package/dist/engine/ownership.js +9 -5
  106. package/dist/engine/registry-state.d.ts +1 -0
  107. package/dist/engine/registry-state.js +1 -1
  108. package/dist/engine/schema-builder.js +3 -0
  109. package/dist/engine/strip-unreadable-related-lists.d.ts +4 -0
  110. package/dist/engine/strip-unreadable-related-lists.js +80 -0
  111. package/dist/entrypoint/index.js +1 -0
  112. package/dist/entrypoint/internals.d.ts +2 -0
  113. package/dist/files/assert-file-refs-belong-to-tenant.js +26 -6
  114. package/dist/files/bind-pending-file-refs.d.ts +3 -2
  115. package/dist/files/bind-pending-file-refs.js +11 -12
  116. package/dist/files/bind-referenced-files.write.js +5 -5
  117. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  118. package/dist/files/create-file-ref-from-bytes.js +2 -0
  119. package/dist/files/create-file-ref.d.ts +2 -0
  120. package/dist/files/create-file-ref.js +39 -22
  121. package/dist/files/dispose-removed-file-refs.js +5 -8
  122. package/dist/files/file-field-slots.d.ts +10 -0
  123. package/dist/files/file-field-slots.js +53 -0
  124. package/dist/files/file-ref-entity.d.ts +1 -1
  125. package/dist/files/file-ref-table.d.ts +1 -1
  126. package/dist/files/file-routes.js +30 -9
  127. package/dist/files/index.d.ts +1 -0
  128. package/dist/files/index.js +1 -0
  129. package/dist/files/load-save-file-refs.js +3 -4
  130. package/dist/files/record-read-check.js +2 -30
  131. package/dist/files/record-write-check.d.ts +2 -0
  132. package/dist/files/record-write-check.js +18 -3
  133. package/dist/files/upload-field-resolution.d.ts +4 -1
  134. package/dist/files/upload-field-resolution.js +57 -12
  135. package/dist/pipeline/dispatch-batch.js +6 -5
  136. package/dist/pipeline/dispatch-write.js +2 -1
  137. package/dist/pipeline/write-result-masking.js +2 -1
  138. package/dist/schema-cli/validate-command.js +46 -12
  139. package/dist/schema-cli.js +1 -1
  140. package/dist/ui-types/index.d.ts +2 -1
  141. package/dist/ui-types/index.js +2 -1
  142. package/package.json +3 -3
  143. package/src/changes.json +67 -0
@@ -27,36 +27,32 @@ const ACTION_FORM_STORAGE_SEGMENT = "action-form";
27
27
  const SAFE_IDENTIFIER = /^[A-Za-z0-9_-]+$/;
28
28
  class AttachTargetLockedError extends Error {
29
29
  }
30
- function acceptFile(deps, input) {
31
- const { fileName, declaredMimeType, data, entityType, entityId, fieldName } = input;
32
- const rejected = (error) => ({ kind: "rejected", error });
33
- // These interpolate directly into the storage key (buildStorageKey) —
34
- // reject anything but a safe identifier so a caller can't path-traverse
35
- // out of its tenant's storage prefix (../.. segments, absolute paths).
30
+ // These interpolate directly into the storage key (buildStorageKey) —
31
+ // reject anything but a safe identifier so a caller can't path-traverse
32
+ // out of its tenant's storage prefix (../.. segments, absolute paths).
33
+ function invalidIdentifierError(input) {
34
+ const { entityType, entityId, fieldName, subField } = input;
36
35
  for (const [field, value] of [
37
36
  ["entityType", entityType],
38
37
  ["entityId", entityId],
39
38
  ["fieldName", fieldName],
39
+ ["subField", subField],
40
40
  ]) {
41
41
  if (value !== undefined && !SAFE_IDENTIFIER.test(value)) {
42
- return rejected(`invalid_${field}: must match /^[A-Za-z0-9_-]+$/`);
42
+ return `invalid_${field}: must match /^[A-Za-z0-9_-]+$/`;
43
43
  }
44
44
  }
45
- const field = resolveUploadField(deps.registry, input, deps.attachTargets);
46
- if (field.kind === "rejected")
47
- return field;
48
- if (field.kind === "unresolvable") {
49
- return rejected("unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload");
45
+ if (subField !== undefined && (entityType === undefined || fieldName === undefined)) {
46
+ return "invalid_sub_field: subField requires entityType and fieldName";
50
47
  }
51
- const fieldDef = field.kind === "resolved" ? field.fieldDef : undefined;
52
- const maxSize = fieldDef?.maxSize || deps.defaultMaxUploadSize;
53
- const accept = fieldDef?.accept;
54
- const validationError = validateFile({ fileName, mimeType: declaredMimeType, size: data.byteLength }, { maxSize, accept });
55
- if (validationError)
56
- return rejected(validationError);
48
+ return undefined;
49
+ }
50
+ function acceptFileContent(input, accept) {
51
+ const { fileName, declaredMimeType, data } = input;
57
52
  const contentValidation = validateFileContent(fileName, data, accept);
58
- if (contentValidation.kind === "rejected")
59
- return rejected(contentValidation.error);
53
+ if (contentValidation.kind === "rejected") {
54
+ return { kind: "rejected", error: contentValidation.error };
55
+ }
60
56
  // A normalized upload's real type is already known from the sniffed
61
57
  // bytes — resolveUploadMimeType's alias/rewrite step only applies to the
62
58
  // declared mimeType of an unnormalized upload.
@@ -68,8 +64,9 @@ function acceptFile(deps, input) {
68
64
  };
69
65
  }
70
66
  const mimeTypeResolution = resolveUploadMimeType(fileName, declaredMimeType, data);
71
- if (mimeTypeResolution.kind === "rejected")
72
- return rejected(mimeTypeResolution.error);
67
+ if (mimeTypeResolution.kind === "rejected") {
68
+ return { kind: "rejected", error: mimeTypeResolution.error };
69
+ }
73
70
  return {
74
71
  kind: "accepted",
75
72
  mimeType: resolveContentType({
@@ -80,6 +77,26 @@ function acceptFile(deps, input) {
80
77
  extension: undefined,
81
78
  };
82
79
  }
80
+ function acceptFile(deps, input) {
81
+ const { fileName, declaredMimeType, data } = input;
82
+ const rejected = (error) => ({ kind: "rejected", error });
83
+ const identifierError = invalidIdentifierError(input);
84
+ if (identifierError !== undefined)
85
+ return rejected(identifierError);
86
+ const field = resolveUploadField(deps.registry, input, deps.attachTargets);
87
+ if (field.kind === "rejected")
88
+ return field;
89
+ if (field.kind === "unresolvable") {
90
+ return rejected("unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload");
91
+ }
92
+ const fieldDef = field.kind === "resolved" ? field.fieldDef : undefined;
93
+ const maxSize = fieldDef?.maxSize || deps.defaultMaxUploadSize;
94
+ const accept = fieldDef?.accept;
95
+ const validationError = validateFile({ fileName, mimeType: declaredMimeType, size: data.byteLength }, { maxSize, accept });
96
+ if (validationError)
97
+ return rejected(validationError);
98
+ return acceptFileContent(input, accept);
99
+ }
83
100
  // Upload step: validates and writes the binary. Outside any transaction on purpose — network/disk
84
101
  // I/O doesn't belong inside a PG connection's tx window.
85
102
  export async function writeAcceptedFileBytes(deps, input) {
@@ -1,7 +1,6 @@
1
- import { isFileField } from "../engine/field-helpers.js";
2
1
  import { reraiseAsKumikoError } from "../errors/index.js";
3
- import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
2
  import { getFileRefExecutor } from "./create-file-ref.js";
3
+ import { fileRefIdsInField, holdsFileRefs } from "./file-field-slots.js";
5
4
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
6
5
  // The bound fieldName may differ from the field the id was removed from: an id
7
6
  // moved A -> B stays bound to A (binding never re-targets).
@@ -10,15 +9,13 @@ function isBoundToRecord(row, entityName, entityId, fileFields) {
10
9
  return (row["entityType"] === entityName &&
11
10
  row["entityId"] === entityId &&
12
11
  typeof boundField === "string" &&
13
- isFileField(fileFields[boundField]));
12
+ holdsFileRefs(fileFields[boundField]));
14
13
  }
15
14
  // Across all file fields: an id moved from one field to another is still in use.
16
15
  function fileRefIdsStoredInAnyField(fields, data) {
17
16
  const ids = new Set();
18
17
  for (const [fieldName, fieldDef] of Object.entries(fields)) {
19
- if (!isFileField(fieldDef))
20
- continue;
21
- for (const fileRefId of fileRefIdsOf(data[fieldName]))
18
+ for (const fileRefId of fileRefIdsInField(fieldDef, data[fieldName]))
22
19
  ids.add(fileRefId);
23
20
  }
24
21
  return ids;
@@ -50,9 +47,9 @@ export async function disposeRemovedFileRefs(args) {
50
47
  const executor = getFileRefExecutor();
51
48
  const keptAnywhere = fileRefIdsStoredInAnyField(entity.fields, result.data);
52
49
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
53
- if (!isFileField(fieldDef) || !(fieldName in result.changes))
50
+ if (!holdsFileRefs(fieldDef) || !(fieldName in result.changes))
54
51
  continue;
55
- for (const fileRefId of fileRefIdsOf(result.previous[fieldName])) {
52
+ for (const fileRefId of fileRefIdsInField(fieldDef, result.previous[fieldName])) {
56
53
  if (keptAnywhere.has(fileRefId))
57
54
  continue;
58
55
  const row = await executor.detail({ id: fileRefId }, user, db);
@@ -0,0 +1,10 @@
1
+ import type { EmbeddedFileSubFieldDef, EmbeddedSubFieldDef, FieldDefinition } from "../engine/types/fields.ts";
2
+ export declare function fileRefIdsOf(value: unknown): readonly string[];
3
+ export declare function isFileSubField(sub: EmbeddedSubFieldDef): sub is EmbeddedFileSubFieldDef;
4
+ /** A field whose value carries FileRef ids: a file field itself, or an embedded field with file sub-fields. */
5
+ export declare function holdsFileRefs(def: FieldDefinition | undefined): boolean;
6
+ /** FileRef ids a field value names. `file_refs.fieldName` stays the top-level field, never the sub-field. */
7
+ export declare function fileRefIdsInField(def: FieldDefinition | undefined, value: unknown): string[];
8
+ /** FileRef ids per file sub-field of an embedded field value, pooled over all rows. */
9
+ export declare function fileRefIdsBySubField(def: FieldDefinition | undefined, value: unknown): ReadonlyMap<string, readonly string[]>;
10
+ export declare function resolveFileSubField(def: FieldDefinition | undefined, subField: string): EmbeddedFileSubFieldDef | undefined;
@@ -0,0 +1,53 @@
1
+ import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
+ import { isFileField } from "../engine/field-helpers.js";
4
+ export function fileRefIdsOf(value) {
5
+ if (typeof value === "string")
6
+ return isUuid(value) ? [value] : [];
7
+ if (!Array.isArray(value))
8
+ return [];
9
+ return value.filter((item) => typeof item === "string" && isUuid(item));
10
+ }
11
+ export function isFileSubField(sub) {
12
+ return sub.type === "images" || sub.type === "files";
13
+ }
14
+ function fileSubFieldNames(def) {
15
+ return Object.entries(def.schema).flatMap(([name, sub]) => (isFileSubField(sub) ? [name] : []));
16
+ }
17
+ /** A field whose value carries FileRef ids: a file field itself, or an embedded field with file sub-fields. */
18
+ export function holdsFileRefs(def) {
19
+ if (def === undefined)
20
+ return false;
21
+ if (isFileField(def))
22
+ return true;
23
+ return def.type === "embedded" && Object.values(def.schema).some(isFileSubField);
24
+ }
25
+ /** FileRef ids a field value names. `file_refs.fieldName` stays the top-level field, never the sub-field. */
26
+ export function fileRefIdsInField(def, value) {
27
+ if (def === undefined)
28
+ return [];
29
+ if (isFileField(def))
30
+ return [...fileRefIdsOf(value)];
31
+ if (def.type !== "embedded")
32
+ return [];
33
+ const subNames = fileSubFieldNames(def);
34
+ const rows = Array.isArray(value) ? value : [value];
35
+ return rows.flatMap((row) => isPlainObject(row) ? subNames.flatMap((name) => fileRefIdsOf(row[name])) : []);
36
+ }
37
+ /** FileRef ids per file sub-field of an embedded field value, pooled over all rows. */
38
+ export function fileRefIdsBySubField(def, value) {
39
+ const bySubField = new Map();
40
+ if (def?.type !== "embedded")
41
+ return bySubField;
42
+ const rows = Array.isArray(value) ? value : [value];
43
+ for (const name of fileSubFieldNames(def)) {
44
+ bySubField.set(name, rows.flatMap((row) => (isPlainObject(row) ? fileRefIdsOf(row[name]) : [])));
45
+ }
46
+ return bySubField;
47
+ }
48
+ export function resolveFileSubField(def, subField) {
49
+ if (def?.type !== "embedded")
50
+ return undefined;
51
+ const sub = def.schema[subField];
52
+ return sub !== undefined && isFileSubField(sub) ? sub : undefined;
53
+ }
@@ -19,7 +19,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
19
19
  readonly sensitive?: boolean;
20
20
  readonly default?: number;
21
21
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
22
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
22
+ } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & import("@cosmicdrift/kumiko-types/fields").FieldStateConditions & {
23
23
  required: true;
24
24
  };
25
25
  readonly entityType: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
@@ -18,7 +18,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
18
18
  readonly sensitive?: boolean;
19
19
  readonly default?: number;
20
20
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
21
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
21
+ } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & import("@cosmicdrift/kumiko-types/fields").FieldStateConditions & {
22
22
  required: true;
23
23
  };
24
24
  readonly entityType: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
@@ -17,6 +17,7 @@ import { listActionFormFileFields, mayUseActionForm } from "./action-form-upload
17
17
  import { buildContentDispositionHeader } from "./content-disposition.js";
18
18
  import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
19
19
  import { createDefaultGuard, DEFAULT_PRIVILEGED_ROLES } from "./default-file-guard.js";
20
+ import { isFileSubField } from "./file-field-slots.js";
20
21
  import { createFileContext, createTenantFileContext } from "./file-handle.js";
21
22
  import { fileRefEntity } from "./file-ref-entity.js";
22
23
  import { fileRefsTable } from "./file-ref-table.js";
@@ -24,6 +25,7 @@ import { collectRecordOnlyFileEntityTypes } from "./record-only-file-entity-type
24
25
  import { isStoredRecordLocked, RecordWriteDecisions, } from "./record-write-check.js";
25
26
  import { neutralFileNameForMimeType } from "./resolve-content-type.js";
26
27
  import { parseMaxSize, resolveServedContentType } from "./types.js";
28
+ import { isAnonymousPendingUploadAllowed, registryHasAnonymousUploadField, } from "./upload-field-resolution.js";
27
29
  import { withUploadLedger } from "./upload-ledger.js";
28
30
  // Exactly the httpStatus values declared by KumikoError subclasses across packages/*.
29
31
  const ERROR_STATUSES = [400, 401, 403, 404, 409, 422, 429, 500, 503, 504];
@@ -47,6 +49,12 @@ const BATCH_META_CONCURRENCY = 10;
47
49
  // Multipart framing plus the small entityType/entityId/fieldName parts
48
50
  // riding alongside the file.
49
51
  const MULTIPART_OVERHEAD_MARGIN_BYTES = 64 * 1024;
52
+ function fileFieldMaxSizes(field) {
53
+ if (field.type === "embedded") {
54
+ return Object.values(field.schema).flatMap((sub) => isFileSubField(sub) && sub.maxSize ? [sub.maxSize] : []);
55
+ }
56
+ return isFileField(field) && field.maxSize ? [field.maxSize] : [];
57
+ }
50
58
  // entityType/fieldName (and therefore the field's own maxSize) are only
51
59
  // known after the body is parsed, so this covers the largest configured
52
60
  // field, not just the global default.
@@ -54,8 +62,8 @@ export function resolveMaxUploadBodyBytes(options) {
54
62
  let maxBytes = parseMaxSize(options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE);
55
63
  for (const entity of options.registry?.getAllEntities().values() ?? []) {
56
64
  for (const field of Object.values(entity.fields)) {
57
- if (isFileField(field) && field.maxSize) {
58
- maxBytes = Math.max(maxBytes, parseMaxSize(field.maxSize));
65
+ for (const maxSize of fileFieldMaxSizes(field)) {
66
+ maxBytes = Math.max(maxBytes, parseMaxSize(maxSize));
59
67
  }
60
68
  }
61
69
  }
@@ -148,21 +156,33 @@ export function createFileRoutes(options) {
148
156
  // POST /files — multipart upload.
149
157
  api.post("/files", uploadBodyLimit, async (c) => {
150
158
  const user = getUser(c);
151
- // Anonymous visitors share one user id and cannot bind pending uploads (bind-pending-file-refs),
152
- // so an anonymous upload could never be used; 401 lets the client tell an ended session apart.
153
- if (isAnonymousSessionUser(user))
159
+ const isAnonymous = isAnonymousSessionUser(user);
160
+ // 401 (not 403) lets the client tell an ended session apart. Without any opted-in field
161
+ // no body is worth reading.
162
+ if (isAnonymous && !registryHasAnonymousUploadField(options.registry)) {
154
163
  return unauthenticatedUploadResponse(c);
164
+ }
155
165
  const body = await c.req.parseBody();
156
166
  const file = body["file"];
157
- if (!file || !(file instanceof File)) {
158
- return c.json({ error: "missing_file: expected multipart field 'file'" }, 400);
159
- }
160
167
  const entityType = typeof body["entityType"] === "string" ? body["entityType"] : undefined;
161
168
  // Post-ES migration entities use UUID ids; we accept the raw string and
162
169
  // store it in the text entityId column.
163
170
  const entityId = typeof body["entityId"] === "string" ? body["entityId"] : undefined;
164
171
  const fieldName = typeof body["fieldName"] === "string" ? body["fieldName"] : undefined;
172
+ const subField = typeof body["subField"] === "string" ? body["subField"] : undefined;
165
173
  const actionForm = typeof body["actionForm"] === "string" ? body["actionForm"] : undefined;
174
+ if (isAnonymous &&
175
+ !isAnonymousPendingUploadAllowed(options.registry, {
176
+ entityType,
177
+ entityId,
178
+ fieldName,
179
+ actionForm,
180
+ })) {
181
+ return unauthenticatedUploadResponse(c);
182
+ }
183
+ if (!file || !(file instanceof File)) {
184
+ return c.json({ error: "missing_file: expected multipart field 'file'" }, 400);
185
+ }
166
186
  // Ahead of the record check below, which would answer 404/403 for the entity half.
167
187
  if (actionForm !== undefined && (entityType !== undefined || entityId !== undefined)) {
168
188
  return c.json({ error: "invalid_action_form: actionForm cannot be combined with entityType or entityId" }, 400);
@@ -185,7 +205,7 @@ export function createFileRoutes(options) {
185
205
  const decision = options.registry !== undefined && options.registry.getEntity(entityType) === undefined
186
206
  ? RecordWriteDecisions.writable
187
207
  : options.canWriteRecord
188
- ? await options.canWriteRecord({ entityType, entityId, fieldName, user })
208
+ ? await options.canWriteRecord({ entityType, entityId, fieldName, subField, user })
189
209
  : RecordWriteDecisions.forbidden;
190
210
  switch (decision) {
191
211
  case RecordWriteDecisions.writable:
@@ -223,6 +243,7 @@ export function createFileRoutes(options) {
223
243
  entityType,
224
244
  entityId,
225
245
  fieldName,
246
+ subField,
226
247
  actionForm,
227
248
  });
228
249
  if (outcome.kind === "rejected")
@@ -8,6 +8,7 @@ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytes
8
8
  export { createDefaultGuard } from "./default-file-guard.ts";
9
9
  export { deleteFileRefsBoundToRecord } from "./delete-record-file-refs.ts";
10
10
  export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
11
+ export { holdsFileRefs } from "./file-field-slots.ts";
11
12
  export type { FileContext } from "./file-handle.ts";
12
13
  export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.ts";
13
14
  export { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.ts";
@@ -5,6 +5,7 @@ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytes
5
5
  export { createDefaultGuard } from "./default-file-guard.js";
6
6
  export { deleteFileRefsBoundToRecord } from "./delete-record-file-refs.js";
7
7
  export { createFilesFeature, FILES_FEATURE } from "./feature.js";
8
+ export { holdsFileRefs } from "./file-field-slots.js";
8
9
  // `createFileHandle` is an implementation detail — construct handles via
9
10
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
10
11
  export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.js";
@@ -1,8 +1,7 @@
1
1
  import { selectMany } from "../db/query.js";
2
2
  import { tenantDbRunner } from "../db/tenant-db-runner.js";
3
- import { isFileField } from "../engine/field-helpers.js";
4
3
  import { getStreamVersions } from "../event-store/event-store.js";
5
- import { fileRefIdsOf } from "./bind-pending-file-refs.js";
4
+ import { fileRefIdsInField, holdsFileRefs } from "./file-field-slots.js";
6
5
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
7
6
  import { fileRefsTable } from "./file-ref-table.js";
8
7
  // One query for every fileRef a save names, restricted to the writer's own tenant and live
@@ -19,9 +18,9 @@ export async function loadSaveFileRefs(args) {
19
18
  return new Map();
20
19
  const ids = new Set();
21
20
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
22
- if (!isFileField(fieldDef))
21
+ if (!holdsFileRefs(fieldDef))
23
22
  continue;
24
- for (const id of fileRefIdsOf(result.changes[fieldName]))
23
+ for (const id of fileRefIdsInField(fieldDef, result.changes[fieldName]))
25
24
  ids.add(id);
26
25
  }
27
26
  return loadFileRefRows(db, [...ids], user.tenantId);
@@ -1,35 +1,7 @@
1
- import { toKebab } from "../engine/qualified-name.js";
1
+ import { collectEntityDetailQueries } from "../api/entity-read-access.js";
2
2
  import { isKumikoError } from "../errors/index.js";
3
3
  import { createFallbackLogger } from "../logging/index.js";
4
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
- for (const projection of registry.getAllProjections().values()) {
21
- const entityName = projection.source;
22
- if (typeof entityName !== "string")
23
- continue;
24
- const owner = registry.getReadModelEntityOwner(entityName);
25
- if (owner === undefined)
26
- continue;
27
- const qn = `${owner}:query:${toKebab(entityName)}:detail`;
28
- if (registry.getAllQueryHandlers().has(qn))
29
- detailQueryByEntity.set(entityName, qn);
30
- }
31
- return detailQueryByEntity;
32
- }
33
5
  // "May this user read the record?" is answered by the entity's own detail
34
6
  // query run as that user: handler access, row ownership and parent visibility
35
7
  // all apply, exactly as when the record is opened. null (not found or not
@@ -38,7 +10,7 @@ function buildDetailQueryIndex(registry) {
38
10
  export function createRecordReadCheck(registry, query) {
39
11
  let detailQueryByEntity;
40
12
  return async ({ entityType, entityId, user }) => {
41
- detailQueryByEntity ??= buildDetailQueryIndex(registry);
13
+ detailQueryByEntity ??= collectEntityDetailQueries(registry);
42
14
  const detailQuery = detailQueryByEntity.get(entityType);
43
15
  if (detailQuery === undefined)
44
16
  return false;
@@ -12,6 +12,8 @@ export type RecordWriteCheck = (args: {
12
12
  readonly entityType: string;
13
13
  readonly entityId: string;
14
14
  readonly fieldName: string | undefined;
15
+ /** Embedded file sub-field of `fieldName` the upload is meant for. */
16
+ readonly subField?: string | undefined;
15
17
  readonly user: SessionUser;
16
18
  }) => Promise<RecordWriteDecision>;
17
19
  export declare function findImplicitEntityTable(registry: Registry, entityType: string): import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable | undefined;
@@ -1,7 +1,9 @@
1
1
  import { hasAccess } from "../engine/access.js";
2
+ import { userHasWriteRole } from "../engine/field-access.js";
2
3
  import { normalizeAccessEntry, userCanWriteFieldRow } from "../engine/ownership.js";
3
4
  import { toKebab } from "../engine/qualified-name.js";
4
5
  import { isRecordLocked } from "../engine/screen-helpers.js";
6
+ import { resolveFileSubField } from "./file-field-slots.js";
5
7
  export const RecordWriteDecisions = {
6
8
  writable: "writable",
7
9
  notFound: "not_found",
@@ -19,6 +21,20 @@ export function findImplicitEntityTable(registry, entityType) {
19
21
  function isEntityUpdateWrite(qn, entityName) {
20
22
  return qn.endsWith(`:write:${toKebab(entityName)}:update`);
21
23
  }
24
+ // The named field's own write rules against the stored row. The upload names no embedded row,
25
+ // so a sub-field only gets the role gate; its row-level rules run when the record is saved.
26
+ function mayWriteNamedField(args) {
27
+ const { entity, fieldName, subField, user, row } = args;
28
+ if (fieldName === undefined)
29
+ return true;
30
+ const fieldRules = normalizeAccessEntry(entity.fields[fieldName]?.access?.write);
31
+ if (!userCanWriteFieldRow(user, fieldRules, row, row))
32
+ return false;
33
+ const subFieldRules = subField === undefined
34
+ ? undefined
35
+ : normalizeAccessEntry(resolveFileSubField(entity.fields[fieldName], subField)?.access?.write);
36
+ return subFieldRules === undefined || userHasWriteRole(user, subFieldRules);
37
+ }
22
38
  // "May this user attach a file to the record?" asks what saving the record would ask: the
23
39
  // entity's update handler, the row-level and field-level write rules against the stored row,
24
40
  // and the lock. Not-readable, foreign-tenant and missing records answer alike (not_found) so
@@ -33,7 +49,7 @@ export function createRecordWriteCheck(args) {
33
49
  }
34
50
  return undefined;
35
51
  }
36
- return async ({ entityType, entityId, fieldName, user }) => {
52
+ return async ({ entityType, entityId, fieldName, subField, user }) => {
37
53
  if (!(await canReadRecord({ entityType, entityId, user }))) {
38
54
  return RecordWriteDecisions.notFound;
39
55
  }
@@ -53,8 +69,7 @@ export function createRecordWriteCheck(args) {
53
69
  if (!userCanWriteFieldRow(user, entity.access?.write, row, row)) {
54
70
  return RecordWriteDecisions.forbidden;
55
71
  }
56
- const fieldWriteRules = fieldName === undefined ? undefined : entity.fields[fieldName]?.access?.write;
57
- if (!userCanWriteFieldRow(user, normalizeAccessEntry(fieldWriteRules), row, row)) {
72
+ if (!mayWriteNamedField({ entity, fieldName, subField, user, row })) {
58
73
  return RecordWriteDecisions.forbidden;
59
74
  }
60
75
  if (isRecordLocked(entity.lockedWhen, row))
@@ -4,6 +4,7 @@ type UploadTarget = {
4
4
  readonly entityType?: string | undefined;
5
5
  readonly entityId?: string | undefined;
6
6
  readonly fieldName?: string | undefined;
7
+ readonly subField?: string | undefined;
7
8
  readonly actionForm?: string | undefined;
8
9
  };
9
10
  export type AttachTargets = "entities" | "entities-and-read-models";
@@ -15,10 +16,12 @@ type AttachedFieldResolution = {
15
16
  } | {
16
17
  readonly kind: "unresolvable";
17
18
  };
18
- export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined, attachTargets?: AttachTargets): AttachedFieldResolution;
19
+ export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined, attachTargets?: AttachTargets, subField?: string): AttachedFieldResolution;
19
20
  type UploadFieldResolution = AttachedFieldResolution | {
20
21
  readonly kind: "rejected";
21
22
  readonly error: string;
22
23
  };
23
24
  export declare function resolveUploadField(registry: Registry | undefined, input: UploadTarget, attachTargets: AttachTargets | undefined): UploadFieldResolution;
25
+ export declare function registryHasAnonymousUploadField(registry: Registry | undefined): boolean;
26
+ export declare function isAnonymousPendingUploadAllowed(registry: Registry | undefined, target: UploadTarget): boolean;
24
27
  export {};
@@ -1,30 +1,48 @@
1
1
  import { isFileField } from "../engine/field-helpers.js";
2
2
  import { isActionFormScreenQn, resolveActionFormFileField } from "./action-form-upload.js";
3
+ import { resolveFileSubField } from "./file-field-slots.js";
4
+ function findAttachEntity(registry, entityType, attachTargets) {
5
+ return (registry?.getEntity(entityType) ??
6
+ (attachTargets === "entities-and-read-models"
7
+ ? registry?.getReadModelEntity(entityType)
8
+ : undefined));
9
+ }
3
10
  // entityType/fieldName drive the GDPR-forget decision downstream
4
11
  // (fileRefDeleteHook resolves the field's PII annotation from exactly this
5
12
  // pairing) — attaching to a field nobody can resolve would leave that
6
- // decision hanging on an unverified client string. Either both are omitted
13
+ // decision hanging on an unverified client string. `subField` names a file sub-field of an
14
+ // embedded `fieldName`; the binding stays on the top-level field. Either both are omitted
7
15
  // (unattached upload, allowed) or both must resolve to a real registered
8
16
  // field. "entities-and-read-models" is for server-side callers only: a client upload must
9
17
  // never name a projection entity, whose rows nobody can write-check.
10
- export function resolveAttachedField(registry, entityType, fieldName, attachTargets = "entities") {
18
+ export function resolveAttachedField(registry, entityType, fieldName, attachTargets = "entities", subField) {
11
19
  if (entityType === undefined && fieldName === undefined)
12
20
  return { kind: "unattached" };
13
- const entity = entityType !== undefined
14
- ? (registry?.getEntity(entityType) ??
15
- (attachTargets === "entities-and-read-models"
16
- ? registry?.getReadModelEntity(entityType)
17
- : undefined))
18
- : undefined;
19
- const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
20
- if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
21
+ if (!entityType || !fieldName)
22
+ return { kind: "unresolvable" };
23
+ const fieldDef = findAttachEntity(registry, entityType, attachTargets)?.fields[fieldName];
24
+ if (!fieldDef)
25
+ return { kind: "unresolvable" };
26
+ if (subField !== undefined) {
27
+ const subFieldDef = resolveFileSubField(fieldDef, subField);
28
+ return subFieldDef === undefined
29
+ ? { kind: "unresolvable" }
30
+ : { kind: "resolved", fieldDef: subFieldDef };
31
+ }
32
+ if (!isFileField(fieldDef))
21
33
  return { kind: "unresolvable" };
22
34
  return { kind: "resolved", fieldDef };
23
35
  }
24
36
  export function resolveUploadField(registry, input, attachTargets) {
25
- const { entityType, entityId, fieldName, actionForm } = input;
37
+ const { entityType, entityId, fieldName, subField, actionForm } = input;
26
38
  if (actionForm === undefined) {
27
- return resolveAttachedField(registry, entityType, fieldName, attachTargets);
39
+ return resolveAttachedField(registry, entityType, fieldName, attachTargets, subField);
40
+ }
41
+ if (subField !== undefined) {
42
+ return {
43
+ kind: "rejected",
44
+ error: "invalid_action_form: actionForm cannot be combined with subField",
45
+ };
28
46
  }
29
47
  if (entityType !== undefined || entityId !== undefined) {
30
48
  return {
@@ -49,3 +67,30 @@ export function resolveUploadField(registry, input, attachTargets) {
49
67
  }
50
68
  return { kind: "resolved", fieldDef };
51
69
  }
70
+ const anonymousUploadRegistries = new WeakMap();
71
+ export function registryHasAnonymousUploadField(registry) {
72
+ if (registry === undefined)
73
+ return false;
74
+ const cached = anonymousUploadRegistries.get(registry);
75
+ if (cached !== undefined)
76
+ return cached;
77
+ let found = false;
78
+ for (const [, entity] of registry.getAllEntities()) {
79
+ for (const fieldDef of Object.values(entity.fields)) {
80
+ if (isFileField(fieldDef) && fieldDef.anonymousUpload === true)
81
+ found = true;
82
+ }
83
+ }
84
+ anonymousUploadRegistries.set(registry, found);
85
+ return found;
86
+ }
87
+ // Anonymous visitors share one user id and cannot bind, so only an opted-in field's
88
+ // pending upload (no record, no action form) is allowed.
89
+ export function isAnonymousPendingUploadAllowed(registry, target) {
90
+ if (target.entityId !== undefined || target.actionForm !== undefined)
91
+ return false;
92
+ const resolution = resolveAttachedField(registry, target.entityType, target.fieldName);
93
+ return (resolution.kind === "resolved" &&
94
+ isFileField(resolution.fieldDef) &&
95
+ resolution.fieldDef.anonymousUpload === true);
96
+ }
@@ -176,12 +176,13 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
176
176
  for (const r of results) {
177
177
  if (!r.isSuccess)
178
178
  continue;
179
- if (!isLifecycleResult(r.data))
179
+ const lifecycleResult = r.lifecycle ?? r.data;
180
+ if (!isLifecycleResult(lifecycleResult))
180
181
  continue;
181
- if (r.data.kind === "save")
182
- saves.push(r.data);
183
- else if (r.data.kind === "delete")
184
- deletes.push(r.data);
182
+ if (lifecycleResult.kind === "save")
183
+ saves.push(lifecycleResult);
184
+ else if (lifecycleResult.kind === "delete")
185
+ deletes.push(lifecycleResult);
185
186
  }
186
187
  // skip: nothing to flush when there are no lifecycle hooks or no successful save/delete
187
188
  if (!lifecycle || (saves.length === 0 && deletes.length === 0))
@@ -508,7 +508,7 @@ async function invokeWriteHandler(handler, type, gated, user, handlerContext) {
508
508
  async function runWriteLifecycle(ctx, args) {
509
509
  try {
510
510
  const runner = resolveDbSource(ctx, args.tx);
511
- await runLifecycle(ctx, args.type, args.result.data, args.handlerContext, args.user, args.origin, args.afterCommitHooks, runner);
511
+ await runLifecycle(ctx, args.type, args.result.lifecycle ?? args.result.data, args.handlerContext, args.user, args.origin, args.afterCommitHooks, runner);
512
512
  }
513
513
  catch (e) {
514
514
  return writeFailure(wrapToKumiko(e));
@@ -584,5 +584,6 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
584
584
  // data / previous / changes). Feature code that fed a plaintext through
585
585
  // to the return payload fails here instead of hitting the client.
586
586
  assertNoSecretLeak(result.data);
587
+ assertNoSecretLeak(result.lifecycle);
587
588
  return result;
588
589
  }
@@ -25,10 +25,11 @@ function maskLifecycleOrRow(entity, data) {
25
25
  export function maskWriteResultForClient(registry, handlerType, result) {
26
26
  if (!result.isSuccess)
27
27
  return result;
28
+ const { lifecycle, ...clientResult } = result;
28
29
  const entityName = registry.getHandlerEntity(handlerType);
29
30
  const entity = entityName ? registry.getEntity(entityName) : undefined;
30
31
  const data = maskLifecycleOrRow(entity, result.data);
31
- return data === result.data ? result : { ...result, data };
32
+ return data === result.data && lifecycle === undefined ? result : { ...clientResult, data };
32
33
  }
33
34
  export function maskBatchResultForClient(registry, commands, batch) {
34
35
  const results = batch.results.map((result, index) => {