Skip to content

Commit b8a6913

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4741-package-dependency-dual-source
2 parents 76b5ddc + c44dd5e commit b8a6913

29 files changed

Lines changed: 2411 additions & 151 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
fix(objectql,platform-objects): 一次启动不能证明它自己随即违反的契约 —— ADR-0104 空库自证改为在本次启动写完数据后下结论 (#4769)
7+
8+
一个全新部署第一次 `pnpm dev` 全绿(130 rows,0 ERROR),**第二次启动开始永久 10 条
9+
ERROR**、10 条种子记录写不进去。数据没变、代码没变,只是重启了一次;被拒的正是首启
10+
自己写进去的数据。
11+
12+
根因不是哪个值算错了,是**顺序反了**`sys_migration` 里那两行
13+
(`adr-0104-file-references` / `adr-0104-value-shapes`)带着
14+
`{"attested":"datastore-created-empty"}` 写在 `kernel:ready`,而同一次启动的 seed
15+
还在往里写行。「空库 ⇒ 没有历史值」这个推理成立的前提是**没有数据可写**,而它恰恰
16+
写在即将写入 130 行之前 —— 证明落笔那一刻是真的,一秒之后就不是了。于是首启在
17+
warn-first 下把数据留下,之后每一次启动读到这张证书、进入 strict、拒掉前任写下的
18+
那批行。
19+
20+
## 改了什么
21+
22+
**证书必须覆盖它所声称的那批数据。**
23+
24+
- **写入时机**:新库自证改为在**本次启动自己的数据落定之后**进行 ——
25+
`app:seeded`(inline seed 结算点,含超出 `OS_INLINE_SEED_BUDGET_MS` 后台跑完的
26+
那一半),不 seed 的 kernel 仍由 `kernel:ready` 兜底。两条路径进的是同一个幂等
27+
调用。
28+
- **写入前提**:`attestFreshDatastore` 先问引擎「这次启动放行过违反该契约的值吗」。
29+
引擎在 warn-first 放行每一个不合形状的值时,用**与 strict 模式完全相同的判定**
30+
它记下来 —— 证明干净需要扫全库,证伪只需要一个反例,而这个反例写路径已经算出来
31+
了。任一条被本次启动证伪的迁移 id **不再自证**,部署维持 warn-first(真实且可
32+
恢复),并在日志里指名是哪个 `对象.字段` 让这道闸没关上、该跑哪条 `os migrate`
33+
两行一起改:`adr-0104-file-references``adr-0104-value-shapes` 各自独立判定,
34+
一个 `cover` 不合形状不牵连 `location`,反之亦然。
35+
- **写入之后**:证书若在签发之后被本次启动推翻(操作员显式开了
36+
`OS_ALLOW_LAX_MEDIA_VALUES` / `OS_ALLOW_LAX_VALUE_SHAPES`,或后台 seed 收尾晚于
37+
签发),引擎**撤销**它 —— `verified_at` 清空、`blocking` 记上、`details` 保留原
38+
`attested` 并补一条 `revoked`。只针对**本次启动亲手创建的库**上的自证行:扫过全
39+
库的真实迁移证据不会被一次写入的观察推翻。
40+
41+
**记忆化的第二张脸也一并修了。** 首启之所以「看起来是绿的」,一半靠的是进程内正好
42+
缓存了 `false``sys_migration` 在 kernel init 期间才注册,而第一条写可能赶在它之
43+
前 —— 那次读根本没读到账本,却被当成结论冻结了一整个进程的姿态。现在区分两种否定:
44+
**问过了、账本说不**(结论,照旧缓存)与**根本问不到**(未注册 / 查询抛错 —— 依旧
45+
`false`,闸依旧关着,但不记住,下一次写再问一次)。代价是账本存在之前每次写多一
46+
次 registry 查表(在任何查询之前就短路),账本可读之后即止。
47+
48+
启动横幅那条 ADR-0104 建议行(`kernel:bootstrapped`)也改为直接读账本而非读记忆化
49+
结果 —— 否则一个刚刚自证成功的新部署会被告知去跑一条已经不需要跑的迁移。
50+
51+
## 对既有部署的影响
52+
53+
- 数据本来就合规的新部署:行为不变,照旧 born-migrated,启动即 strict。
54+
- 种子数据不合规的新部署:**不再**发出那张假证书。首启与之后每一次启动一致地停在
55+
warn-first,并且每次都告诉你是哪一个值、跑哪条命令。数据本身该怎么修还是怎么修
56+
(showcase 的 `cover` 种子值在 #4774 单独跟踪)。
57+
- 已经跑过 `os migrate … --apply` 的部署:完全不受影响 —— 扫描得来的证据不经由本
58+
次改动的任何路径改写。
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): 限流计数器改为惰性解析 kernel cache —— 修掉误报的告警,也修掉「共享限流从未生效」的功能洞 (#4772)
6+
7+
`pnpm dev`(showcase)每次冷启都会打一条:
8+
9+
```
10+
WARN [auth] no cache service registered — rate-limit counters use a per-process in-memory
11+
store; a multi-node deployment needs a shared cache (Redis) to enforce limits globally
12+
```
13+
14+
`CacheServicePlugin` 就在 **21ms 后**注册好了,它本来就在已加载插件列表里。这条告警把运维引向「你需要 Redis」,接完 Redis 还是同一条告警 —— 因为缺的不是 Redis。
15+
16+
**这不只是日志误报。** `AuthPlugin.init()` 里那次 `getServiceAsync('cache')`
17+
探测的结论会被**冻结整个进程生命周期**:better-auth 实例是懒创建的,但它读的是 init
18+
时定下的 config。所以标准组合下 auth 这一侧永远拿着「没有 cache」这个结论,限流计数器
19+
**从未**用上共享存储 —— 多节点部署的限额从来没有被全局强制过,每个节点各算各的,轮换
20+
节点即可绕过。ADR-0069 D2 声明的能力与运行时不一致。
21+
22+
**修法:把「取 cache 服务」放回真正用到它的那一刻。** 新增
23+
`createLazyCacheRateLimitStorage()`,实现 better-auth 的 `rateLimit.customStorage`
24+
计数器被消费时才解析 `cache` 服务(这一刻必然在 `kernel:ready` 之后,因此与插件启动
25+
顺序无关),解析到就一直用它。告警保留,但只在**计数器真的要用共享存储、而此刻确实
26+
一个 cache 服务都没有**时才打一次 —— 那时它才是真信号,「加一个共享缓存」也才是对的
27+
建议。真没有 cache 的部署仍然限流,只是退化成进程内计数(降级,不是关闭)。
28+
29+
**刻意走 `rateLimit.customStorage` 而不是 `secondaryStorage`** 后者会连带把**会话
30+
的记录之处**搬进缓存:better-auth 的 `createSession` 不再写 `sys_session` 行,
31+
`findSession` 直接从缓存快照作答、根本不查库;而 ADR-0069 D4 的空闲超时 / 绝对时长
32+
上限 / 并发上限**全部靠写那一行来撤销会话**。所以自动把 cache 绑成 `secondaryStorage`
33+
会静默废掉 D4 的三个管控。本次因此不再从 cache 服务自动派生 `secondaryStorage`
34+
它回归「宿主显式提供才生效」,`cacheSecondaryStorage()` 改为从包根导出,供知情的宿主
35+
自行选用。会话到底该存哪,是一个需要维护者裁定的架构问题,记录在 #4785
36+
37+
对使用者的影响:
38+
39+
- 配了 cache 插件的部署不再出现那条 warn,改为一条 info(计数器已绑定到 cache 服务);
40+
- 多节点 + Redis cache 的部署,限流计数**现在真的**是全局的;
41+
- 新增 `AuthManagerOptions.rateLimitStorage`(counters-only,不迁移会话);宿主自己
42+
提供的 `secondaryStorage` 行为不变,仍然优先并继续走
43+
`rateLimit.storage: 'secondary-storage'`
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
"@objectstack/plugin-approvals": patch
4+
---
5+
6+
fix(automation,approvals): 节点类型校验推迟到插件贡献完成之后 —— approval flow 不再被误报"运行时会失败" (#4771)
7+
8+
showcase 每次冷启都打印 8 条断言:这些 flow "will fail at execution time"。8 条全是假的。
9+
`AutomationServicePlugin.start()` 从 ObjectQL registry 拉起 flow 并**当场**校验节点类型,而
10+
`ApprovalsServicePlugin.start()` 在 0.8 秒后才注册 `approval` 执行器 —— 校验器在词汇表还没
11+
成型的时候就下了结论。
12+
13+
真正的代价不是噪音,是信号丢失:**真的没装 approvals 插件**的部署会得到一模一样的 8 条告警,
14+
所以这条 warn 无法区分"健康"和"坏掉",信噪比为 0。
15+
16+
ADR-0018 明确把节点词汇表定义为**开放、可运行时扩展**的(插件通过
17+
`registerNodeExecutor(type)` 贡献类型)。因此校验只在词汇表**封闭**的那一刻才成立:
18+
19+
- `AutomationEngine.sealNodeTypeVocabulary()` —— 宣告词汇表封闭,对**所有**已注册 flow 跑一次
20+
权威校验,每个有问题的 flow warn 一条。`AutomationServicePlugin``kernel:bootstrapped`
21+
调用它(严格晚于每个插件的 `start()` 和每个 `kernel:ready` handler —— 本插件自己的
22+
`kernel:ready` 还会再注册一批 flow,别的插件也可能在它的 `kernel:ready` 里贡献执行器)。
23+
- `AutomationEngine.getUnknownNodeTypeAudit(): UnknownNodeTypeAuditEntry[]` —— 同一发现的
24+
**状态**形态,供 host(CLI 启动摘要、健康检查)直接读,而不是去 grep 日志。与
25+
`getTriggerBindingAudit()` 同一套路。
26+
- 封闭之后 `registerFlow` **恢复即时告警**:Studio 发布 / dev reload 进正在运行的服务器时,
27+
词汇表确实是完整的,那句断言此时为真。所以这是时序修复,不是把告警静音。
28+
29+
告警文案也随之改成它现在能承诺的事:"Every plugin has started, so nothing will register them
30+
now — these nodes fail at execution time with NO_EXECUTOR",并给出补救动作。
31+
32+
一并修掉同一缺陷类的另一半:`ApprovalsServicePlugin`**拿不到 automation 引擎**时,把
33+
"`approval` 节点没注册"记成 `info` —— 而 dev 的默认日志级别是 `warn`,于是**真降级发生时反而
34+
看不见**(#4632:静默降级必须响亮)。现在是 `warn`,写明后果(该部署里每个 ADR-0019 approval
35+
flow 都会以 NO_EXECUTOR 失败)和补救(装 `@objectstack/service-automation`)。`catch` 同时收窄
36+
到"服务查找"这一步,`registerApprovalNode` 内部真出错时会以自己的身份抛出,而不再被贴上
37+
"no automation engine" 的错误标签;`automation` 服务存在但不接受节点执行器的分支从前**一条日志
38+
都不打**,现在同样 warn。
39+
40+
**嵌入式 host 注意**:直接 `new AutomationEngine()` 而不经过 `AutomationServicePlugin` 的宿主,
41+
需要在自己的插件都装好之后调用一次 `sealNodeTypeVocabulary()`,才能拿到这条告警(以及之后的
42+
即时校验)。

content/docs/automation/flows.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ Each node performs a specific action in the flow.
137137
| Property | Type | Required | Description |
138138
| :--- | :--- | :--- | :--- |
139139
| `id` | `string` || Unique node identifier |
140-
| `type` | `string` || Node type — a built-in id from the table above **or** a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry at `registerFlow()` |
140+
| `type` | `string` || Node type — a built-in id from the table above **or** a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry once that registry is complete — plugins contribute node types while they start, so flows registered during boot are checked in one pass when the vocabulary closes (all plugins started), and anything registered after that (Studio publish, dev reload) is checked immediately. Unknown types warn, never reject; executing one fails with `NO_EXECUTOR` |
141141
| `label` | `string` || Display label |
142142
| `config` | `object` | optional | Type-specific configuration — the registered executor's `configSchema` owns its shape. Keys that schema does not declare are rejected at `registerFlow()`, and the built-in executors `parse()` the value against their Zod contract before running (#4277) |
143143
| `connectorConfig` | `object` | optional | `{ connectorId, actionId, input }` for a `connector_action` node |

docs/adr/0069-enterprise-authentication-hardening.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ Each row in D1-D6 names exactly one of these seams. No setting is introduced wit
139139
| Phase | Status | Notes |
140140
|---|---|---|
141141
| **P1** (D1/D2/D3 + D7 fields) |**implemented** | Password complexity/history/expiry (`assertPasswordComplexity`/`assertPasswordNotReused`/`stampPasswordChangedAt`), HIBP (`haveIBeenPwned` plugin), account lockout (`assertAccountNotLocked`/`recordSignInOutcome` + `unlock_user` action), enforced MFA + grace (`computeAuthGate``MFA_REQUIRED`, per-org `require_mfa`), rate-limit tuning (`customRules`). All settings in `auth.manifest.ts`, bound via `bindAuthSettings`. Login-audit fields `last_login_at`/`last_login_ip` stamped on sign-in (`stampLastLogin`). |
142-
| **P2** (D4/D5) | 🟡 **mostly implemented** | Session idle/absolute/concurrent (`enforceSessionControls`/`enforceConcurrentCap`), the **global** IP allow-list (`isClientIpAllowed`, `auth.allowed_ip_ranges`), and the **shared multi-node rate-limit + session store** (better-auth `secondaryStorage` bound to the kernel cache service via `cacheSecondaryStorage`; shared iff the cache is — Redis adapter in a cluster) are landed. **Remaining:** per-org `sys_organization.allowed_ip_ranges` (+ optional `sys_user.allowed_ip_ranges` override) — tracked in #2571. |
142+
| **P2** (D4/D5) | 🟡 **mostly implemented** | Session idle/absolute/concurrent (`enforceSessionControls`/`enforceConcurrentCap`), the **global** IP allow-list (`isClientIpAllowed`, `auth.allowed_ip_ranges`), and the **shared multi-node rate-limit counters** (better-auth `rateLimit.customStorage` fed by the kernel cache service through `createLazyCacheRateLimitStorage`; shared iff the cache is — Redis adapter in a cluster) are landed. **Correction (#4772):** this row previously claimed a shared **session** store via `secondaryStorage` as landed. It was not: the binding was taken in `AuthPlugin.init()`, which runs *before* `CacheServicePlugin` registers `cache`, so it never fired in the standard composition — and the counters it was supposed to share never reached the cache either. The counters now ride `rateLimit.customStorage`, resolved at counting time. The **session** half is deliberately NOT auto-wired: better-auth answers `findSession` from a `secondaryStorage` snapshot without reading the database, while D4 above revokes by writing the `sys_session` row, so a cache-backed session store silently disables idle-timeout / absolute-max / concurrent-cap enforcement. `cacheSecondaryStorage` remains exported for a host that opts into that trade knowingly. **Remaining:** per-org `sys_organization.allowed_ip_ranges` (+ optional `sys_user.allowed_ip_ranges` override) — tracked in #2571; the session-store question — tracked in #4785. |
143143
| **P2/P3** (D6) | 🟡 partial | Generic OIDC RP wired (`genericOAuth`/`sso`); admin OIDC **trust-list settings UI** still env/`sys_sso_provider`-only. |
144144
| **P3** (SAML, broader social) | 🟡 partial | `@better-auth/sso` present (SAML now better-auth-native — see Addendum); broader settings-driven social providers pending. |
145145

0 commit comments

Comments
 (0)