Skip to content

Commit 4ee2885

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20102-retire-saved-report-stack
2 parents aab0e0f + f09d412 commit 4ee2885

52 files changed

Lines changed: 6168 additions & 237 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.

‎.changeset/18783-server-can-option-visibility.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ stage: Field.select({
3434
- If the security service cannot resolve the map, a write that needs it is refused with the resolution's own error — fail closed. It is never read as "no grants".
3535
- With no security plugin, or an engine older than the seam, there is no permission data. The gate stays unevaluable and the value is admitted with the same `warn` as before, which names the missing input. The security plugin logs one `warn` at start when the engine lacks the seam.
3636

37-
**Known gap, not changed here.** `can()` reads only the per-object entries of the map, and `/auth/me/permissions` lists an object for a `'*'` wildcard grant only when that grant carries a super-user bit. So a subject whose access to an object comes only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — gets `false` from `current_user.can('<that object>', …)`, although the data plane admits the write. Before this release such a gate was never enforced for anyone; after it, that population is refused on a `can`-gated option. Any client that answers `can()` from the same `/auth/me/permissions` map gets the same `false`.
37+
**Plain-wildcard coverage, closed in this release.** `can()` reads only the per-object entries of the map. Before #20083, `/auth/me/permissions` listed an object for a `'*'` wildcard grant only when that grant carried a super-user bit, so a subject whose access to an object came only from a plain wildcard — for example `organization_admin_no_bypass`, which a deployment without an organization wall grants to organization owners and admins — got `false` from `current_user.can()` for that object, although the data plane admits the write, and was refused on a `can`-gated option. That gap is closed in this same release by #20083 (`.changeset/20083-effective-map-plain-wildcard.md`): `buildEffectiveObjectPermissions` now puts each set's plain `'*'` on the registered public objects that set does not name, so that population's map — and any client that answers `can()` from the same `/auth/me/permissions` map — carries an entry for each object the wildcard covers, with the wildcard's grants, narrowed on a guarded managed object by the same managed-write clamp as every other entry. The map still differs from `PermissionEvaluator.checkObjectPermission` for subjects holding a super-user wildcard: an entry the super-user set itself names narrower can read as granted, and an entry reached through a super-user wildcard carries no `transfer`.
3838

3939
**No spec key, route or config key is added or removed.**
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): `GET /meta/app` leaves out a `type: 'doc'` navigation entry the caller may not read (#19790)
6+
7+
`DocNavItemSchema` declares that a `doc` entry the member may not read is not
8+
rendered, and that a `book` entry is not rendered for a member with no readable
9+
page in it. Until now only a renderer could honour that. The server's app-nav
10+
filter pruned on `requiredPermissions`, `requiresService` and object servability,
11+
so every member of the app got the entry. That included its label and the gated
12+
book or doc name, however the book was gated.
13+
14+
The filter now applies the docs audience (ADR-0046 §6.7) on both the list route
15+
(`GET /meta/app`) and the by-name route (`GET /meta/app/:name`), in the top-level
16+
navigation, inside `children` and inside `areas[].navigation`:
17+
18+
- **`doc`**: the entry is dropped when the doc's effective audience does not
19+
admit the caller. This is the answer `GET /meta/doc/:name` gives.
20+
- **`book`**: the entry is dropped when the book's own audience does not admit
21+
the caller, or when none of its pages is readable. A book's pages are the docs
22+
its groups claim. The *Uncategorized* group that the book tree adds does not
23+
count.
24+
- **`book` + `doc`**: the entry is dropped when either of those checks fails.
25+
- An app emptied by the prune is still served, as it is today when
26+
`requiredPermissions` empties one. An emptied `group` or area collapses, as
27+
with every other gate.
28+
29+
The verdicts come from the same resolution that `/meta/doc`, `/meta/doc/:name`,
30+
`/meta/book` and `/meta/book/:name/tree` now share. What those reads return is
31+
unchanged.
32+
33+
**Fails closed.** If the book or doc list read throws while `/meta/app` is being
34+
answered, every `doc` entry is left out of that one response and a warning is
35+
logged. The rest of the navigation is still served. If the caller's
36+
permission-set holdings cannot be resolved, set-gated entries are dropped, as
37+
set-gated content already is.
38+
39+
**Cost.** An app list with no `doc` entry performs no extra read. Otherwise each
40+
request adds one `book` list read, one permission-set resolution when some book
41+
is set-gated, and one `doc` list read when a set-gated book exists or an entry
42+
names only a book. Each of these happens once per request, however many apps are
43+
listed. Nothing is cached across requests.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
"@objectstack/driver-sqlite-wasm": patch
4+
"@objectstack/driver-turso": patch
5+
---
6+
7+
fix(driver-sql, driver-turso): on SQLite, `$like` / `$ilike` read the whole stored value, instead of stopping at its first U+0000 (#20024)
8+
9+
Clause-②: no
10+
11+
On the SQLite faces `$like` and `$ilike` compiled to `GLOB`, and SQLite's `glob()` reads the stored value only up to its first U+0000. So a pattern without U+0000 answered a different question over a value holding one, and nothing raised. Measured on `SqlDriver` over better-sqlite3 (SQLite 3.53.4), on `SqliteWasmDriver` over sql.js (3.49.1), on `TursoDriver`'s local mode, and on its remote transport over a local libSQL engine (3.45.1). All four answered alike:
12+
13+
- `$like: 'a'` returned a value stored as `'a'` + U+0000 + `'b'`, and `$like: ''` returned U+0000 + `'z'`;
14+
- `$like: '%b'`, `$like: 'a_b'` and `$ilike: 'A_B'` did not return `'a'` + U+0000 + `'b'`;
15+
- `$like: '_'` did not return a value that is a lone U+0000.
16+
17+
Over 108 patterns and 22 `$not` / `$or` / `$and` compositions against 59 stored values, 359 of the 3380 cells over values holding U+0000 differed from `@objectstack/formula` on each face.
18+
19+
What changes: a stored value holding U+0000 now has each U+0000 replaced by one stand-in character before `GLOB` reads it. The stand-in is never a literal character of the pattern, never an ASCII letter, and never U+0000. A U+0000 in the value can only be matched by `%` or `_`, and so can the stand-in, so the answer is the one the whole value gives. Such a filter now returns the rows `driver-memory` and `@objectstack/formula` return for it, under `$not`, `$or` and `$and` as well: 0 of those 3380 cells differ on any of the four faces. `$ilike` still folds ASCII letters only.
20+
21+
- `@objectstack/driver-sql`: the SQLite arm of `SqlDriver`'s `$like` / `$ilike` compiler. `SqliteWasmDriver` and `TursoDriver`'s local mode inherit it.
22+
- `@objectstack/driver-sqlite-wasm`: none of its own code changes. It inherits the fix.
23+
- `@objectstack/driver-turso`: the remote transport's own emitter, changed the same way.
24+
25+
What does not change:
26+
27+
- A stored value without U+0000 gets the same answer as before: 0 of 16640 such cells moved on any face.
28+
- A pattern that is a literal prefix followed only by `%` (`'ab%'`, `'%'`) compiles to the same `GLOB` with the same bound pattern as before. Cutting the value at its first U+0000 cannot change that answer.
29+
- No index is lost. Under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, each `$like` pattern measured that starts with a literal (`'ab%'`, `'ab_'`, `'ab%cd'`, `'abc'`, `'a%b%'`) keeps its covering-index search, and each one that starts with a wildcard still scans. A case-exact pattern with a literal prefix now leads with a `GLOB` on that prefix followed by `*`, which every matching value satisfies and which is what keeps that search.
30+
- `_` still matches one character, as `GLOB`'s `?` does. A character outside the Basic Multilingual Plane is one character to `_` on SQLite and two to `@objectstack/formula`, which counts UTF-16 units. That difference is older than this change, and this change does not alter it.
31+
- A pattern holding U+0000 is still refused (`INVALID_FILTER` / 400).
32+
- The Postgres and MySQL arms are untouched.
33+
34+
Cost, measured over 10,000 rows on the three engines: against a value without U+0000 the new compile adds one `instr()` per row, and those queries took 1.1 to 2.4 times as long as `GLOB` alone, at most 4.5 ms. A value holding U+0000 pays the rewrite: 10,000 rows each holding one to three U+0000 took up to 35 ms, against at most 3 ms for `GLOB`.

‎.changeset/20024-sqlite-glob-stored-nul.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,4 @@ What does not change:
2626
- `$startsWith` with a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The stored value's cut cannot change its answer.
2727
- No index is lost. The SQL `SqlDriver` compiles, run under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, scanned the table for these four operators under `GLOB` and still does; `$startsWith` keeps its index search.
2828
- The Postgres and MySQL arms are untouched.
29-
- `$like` and `$ilike` still compile to `GLOB` on SQLite, so they still read a stored value only up to its first U+0000, and a pattern holding U+0000 is still cut there.
29+
- `$like` and `$ilike` are outside this entry. Two other entries in this release cover them: on SQLite they now read the whole stored value as well, and every driver that answers `$like` refuses a pattern holding U+0000 (`INVALID_FILTER` / 400).
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/driver-sql': minor
4+
'@objectstack/driver-sqlite-wasm': minor
5+
'@objectstack/driver-turso': minor
6+
'@objectstack/driver-memory': minor
7+
---
8+
9+
fix(spec, drivers)!: a `$like` / `$ilike` pattern holding U+0000 is refused by every driver that answers `$like`, instead of being cut at the NUL on SQLite
10+
11+
Clause-②: yes (narrowing)
12+
13+
On the SQLite faces `$like` / `$ilike` compile to `GLOB`, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there, so the filter answered a different question, and nothing raised. Measured through `find` over 13 stored values (12 non-NULL), against `@objectstack/formula` on the same rows: all 20 U+0000 cases of the probe (10 patterns, bare and under `$not`) differed on `SqlDriver` over better-sqlite3, on `SqliteWasmDriver`, on `TursoDriver`'s local mode, and on its remote mode over a stub and over a real `@libsql/client` engine, with identical answers on all five. For example:
14+
15+
- `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns the two ending in U+0000;
16+
- `$like: 'a'` + U+0000 + `'b'` also returned `'a'`;
17+
- `$ilike: 'AB'` + U+0000 also returned `'AB'` and `'ab'`.
18+
19+
`driver-memory` answered all 20 as `formula` does. SQLite has no NUL-safe pattern primitive to compile to instead: `LIKE` cuts the same way, `replace()` cannot target U+0000, and `instr()` has no wildcards. So the one contract is a refusal, the way a pattern ending in a lone unpaired backslash is refused.
20+
21+
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A filter that answered before is now refused**: a `$like` or `$ilike` pattern holding U+0000 anywhere (at the start, in the middle, at the end, alone, or after a backslash) gets `INVALID_FILTER` / 400, on every door that already refused the lone trailing backslash:
22+
23+
- `@objectstack/driver-sql`: on the filter walk, before a dialect is chosen, so SQLite, Postgres and MySQL all refuse it. `@objectstack/driver-sqlite-wasm` and `TursoDriver`'s local mode inherit it; `@objectstack/driver-sqlite-wasm`'s own code does not change.
24+
- `@objectstack/driver-turso`: the remote transport's `$like` / `$ilike` arm, before anything is sent to the engine.
25+
- `@objectstack/driver-memory`: the shape gate of the query path and of the reference matcher `match()`, and the QueryAST `comparison` spelling (`like` / `ilike`).
26+
- `@objectstack/spec` exports the shared test, `hasNulInLikePattern`, beside `hasDanglingLikeEscape`, and the `$like` operator's description now names the refusal.
27+
28+
On `driver-sql` and the Turso remote transport the refusal goes through the read-scope provenance seam, like every other filter-compile refusal there. On `driver-sql` (and so `driver-sqlite-wasm` and Turso's local mode), a caller whose predicate is marked `'author'` reads the operator, the field, the filter path and the pattern, with U+0000 written as `\u0000`. Any other caller gets only the class statement, and the rest goes to the server log. The remote transport withholds the same way, and through `TursoDriver` in remote mode no mark reaches it, so every caller gets the class statement there. On `driver-memory` every caller reads the full text, as for its dangling-escape refusal.
29+
30+
A pattern that ends in a lone unpaired backslash AND holds U+0000 keeps the dangling-escape refusal it had before.
31+
32+
**What stays accepted**, pinned per face: every `$like` / `$ilike` pattern without U+0000 answers exactly as before.
33+
34+
**Not changed here:**
35+
36+
- A pattern without U+0000 matched against a STORED value that holds U+0000 is not refused: it is well formed, and on the SQLite faces it reads the whole stored value, by its own entry in this release.
37+
- `@objectstack/formula` still evaluates such a pattern. It refuses nothing, and answers `false` for a dangling escape rather than refusing it, so it is not one of these doors.
38+
- `driver-mongodb`, objectql `having` and `service-analytics` refused every `$like` / `$ilike` before this change, and still do.
39+
40+
**What an affected author does.** Remove the U+0000 from the pattern. No escape makes it portable: a backslash before it still leaves a U+0000 in the pattern.
41+
42+
Blast radius, measured on this tree: no example or template writes a `$like` or `$ilike`, and the published `objectstack-query` skill and the hand-written docs that show one show no pattern holding U+0000. Whether any out-of-repo caller sends one is NOT measured and is not claimed to be zero.
43+
44+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing at the filter-compile doors: no key, Zod schema, object definition or stored representation is added, removed or renamed. `$like` and `$ilike` keep their names and their `z.string()` comparand, and the only spec symbol added is the predicate `hasNulInLikePattern`. What moves is which PATTERN VALUES the drivers answer, and no rewrite of a stored pattern keeps its meaning (dropping the U+0000 changes which rows match), so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. -->
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest): four published doors refuse a `?limit=` they cannot read with `400 VALIDATION_FAILED`, instead of substituting, clamping or dropping it and answering `200` (#20061, #20062)
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+
`GET /data/import/jobs`, `GET /data/:object/export`, `GET /meta/:type/:name/history`
14+
and `GET /search` read `?limit=` with a bare `Number()`. That coercion does not
15+
fail. It invents a value, and the door served it with a `200`:
16+
- `?limit=0` on the import-job history answered the 50-row default against a
17+
declaration of `min(1).max(200)`;
18+
- `?limit=abc` on the export downloaded one row;
19+
- on the metadata history it returned the whole change log;
20+
- on search it removed the overall cap.
21+
22+
Since `@objectstack/client` sends `limit` exactly as the caller wrote it, the door
23+
is the only place such a value can be refused.
24+
25+
Each door now reads the parameter against its own declaration:
26+
- **`GET /data/import/jobs`** reads `limit` and `offset` through
27+
`ListImportJobsRequestSchema` (`limit` `int().min(1).max(200)`, default 50;
28+
`offset` `int().min(0)`, default 0).
29+
- **`GET /meta/:type/:name/history`** reads `limit` through
30+
`HistoryMetaItemRequestSchema.limit` (`z.number()`, so any finite number, as
31+
before).
32+
- **`GET /data/:object/export`** and **`GET /search`** declare no request schema.
33+
There `limit` must be a whole number. Their range handling is unchanged: the
34+
export floor of 1 and cap of 50000, and search's `[1, 100]` clamp.
35+
36+
A value outside the declaration answers `400` with the data surface's existing
37+
envelope, `{ error, code: 'VALIDATION_FAILED', fields }`. `fields[0].field` names
38+
the parameter. `fields[0].code` is the ADR-0114 member for the failed constraint:
39+
`invalid_type` for a value that is not a number (or not a whole one, where one is
40+
required), `min_value` or `max_value` for one outside a declared bound. The
41+
service is never called.
42+
43+
What changes, per door (every row answered `200` before):
44+
45+
| door | request | answered before | answers now |
46+
|:--|:--|:--|:--|
47+
| `GET /data/import/jobs` | `?limit=0`, `?limit=-3` | 50 rows / 1 row | `400`, `min_value` |
48+
| `GET /data/import/jobs` | `?limit=201`, `?limit=500` | 200 rows | `400`, `max_value` |
49+
| `GET /data/import/jobs` | `?limit=abc`, `?limit=1.5`, `?limit=Infinity` | 50 / 1.5 / 200 rows | `400`, `invalid_type` |
50+
| `GET /data/import/jobs` | `?offset=-1`, `?offset=abc`, `?offset=1.5` | offset 0 / 0 / 1.5 | `400`, `min_value` / `invalid_type` |
51+
| `GET /data/:object/export` | `?limit=abc`, `?limit=` (empty) | a one-row export | `400`, `invalid_type` |
52+
| `GET /data/:object/export` | `?limit=1.5`, `?limit=Infinity` | limit 1.5 / capped to 50000 | `400`, `invalid_type` |
53+
| `GET /meta/:type/:name/history` | `?limit=abc`, `?limit=Infinity` | the whole change log | `400`, `invalid_type` |
54+
| `GET /meta/:type/:name/history` | `?limit=` (empty) | zero events | `400`, `invalid_type` |
55+
| `GET /search` | `?limit=abc` | no overall cap | `400`, `invalid_type` |
56+
| `GET /search` | `?limit=1.5`, `?limit=Infinity` | a cap of 1.5 / 100 | `400`, `invalid_type` |
57+
58+
A blank value such as `?limit=%20` is refused on every door.
59+
60+
**Unchanged:**
61+
- An absent `limit` keeps each door's default: 50 jobs, a 10000-row export, the
62+
full history, search's 20.
63+
- An empty `?limit=` on the import-job history and on search still means absent,
64+
as it always did there.
65+
- Every conforming value reaches the service exactly as before, including the
66+
ranges no card here takes a position on: export `?limit=0` still exports one
67+
row, search `?limit=500` is still clamped to 100, and history still forwards
68+
`0` or `1.5` because its declaration admits them.
69+
70+
**Fix for a caller that now gets the `400`:** send `limit` as a whole number, within
71+
the declared range where the door declares one (`1`–`200` for import jobs), or
72+
omit it to get the door's default.
73+
74+
<!-- 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 four REST doors accept, back to what their declarations (`ListImportJobsRequestSchema`, `HistoryMetaItemRequestSchema`) 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`). -->

0 commit comments

Comments
 (0)