You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 87fc795
Browse filesBrowse the repository at this point in the historyBrowse files
feat(service-settings): the namespace read lists what is stored beneath an env pin (#30835)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D1tRdB8VLqzuDVQ4awYfofa)_
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 仍是唯一生效项。
- 为什么改:管理员用环境变量临时锁定某项(如允许 IP 列表)自救后,必须先清掉下面那份已保存的旧值再去掉变量,否则旧值回来;Setup
页(#30665)要知道"下面有没有已保存的值"才能显示"清除已保存的值",此前读取接口不给这个信息。
- 风险与回滚:只加信息、不改已有字段;秘密类字段下面只给掩码或 null,且根本不解密;`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
- **The namespace read lists what is stored beneath a pin.**
`SettingsService.getNamespace()`, the read `GET
/api/settings/:namespace` already passes through (redacted,
`settings-routes.ts` untouched), answers a key whose `OS_*` override is
in force with the same `value`, `source: 'env'`, `locked: true` and
`lockedReason` as before. Its `cascadeChain` now lists, below the `env`
entry, 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 own `locked` / `lockedReason`), then `default`. Their
`effective` flag is removed; the `env` entry stays first and the only
effective one, so the schema's "the first entry where `value` is
non-null is also the effective `source`" stays true.
- **The scope a clear nulls is readable.** A clear (`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 same
`preferredRow` / reach / user-owner rules, so the entry at the
specifier's `scope` is the value that clear removes (non-null before,
`null` after). An upper row's lock rides along, so a clear that would be
refused `locked-by-SCOPE` is visible too.
- **Secrets.** A secret key's entries beneath a pin carry
`SETTINGS_SECRET_MASK` when a value is stored and `null` when none is,
in process too: `storedSecretPresence` reads the row, the secret is
never decrypted. No in-process consumer reads those entries (consumers
read `value`, 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.
- **Cost.** `getMany` now delegates to a private `resolveMany(…,
beneathPins)`; `getNamespace` passes `true`. 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 in
`settings-getmany.test.ts`: same find count with and without the pin).
- **Docs.** `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 that `get()` /
`getMany()` answer from the env alone.
### Accepted deviation from the order (seat, thread note)
The order's item 5 named `SettingsService.get` beside the inspection
path. `get()` and `getMany()` keep answering a pin from the env alone,
without touching the store: they are per-request reads
(`resolve-authz-context` reads `getMany('localization', …)` on every
request context, plugin-auth reads `get()` on request paths), and
filling them would add two store reads per request wherever a namespace
is fully pinned. `settings-prebind-read-warning.test.ts` case 5 pins
`get()`'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`'s `sourceBadgeOf` returns "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 (real
`SettingsService`, 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 #30448's
door) the stored entry reads `null`, 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, and `decrypt` is never called, and after the clear it reads
`null`; an override coercing to `null` keeps its single entry; `get()`
and `getMany()` still answer from the env alone.
- `settings-routes.test.ts`, new top-level describe (REST,
`LocalCryptoProvider` + `sys_secret` store): `GET /:ns` lists a pinned
key's stored row and a pinned secret's mask, the wire carries neither
plaintext, `decrypt` is never called; `PUT { key: null }` through the
pin, then `GET` lists the entry `null`. The existing env-locked secret
pin now expects the `default` entry beneath the `env` one (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.
<details><summary>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)</summary>
- A. `getNamespace` asks `resolveMany(…, 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 the `get()` / `getMany()` case
stay green, as they pin the unfilled shape.
- B. a pinned secret materialised through `materialiseRow` (decrypting):
2 failed / 205 passed — the service and REST secret cases.
- C. the null-value guard removed: 1 failed / 160 passed — the
null-coercion case.
</details>
## Acceptance notes
- **Named gap: an override whose value coerces to `null`** (`OS_X=null`
on a key whose default is an array or object, which `coerceEnvValue`
JSON-parses) keeps its single `env` entry. 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 (a
`packages/spec` change, maintainer consent); not done here.
- The `PUT` answer is `get()`'s, so a clear's response carries the
env-only chain; the next namespace read shows the cleared entry.
- `resetNamespace` is unchanged: it still reads `resolveStored` for a
pinned key, which covers the null-coercion case the namespace read
leaves out.
- Spec prose, not changed here (`packages/spec` is out of surface):
`cascadeChain`'s docblock says "`locked: true` on 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 an `env` entry.
## Verification
Local runs on the final head are quoted with that head in the report
comment on #30823: `@objectstack/service-settings` `test` and
`typecheck`; every family `node scripts/pm/dispatch-gates.mjs
--commands` derives from this diff, reconciled with `--ran`; `pnpm
lint`. CI's required checks are the authority for everything else.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016WV4d13xV55y9KgsteqXK9)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(service-settings): the namespace read lists what is stored beneath a field an `OS_*` variable pins, so Setup can tell whether "clear the saved value" has anything to clear
6
+
7
+
Clause-②: yes (widening)
8
+
Metadata-protocol: none
9
+
10
+
-**Now.**`getNamespace()`, the read `GET /api/settings/:namespace` serves, answers a key whose `OS_*` override is in force with the same `value`, `source: 'env'`, `locked: true` and `lockedReason` as before, and its `cascadeChain` now lists, below the `env` entry, 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 own lock), then `default`. The `env` entry stays first and is still the only one flagged `effective`. An entry at the key's declared scope with a non-null value is the value a clear (`null`, accepted through a pin since the ADR-0007 D6 amendment) removes; after that clear the entry reads `null`.
11
+
-**Secrets.** A secret key's entries beneath a pin carry the read mask when a value is stored and `null` when none is, in process too; the stored secret is never decrypted for them, so a key pinned because its stored secret no longer decrypts still reads.
12
+
-**Unchanged.**`get()` and `getMany()` still answer an override in force from the env alone, without reading the store: they are the reads request paths call. An override whose value coerces to `null` keeps its single `env` entry, because any non-null entry listed below it would read as the effective one.
13
+
-**Cost.** 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.
0 commit comments