Skip to content

Commit 2ad7a62

Browse files
authored
Merge branch 'main' into claude/issue-5978-break-glass-third-path
2 parents 6947bec + e3ef52b commit 2ad7a62

118 files changed

Lines changed: 19234 additions & 15286 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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): a dataset refusal that declares an ADR-0112 envelope is never degraded to an empty result (#5717)
6+
7+
`queryDataset` wraps execution in a catch that exists for one deliberate reason
8+
(#5033): a widget whose backing object is not mounted in this kernel renders
9+
"no data" instead of failing with a 500. The criterion for "not mounted" was
10+
`isMissingSourceError` — a substring match over the error MESSAGE. So the
11+
leniency was available to any error that happened to phrase itself like a
12+
driver, and #5352 / #5367's finding on the REST face — "the wire shape of an
13+
error family must not be a property of its wording" — applied here one level
14+
worse: the outcome was not a wrong status code but a **silent empty result**.
15+
No exception, no 4xx, no 5xx; one `warn` line and a confident empty chart, which
16+
is the "populated table, Total Spend: 0" symptom #5033 was filed about.
17+
18+
One refusal already matched. `dataset-compiler.ts` refuses an `include` naming a
19+
relationship the object graph does not have with
20+
21+
> `[dataset-compiler] dataset "X" includes relationship "R" which does not exist on object "O".`
22+
23+
which carries both `relation` (inside "relationship") and `does not exist` — and
24+
that conjunction was the postgres limb. It has never gone off for one reason:
25+
`queryDataset` compiles **before** the try, so that throw has never been inside
26+
the catch's reach. A mine, wired and unarmed.
27+
28+
**Two independent defences, so the disarming does not depend on either one.**
29+
30+
- **The criterion (main change).** An error carrying an ADR-0112 envelope —
31+
numeric `status` + non-empty `code`, the same structural fact
32+
`rest-server.ts`'s `/analytics/dataset/query` catch reads — is re-thrown
33+
untouched, ahead of any message inspection. Its producer already answered the
34+
classification question. The status RANGE is deliberately not part of the
35+
test: a `DATASET_INVALID` / 400 rendered as an empty grid is the loud case,
36+
but a declared 5xx (`READ_SCOPE_COMPILE_FAILED` — an RLS lowering that failed
37+
closed) is if anything worse to swallow, since nobody is told at all.
38+
- **The sniffer.** Its postgres limb is now anchored to postgres's actual
39+
wording (`relation "x" does not exist`) instead of "any sentence containing
40+
both words" — the same pattern the sibling `missingSourceRelation` already
41+
used, so "is something missing" and "what is missing" can no longer disagree.
42+
43+
**Observable behaviour change — read this if you alert on empty widgets.** The
44+
guarantee is new, not the status of any shipped message: measured over the 13
45+
real wordings this repo carries (three driver families including sql-prefixed
46+
and schema-qualified forms, the framework's not-registered signals, and this
47+
package's own refusals), exactly one verdict moves — the compiler refusal above,
48+
which reaches callers as `400 DATASET_INVALID` either way because its throw site
49+
sits outside the try. What changes is that a caller-shaped refusal raised
50+
**during execution** can no longer become `{rows: [], fields: [], totals: []}`
51+
by phrasing alone: it now propagates and the route answers its declared code
52+
(4xx as itself, declared 5xx through `ANALYTICS_QUERY_FAILED`). A dashboard that
53+
silently rendered an empty chart for such a refusal will now surface the error.
54+
55+
**#5033's leniency is untouched, and that is asserted rather than claimed.** A
56+
bare driver error is still classified by its words and still degrades: `no such
57+
table` (sqlite/libsql), postgres's real `relation "x" does not exist`, mysql's
58+
`doesn't exist`, the framework's not-registered signals — and a bare error
59+
naming a JOINED table still fails loudly as a cross-datasource dataset. Those
60+
cases are green in all four states of the reverse verification
61+
(`dataset-degradation-envelope.test.ts`), including with both defences reverted.
62+
63+
The compile point deliberately stays outside the try. Moving it in would newly
64+
expose the compiler's own bare invariants and the host-supplied relationship
65+
resolver to this degradation path — widening leniency in the opposite direction
66+
from the fix.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
修复:逃出路由 handler 的抛出不再被静默丢弃 —— 适配器接缝现在有诊断出口
6+
7+
`HonoHttpServer.runHandler()` 的兜底 `.catch` 此前把 rejection 显式丢弃(参数名就是 `_err`),`wrap()` 随后回一个 `{ error: 'No response from handler' }` 的 500。净效果是:**任何**逃出 handler 的抛出,在以本适配器为 transport 的 host 上都表现为一个不带原因的裸 500,而且**任何地方都没有日志** —— 连 stack 都没有。
8+
9+
现在该接缝会按 `Logger` 契约打一条 `error` 记录,带上原始 `message` / `stack` 与定位所需的请求上下文(`method` + `path`)。
10+
11+
- **`Error` 走契约的 `error` 形参槽**,不塞进结构化 meta。`Error``message` / `stack` 是 non-enumerable,直接进 meta 会序列化成 `{}` —— 那比没有日志更糟,因为它会报告成功。跨 realm 的 `Error`(`instanceof` 不成立)会按 `name`/`message`/`stack` 重建;`throw 'boom'` 这类非 `Error` 抛出会被描述进 message 而不是丢掉。
12+
- **请求体不入日志** —— 只有 `method``path`
13+
- **默认就有日志出口。** 未接线时适配器用 `createLogger()`,而不是静默:直接内嵌 `HonoHttpServer` 的 host(serverless 入口)正是本问题的生产现场,静默默认会对它们原样复现该 bug。`HonoServerPlugin.init()` 会用 `ctx.logger` 替换掉默认值;要静默须显式传 `NoopLogger`
14+
15+
新增 `HonoHttpServer.setLogger(logger)`(纯新增,不改 `IHttpServer` 契约)。
16+
17+
⚠️ **响应形状一字未改**:兜底 body 仍是 `{ error: 'No response from handler' }` + 500,已加测试钉住。把它收成声明信封会改变线上响应形状,属另一项尚未裁决的契约决策,不随本次改动附带。

.changeset/sms-daily-send-quota.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/service-sms": minor
3+
"@objectstack/service-settings": minor
4+
"@objectstack/service-messaging": minor
5+
---
6+
7+
feat(sms): 短信全局日发送配额 —— 成本总量闸 (#2814)
8+
9+
#2780 给 OTP 端点落了**按号码**的防滥用(60s 冷却 + 每号码 5 条/小时)。那挡住的是「一个号码花多少钱」,挡不住「这套部署一天花多少钱」:攻击者轮换上万个不同号码时,每个号码都稳稳待在自己的预算里,而日累计账单没有任何上限——这正是 SMS pumping / toll fraud 的典型打法。更要紧的是,按号码那道闸住在 better-auth 的 `hooks.before` 里,只看得见 auth 端点:`notify(channels:['sms'])` 与邀请短信从旁边直接走过去,一条都不计数。
10+
11+
本次新增一道**总量**闸,扣减点放在所有出站短信本来就必经的那一处 —— `SmsService.send()`。OTP、邀请、messaging `sms` channel 三条路无论从哪扇门进来,都记在同一本账上。
12+
13+
## 新增设置项
14+
15+
`sms` 命名空间新增 `daily_quota`(Daily send limit,number,默认 `0` = 不限):这套部署每个 **UTC 自然日**允许发出的短信总条数。超出后拒发,直到 00:00 UTC。env 覆盖沿用既有的每键机制,无需额外接线:`OS_SMS_DAILY_QUOTA=2500`
16+
17+
`0` 是出厂姿态,所以升级本身不改变任何现有部署的发送行为——闸门要由运营者显式配置才会闭合。
18+
19+
## Observable behaviour change
20+
21+
**配置配额后,发送可能被拒**,两条路径的表现分别是:
22+
23+
- OTP / 邀请路径 —— `SendSmsResult.status='failed'``error``TOO_MANY_REQUESTS: daily SMS quota exhausted`。刻意与按号码闸抛出的 `TOO_MANY_REQUESTS` 用同一个码,且**不带任何剩余额度细节**:从外面看,两道墙必须长得一样,攻击者不该能试探出自己撞的是哪一道。
24+
⚠️ 但这个码**目前到不了 HTTP 调用方**`AuthManager.deliverPhoneOtp` 把它重抛成普通 `Error`,而 better-call 对非 `APIError` 一律回 500(实测,见 #6039)。也就是说 OTP 端点上,按号码闸回 429、总量闸回 500。补齐要动 plugin-auth,已单独立案。
25+
- messaging `sms` channel —— `SendResult.ok=false`,且 `classifyError` 返回 `'rate_limited'`(此前一律 `'retryable'`)。投递落进 outbox 走退避重试 / 死信,不会被静默丢弃;`rate_limited``retryable` 走同一条重试阶梯,但把「额度用尽」与「网关抖动」在投递记录上区分开。
26+
27+
## 计数落在哪里
28+
29+
复用仓内唯一那份定窗计数(`incrementFixedWindow`)与它的惰性存储解析(`createLazyCounterStore`#4772/#4790),不写第三份:
30+
31+
- 有 kernel `cache` 服务时计在共享 cache(集群共享与否取决于 cache 本身);
32+
- 解析不到时降级为有界的进程内计数,并由解析器**点名**打一条 warn,说明降级的代价(N 节点部署最多可花 N× 配额);
33+
- 解析在**计数被消费时**发生,而非插件 init —— 后注册的 cache 也能在下一次发送时被接上(#4772 的坑)。
34+
35+
窗口是 UTC 自然日,且由两个机制同时保证:计数键带 UTC 日期(`sms-daily-sends:2026-08-06`),窗口开启时的 TTL 恰为距下一个 UTC 午夜的秒数。任一机制单独也能翻窗,合起来则不可能互相矛盾。
36+
37+
## 两条刻意的姿态
38+
39+
- **fail-open**:计数存储读不到时,闸门**放行**并打一次 warn。短信成本闸不能把登录拖下水(#2814 诉求 4)。
40+
- **配额值的钳制在消费侧**:manifest 上的 `min: 0` 今天并不被 `validatePatch` 执行(#5932),所以负值 / `NaN` / `Infinity` / 非数字都会原样抵达读取方。这些一律降级为 `0`(不限)并**点名**打 warn,而不是拒发、也不是替运营者编一个别的默认值——一个设置表单里的手误不该变成手机登录的全站故障,而编一个没人声明过的上限只会把手误藏进看似合理的行为里。
41+
42+
## 不在本次范围
43+
44+
诉求中的**每租户日配额**`daily_quota_per_tenant`)未实现:`SendSmsInput``@objectstack/spec/contracts`)不携带任何租户标识,而在 service 侧另造一个只此一家的拼法就是 Prime Directive #12 明令禁止的影子契约。租户维度要么落在 spec 契约上,要么不落——详见 #2814 上的讨论。
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
refactor(spec): 三个热点生成物按 category / entrypoint 分片,合并队列的串行税消失 (#5837)
6+
7+
`merge=os-regen`(#4675)只在**本地** git 生效。合并队列在服务端重建 PR 时不跑自定义
8+
merge driver,所以两个都动过 `authorable-surface.json`(310KB、7941 行排序数组,每个
9+
spec PR 都会重写)的 PR 在队列里是纯文本冲突,第二个必然被踢——spec 车道一次只能放行
10+
一单。driver 自己的注释里记着这笔税的形状:一个下午 4 次合并 9 处冲突,****真语义
11+
冲突,全部是集合并集。付的是「单体文件」这个形状的钱,不是分歧的钱。
12+
13+
**布局变化(发布产物路径,见下方档位说明)**
14+
15+
| 之前 | 现在 | 分片键 |
16+
|:---|:---|:---|
17+
| `packages/spec/authorable-surface.json` | `packages/spec/authorable-surface/< category >.json` | def key 的 category 段 |
18+
| `packages/spec/json-schema.manifest.json` | `packages/spec/json-schema.manifest/< category >.json` | 同上 |
19+
| `packages/spec/api-surface.json` | `packages/spec/api-surface/< entry >.json` | 已发布入口(`.``root.json`) |
20+
21+
有意维持单体:`spec-changes.json`(按版本键控)、`api-surface-signatures.json`(1.3KB)、
22+
`authorable-surface.base.json`(只有显式 `--update-base` 会写,从不在 churn 路径上,且
23+
它的 `baseRev` 是整个 surface 的**一个** commit——分片会让不同分片镜像不同 revision,
24+
那是任何上游 commit 都没有过的状态)。
25+
26+
**ratchet 语义逐条不变。** 所有门禁读**整个目录**当作一个集合,而不是「这次构建会写的
27+
那些分片」:删掉一整个分片文件 = 删掉它的 key,checks (a)/(c) 看到的缺失 key 与从前删
28+
单体文件里的行时一模一样;没人重生成的分片报**陈旧**而不是被跳过;#4662 的逐字节规范
29+
形式比对现在是逐分片做的,手改因此还能被**指名到文件**#5976 的 def key 撞名守卫不受
30+
影响——它按剥后缀的 schema 名判定(`shared/HttpMethod`),与输出路径无关,且仍在两个
31+
ratchet 之前运行。
32+
33+
**对消费者的可见影响,以及为什么是 patch。** `@objectstack/spec``files`
34+
`api-surface.json` 改成了 `api-surface`,所以 npm 包内该快照的路径变了(它不在
35+
`exports` 里,不是可 import 的子路径,是给工具读的文件)。导出面本身**零变化**
36+
(`check:api-surface` 实测 0 breaking / 0 added),运行时行为、类型、schema 一律未动,
37+
因此不是 major;仓内唯一的读点(release 工作流的 surface diff)随之更新,并且会按上游
38+
tarball 实际携带的形状读取——`api-surface/` 目录(本次起)、`api-surface.json` 单文件
39+
(protocol 15 起至本次)、两者皆无(protocol 15 之前)。外部若有直接读
40+
`node_modules/@objectstack/spec/api-surface.json` 的工具,改读 `api-surface/` 目录并把
41+
各分片的 `exports``entry` 合并即可,内容逐条相同。
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): `/sso/register` 的管理员门禁改用唯一那把等级尺,不再手抄一份大小写敏感的判据 (#5942)
6+
7+
ADR-0024 的 `POST /sso/register` 门禁问的是「这个 membership 是不是本组织的管理员」。
8+
它此前用的是一份手抄判据:
9+
10+
```ts
11+
raw.split(',').map((s) => s.trim()).some((r) => r === 'owner' || r === 'admin')
12+
```
13+
14+
同一个问题在 plugin-auth 内还有另一把尺 —— `invitation-role-cap.ts` 的等级尺
15+
(`parseOrgRoles()``.trim().toLowerCase()`,`isOrgAdminGrade()` 据此评级),
16+
break-glass ban 守卫(`last-admin-ban-guard.ts`,ADR-0024 D5.2)用的就是它。
17+
两把尺在大小写上不一致:`sys_member.role` 若存成 `Owner` / `ADMIN`,ban 守卫把这一行
18+
算作**管理员**,而 `/sso/register` 门禁算作**非管理员**。同一条安全路径上的两个答案
19+
互相矛盾,而且两个方向的错都不出声。
20+
21+
现在门禁改问 `isOrgAdminGrade(m.role)` —— 「哪种 membership 算管理员」在 plugin-auth
22+
内只剩一个答案,两处自此同尺。
23+
24+
**用户可见的行为变化,只有一个方向:放宽,且只放宽在此前判错的取值上。**
25+
`sys_member.role` 为大小写非常规值(`Owner` / `ADMIN` / ` Admin `,以及
26+
`member,Owner` 这类逗号拼写)或数组拼写(`['owner']`)的成员,此前会被
27+
`/sso/register` **误拒**,现在正确判为管理员并放行。**没有任何收窄**:此前被判为管理员
28+
的取值,换尺后仍然是管理员(已逐值实测,见 PR)。
29+
30+
ADR-0108 的封闭词表(`owner` / `admin` / `delegated_admin` / `member`)全为小写,UI 与
31+
better-auth 写入的也是小写,所以正常部署下答案逐值不变 —— 这也是为什么它此前只是一条
32+
静默分歧,而不是线上故障。要撞上分歧得有一条绕过表单的写入(导入、外部写入、手工 SQL)。
33+
34+
`isOrgOrPlatformAdmin` 名字里的 platform_admin 半边**未改动**,仍由
35+
`packages/core/src/security/resolve-authz-context.ts` 权威推导;那几处实现的合流是
36+
另一个决策件。

0 commit comments

Comments
 (0)