Repository navigation
Commit 2445cb4
fix(service-settings): a clear goes through an env pin, so the allowed-IP lockout recovery survives removing the variable (#30448)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgE9CDELX3sPuAkeE2cdR3ph)_
domain:server#2 · os-bill · session_01Utp6CZjAtpVuoYZBzB9n5i
Fixes #30385
Clause-②: yes (widening) — `SettingsService.setMany` / `set` (and `PUT
/api/settings/:namespace`) accept a clear (`null` / `undefined`) of a
key an `OS_*` override in force pins, where every write to such a key
was refused `409 SETTINGS_LOCKED`; every other write is still refused.
No export, type or error code changes.
Metadata-protocol: none
## 维护者速读
- 改了什么:环境变量钉住的设置项(如
`OS_AUTH_ALLOWED_IP_RANGES`)现在可以“清除”:清除只清掉它下面存的值,当前生效的仍是环境变量;其他任何写入照旧
409。IP 白名单的拒绝提示改写成完整的四步救援。
- 为什么改:卡上实测,管理员被自己的 IP 白名单锁在外面后,按提示把变量设空能进来,但存着的旧列表清不掉(保存一律
409),一去掉变量重启又被锁,文档写的救援办法只管一次。
- 风险与回滚:放宽,只放行“清除”这一种写入;行锁、平台档、写入谓词照旧拦。依赖维护者对 ADR-0007 第 6 条(env
锁定拒绝一切写入)的修订,修订未定前席位不落地。回滚即撤本 PR。
- 先看:`packages/services/service-settings/src/settings-service.ts` 的
`setMany` 预检里 env
锁那一处分支;`packages/plugins/plugin-auth/src/allowed-ip-ranges.ts`
四种语言的恢复句;`packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts`
末尾的端到端用例;`content/docs/permissions/authentication.mdx` 的锁定恢复条目(已改成四步)。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/allowed-ip-ranges.pin.test.ts`,预期 1 个文件、10 条全绿(最后一条是 env 置空 → 清除 →
去掉变量重启后仍能进)。
- 需要你做的:裁定 ADR-0007 第 6 条的修订(席位已提交),之后席位落地。
## What changes
**The rule: an `OS_*` override in force refuses every write to the key
it pins except a clear, and a clear clears the value stored beneath
it.**
- **The env-lock branch (`@objectstack/service-settings`, `setMany`'s
pre-flight).** It threw `SettingsLockedError` (`locked-by-env`) whenever
`effectiveEnvOverride` was in force, whatever the patch value. A clear
(`null` / `undefined`, the spelling every clear in this service uses,
never a value such as `''` that a key would store) now goes on to the
judgments any clear meets: the Phase 2 row locks (`lockThatRefuses`),
the platform-rung rule, the write predicates. The write then nulls the
stored row as every clear does (the audit records a `reset`), so the key
falls back to its default once the override is removed. Nothing in force
changes: the answer still reads the env value with `source: 'env'` and
`locked: true`. No change to `effectiveEnvOverride`,
`auditEnvOverrides`, `validatePatch`, the routes or any export.
- **Why the stored value needed a door.** The override hides the stored
row from every read and refused every write to it, so an operator who
pinned a key to recover from a bad stored value got that value back the
moment the pin was lifted. For `auth.allowed_ip_ranges` that made the
documented lockout recovery hold only while the variable stayed set.
- **The recovery copy (`@objectstack/plugin-auth`,
`allowed-ip-ranges.ts`).** The save refusal's recovery sentence, in en,
zh-CN, ja-JP and es-ES, now names the four steps that work: start the
server with `OS_AUTH_ALLOWED_IP_RANGES` set empty, sign in, clear the
stored list with a settings request that sets `allowed_ip_ranges` to
`null` (`PUT /api/settings/auth`), then remove the variable. It names
the settings API, not Setup, because the console renders a pinned field
read-only and offers no reset on it.
- **Docs.** `docs/HARDENING.md` (the Network row gains the recovery);
`content/docs/protocol/kernel/config-resolution.mdx` (the write-error
table: an override in force refuses a write "and the write is not a
clear"; added to the claimed surface by the seat);
`packages/services/service-settings/README.md` (Env override: every
write but a clear (`null`) fails with HTTP 409, and a clear removes the
value stored beneath the override and changes nothing in force);
`content/docs/permissions/authentication.mdx` (the "If you are already
locked out" bullet now walks the four steps, and says that removing the
variable without the clear brings the stored list back). The changeset
names the earlier allowed-IP entry's recovery sentence it supersedes.
## Tests
`packages/services/service-settings/src/settings-service.test.ts`, new
top-level describe (5 cases), on the real `SettingsService`:
- a clear under a pin set over a stored value: the answer reads `{
value: env, source: 'env', locked: true }`, a `reset` change event
fires, and once the pin is lifted the key reads its default, not the old
value;
- `undefined` is a clear too, beside an ordinary write in the same
batch;
- every other write is refused `SETTINGS_LOCKED` / `locked-by-env` as
before, `''` included, and a batch carrying one stores nothing;
- a locked row at an upper rung still refuses a clear below it
(`locked-by-global`);
- a locked row at the write's own rung refuses a non-platform clear
(`locked-by-global`), and the platform administrator's clear goes
through.
`packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts`, new
top-level describe (1 case), on the real `AuthPlugin`, the real
`SettingsService` with the shipped auth manifest and the real settings
routes: a stored list that refuses `::1`; restart with
`OS_AUTH_ALLOWED_IP_RANGES=''` admits it; `''` through `PUT
/api/settings/auth` is `409 SETTINGS_LOCKED` (`details` exact); `null`
is `200` with the value read from env, locked, and the gate stays open;
remove the variable, restart: `::1` is admitted.
<details><summary>Suites and typecheck (head 173cc44)</summary>
- `pnpm --filter @objectstack/service-settings test`: Test Files 68
passed (68), Tests 1161 passed (1161); `typecheck`: exit 0.
- `pnpm --filter @objectstack/plugin-auth test`: Test Files 224 passed
(224), Tests 3955 passed | 10 skipped (3965); `typecheck` (tsc, the
examples project and `check:test-typecheck`): exit 0.
</details>
<details><summary>Measured before the fix (base 0e4dfbe, real
AuthPlugin + SettingsService + settings routes)</summary>
1. Stored list `203.0.113.0/24`, no override: `::1` refused at the gate.
2. `OS_AUTH_ALLOWED_IP_RANGES=''`, restart: `::1` admitted; the key
answers `{ value: '', source: 'env', locked: true }`.
3. Through `PUT /api/settings/auth`: `''` and `null` both `409
SETTINGS_LOCKED` (`locked-by-env`); `resetNamespace('auth')` returns
without clearing it (the key resolves `env`, so the reset skips it).
4. Remove the override: the key answers the stored list again (`source:
'global'`); restart: `::1` refused.
</details>
<details><summary>Ablations (each through `scripts/ablation-replace.mjs`
on `settings-service.ts`: anchor 1 → 0, blob changed, restore blob ==
HEAD blob, `git diff HEAD` empty; both test files resolve
`service-settings` from source, so no build sits between)</summary>
- A. the clear exemption removed (`if (/* ABLATION-30385-A */
this.effectiveEnvOverride(…))`): service-settings 4 failed | 1 passed
(the clear, `undefined`, and both lock cases, which now answer
`locked-by-env` instead of `locked-by-global`); plugin-auth pin 1 failed
| 9 passed (the end-to-end recovery).
- B. every write let through the pin (`if (false /* ABLATION-30385-B */
&& …)`): service-settings 1 failed | 4 passed (every other write
refused); plugin-auth pin 1 failed | 9 passed (the `409` step).
- C. a clear skipping the Phase 2 row locks (`if (clears) continue; //
ABLATION-30385-C` after the env branch): service-settings 2 failed | 3
passed (both lock cases); plugin-auth pin 10 passed.
</details>
## Acceptance notes
- **ADR-0007 D6 dependency.** ADR-0007 (Accepted) D6 reads "Writes from
UI are rejected with HTTP 409 + reason `locked-by-env`", and its REST
table "409 if any key locked-by-env". This change lets a clear through,
so D6 needs its amendment (AGENTS.md Prime Directive 13). `docs/adr/**`
is Tier H and outside this change; the seat has put the amendment to the
maintainer, and this PR does not land before it. The code comment cites
ADR-0007 D6.
- **Setup cannot issue the clear yet.** `SettingsField` disables a
locked field and hides "Reset to default" on it, so the clear is a
settings API request for now; the console half is the seat's to file.
The env answer also lists no stored value beneath the pin, so the
console cannot tell whether there is one to clear.
- **`content/docs/permissions/authentication.mdx` rewritten here.** PR
#30382 merged first (2026-11-05T10:10Z, `7d7babb1`), adding the "Saving,
clearing and lockout recovery" subsection. Its last bullet ("a save to
it is refused with `409 SETTINGS_LOCKED`. Remove the variable, and the
stored list applies again") is false after this change. Seat ruling:
whichever lands second rewrites that bullet, so this PR does. It now
names the four steps: start with `OS_AUTH_ALLOWED_IP_RANGES` empty, sign
in, `PUT /api/settings/auth` with `allowed_ip_ranges: null`, remove the
variable.
- **`resetNamespace` (the `reset` action) still skips a pinned key.** It
builds its patch from keys whose resolved source is a stored rung, and a
pinned key resolves `env`, so a row stored beneath a pin survives a
namespace reset while the action answers "environment/default
configuration is back in effect". Unchanged here (outside the env-lock
branch); reported to the seat.
## Verification
Suites, typecheck, lint and the full dogfood on head `173cc44e`
(`origin/main` merged in; its one conflict was the tail of
`allowed-ip-ranges.pin.test.ts`, where PR #30392 appended its own
describe; both are kept). Head `5c0cbbfb` adds one docs commit for the
contract review's two must-fixes
(`packages/services/service-settings/README.md`,
`content/docs/permissions/authentication.mdx`) and changes no code, so
those were not re-run on it. The derived gates were re-run on
`5c0cbbfb`.
<details><summary>Derived gates: 93 derived, 93 exit 0 (head
5c0cbbf)</summary>
- `5c0cbbfb`: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 93 commands for the 10 changed paths
(one more than round 1: `pnpm check:merge-driver`); each ran with its
exit code captured before any pipe. `--ran` reconciliation: "93 derived
famil(ies) accounted for — 93 run, 0 NOT-MEASURED". They ran on commit
`b4b507a9`; the pushed `5c0cbbfb` carries the same tree (`af8723c4`),
its message reworded only because the pre-push
`check:commit-card-trailers` refused a card trailer.
- `173cc44e`: 92 derived, 92 exit 0, `--ran` "92 derived famil(ies)
accounted for — 92 run, 0 NOT-MEASURED". `check:dual-build-cjs-loads`
and `check-plugin-teardown-shape --self-test` exited 3 on the first
round (no whole-workspace build; shallow checkout); after building the
dogfood closure and fetching `621a4876`, both exit 0.
- `pnpm lint` (`eslint . --no-inline-config`): exit 0.
</details>
<details><summary>Full dogfood (asked by the order; head
173cc44)</summary>
- Built the closure, then `pnpm --filter @objectstack/dogfood test`
under the verify lock: `VERDICT command-exit 0`, Test Files 419 passed |
3 skipped (422), Tests 3492 passed | 36 skipped (3528).
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic
(untouched).
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: n/a.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English (the 维护者速读 block is
Chinese by the Communication rule).
<!-- signed: domain:server#2 · os-bill ·
session_01Utp6CZjAtpVuoYZBzB9n5i -->
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>1 parent ad6826c commit 2445cb4
10 files changed
Lines changed: 238 additions & 24 deletions
File tree
- .changeset
- content/docs
- permissions
- protocol/kernel
- docs
- packages
- plugins/plugin-auth/src
- services/service-settings
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
662 | 662 | | |
663 | 663 | | |
664 | 664 | | |
665 | | - | |
| 665 | + | |
666 | 666 | | |
667 | 667 | | |
668 | 668 | | |
| |||
681 | 681 | | |
682 | 682 | | |
683 | 683 | | |
684 | | - | |
685 | | - | |
686 | | - | |
687 | | - | |
688 | | - | |
689 | | - | |
690 | | - | |
691 | | - | |
692 | | - | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
693 | 702 | | |
694 | 703 | | |
695 | 704 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
221 | | - | |
| 221 | + | |
222 | 222 | | |
223 | 223 | | |
224 | 224 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
241 | 241 | | |
242 | 242 | | |
243 | 243 | | |
244 | | - | |
| 244 | + | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
24 | 28 | | |
25 | 29 | | |
26 | 30 | | |
| |||
385 | 389 | | |
386 | 390 | | |
387 | 391 | | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
33 | | - | |
34 | | - | |
35 | | - | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
36 | 40 | | |
37 | 41 | | |
38 | 42 | | |
| |||
132 | 136 | | |
133 | 137 | | |
134 | 138 | | |
135 | | - | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
136 | 143 | | |
137 | 144 | | |
138 | 145 | | |
| |||
141 | 148 | | |
142 | 149 | | |
143 | 150 | | |
144 | | - | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
145 | 154 | | |
146 | 155 | | |
147 | 156 | | |
| |||
150 | 159 | | |
151 | 160 | | |
152 | 161 | | |
153 | | - | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
154 | 166 | | |
155 | 167 | | |
156 | 168 | | |
| |||
161 | 173 | | |
162 | 174 | | |
163 | 175 | | |
164 | | - | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
165 | 180 | | |
166 | 181 | | |
167 | 182 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
33 | | - | |
34 | | - | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
35 | 38 | | |
36 | 39 | | |
37 | 40 | | |
| |||
0 commit comments