Skip to content

Commit 5a9a01d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20327-client-suites-cold-start
2 parents 937e6e1 + d498113 commit 5a9a01d

63 files changed

Lines changed: 4354 additions & 351 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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: a `sys_user_position` write whose `position` names no `sys_position` row in the writer's catalog is refused (#16712, #20297), instead of answering 201 over an assignment that grants nothing
6+
7+
Clause-②: yes
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is renamed, retired or re-typed: `sys_user_position.position` stays a `Field.text`, every object key and every stored row parse exactly as before, and no stored assignment is rewritten or refused on read, so `objectstack migrate meta` has nothing to act on. What narrows is the ACCEPT SET of one runtime write path: a non-system insert, or an update that changes `position`, whose value names no `sys_position` row in the writer's organization or among the organization-less rows is refused `400 VALIDATION_FAILED` with the envelope the row's sibling lookup columns already answer with. The refusal text names the value and its own fix, and the measured in-repo and consumer writers (hotcrm, hotclm) all write catalog names, so there is no document for a migration to act on. -->
10+
11+
**BREAKING** accept-set narrowing on the `sys_user_position` write path —
12+
shipped as `minor` under the launch-window convention (`check-changeset-no-major`
13+
refuses `major`; breaking-ness is carried by this banner and the ADR-0087
14+
disposition, not by the level). Maintainer-confirmed ruling on #16712 (option A),
15+
with the catalog it reads settled on #20297: the writer's organization, under
16+
the platform's standing tenancy rule.
17+
18+
**What changed.** `sys_user_position.position` is the position's machine NAME
19+
(`sys_position.name`), but it is declared `Field.text`, so a value naming no
20+
catalog row — most often the position's record ID, written where its name
21+
belongs — was stored with a `201` and then resolved to nothing: the holder got
22+
no permission set, no sharing rule reached them, and they signed in to an app
23+
that reads nothing, with no error anywhere. Such a write is now refused:
24+
25+
- `400 VALIDATION_FAILED`, one `fields[]` entry per offending value at
26+
`field: 'position'`, `code: 'reference_not_found'`,
27+
`constraint: { target: 'sys_position', targetField: 'name' }` — the same
28+
envelope a bad `user_id` or `organization_id` on the same row already gets.
29+
- The message names the value and says the column takes the catalog NAME. When
30+
the value is the record id of a position the writer's own organization can
31+
see, it names that position and says to write its name.
32+
33+
**Which writes.** Every non-system insert (one row or a batch, refused whole),
34+
every non-system update by id that CHANGES `position`, and every predicate
35+
update (`multi: true`) that sets it. The check runs after authorization: a
36+
caller who may not write the table is still refused `403` on authority and never
37+
sees the catalog verdict.
38+
39+
**Whose catalog.** The writer's: the positions of the writer's own
40+
organization plus the organization-less ones — the same reach the engine gives
41+
its own lookup-reference check. A name that only ANOTHER organization's
42+
catalog carries is refused exactly like any unknown name, with the same
43+
envelope and the same message, so the answer says nothing about other
44+
organizations. On a single-organization deployment the declared positions
45+
carry no organization and any other position can only carry the one
46+
organization there is, so every writer sees the whole catalog. A writer whose
47+
context names no organization sees every organization's positions.
48+
49+
**What did not change.**
50+
51+
- A **deactivated** position is still a catalog row: an assignment naming it is
52+
accepted and, as before, grants nothing (ADR-0049).
53+
- **Stored rows** are untouched. An update that edits another column, or echoes
54+
the unchanged `position` back, is not judged, so an existing row whose name
55+
is no longer in the catalog stays editable.
56+
- **System-context writes** are not judged — the seed loader (which on a fresh
57+
single-organization boot writes `stack.data` before the declared position
58+
catalog exists), invitation acceptance and the platform's own bootstraps. That
59+
is the same stand-down the engine's lookup check takes.
60+
61+
**Who is affected.** A client, script or AI author that writes a position's id,
62+
a misspelled name, a name not yet created, or — on a deployment that walls
63+
organizations off — a name only another organization has. The fix is the one
64+
the refusal names: write the name of a position in the writer's own catalog, or
65+
create the position there first.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
A row-level-security predicate that compares a field with a `json` or `multiple` field is refused when it is authored, at `os validate` / `os build` / `os lint` and at the metadata save door, instead of only when it runs (#19886).
6+
7+
**BREAKING** — an accept-set narrowing, shipped by `@objectstack/lint` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is already registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`, which names this class.
8+
9+
Clause-②: no (narrowing)
10+
11+
`record.status != record.tags`, with `tags` a `json` field or a `multiple` lookup, lowers to a legal filter shape, because the CEL lowering sees the predicate's text and not the object's field types, and the engine's filter admission does not judge a `{ $field }` reference against the referenced column's type either. Measured before this change: 400 cells (`==`, `!=`, `!(==)`, `>`, `<=`; a `json`, `address`, `multiselect`, `multiple` lookup and `multiple` user field; both operand orders; `using` on `select` / `all` / `update` / `delete` / `insert` and `check` on `insert` / `update` / `all`) were all accepted by the real `os validate` and by the save door. The runtime refused every one of them, measured through the real plugin-security on driver-sql: a read the `using` scopes answered `INVALID_FILTER` / 400, a by-id update or delete it scopes `PERMISSION_DENIED` / 403, and every insert or by-id update judged by the `check` (or by a `using` standing in as the check) `INVALID_FILTER` / 400, with nothing stored.
12+
13+
What changes:
14+
15+
- `@objectstack/lint`: `validateRlsPredicateEnforceability` reports `rls-predicate-unenforceable` for every lowered field-to-field comparison (`==`, `!=`, `>`, `>=`, `<`, `<=`, on either side, under `!` too) in which either column is DECLARED to hold a list or an object. The declaration is read from the stack's own objects through the spec's value-shape classes, the same two driver-sql refuses such a comparison by: a structured JSON type (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`), or a multi-valued field (`multiselect`, `checkboxes`, `tags`, or `select` / `radio` / `lookup` / `user` / `file` / `image` with `multiple: true`). It judges `using` and `check` on every operation. The finding names each comparison and the declaration behind it, and states the clause's run-time consequence. A clause it refuses is not also handed to the engine's filter judge, so one defect earns one finding.
16+
- Both doors run this rule already, so both refuse: `os validate` / `os build` / `os lint` fail, and a publish through the metadata save door answers `422 INVALID_METADATA` with the same sentence in `issues[]`. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning.
17+
18+
Not changed: a field compared with a single-valued field (`record.status != record.owner`, `record.amount > record.budget`), a `json` or `multiple` field compared with a literal or tested against `null`, and any column the stack does not declare (an object from another package, an external object with no field map), which the rule does not judge. The runtime refusals of stages 2d and 2e stay as the backstop. The stage 2d changeset's sentence that a field compared with a `json` or `multiple` field "is not reported at authoring time" no longer holds: it is now reported at both doors.
19+
20+
No shipped predicate moves: 0 of the 187 `using` / `check` / `condition` strings in this repository's packages and examples, and 0 of the 3 in the cloud repository, compare a field with a `json` or `multiple` field, and the real `os validate` over `app-crm`, `app-multi-package`, `app-showcase` and `app-todo` reports no `rls-predicate-*` finding.
21+
22+
**What to change.** A field compared with a `json` or `multiple` field has no row-filter form: compare with a single-valued column, or with a literal or a `current_user` value ("one of these values" is `record.status in ['open', 'pending']`, or `record.owner in current_user.org_user_ids`), or move the condition into a validation rule or a hook.
23+
24+
<!-- adr-0087: not-required (already-registered cel-predicate-one-value-comparand-refused) The entry already names this exact class in its surface ("a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does") and carries its prescription in its replacement ("A field compared with a json or multiple field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook"). This change moves where that registered class is refused, from run time to authoring time; it adds no class and no prescription the entry does not already hold, and it rewrites no stored metadata. -->
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest): the remaining numeric query reads refuse a value they cannot read with `400 VALIDATION_FAILED`, instead of dropping it or handing on `NaN` and answering `200` (#20139)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING**: shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA). The banner and the ADR-0087
11+
disposition below carry the breaking-ness, not the level.
12+
13+
Five published doors still read a numeric query parameter with a bare `Number()`.
14+
That coercion does not fail. It invents `NaN` or `0`, and the door dropped it or
15+
served it with a `200`:
16+
- `GET /meta/:type/:name/history?sinceSeq=abc` read the change log from the start;
17+
- `GET /meta/:type/:name/audit?limit=abc` served the producer's default 100 events;
18+
- `GET /meta/:type/:name/diff?from=abc` diffed a different pair of versions;
19+
- `GET /search?perObject=abc` removed the per-object cap;
20+
- `GET /approvals/requests?limit=abc` served the unpaged 500-row window instead of a page.
21+
22+
Each door now reads the parameter the way `?limit=` on import jobs, export, history and
23+
search already does:
24+
- **`GET /meta/:type/:name/history`** reads `sinceSeq` through
25+
`HistoryMetaItemRequestSchema.sinceSeq` (`z.number()`, so any finite number, as before).
26+
- **`GET /meta/:type/:name/audit`** reads `limit` through
27+
`AuditMetaItemRequestSchema.limit` (`z.number()`; the implementation's `[1, 500]`
28+
clamp is unchanged).
29+
- **`GET /meta/:type/:name/diff`** (`from` / `to`, and their `fromVersion` /
30+
`toVersion` spellings), **`GET /search`** (`perObject`) and
31+
**`GET /approvals/requests`** (`limit` / `offset`) declare no request schema. There the
32+
value must be a whole number. Each service's own range handling is unchanged.
33+
34+
A value the door cannot read answers `400` with the data surface's existing envelope,
35+
`{ error, code: 'VALIDATION_FAILED', fields }`. `fields[0].field` names the parameter as
36+
the caller spelled it, and `fields[0].code` is `invalid_type`. The service is never
37+
called. On `GET /approvals/requests` this is a `400`, not the route's
38+
`500 APPROVAL_REQUEST_LIST_FAILED`.
39+
40+
What changes, per door (every row answered `200` before):
41+
42+
| door | request | answered before | answers now |
43+
|:--|:--|:--|:--|
44+
| `GET /meta/:type/:name/history` | `?sinceSeq=abc`, `?sinceSeq=Infinity` | the change log from the start | `400`, `invalid_type` |
45+
| `GET /meta/:type/:name/history` | `?sinceSeq=` (empty) | `sinceSeq: 0` applied as a cursor | `400`, `invalid_type` |
46+
| `GET /meta/:type/:name/audit` | `?limit=abc`, `?limit=Infinity` | the default 100 events | `400`, `invalid_type` |
47+
| `GET /meta/:type/:name/audit` | `?limit=` (empty) | one event | `400`, `invalid_type` |
48+
| `GET /meta/:type/:name/diff` | `?from=abc`, `?from=Infinity` | the version before `to`, diffed instead | `400`, `invalid_type` |
49+
| `GET /meta/:type/:name/diff` | `?to=abc` | the current body, diffed instead | `400`, `invalid_type` |
50+
| `GET /meta/:type/:name/diff` | `?from=1.5`, `?to=2.5` | a diff against a version that cannot exist | `400`, `invalid_type` |
51+
| `GET /search` | `?perObject=abc` | no per-object cap | `400`, `invalid_type` |
52+
| `GET /search` | `?perObject=1.5`, `?perObject=Infinity` | a cap of 1.5 / clamped to 25 | `400`, `invalid_type` |
53+
| `GET /approvals/requests` | `?limit=abc`, `?limit=Infinity` | the unpaged 500-row list, no `total` | `400`, `invalid_type` |
54+
| `GET /approvals/requests` | `?limit=` (empty) | a one-row page | `400`, `invalid_type` |
55+
| `GET /approvals/requests` | `?offset=abc` | the first page | `400`, `invalid_type` |
56+
| `GET /approvals/requests` | `?offset=` (empty) | the service's 50-row paged mode | `400`, `invalid_type` |
57+
| `GET /approvals/requests` | `?limit=1.5`, `?offset=1.5` | 1.5 handed to the engine | `400`, `invalid_type` |
58+
59+
A blank value such as `?sinceSeq=%20` is refused on every one of these doors.
60+
61+
**Unchanged:**
62+
- An absent parameter keeps each door's default: the history log from the start, the
63+
audit trail's 100 events, previous-vs-current on `/diff`, search's 5 per object, and the
64+
unpaged approvals list.
65+
- An empty `?perObject=`, `?from=` or `?to=` still means absent, as it always did there.
66+
- Every conforming value reaches the service exactly as before, including the ranges no
67+
card here takes a position on: history still forwards `sinceSeq=0` or `1.5`, audit still
68+
forwards `limit=0` or `900` to its own clamp, `/diff` still forwards `from=0`, search
69+
`perObject=50` is still clamped to 25, and approvals `limit=0` / `offset=-1` still reach
70+
the service's own clamp.
71+
- `GET /data/:object/export?page=` is unchanged. It sets only the export's chunk size, and
72+
no value of it changes the rows exported.
73+
- `POST /meta/:type/:name/rollback` `toVersion` is unchanged. It already refused an
74+
unreadable value with `400 INVALID_REQUEST`.
75+
76+
**Fix for a caller that now gets the `400`:** send the parameter as a number (a whole
77+
number on `/diff`, search and approvals), or omit it to get the door's default.
78+
79+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: no spec key, metadata property or exported symbol is added, removed or renamed, and `packages/spec` is untouched, so `objectstack migrate meta` has nothing to rewrite and no ADR-0087 registry gains a row. What narrows is the set of HTTP query values five REST doors accept, back to what their declarations (`HistoryMetaItemRequestSchema`, `AuditMetaItemRequestSchema`) or a whole-number reading already said. A query string is neither authored nor persisted. The other categories are closed on facts: `@objectstack/rest` publishes (not `unpublished`), no ADR-0087 id covers an HTTP query value (not `registered` / `already-registered`), and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

‎.changeset/20156-alternate-door-read-gates.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ What each door answers now:
1313

1414
- `/published` answers exactly what the plain read answers the same caller, for every type: the same refusal, or the same pruned or masked document. That includes the dashboard widget gate: a widget bound to an optional service this deployment does not register is left out of `/published`, as it is from the plain read.
1515
- `/layers`, `?layers=true` and `/diff` refuse a `doc`, `book` or `app` the plain read refuses whole, with the same status and code. For an app, that means one whose `requiredPermissions` the caller lacks (`403`) or an unpublished app to a non-builder (`404`). They mask object fields as the plain read does. `/diff` of a `doc`, `book` or `app` with nothing behind the name answers `404 RESOURCE_NOT_FOUND`, as the plain read does. They do not apply the dashboard widget gate or any other per-deployment gate: they show the stored version, which is what an author edits.
16-
- For an app the caller may open, `/layers`, `?layers=true` and `/diff` answer by who is asking. A caller who may save the app (the one `PUT /meta/app/:name` admits) receives the full stored version, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them: Studio's designer saves back what it loads, so a pruned load would delete those entries. Every other caller receives the app without the entries withheld from them, left out as the plain read leaves them out, on every layer and on both sides of a diff. The plain read and `/published` prune for every caller, authors included. An app the plain read refuses whole is refused on these doors to an author too.
16+
- For an app the caller may open, `/layers`, `?layers=true` and `/diff` answer by who is asking. A caller who may save the app (the one `PUT /meta/app/:name` admits) receives the full stored version, including the navigation entries that `requiredPermissions` or the documentation audience withhold from them: Studio's designer saves back what it loads, so a pruned load would delete those entries. Every other caller receives the app without the entries withheld from them, left out as the plain read leaves them out, on every layer and on both sides of a diff. The plain read and `/published` prune for every caller, authors included, except the plain read's `?state=draft`: it serves the pending draft, a stored version, and answers as these three doors do. An app the plain read refuses whole is refused on these doors to an author too.
1717
- `/history` and `/audit` answer the plain read's refusal when the plain read refuses the doc, book or app whole. Otherwise they serve the events, which carry no document body.
1818

1919
Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts, including an integration reading `?layers=true` anonymously, now receives the plain read's answer, except that a caller who may save an app reads it whole on `/layers`, `?layers=true` and `/diff`. To read a gated doc or book through any of these doors, hold the permission set its book names.

0 commit comments

Comments
 (0)