Skip to content

Commit 21cec87

Browse files
committed
Merge origin/main into claude/issue-5672-unified-capabilities
2 parents 6a0da43 + 9a15446 commit 21cec87

225 files changed

Lines changed: 10785 additions & 2496 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: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
"@objectstack/rest": patch
4+
"@objectstack/spec": patch
5+
---
6+
7+
fix(analytics,rest)!: an RLS read-scope lowering failure is a `500`, not the caller's `400` — and its policy detail no longer reaches the response (#5367)
8+
9+
**Observable behaviour change — read this if you alert, retry, or assert on status.**
10+
A request whose dataset carries an RLS read scope that `read-scope-sql.ts` cannot
11+
lower used to answer `400 DATASET_INVALID` with the refusal message echoed
12+
verbatim. It now answers `500 ANALYTICS_QUERY_FAILED` with the message withheld
13+
(`"Internal server error"`); the full text goes to the server log. Monitoring that
14+
counted these as client errors will see a 4xx disappear and a 5xx appear, and a
15+
client retrying on 5xx will now retry a request that cannot succeed until an
16+
administrator fixes the policy. Both follow from the correction below and are
17+
stated rather than buried.
18+
19+
## What was wrong
20+
21+
These ten fail-closed refusals were the last family `/analytics/dataset/query`
22+
classified by **prose** — the final entry of the hardcoded message-substring list
23+
#5352 introduced, which #5367's first PR had already shrunk from six entries to
24+
one. Two defects in one verdict:
25+
26+
- **Misattribution.** `compileScopedFilterToSql(filter, alias)` receives an RLS
27+
`FilterCondition` the security service compiled from an **administrator's**
28+
sharing rule / permission set, and a join alias the **dataset compiler**
29+
generated. Neither is caller input — the caller's own predicate goes through
30+
`filter-normalizer.ts` and has answered `INVALID_FILTER` / 400 since #5352. So
31+
what can arrive here is a broken policy, or drift between two of our own
32+
components (#5557's `$regex` was literally the second case). For this request's
33+
caller both are a **server** fault; `400` told them to fix a request that was
34+
never wrong and kept the real fault out of 5xx alerting.
35+
- **Disclosure.** A 400 echoed the message, so
36+
`unsafe field identifier "secret_policy_field"` and
37+
`unsupported operator "$regex" on "owner_email"` handed a tenant the field names
38+
and comparands of the RLS policy governing them.
39+
40+
The maintainer ruled on 2026-08-06 (option B on #5367's decision card; option A
41+
was `READ_SCOPE_INVALID` / 422, rejected because no consumer reads a code on this
42+
path, a 4xx misreports a condition the client cannot fix, and 422 would have left
43+
the disclosure question to be re-decided message by message).
44+
45+
## What changed
46+
47+
- `read-scope-sql.ts` gains a module-local `readScopeCompileError` — the twin of
48+
`filter-normalizer.ts`'s `invalidFilterError`, and likewise **the only way the
49+
module refuses**. All ten sites carry `READ_SCOPE_COMPILE_FAILED` / **500**.
50+
`:104`'s alias-vs-field split (option C on the card) collapses under B: both
51+
branches answer the same verdict, pinned so the collapse is a recorded decision.
52+
- `rest-server.ts` loses branch ② entirely. **The message-sniffing mechanism is
53+
fully retired** — nothing in this catch reads prose any more, and #5367's
54+
Prime-Directive-#12 retirement schedule ("declared, loud, tested AND removable
55+
on a schedule") is paid off.
56+
- The route's 5xx branch now withholds the message of any producer that
57+
**declares** a server fault (`status >= 500` with a `code`). This was needed
58+
rather than inherited: `looksLikeInternalErrorLeak` (#3867/#5520) is a heuristic
59+
over SQL/driver *phrasing*, and measured, every read-scope message returns
60+
`false` from it — so retiring the list alone would have moved the policy content
61+
from a 400 body into a 500 body instead of out of the response. Teaching that
62+
heuristic to recognise `[read-scope-sql]` would have been *more* message
63+
sniffing, so the rule keys on the ADR-0112 envelope instead. **Undeclared** 5xx
64+
errors keep #5667's tiering, so a self-authored fault ("no strategy can handle
65+
query …") stays readable.
66+
- `READ_SCOPE_COMPILE_FAILED` is registered in `ERROR_CODE_LEDGER` under
67+
`@objectstack/service-analytics` (ADR-0112 D3) and typed as
68+
`RegisteredErrorCode` at the constructor, so an unregistered code is a compile
69+
error. It is legible on the wire through the sibling `/analytics/query` exit,
70+
which puts a thrown `err.code` in `error.details.code` (#3842).
71+
72+
**Which inputs are refused did not change.** No refusal condition moved: nothing
73+
that used to lower now throws, and nothing that used to throw now lowers. That is
74+
pinned input-by-input — refusals *and* accepted read scopes with their compiled
75+
SQL and bind params — in `read-scope-refusal-envelope.test.ts`, which is green both
76+
before and after; only the envelope assertions move.
77+
78+
Coverage: `read-scope-refusal-envelope.test.ts` (service-analytics) drives all ten
79+
sites through the real compiler; `analytics-read-scope-refusal-envelope.test.ts`
80+
(rest) drives five policy shapes end-to-end through a real `AnalyticsService`,
81+
asserting the 500, that the body contains no policy detail, and that the withheld
82+
text is present in the log — plus a positive control and both sides of the
83+
declared-vs-undeclared withhold.

.changeset/app-area-fail-open-gates-removed.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ names* are genuinely enforced one level up and one level down:
3838
from the app's top-level `navigation` tree, and re-checked in the shell;
3939
item-level `visible` is a real CEL gate in the shell.
4040

41-
Three layers, of which the middle one was theatre — `filterAppForUser` reads the
42-
app's `requiredPermissions` and then walks **only** `item.navigation`; it never
43-
touches `item.areas`, and the client renders every area in the switcher. ADR-0078
44-
false compliance, the same shape as `capabilities.readOnly` (#4583).
41+
Three layers, of which the middle one was theatre — at the time of the
42+
retirement `filterAppForUser` read the app's `requiredPermissions` and then
43+
walked **only** `item.navigation`; it never touched `item.areas`, and the client
44+
rendered every area in the switcher. ADR-0078 false compliance, the same shape as
45+
`capabilities.readOnly` (#4583).
4546

4647
**Removed rather than enforced (ADR-0049), deliberately.** Enforcing area gates
4748
is not wrong, it is unscoped: it needs semantics settled first — when an area is
@@ -51,8 +52,21 @@ not invent an authorization mechanism. Removing a gate that never gated is
5152
strictly safer than shipping a major with it still declared, which would have
5253
kept authors writing it for all of 17.x.
5354

54-
**One caveat the prescription carries rather than hides:** per-item gating
55-
*inside* an area is enforced by the shell only, because the server does not walk
56-
`areas`. Anything that must never reach the browser belongs in the app's
57-
top-level `navigation` tree, or in its own app. Trading one false belief for a
58-
weaker one would have repeated the defect this removal exists to end.
55+
**The caveat this prescription used to carry is closed — in this same major.**
56+
It read: per-item gating *inside* an area is enforced by the shell only, because
57+
the server does not walk `areas`. #4722 landed inside the 17.0.0 window and made
58+
that false: `filterAppForUser` now runs the **same** `filterNav` over every
59+
`areas[].navigation`, so an **item**'s `requiredPermissions` / `requiresService`
60+
is stripped server-side in **both** trees and a gated entry never ships in the
61+
`/meta` body. So the retirement kit's advice needs no navigation restructuring —
62+
gating the items of the area you already have is server-enforced.
63+
64+
Read that as the boundary closing, **not** as the area-level keys coming back.
65+
They stay retired; #4722 gave an area no gate of its own, it enforces the items
66+
inside one. And the other half of the asymmetry is unchanged, which is why
67+
`requiredPermissions` is the key to reach for: `visible` (CEL) and
68+
`requiresObject` are still evaluated client-side **only** at every level —
69+
server-side CEL needs a bound `user` context the read layer does not have. So
70+
anything that must never reach the browser goes in `requiredPermissions`, never
71+
in `visible`. Trading one false belief for a weaker one would have repeated the
72+
defect this removal exists to end.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): better-auth 的 `contains` 下译为 `$contains`,比较值不再当正则求值 (#5710)
6+
7+
`convertWhere()` 把 better-auth 的 `contains` 译成 `{ field: { $regex: value } }`,
8+
于是一个**未转义、来自调用方**的比较值(`/admin/list-users``searchValue`
9+
SCIM 过滤值)坐进了正则的**模式位**。它的含义随后端分叉:
10+
11+
- `driver-memory``new RegExp(value)` 求值 —— `contains('a.b')` 命中 `axb`,
12+
`^x` 变成锚定,而值里一个不配对的 `(` 让模式非法(mingo 查询路径直接抛
13+
`SyntaxError`,参考匹配器则吞成静默零命中);
14+
- `driver-sql` / `driver-sqlite-wasm` / `driver-turso` 编成子串
15+
`LIKE '%value%'`(`%`/`_`/`\` 有转义、带显式 `ESCAPE`),元字符是字面量。
16+
17+
同一个认证查询,在应用测试常用的内存替身上和生产的 SQL 后端上给出**不同答案**,
18+
且分叉发生在认证路径上。
19+
20+
现在这一支发出 `$contains` —— 协议 `FILTER_OPERATORS` 里的算子,五后端都必须按
21+
**字面子串**求值,正是 better-auth `contains` 的本意(其 `Where.mode` 默认
22+
`"sensitive"`,与 #5701 Q2=A 裁定的 `$contains` 大小写敏感契约同向)。
23+
24+
**对使用方的影响**:凭 `/admin/list-users?searchValue=…` 之类接口依赖「元字符按正则
25+
生效」的调用会改变结果 —— 那是本次修复的缺陷本身,不是可依赖的行为。搜索
26+
`a.b` 从此只命中含字面 `a.b` 的行,不再命中 `axb`;含非法正则字符的搜索值不再
27+
报错或静默返回空,而是按字面子串匹配。
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): re-anchoring `authorable-surface.base.json` is an explicit act, not a build side effect (#5358)
6+
7+
`packages/spec/authorable-surface.base.json` is the in-tree anchor the #4650 deletion gate
8+
falls back to where `origin/main` is out of reach (#5235). It is not a projection of this
9+
package's source — it is a snapshot of an **upstream** commit, and it is the baseline
10+
precisely because the commit under test cannot rewrite it.
11+
12+
Until now every `gen:schema` run rewrote it whenever it had drifted from the git-resolved
13+
baseline. `gen:schema` is `pnpm build`'s first step, so this fired on any build of any
14+
package that merely has `@objectstack/spec` in its dependency closure, and on `check:docs`
15+
(whose first step is `gen:schema`). Three developers hit it independently, from three
16+
unrelated tasks:
17+
18+
- a plain `pnpm build`: `baseRev` advanced to HEAD, **−110 keys**;
19+
- `pnpm --filter "@objectstack/cli^..." build`: the same 110;
20+
- `pnpm --filter "@objectstack/service-automation^..." build`: `baseRev` advanced, +3 keys.
21+
22+
The 110 were the `ui/ComponentAnimation` family that had just been retired. An anchor
23+
advanced past a retirement cannot see that retirement any more — and the gate is green
24+
before *and* after, because both states are internally consistent. All three were caught
25+
only by reading `git status` line by line before committing; a `git add -A` would have
26+
carried the moved baseline into a PR about something else.
27+
28+
The anchor now moves only in a mode of its own:
29+
30+
```bash
31+
pnpm --filter @objectstack/spec gen:authorable-surface-base # build-schemas.ts --update-base
32+
```
33+
34+
- `gen:schema` and any build: never write it. A lagging anchor prints one ℹ️ line saying so,
35+
naming this command — lag was already not an error (on `main` the merge base is HEAD, so
36+
the file necessarily trails its own surface by one PR).
37+
- `check:authorable-surface`: unchanged, still strictly read-only, and still fatal when the
38+
committed anchor is missing, malformed, or inauthentic.
39+
- `--check --update-base` is refused: a check that repairs what it detects can never report it.
40+
41+
Nothing about anchor **authenticity** changes: `baseRev` must still be an ancestor of
42+
`origin/main` with keys matching that commit's `authorable-surface.json`, the anchor is
43+
still written only from a git-resolved baseline (never from the build being checked), and
44+
the write still happens after the deletion gate has adjudicated the run, so the explicit
45+
mode cannot walk the baseline past an unproven deletion either.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
CLI: load the SQL driver's schema-work classifier lazily, so an unbuilt driver no longer breaks command discovery (#5726)
6+
7+
`packages/cli/src/utils/schema-migrate.ts` statically value-imported
8+
`isInPlaceSchemaWork` from `@objectstack/driver-sql`. oclif's `findCommand`
9+
`import()`s every command module on every CLI invocation, and nine commands
10+
reach that file (`meta:resync`, `migrate`, and seven `migrate:*`), so a
11+
workspace whose `packages/drivers/driver-sql/dist` was not built printed nine
12+
`MODULE_NOT_FOUND` blocks — naming nine commands the operator never invoked —
13+
in front of whatever command they actually ran, and dropped all nine out of the
14+
command table (`os migrate plan` answered `Command migrate:plan not found.`).
15+
16+
The import is now `await import('@objectstack/driver-sql')` at the point of use,
17+
inside the two renderers that need the classifier. The classifier keeps its one
18+
definition in the driver — it is a fact about `PendingSchemaWorkKind` and a copy
19+
in the CLI could disagree, listing a row rewrite under the heading that promises
20+
the work is never data-losing.
21+
22+
No user-visible behaviour change: this is local/worktree developer experience
23+
only, and CI always builds before running the CLI. `renderPendingSchemaWork` and
24+
`summarizePendingSchemaWork` — internal helpers, not part of the package's
25+
public entry — are now `async`.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli): `libsql://` URLs boot a Turso driver — via an optional package, never a silent SQLite fallback (#5602)
6+
7+
`os start --database libsql://my-db.turso.io --database-auth-token $TURSO_TOKEN`
8+
`os start --help` 自己列出的 example,但在此之前它必然 `exit 1`:CLI
9+
URL → driver 推断认得 `libsql://`,却当场抛 `UnsupportedDriverError` —— 而 runtime 的
10+
环境 provisioning 把 turso 排在偏好第一位。两处口径相反的原因(driver 不在开源分发里)
11+
已随 #4645`@objectstack/driver-turso` 迁回本仓而消失。
12+
13+
现在这条 example 成真:
14+
15+
- **识别即构造。** `libsql://` / `*.turso.io` 解析为 `turso` datasource 定义,
16+
`--database-auth-token`(`OS_DATABASE_AUTH_TOKEN`,回落到 vendor 自己的
17+
`TURSO_AUTH_TOKEN`)进入 driver 配置 —— 该 flag 此前只被转发进子进程环境、无人读取。
18+
- **可选依赖 + 动态 import。** `@objectstack/driver-turso` 声明为 CLI 的
19+
**optional peer**(它会拖入 `@libsql/client`),默认安装体积不变;只有真正选了 libSQL
20+
的启动才会动态 import 它,并通过 `DefaultDatasourcePlugin` 既有的 host-factory 接缝注入。
21+
连接路径、`bootCritical` 失败裁决、`OS_ALLOW_DRIVER_CONNECT_FAILURE` 逃生舱与
22+
Setup → Datasources 的状态留存因此与其他 driver 完全一致(#3826)。
23+
- **包缺席时响亮失败。**`MissingDriverPackageError`,消息给出精确安装命令
24+
(`npm install @objectstack/driver-turso`)、说明它是 optional peer,并说明为什么
25+
⛔ 不回退 SQLite:静默降级会让服务器对着一个空的本地库启动,而你的 libSQL 数据原封不动,
26+
每一次写入都落在错误的数据库里(#3276 的教训)。
27+
- **仍然拒收的形状。** `--database-driver turso` 但没有任何 URL —— libSQL 没有可猜的默认值,
28+
这条继续抛 `UnsupportedDriverError`,而不是悄悄用 SQLite 默认值顶上。
29+
30+
`os start` 的 example 加了「需安装 driver 包」注记,Drivers / Self-hosting /
31+
Environment variables / CLI 四处文档同步为「可选包支持」口径。
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(security): `controlled_by_parent` 现在真的跟随主档访问 —— 折入主档的归属与共享授权 (#5386)
6+
7+
**这是一次安全收紧。** 升级后,此前被越权看到 / 写到的明细行会读不到、写不了 —— 那正是
8+
声明本来就要求的边界。
9+
10+
ADR-0055 的 `controlled_by_parent` 对作者的承诺是「子记录跟随父记录的访问」。实现只兑现了
11+
一半:派生用的主档 id 集来自 `computeRlsFilter(master, 'find')`,即只有 Layer 0(租户)与
12+
Layer 1(`rowLevelSecurity` 策略)。归属(owner scope)与 `sys_record_share` 授权由**另一个
13+
插件** `plugin-sharing``buildReadFilter` 贡献,而它对「有效共享模型不是 `private`」的对象
14+
返回 `null` —— `controlled_by_parent` 在那边恰好映射为 `public`。于是记录级访问的两半在派生
15+
对象上从未相遇。
16+
17+
后果比文档里那句「sharing grants 未折入」读起来严重得多:
18+
19+
- 主档上**没有写任何 `rowLevelSecurity`** 的应用,得到的是一个**不受限的主档 id 集**,派生
20+
过滤器等于什么都没收窄 —— 只要持有对象级 read,全部明细行可读。行项目类对象(报价行、
21+
发票行)是这个形状的常客,而它们携带逐行定价与折扣。
22+
- 在主档上补写 RLS 也不是绕法:RLS 与 sharing 过滤器是 **AND**,补写会连同被共享进来的行
23+
一起切掉。
24+
- 写这半有同样的洞,而且是从另一侧来的:`assertControlledByParentWrite` 只在主档的写 RLS
25+
编译出非空过滤器时才检查主档行,主档没写 RLS 时**整段跳过** —— 持有 `allowEdit` 的调用者
26+
可以改自己根本看不到的父记录下的明细。
27+
28+
**修复**:主档可达性改走与「直接读 / 直接写主档」完全相同的路径,复用既有合成点,不在
29+
plugin-security 里重刻一份 sharing 语义。
30+
31+
- 读:`computeControlledByParentFilter` 现在把主档的读 RLS 与 `resolveSharingReadFilter`
32+
(`getReadFilter` 已经在用的那个 OWD/共享半边)AND 起来再解析主档 id 集。哪一半生效由
33+
**主档自己的有效共享模型**决定,因此派生出的可见集与直接 find 主档逐点一致。
34+
- 写:`assertControlledByParentWrite` 在原有的 CRUD `update` + 写 RLS 之外,**无条件**追问
35+
plugin-sharing 的单记录写闸 `canEdit`(归属按写深度放宽、`edit` 级共享、
36+
`modifyAllRecords` 旁路)—— 无条件,正因为写 RLS 那一半在常见情形下会被整段跳过。
37+
- 两侧解析失败一律**fail closed**(主档 id 集为空 / 拒绝写),而不是悄悄放宽回全员可见。
38+
39+
未变更的部分:v1**单层**语义 —— 主档自身的 `controlled_by_parent` 仍不递归下钻;没有装
40+
`plugin-sharing` 的部署行为不变(那种部署里主档本身也没有归属与共享可言,派生集依旧与直接
41+
读主档相等);`read` 级共享仍然只开读不开写,与直接访问主档的逐动词答案一致。

0 commit comments

Comments
 (0)