Skip to content

Commit b0bf2f6

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-4m2bvo
# Conflicts: # packages/services/service-analytics/src/strategies/native-sql-strategy.ts
2 parents f4f3e41 + 38050ce commit b0bf2f6

86 files changed

Lines changed: 6936 additions & 978 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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/core': patch
6+
---
7+
8+
fix(security): a hook that runs as system and an action body are refused writes to position holdings, permission-set grants, invitations and memberships, as a system flow already is
9+
10+
Clause-②: no (narrowing: app-authored hook and action-script writes to the four identity objects are refused at run time, as #25251 did for flows; the seat runs the contract review before the queue)
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What changes is the run-time answer to a hook or action body's elevated write to four platform objects; the save-time refusal and its semantic entry are the spec half of the same card. -->
14+
15+
**BREAKING** (a served-set narrowing) for app-authored automation that writes `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` under system elevation. A census of the hooks and action bodies in this repository and in HotCRM found none that does.
16+
17+
A `runAs: 'system'` flow has been refused these writes since 18.1. Two other kinds of app-authored automation run with the same elevation and were not: a hook that declares `runAs: 'system'`, and an action body (`type: 'script'`), which always runs elevated. Their writes skipped the delegated-administration gate (so a grant carried no `granted_by`) and the invitation door's placement and role-cap checks. Both are now refused the same way:
18+
19+
- An insert, update, delete, transfer, restore or purge of one of the four objects, from a `runAs: 'system'` hook (a sandboxed body or an in-process handler) or from an action body (through `ctx.api` or `ctx.engine`), answers `403 PERMISSION_DENIED`. Nothing is written. A hook's refusal aborts the triggering write under the default `onError: 'abort'`.
20+
- The message names the source and the fix. For position holdings and permission-set grants: set the hook's `runAs` to `'user'`, so the delegated-administration gate judges the user the hook acts for and records them as `granted_by`. An action body has no `runAs`, so its grant moves to a `runAs: 'user'` flow, which an action of type `flow` runs as the invoking user. For invitations and memberships: use the organization endpoints.
21+
22+
Unchanged: reads of the four objects, and writes to every other object, from the same hook or body. A hook under `runAs: 'user'`, which the ordinary gates judge as its user. The platform's own writers (better-auth and its reconciliation, invitation placement, the seeders, any `sudo()`), which carry no app-automation mark. A system flow's refusal, word for word.
23+
24+
**What changes for you.** A hook or action body that grants a position or a permission set under elevation now fails. Declare `runAs: 'user'` on the hook, or move an action body's grant into a `runAs: 'user'` flow. Invitations and memberships change through `POST /api/v1/auth/organization/invite-member`, `cancel-invitation`, `update-member-role` and `remove-member`.
25+
26+
`@objectstack/core` exports the mark: `APP_AUTOMATION_ELEVATION`, `markAppAutomationElevation`, `appAutomationElevationOf` and `carryAppAutomationElevation`.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(storage)!: a record write claims or copies only a file its caller may read
6+
7+
The file-reference claim path (ADR-0104 D3: a file field holds a `sys_file` id, at most one record's field owns a file, and writing an id another record owns copies the bytes into a fresh file the writing record owns) now asks, before it copies or claims a referenced file, whether the write's caller may read that file. The question is the download door's own verdict, asked under the write's caller context: the uploader, a reader of the one record whose field owns the file (its object's `fileAccessDelegate` where one is declared), a member of the organization for a file no record owns, a reader of a parent record for an attachments-scope file, anyone for a `public_read` file. The door and the claim path now call one judgement, so one file has one access rule.
8+
9+
A file the caller may not read is answered as the organization-scoped lookup answers a file it cannot see: the value is kept as written, nothing is claimed or copied, and constraint verdicts no longer run ahead of the read check. A copy this write made is claimed by the same write without a second question. System writes and context-less programmatic calls have no caller to ask and reference files as before; so does a kernel with no `auth` service, where the download door binds no gate. An authorization-store outage fails the write with its declared envelope instead of reading as either answer.
10+
11+
Clause-②: no (narrowing) — a record write reaches fewer files: a file its caller may not read is no longer copied or claimed for it. Nothing that was refused becomes accepted.
12+
13+
Metadata-protocol: none (a write path's file bookkeeping; nothing a metadata author writes changes)
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which files the file-reference claim path copies or claims for a record write (those its caller may read). The remedy is a change of who writes the reference (a caller who may read the file), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
16+
17+
**BREAKING** for a write that referenced a file its caller may not read, shipped as `patch` because the metadata protocol does not move.
18+
19+
- **Before.** The claim path copied or claimed a referenced file without asking whether the write's caller may read it.
20+
- **Now.** Both happen only when the caller may read the file under the download door's rule. Otherwise the record keeps the id as written and owns nothing: the file's readers are unchanged, and the written record's readers gain no access to it through that record.
21+
- **Fix.** Reference a file as a caller who may read it (its uploader, or a reader of the record that owns it), or upload the file again. A server-side process that copies files between records on purpose runs as a system write.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/skills': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
docs(skills, spec): state that every published package declares exactly one anchor-safe `isDefault: true` permission set
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
`objectstack-data/rules/security.md` said what `isDefault: true` means (the `everyone` baseline, ADR-0090 D5) but not that a published package must declare one. Its `isDefault` bullet now states the requirement as a check an author can run: exactly one such set, anchor-safe, meaning `describeAnchorForbiddenBits(set, 'everyone')` from `@objectstack/spec/security` returns `null`; otherwise lint rule `security-anchor-high-privilege` and the binding refuse it. It also says what happens without one (invited members get nothing until an admin assigns a set to each) and that no gate counts the sets. The Marketplace Protocol header in `marketplace.zod.ts`, which the generated marketplace reference page renders, gains a matching "What a published package declares" section. No lint rule or schema changes. To stay within the skill file's token ceiling, three source-path asides, a section reference and one sentence are trimmed in the same file.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
perf(metadata-protocol,runtime): served metadata lists parse each unchanged document once, and the dispatcher's `/meta/app` reads objects on the execution face
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Diagnostics verdicts are memoized.** `computeMetadataDiagnostics` remembers a verdict per schema object and per document content (a SHA-256 of an encoding that keeps apart everything the parse keeps apart), so a served list of unchanged documents parses each one once instead of on every read. A replaced type schema starts from nothing, each caller gets its own copy, and a document with no faithful key is parsed every time as before. The served bytes are unchanged. Measured on the showcase app (108 objects, local, main-thread CPU per request): `GET /meta/object` 85.75 ms → 37 ms.
12+
- **The dispatcher's `/meta/app`** (the `${prefix}/*` catch-all) reads each object's `enable` for its nav prune through `getMetaItemsForExecution` where the protocol has it, as `RestServer` does, and `getMetaItems` otherwise.
13+
- **No public surface moves.** The built `dist/index.d.ts` of both packages is byte-identical at `cb0a2fcf` and at this head.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
perf(rest): `GET /meta/app` judges its navigation on the protocol's execution face, so it no longer schema-parses every object on each read
6+
7+
Clause-②: yes (widening): the exported `RestProtocol` type gains one optional member, `getMetaItemsForExecution?`. Measured on the built `dist/index.d.ts` at `cb0a2fcf` (before) and at this head (after): the export list is unchanged (67 names), and outside doc comments the one changed line is that member. Every other member signature is byte-identical. The member is optional, so nothing a protocol must provide changes.
8+
9+
Metadata-protocol: none
10+
11+
- **What.** The nav-servability gate (#24031) reads each object's `enable` to prune the nav entries whose object refuses `list`. It read them through the served `getMetaItems`, which stamps `_diagnostics` on every object, so every `/meta/app` read ran a full schema parse of every object definition. `RestServer.metaListSource` now asks `getMetaItemsForExecution` for `object` when the protocol has it (the same list, without the serving decorations) and `getMetaItems` otherwise. The batch route's exposure gate and the by-name gate's list fallback read the same source. The `book` and `doc` lists stay on the served face.
12+
- **Measured** on the showcase app (108 objects, local, main-thread CPU per request): `GET /meta/app` 73.25 ms → 16.75 ms.
13+
- **Unchanged.** Every `/meta` answer: the same nav prune, and `GET /meta/object` still serves `_diagnostics` on every object.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): `GET /i18n/translations/:locale` carries an `ETag` and `Cache-Control: private, no-cache`, and a matching `If-None-Match` is answered `304`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** The translation bundle (both spellings, `/i18n/translations/:locale` and `/i18n/translations?locale=`) answered every read with the whole locale bundle and neither header, so a browser re-downloaded it on every console open: 80–88 KB compressed on a hosted tenant.
11+
- **Now.** The `200` carries a weak `ETag`, a SHA-256 of the exact envelope served (`locale`, `requestedLocale` and `translations`), computed on every read, and `Cache-Control: private, no-cache`. A request whose `If-None-Match` names that representation (a strong or weak tag, a comma list, or `*`) is answered `304` with no body and the same two headers. A bundle that changed since the validator was issued is served in full under a new validator.
12+
- **Unchanged.** The `200` body is byte-for-byte the envelope it was. An anonymous caller is still refused `401` before any validator is read, and refusals carry neither header. `/i18n/locales` and `/i18n/labels/*` are untouched.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(objectql,metadata-protocol): an aggregate query that would answer a wrong table is refused 400, and an empty aggregate list no longer answers "not registered"
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Date bucket on a non-date field.** `engine.aggregate` refuses a `groupBy` entry whose `dateGranularity` buckets a field the object declares neither a `date` nor a `datetime`: `INVALID_FIELD` / 400, `path: 'groupBy.N.dateGranularity'`. Before, every row fell into one `null` bucket (a `number` field into a `1970-01` bucket) under 200. A `formula` or undeclared field is not judged.
12+
- **Two columns under one name.** `engine.aggregate` refuses a query whose aggregated row would carry two values under one column: an aggregation `alias` used twice, an alias equal to a group column (the field name, or the entry's own `alias`), or two group entries projecting one column. `INVALID_QUERY` / 400, `path` at the second position (`aggregations.1.alias`, `groupBy.1.field`). Before, the last value written won under 200: a count overwritten by a sum, or the group keys replaced by counts. Give the entry its own `alias`.
13+
- Both refusals run at the engine's `aggregate` entry, before any driver is asked, so the REST query door, its dispatcher twin, the analytics ObjectQL strategy and the MCP data bridge answer alike.
14+
- **Empty aggregate list.** `POST /data/:object/query` with `aggregations: []` (or `groupBy: []`, or both) and no non-empty axis beside it answers `400 INVALID_QUERY` naming `query.aggregations` (or `query.groupBy`) as empty. Before, it answered `404 OBJECT_NOT_FOUND`, "Object '…' is not registered", for a registered object. An empty list beside a non-empty axis is served as before.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(runtime): the automation run doors answer another flow's run as an unknown one
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** Every `/automation/:name/runs/:runId/*` door read the run by `runId` alone, so the `:name` segment was accepted and ignored. `POST /automation/OTHER_FLOW/runs/RUNID/resume` completed another flow's paused run, `GET /automation/OTHER_FLOW/runs/RUNID` and `…/screen` served it, and `…/cancel` and `…/restore-suspension` acted on it.
12+
- **Now.** A flow name that does not own the run, whether it exists or not, is answered exactly as the door answers a run nobody holds:
13+
- `POST …/resume`: `404 RUN_NOT_FOUND`, `No suspended run 'RUNID'`, the engine's own unknown-run answer.
14+
- `GET …/runs/:runId`: `404 RUN_NOT_FOUND`, `Execution not found`. An unknown run now carries the same code (it was the status-derived `RESOURCE_NOT_FOUND`), so the two doors onto one run agree it is not there.
15+
- `GET …/screen`: `404 No pending screen for run`, unchanged.
16+
- `POST …/cancel`: `200 { cancelled: false }` with the not-found notice.
17+
- `POST …/restore-suspension`: `404`, `refusal: 'RUN_NOT_FOUND'`, `No run 'RUNID' is known`.
18+
- **Nothing is consumed.** The check runs after the doors' existing gates and before the verb, so a mismatched resume, cancel or restore leaves the run as it was. Who may act is unchanged.
19+
- **Owner.** The run's `flowName` from `getRun`, else the suspended-run list. A service that can name neither keeps the previous answer.
20+
- **Ledger.** `RUN_NOT_FOUND` is now registered under `@objectstack/runtime` too, since the door stamps it before the engine is asked.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: admin create-user refuses a password outside the deployment's length range, as set-user-password does
6+
7+
`POST /api/v1/auth/admin/create-user` saved an explicit password of any length up to the maximum: better-auth's own `createUser` checks only `maxPasswordLength`, never `minPasswordLength`. An administrator could create a user with a 1-character password and `mustChangePassword: false`, and the user signed in with it, while `POST /api/v1/auth/admin/set-user-password` and the user's own change-password refuse the same value. Both admin doors now resolve a password through one check of the deployment's `minPasswordLength` / `maxPasswordLength` (the `auth.password_min_length` / `password_max_length` settings, ADR-0069):
8+
9+
- **An explicit password outside the range** is refused with `400 INVALID_REQUEST`, "Password must be at least N characters" (or "at most N"), the answer set-user-password already gave. No new error code. The refusal comes before anything is written: no account, no membership, no permission-set grant, no audit row.
10+
- **A generated password** (`generatePassword: true` with no password typed) is 16 characters, raised to the minimum when the minimum is above 16 and capped at the maximum when the maximum is below 16. Before, it was always 16, so on a deployment with a minimum above 16, set-user-password refused the password it had just generated, and create-user saved it below the minimum.
11+
12+
A password inside the range is applied exactly as before.
13+
14+
Clause-②: no (narrowing) — admin create-user refuses one request shape it used to run: an explicit `password` shorter than the deployment's `minPasswordLength` (one longer than `maxPasswordLength` was already refused by better-auth, now with set-user-password's code). Nothing is widened.
15+
16+
Metadata-protocol: none (an admin REST door's handling of a request body; nothing a metadata author writes changes)
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is how the admin create-user door handles a request body's explicit password: a value outside the deployment's configured length range is refused, as the set-user-password door already refused it. The remedy is a request edit (send a password inside the range, or `generatePassword: true`), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
19+
20+
**BREAKING** for a caller that created users with passwords shorter than the deployment's minimum, or that matched the error code of a too-long one.
21+
22+
- **Before.** `POST /api/v1/auth/admin/create-user` with `password: 'a'` answered `200` and saved the password. A password longer than `maxPasswordLength` answered `400 PASSWORD_TOO_LONG`.
23+
- **Now.** The short one answers `400 INVALID_REQUEST`, "Password must be at least 8 characters" (the deployment's minimum), and creates no user. The too-long one answers `400 INVALID_REQUEST`, "Password must be at most 128 characters" (the deployment's maximum), set-user-password's answer.
24+
- **Fix.** Send a password inside the deployment's `minPasswordLength` / `maxPasswordLength` range, or send `generatePassword: true` and hand the user the returned temporary password. A client that matched `PASSWORD_TOO_LONG` on this door matches `INVALID_REQUEST` instead.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `objectstack build` lowers a hook handler that reads `ctx.provenance` instead of bundling it
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The sandboxed hook body now carries `ctx.provenance` (see the `@objectstack/runtime` entry), so the lowerer no longer refuses a handler that reads it. Such a handler ships as a metadata-only body, and `os lint` no longer reports `hook-body/bundled-fallback` for that read. The CLI's published type surface does not change: `dist/index.d.ts`, `dist/hook-body.d.ts` and `dist/console.d.ts` are byte-identical before and after. Nothing to migrate: a handler that read `ctx.provenance` was bundled before and runs in-process, where it behaves the same. Rebuilding turns it into a body.

0 commit comments

Comments
 (0)