Skip to content

Commit 0774564

Browse files
committed
Merge origin/main into claude/issue-21733-standalone-dev-automigrate
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-Authored-By: Claude <noreply@anthropic.com>
2 parents c20d26d + b7a13c7 commit 0774564

76 files changed

Lines changed: 5972 additions & 438 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an `object-form` page block's `customFields` takes a closed runtime form field, and the `sections` of `object-form` and `object-master-detail-form` take a page-block section shape, instead of any value (#21464)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: registered ui-object-form-custom-fields-typed ui-object-form-sections-typed -->
10+
11+
**BREAKING** — two accept-set narrowings on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the rows: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.
12+
13+
**`@objectstack/spec`**
14+
15+
- **`object-form` `customFields` is a list of closed runtime form fields.** It was `z.unknown()`. Each member is the field the form merges over the fields it generates from the object's metadata and draws as written, and the spec now declares it: `name` (its identity), `label`, `description`, `type`, `inputType`, `widget`, `required`, `disabled`, `readonly`, `hidden`, `placeholder`, `options`, `validation`, `dependsOn`, `visibleWhen`, `readonlyWhen`, `requiredWhen`, `colSpan`, `span`, `group`, and the metadata a field widget reads off it — `multiple`, `rows`, `accept`, `dimensions`, `reference`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `returnType`, `summaryOperations`, `columns`. Members this package already declares take that declaration by reference (the object field's own, the evaluated predicates); `label`, `description` and `placeholder` are plain strings. An `options` entry is the runtime option the form's option controls draw, closed: `label`, `value`, `description` (a lookup searches it), `visibleWhen` (the cascade offers the option only while it holds). Its `value` is a string, a number or a boolean, kept as written — an inline field binds no object column, so a stored field's lowercase-identifier rule does not apply, and `{ label: 'Box', value: 'Box' }` parses.
16+
- **The `sections` of `object-form` and `object-master-detail-form` are one page-block section shape.** They were `z.array(z.unknown())`. A section takes the form view's section keys — `name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`, `columns`, `pane`, `group`, `fields` — and the form view's group-reference rule; each `fields` entry is a field name, the form view's `{ field, … }` entry, or an inline runtime form field (the `customFields` member). The stored form view's `FormSectionSchema` is unchanged.
17+
- **Canonical spellings only.** A page block's `properties` is never parsed on the way to the form, so a form view's parse-time folds do not run there: a section `visibleOn` and a string `columns` reached the form raw and were dropped. Both are refused with the canonical spelling, and so is a `{ field }` entry's or an inline field's `visibleOn`.
18+
- **Refused with what to write instead:** an inline field's legacy `condition`, its `defaultValue` (which seeds nothing), `id`, a `fields` member claim, the `grid` widget's eight snake_case keys (`min_rows`, `max_rows`, `allow_add`, `allow_delete`, `allow_reorder`, `total_field`, `add_label`, `sort_field` — they come in once the widget reads a camelCase spelling), a boolean `validation.required`, a `validation.pattern` / `validate` rule, an option's `color`, `default`, `disabled` or `icon` (no form option control reads them), and a locale map where the form draws a plain string.
19+
- **`ObjectFormProps`, `ObjectMasterDetailFormProps`** and their parsed types carry the field and section types on these members instead of `unknown`; the shapes themselves are module-private. A bare CEL `visibleWhen` parses to its `{ dialect, source }` envelope, as on every evaluated slot, so the `object-form` row's input and parsed types now differ and it gains the one new export, the type `ObjectFormPropsParsed` (ADR-0122), as `ObjectMasterDetailFormPropsParsed` already is.
20+
21+
## FROM → TO
22+
23+
| you wrote | write instead |
24+
|:--|:--|
25+
| `customFields: [{ name: 'b', visibleOn: "record.a != ''" }]` | `customFields: [{ name: 'b', visibleWhen: "record.a != ''" }]` |
26+
| `customFields: [{ name: 'b', condition: { field: 'a', equals: 'x' } }]` | `customFields: [{ name: 'b', visibleWhen: "record.a == 'x'" }]` (`notEquals` is `!=`, `in: [ … ]` is `record.a in [ … ]`) |
27+
| `customFields: [{ name: 'memo', defaultValue: 'X' }]` | `customFields: [{ name: 'memo' }], initialValues: { memo: 'X' }` |
28+
| `customFields: [{ name: 'a', validation: { required: true } }]` | `customFields: [{ name: 'a', required: true }]` (a string `validation.required` is the message) |
29+
| `customFields: [{ name: 'a', validation: { pattern: { value, message } } }]` | `customFields: [{ name: 'a', pattern: '^[A-Z]+$' }]` |
30+
| `customFields: [{ name: 'items', type: 'grid', min_rows: 1 }]` | `customFields: [{ name: 'items', type: 'grid', columns: [ … ] }]` — the widget's defaults until it reads a camelCase key |
31+
| `customFields: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold', default: true }] }]` | `customFields: [{ name: 'tier', type: 'select', options: [{ label: 'Gold', value: 'gold' }] }], initialValues: { tier: 'gold' }` |
32+
| `options: [{ label: 'Gold', value: 'gold', color: '#d4af37' }]` on an inline field | `options: [{ label: 'Gold', value: 'gold' }]` — a colour belongs on the object field's own option |
33+
| `sections: [{ fields: ['a'], visibleOn: 'record.b == 1' }]` | `sections: [{ fields: ['a'], visibleWhen: 'record.b == 1' }]` |
34+
| `sections: [{ fields: ['a'], columns: '2' }]` | `sections: [{ fields: ['a'], columns: 2 }]` |
35+
| `sections: [{ fields: [{ field: 'a', visibleOn: '…' }] }]` | `sections: [{ fields: [{ field: 'a', visibleWhen: '…' }] }]` |
36+
| `sections: [{ label: { en: 'Basics' }, fields: ['a'] }]` | `sections: [{ name: 'basics', label: 'Basics', fields: ['a'] }]` — the heading translates through `objects.<object>._sections.basics.label` |
37+
38+
The one-line fix: write each inline field in camelCase with the members the form draws and each section in its canonical spelling. No conversion is registered: nothing on the load path refuses either shape, and the census below found no working value to respell — the D3 entries `ui-object-form-custom-fields-typed` and `ui-object-form-sections-typed` carry that judgment.
39+
40+
## Who is affected, measured
41+
42+
A writer is a value written on the block: a page-component node (an object literal naming `object-form` or `object-master-detail-form`, flat or in its `properties` bag, or a literal annotated as one), a direct parse through the row, the block's React component inside `schema={{…}}`, the argument a local helper passes in that position at every same-file call site, or — the second pass — any object literal carrying `customFields` or `sections` in a file that names a form block. Values resolve through same-file constants and spreads, and through a `.map` over a constant list — the first run of this census read such a list as non-static and so never parsed the object manager's options; that miss is why an inline option's `value` is now a runtime value. Every static value was parsed through this branch's rows, and each value with a non-static part, and each refusal, was read by hand.
43+
44+
- **objectstack** at `316be321ef`, this branch's merge base: three `object-form` `sections` writers (the showcase's new-project wizard, and one test each in `lint` and `spec`), field names only — all parse. No `customFields` writer. The other `sections` the second pass finds are form views and `record:details` sections, which these rows do not judge.
45+
- **objectui** at the `.objectui-sha` pin `2e818d0b51ec` and at `main` `fd060f076` (every cited reader file is byte-identical between the two; `main` adds three test values, which parse): **`customFields`** — 31 values parse (four block literals; the designer's object manager, whose `icon` and `group` options `{ label: 'Box', value: 'Box' }`, `{ label: 'Custom Objects', value: 'Custom Objects' }`, … parse as runtime option values — typed as the form view's option, a stored field's lowercase identifier, both were refused; and 26 helper and embeddable-form arguments) and two are refused, both probes: a type-level test's `visibleOn` (never drawn) and the fixture pinning that an inline `defaultValue` seeds nothing; the 11 fully non-static values are run-time hand-offs and helper parameters, read by hand. **`sections`** — 102 `object-form` values with a static part parse (the field designer's inline fields and the plugin-form README's inline-field wizard among them), and so do four `object-master-detail-form` values. Two are refused, both probes of shapes objectui's own renderer test says this door refuses at parse: a section declaring neither `fields` nor `group`, and a group-owned `label` / `collapsible` beside `group`. The 26 fully non-static values are run-time hand-offs and helper parameters, read by hand: they use declared keys only, but for objectui's probe that a retired `className` / `gridClassName` reaches nothing. The second pass's other refused section values are `record:details`, detail-view or object-view form-slot sections, which these rows do not judge.
46+
- **hotcrm** at `4054ec2680` and **cloud** at `2205b53010`: no writer of either member.
47+
- **Deployed metadata** was not measured.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
---
4+
5+
The object-schema field mask (ADR-0106 D1) now removes a denied field's references from the rest of the served object document, not only its `fields` entry.
6+
7+
Clause-②: no
8+
9+
A caller who cannot read a field was served a document without that field's definition, but other parts of the document could still name the field. Those parts are now projected too, on every exit that serves an object schema to a restricted caller: the by-name read, the list read and the layered view. What happens to each kind of position:
10+
11+
- **Object-level rule entries** (`validations`, `indexes`, `activityMilestones`). An entry that names or reads a denied field is dropped whole. The platform still evaluates the stored rule on every write.
12+
- **Role pointers** (`nameField`, `displayNameField`, `imageField`, `stageField`, `tenancy.tenantField`, `lifecycle.ttl.field`). A pointer to a denied field is deleted, so the role falls back to its default.
13+
- **Name lists** (`highlightFields`, `searchableFields`, `publicSharing.redactFields`, list-view column lists, `external.columnMap` entries, and a readable field's `relatedListColumns` / `dependsOn`). The denied entries are filtered out. A list-view column in object form is dropped when any of its facets names a denied field: its own `field`, its `prefix.field` or its `summary.field`. A `dependsOn` entry's `param` is the lookup target's key and is not read as a field here. A list left empty is deleted.
14+
- **The inline master-detail grid** (`inlineColumns`, `inlineAmountField`). These are declared on the child object's own `master_detail` field and name the child's own fields. A column that is a denied field, or is computed from one (`expr`), is dropped. A denied `inlineAmountField` is deleted.
15+
- **Expressions** (`titleFormat`, field-group `visibleWhen`, row-CRUD `visibleWhen` / `disabledWhen`, `publicSharing.eligibility`, lifecycle `onlyWhen`, and a readable field's formula `expression`, `visibleWhen` / `readonlyWhen` / `requiredWhen`, `relatedListFilter`, `defaultValue`, `autonumberFormat` and per-option `visibleWhen`). An expression that reads a denied field is deleted. The readable field itself stays.
16+
- **List views and actions.** An entry whose filter, sort, predicate, params or `patch` read a denied field is dropped. So is a list view whose key is a denied field's name.
17+
18+
In these positions an object key counts as a field reference only where keys are field names: a `FilterCondition`, a lifecycle `onlyWhen` map, an action's `patch`. A denied field named like a schema word (`type`, `source`, `name`) no longer removes every rule, view, action or CEL envelope. A dotted path whose root segment is a denied field counts as a reference to it, whether it is a field-keyed key, a name-list entry or a pointer. The mask also terminates on a cyclic document.
19+
20+
Names of another object's fields (`lookupColumns`, `displayField`, `summaryOperations`, …) are left alone, because that object's own projection governs them. A key that is not classified is deleted when it mentions a denied field in any string or key, so a new key over-masks until it is classified. A test holds the classification equal to the live `ObjectSchema`, `FieldSchema`, `InlineGridColumnSchema`, `ListColumnSchema`, `ColumnPrefixSchema` and `ColumnSummaryConfigSchema` key sets. A caller who is denied nothing still gets the same document reference, so nothing changes for unrestricted callers.
21+
22+
The shared contract fixture in `@objectstack/metadata-core/testing` (`FLS_CONTRACT_OBJECT`) now names its fields in each of these positions, including list views (one with object-form columns), actions and the inline grid, and its formula field uses the real `expression` key. The contract's residue check matches a denied name as an identifier token anywhere in the served document, so a name inside an expression fails it. Projection cases also check what must survive (`retained`), so a mask that deletes too much fails as well.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
`POST /api/v1/security/explain` with `{ object, operation: 'read', recordId }` now credits read depth for a row that only the caller's read depth admits.
6+
7+
Clause-②: no
8+
9+
- **Before.** The object's OWD is private. The caller's read depth is wider than `own` (`own_and_reports`, `unit`, `unit_and_below` or `org`). The caller reads a row they do not own and hold no share on. Explain answered `decidedBy: 'sharing'` for that row, with the sharing layer `admitted` and the detail "0 share(s) attached; access is granted for this record." No share granted anything; the read depth did. `depth` is a member of the published `decidedBy` enum, and no answer ever produced it.
10+
- **Now.** That row answers `decidedBy: 'depth'`. The `depth` layer carries a `record` block: `admitted`, naming the depth and, below `org`, the owner it reached. The sharing layer is `not_evaluated`, and its detail says that no share grants the row and that read depth already admits it.
11+
- **The sharing detail names a grant only when one exists**, meaning a share that names the caller. A filter that admits a row with no such share now says which part of the sharing service let the row through.
12+
- **A row a share admits keeps `decidedBy: 'sharing'`** and its "N share(s) attached; access is granted for this record." detail, also when the read depth admits it too. Of the two layers that admit it, sharing comes last in the pipeline, and the `decidedBy` contract names the last layer to admit.
13+
- **No access decision changes.** `visible` is the same for every record; only the layer the report credits moves. No key is added. `depth` and `not_evaluated` are existing values.
14+
- **The `depth` layer's `record` block is new on every record-grained request.** On a write it is `not_evaluated`, because the write depth is judged inside the sharing service's per-record write gate together with ownership and shares, and this report does not separate them.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
'@objectstack/plugin-security': minor
4+
---
5+
6+
A user who can edit a record may delete another user's attachment on it, as the attachment gate declares (#21729).
7+
8+
Clause-②: yes (widening)
9+
10+
- **What was refused.** The attachment gate's delete rule is "the uploader OR a user who can edit the parent record". For every member holding `org_member`, the platform's row-level delete floor in `member_default` (`owner_only_deletes`: only the rows you created) answered first, so a parent editor's delete of someone else's attachment was refused with `PERMISSION_DENIED` before the gate ran.
11+
- **`@objectstack/service-storage`** contributes a delete-only alternate match for `sys_attachment` (`sys_attachment_parent_editor_delete`, every row) when it installs the attachment gate, and only then. The gate decides: a parent editor's delete answers 200, and a caller who can read the attachment but neither uploaded it nor can edit the parent is refused with `ATTACHMENT_DELETE_DENIED`. A caller who cannot read the parent cannot see the attachment, and is still refused with `PERMISSION_DENIED` before the gate runs, so the parent is not named to them.
12+
- **Without `@objectstack/service-storage`** nothing is contributed. A deployment that registers `sys_attachment` without the storage service keeps the floor, and only a row's creator may delete it.
13+
- **The edit limb is unchanged.** Editing another user's attachment row is still refused by the floor for a member it binds, a parent editor included.
14+
- **`@objectstack/plugin-security`** gains the seam: `contributeOwnershipFloorAlternates(plugin, alternates)` on the registered `security` service, an extension of `ISecurityService` that callers feature-detect. Each alternate names one object (never `'*'`), one floor limb (`update` or `delete`; `all` is refused) and a `using` predicate. It lands beside each enabled floor policy of that limb, in that policy's own `positions` domain, so it reaches only the principals the floor binds. A plugin's second call replaces its first, and an empty list withdraws it. A contribution that breaks these rules throws.
15+
16+
Nothing that was admitted before is refused now. No principal outside the floor's domain, and no other object or operation, changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
A file field's declared `accept` / `maxSize` refusal now answers `400 ERR_FILE_CONSTRAINT` with a sentence naming the field and the constraint, instead of `500 INTERNAL_ERROR` with the sentence withheld.
6+
7+
Clause-②: no
8+
9+
- **`FileConstraintError` declares `status = 400`**, as `FileFieldBulkWriteError` in the same module already did. The data API's declared-status passthrough now answers the refusal on create and on update, e.g. `400 {"error":"File exceeds the maximum size declared for 'doc' (5005 bytes > 10 bytes)","code":"ERR_FILE_CONSTRAINT","object":"…"}`. Before, the error declared a registered `code` but no status, so it fell through to the sanitised `500 INTERNAL_ERROR`, and the field and the reason reached only the server log.
10+
- **It carries `field` and `constraint` (`'accept' | 'maxSize'`) as members**, for in-process callers. The constructor is now `new FileConstraintError(field, constraint, message)`. The new `FileConstraint` type is exported beside it. On the HTTP wire the message names both, and its wording is unchanged.
11+
- The accept set is unchanged: the same files are refused, and a refused write still persists no row and claims no file. `ERR_FILE_CONSTRAINT` was already in the error-code ledger.

0 commit comments

Comments
 (0)