Skip to content

Commit d50d46f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20333-create-objectstack-wire-barrels
2 parents e20864f + 26daf0b commit d50d46f

59 files changed

Lines changed: 3854 additions & 344 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: 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.

‎.changeset/20156-app-author-exemption.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
- A caller who may save the app receives the full stored version on these three doors, so a designer that saves back what it loaded keeps every entry. "May save" is exactly what `PUT /meta/app/:name` admits that caller: a system context or `manage_metadata`. `manage_org_presentation` does not save apps, so it does not qualify.
88
- Every other caller who may open the app receives it without the entries `requiredPermissions` or the documentation audience withhold from them, left out as the plain read leaves them out: on each layer, and in the values on both sides of a diff. A diff keeps all of its entries; only their values are pruned.
9-
- Unchanged: the plain read and `/published` still prune for every caller, authors included. An app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on every door, to an author too. `/history`, `/audit`, and the doc, book and dashboard answers do not change.
9+
- Unchanged: the plain read (its `?preview=draft` included) and `/published` still prune for every caller, authors included. The plain read's `?state=draft` is the exception: it serves the pending draft, a stored version, and answers as these three doors do. An app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on every door, to an author too. `/history`, `/audit`, and the doc, book and dashboard answers do not change.
1010

1111
A client that reads these doors as a non-author now receives fewer navigation entries. To read an app's full stored version there, read it as a caller the app's save door admits.
1212

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
**`GET /api/v1/meta/:type/:name?state=draft` now serves the pending draft as a stored version: whoever may save an app reads its draft whole, and nothing is left out because a service is off in this deployment.** Studio's app editors build their edit baseline by merging this draft over the layered view (`…/layers`) and save the result back as a draft. The draft read used to run the rendered read's gates, so it left out the navigation entries an author may not open. For every caller it also left out an entry, an app or a dashboard widget bound to an optional service this deployment does not register. The pruned draft replaced the whole navigation in the merge, and the author's next draft save deleted those entries without any error.
6+
7+
- A caller who may save the app (the one `PUT /api/v1/meta/app/:name` admits: a system context or `manage_metadata`) receives the stored draft whole, including the entries that `requiredPermissions` or the documentation audience withhold from them. This is the answer `/layers`, `?layers=true` and `/diff` already give that caller.
8+
- Every other caller who may open the app receives the draft without the entries `requiredPermissions` or the documentation audience withhold from them, as before.
9+
- No caller has anything left out of a draft by a per-deployment gate any more: an app, a navigation entry or a dashboard widget whose `requiresService` names a service this deployment lacks is part of the stored draft, as on `/layers`.
10+
- Unchanged: an app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a caller without Studio or Setup access) is still refused on the draft read, to an author too. A read with no pending draft still answers `404 NO_DRAFT`. The rendered reads, meaning the plain read without `?state=draft`, its `?preview=draft` preview and `/published`, still prune for every caller, authors included. Docs, books and object schemas answer as before.
11+
12+
A client that reads `?state=draft` as a caller who may save the app now receives every entry of the stored draft. A client that reads it as any caller now also receives the entries and widgets bound to an optional service this deployment lacks.

0 commit comments

Comments
 (0)