Skip to content

fix(service-settings): a clear goes through an env pin, so the allowed-IP lockout recovery survives removing the variable - #30448

Draft
objectstack-fleet[bot] wants to merge 5 commits into
mainfrom
claude/project-thread-3koeqm
Draft

objectstack-fleet[bot] wants to merge 5 commits into
mainfrom
claude/project-thread-3koeqm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

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 末尾的端到端用例。
  • 两分钟验证: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) and 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). 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.

Suites and typecheck (head 173cc44)
  • 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.
Measured before the fix (base 0e4dfbe, real AuthPlugin + SettingsService + settings routes)
  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.
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)
  • 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.

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 untouched here. PR docs(permissions): state the allowed_ip_ranges save refusal, the live clear and the env recovery #30382 (open, another lane) adds its "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 true on main and false after this change. Seat ruling: whichever lands second rewrites that bullet to the four steps.
  • 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

Gates, 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).

Derived gates: 92 derived, 92 exit 0
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 92 commands for the changed paths; each ran with its exit code captured before any pipe.
  • --ran reconciliation: "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 on this head.
  • pnpm lint (eslint . --no-inline-config): exit 0.
Full dogfood (asked by the order; head 173cc44)
  • 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).

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic (untouched).
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is Chinese by the Communication rule).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CZjAtpVuoYZBzB9n5i

…ored value beneath the pin

An env override in force refused every write, clears included, so the
documented allowed-IP recovery (OS_AUTH_ALLOWED_IP_RANGES set empty) could
not clear the stored list and removing the variable locked the caller out
again. A clear (null/undefined) now goes through the pin; every other write
stays 409 SETTINGS_LOCKED. The refusal copy names the full recovery.

Claude-Session: https://claude.ai/code/session_01Utp6CZjAtpVuoYZBzB9n5i
Co-authored-by: Claude <noreply@anthropic.com>
# Conflicts:
#	packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-auth, @objectstack/service-settings, touching 4 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-settings/src/settings-service.types.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via SettingsService (symbol, a top-level class))
  • content/docs/deployment/self-hosting.mdx (via COPY (symbol, a top-level const object))
  • content/docs/kernel/runtime-services/settings-service.mdx (via setMany (symbol, a method of class SettingsService))
  • content/docs/permissions/authentication.mdx (via /api/settings/auth (route, a path literal in COPY; a path literal in recovery))
  • content/docs/protocol/kernel/config-resolution.mdx (via SettingsService (symbol, a top-level class), setMany (symbol, a method of class SettingsService))
  • content/docs/protocol/kernel/index.mdx (via SettingsService (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via setMany (symbol, a method of class SettingsService))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-settings/src/settings-service.types.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0892a94d8e190b7547408d1a37e49ba087ddd635 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 716531ac25b81ac59bc4dbcc20cf860c295d54c8 — the merge of head 173cc44ee855da37932e879685f181625e6102da into base 0892a94d8e190b7547408d1a37e49ba087ddd635, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 716531ac25b81ac59bc4dbcc20cf860c295d54c8 && git checkout 716531ac25b81ac59bc4dbcc20cf860c295d54c8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0892a94d8e190b7547408d1a37e49ba087ddd635 173cc44ee855da37932e879685f181625e6102da && git checkout -B drift-repro 0892a94d8e190b7547408d1a37e49ba087ddd635 && git merge --no-ff 173cc44ee855da37932e879685f181625e6102da

node scripts/docs-audit/affected-docs.mjs --json 0892a94d8e190b7547408d1a37e49ba087ddd635

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0892a94d8e190b7547408d1a37e49ba087ddd635 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant