Repository navigation
feat(service-settings): the namespace read lists what is stored beneath an env pin - #30835
Conversation
…th an env pin getNamespace (the read GET /api/settings/:namespace serves) now lists, below the env entry of a pinned key's cascadeChain, the entries the key's read carries with no override in force: the stored rows the caller's cascade draws on, then the default. The env entry stays first and the only effective one; value, source, locked and lockedReason are unchanged. A secret's entries beneath the pin carry the read mask or null and are never decrypted. get() and getMany() keep answering a pin from the env alone. Claude-Session: https://claude.ai/code/session_016WV4d13xV55y9KgsteqXK9 Co-authored-by: Claude <noreply@anthropic.com>
…the namespace read Service pins: a pinned key over a stored row lists it beneath the env entry; after the clear through the pin it reads null; a pin with no row lists none; a global row and an upper row's lock ride along; a user key lists the caller's own row only; a secret shows the mask, never its value, and is not decrypted; an env value that coerces to null keeps its single entry; get() and getMany() still answer from the env alone. REST pins on GET and on the PUT clear, a read-count pin on the grouped load, and the env-locked secret pin now lists the default entry beneath the env one. Claude-Session: https://claude.ai/code/session_016WV4d13xV55y9KgsteqXK9 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 383edca2f60f01ab08aa2643f4432d53766caa19 && git checkout 383edca2f60f01ab08aa2643f4432d53766caa19
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b6182249c3278a60d07727b59afc291054521b4b de2998b32bdf4b5bca9375072018940ea2ce98c2 && git checkout -B drift-repro b6182249c3278a60d07727b59afc291054521b4b && git merge --no-ff de2998b32bdf4b5bca9375072018940ea2ce98c2
node scripts/docs-audit/affected-docs.mjs --json b6182249c3278a60d07727b59afc291054521b4b
|
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW 契约复审 · CONTRACT_REVIEW_TIER · PASS · 2026-11-06T06:39Z · #30823Head-sha: de2998b Must-fix: none. Should-fix (non-blocking):
Confirmed (read in the code):
Consumers checked:
|
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW 席位核对 · ACCEPT · 2026-11-06T06:40Z · #30823Head-sha: de2998b
|
Requested by Elon · project thread
domain:server#1 · os-elon-musk · session_016WV4d13xV55y9KgsteqXK9
Fixes #30823
Claim: 6677761672 (README surface amendment 6677849918)
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
getNamespace,即GET /api/settings/:namespace)对被OS_*环境变量锁定的字段,在cascadeChain的 env 条目下面列出锁定之下已保存的值(各层已存行 + 默认值);值、来源、锁定状态都不变,env 仍是唯一生效项。get()/getMany()这两个每个请求都会走的轻量读取保持不读库,不增加开销。单 PR 可整体回滚。packages/services/service-settings/src/settings-service.ts的resolveMany与withStoredBeneath/storedSecretPresence(填充与秘密处理);settings-service.test.ts末尾新 describe;settings-routes.test.ts末尾 REST 用例。pnpm --filter @objectstack/service-settings exec vitest run src/settings-service.test.ts src/settings-routes.test.ts src/settings-getmany.test.ts,预期全部通过;其中"锁定之下"相关用例 10 个。What changes
SettingsService.getNamespace(), the readGET /api/settings/:namespacealready passes through (redacted,settings-routes.tsuntouched), answers a key whoseOS_*override is in force with the samevalue,source: 'env',locked: trueandlockedReasonas before. ItscascadeChainnow lists, below theenventry, the entries the key's read carries with no override in force: the stored rows the caller's cascade draws on (global,tenant,user, each with its ownlocked/lockedReason), thendefault. Theireffectiveflag is removed; theenventry stays first and the only effective one, so the schema's "the first entry wherevalueis non-null is also the effectivesource" stays true.null) through a pin writes the key's declared scope, for the caller's user and organization reach. The listed rows are picked by the samepreferredRow/ reach / user-owner rules, so the entry at the specifier'sscopeis the value that clear removes (non-null before,nullafter). An upper row's lock rides along, so a clear that would be refusedlocked-by-SCOPEis visible too.SETTINGS_SECRET_MASKwhen a value is stored andnullwhen none is, in process too:storedSecretPresencereads the row, the secret is never decrypted. No in-process consumer reads those entries (consumers readvalue, still the env value in plaintext as before), and a key pinned because its stored secret no longer decrypts still reads. The REST redaction maps them as before.getManynow delegates to a privateresolveMany(…, beneathPins);getNamespacepassestrue. A pinned key joins the namespace read's existing grouped row load, so the read costs one more load only when every key of a group is pinned (pinned insettings-getmany.test.ts: same find count with and without the pin).content/docs/protocol/kernel/config-resolution.mdx(The Environment Layer) and the package README's "Env override" section say what the namespace read lists beneath a pin, and thatget()/getMany()answer from the env alone.Accepted deviation from the order (seat, thread note)
The order's item 5 named
SettingsService.getbeside the inspection path.get()andgetMany()keep answering a pin from the env alone, without touching the store: they are per-request reads (resolve-authz-contextreadsgetMany('localization', …)on every request context, plugin-auth readsget()on request paths), and filling them would add two store reads per request wherever a namespace is fully pinned.settings-prebind-read-warning.test.tscase 5 pinsget()'s in-window env answer silent for the same reason.ResolvedSettingValueSchema.cascadeChain's docblock already scopes the full chain to "the inspection path used by the settings UI". The card's "the settings REST answer carries it" is met by the namespace read.The reader #30665 plans
The console's Settings page reads values only through
getSettingsNamespace(ui/apps/console/src/pages/settings/api.ts,GET /api/settings/:namespace); the settings REST family has no single-key read. So #30665 reads the namespace endpoint this PR fills.SettingsField.tsx'ssourceBadgeOfreturns "inherited from env" before it looks below the effective entry, so today's badges do not change.Tests
settings-service.test.ts, new top-level describe (realSettingsService, in-memory store): a pinned key over a stored row lists it beneath the env entry and the answer is otherwise the pin's (whole object compared); after the clear through the pin (PR fix(service-settings): a clear goes through an env pin, so the allowed-IP lockout recovery survives removing the variable #30448's door) the stored entry readsnull, and a pin with no row lists none; a global key lists its global row and an upper row's lock rides along; a user key lists the caller's own row only; a secret shows the mask, never its value, anddecryptis never called, and after the clear it readsnull; an override coercing tonullkeeps its single entry;get()andgetMany()still answer from the env alone.settings-routes.test.ts, new top-level describe (REST,LocalCryptoProvider+sys_secretstore):GET /:nslists a pinned key's stored row and a pinned secret's mask, the wire carries neither plaintext,decryptis never called;PUT { key: null }through the pin, thenGETlists the entrynull. The existing env-locked secret pin now expects thedefaultentry beneath theenvone (this change makes its one-entry chain false).settings-getmany.test.ts, new top-level describe: over the engine double, a pin over a global row lists that row at the same find count as the read without the pin.Ablations (scripts/ablation-replace.mjs; each anchor 1 → 0, blob changed, restored blob == HEAD, `git diff HEAD` empty; the suites import src, no build between)
getNamespaceasksresolveMany(…, false)(the fill off): 9 failed / 198 passed across the three files — every new beneath-pin case, the REST cases and the read-count case, plus the updated env-locked secret pin. The null-coercion case and theget()/getMany()case stay green, as they pin the unfilled shape.materialiseRow(decrypting): 2 failed / 205 passed — the service and REST secret cases.Acceptance notes
null(OS_X=nullon a key whose default is an array or object, whichcoerceEnvValueJSON-parses) keeps its singleenventry. Any non-null entry listed beneath it, the default included, would be the chain's first non-null entry and contradict the effective-source rule while the env value is in force. Saying "stored beneath" there needs a schema field (apackages/specchange, maintainer consent); not done here.PUTanswer isget()'s, so a clear's response carries the env-only chain; the next namespace read shows the cleared entry.resetNamespaceis unchanged: it still readsresolveStoredfor a pinned key, which covers the null-coercion case the namespace read leaves out.packages/specis out of surface):cascadeChain's docblock says "locked: trueon an upper entry means writes to lower entries are rejected", which since the ADR-0007 D6 amendment holds for every write but a clear under anenventry.Verification
Local runs on the final head are quoted with that head in the report comment on #30823:
@objectstack/service-settingstestandtypecheck; every familynode scripts/pm/dispatch-gates.mjs --commandsderives from this diff, reconciled with--ran;pnpm lint. CI's required checks are the authority for everything else.Generated by Claude Code