Skip to content

Commit 7567bf7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-18emds
2 parents b045fde + 38050ce commit 7567bf7

58 files changed

Lines changed: 4894 additions & 743 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: 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: 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: 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.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
feat(runtime): a hook body reads `ctx.provenance`, so a guard that tells a flow's write from a user's own write ships as metadata
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The sandboxed hook body now carries the declared `HookContext.provenance`, the same server-stamped write provenance an in-process handler reads. On a write from a flow data node, `ctx.provenance.flowRunId` is that flow run's id. `attributedUserId` and `performedByClientId` ride along when the engine stamps them. Each member is a string or absent, and `ctx.provenance` itself is absent on a write with no origin, such as a plain REST write. Read it as `ctx.provenance?.flowRunId`. The object is frozen in the VM. It is built from the server-side execution context, so a payload field named `provenance` stays on `ctx.input` and never reaches it.
11+
12+
The widening is on the published type surface. `ScriptContext`, exported from the `@objectstack/runtime` barrel, gains one optional member: `provenance?: { flowRunId?: string; attributedUserId?: string; performedByClientId?: string }`. Measured on `dist/index.d.ts` before and after the change:
13+
- the file grows from 338119 to 339467 bytes;
14+
- the export list is byte-identical, with the same 184 names;
15+
- the only declaration that changes is that one member and its doc comment.
16+
17+
Nothing to migrate: the member is optional, and a `ScriptRunner` that ignores it behaves exactly as before.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): an all-NULL `sum` group sorts and pages as the `0` it answers, the same on SQLite and PostgreSQL
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** The native-SQL strategy folded a NULL `sum` (a group whose aggregand is NULL in every row, or a measure-scoped `sum` with no admitted row) to `0` in JavaScript, after the statement had already ordered and paged on the NULL. SQLite sorts NULL lowest and PostgreSQL highest, so `order { total: 'asc' }, limit 2` answered `0` before `-3` on SQLite and cut the `0` group from the page on PostgreSQL. The ObjectQL face answered `-3`, `0`.
11+
- **Now.** The identity `emptyGroupValueFor` declares is in the statement, `COALESCE(SUM(…), 0)`, so `ORDER BY`, `LIMIT` and `OFFSET` read the value the group answers, on every dialect. The echoed `sql` and `/analytics/sql` show it. `count` is not wrapped: SQL `COUNT` already answers `0` over nothing.
12+
- **NULL placement is stated.** `avg`, `min` and `max` keep NULL over nothing, and a dimension can hold NULL. On SQLite and PostgreSQL every `ORDER BY` key now carries `NULLS LAST`, in both directions: the ordering the ObjectQL face and the dataset door's post-pass already apply. A host whose dialect is MySQL or unnamed keeps the driver's placement, because no one spelling parses on all of them.
13+
- **The ObjectQL face's echo states it too.** That face already orders NULL last (`applyOrdering`); its echoed `sql` and `/analytics/sql` now render the same `ORDER BY` as the native face, `NULLS LAST` included, so the printed statement runs to the order the face answered.

0 commit comments

Comments
 (0)