Skip to content

Commit 87fc795

Browse 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>
1 parent e603385 commit 87fc795

7 files changed

Lines changed: 388 additions & 12 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
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.

‎content/docs/protocol/kernel/config-resolution.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,19 @@ OS_ALLOWED_ORIGINS=["a","b","c"] # default is array/object → JSON.parse
262262

263263
If the raw value cannot be parsed for the expected type, the raw string is used.
264264

265+
**What is stored beneath a pin is listed by the namespace read.** An override in
266+
force answers `source: 'env'`, `locked: true`, and `get()` / `getMany()` build
267+
that answer from the env alone, without reading the store. `getNamespace()` — the
268+
read `GET /api/settings/:namespace` serves to the Setup page — also lists, below
269+
the `env` entry of `cascadeChain`, the entries the key's read would carry with no
270+
override in force: its stored rows, then the default. The `env` entry stays the
271+
effective one. An entry at the key's declared scope with a non-null value is the
272+
value a clear (`null`) removes, so the page can tell whether "clear the saved
273+
value" has anything to clear. A secret's entries beneath the pin carry the read
274+
mask or `null`, never its value. An override whose value coerces to `null` keeps
275+
its single `env` entry, since any non-null entry below it would read as the
276+
effective one.
277+
265278
**A value outside a declared option table is ignored, not repaired.** When the
266279
specifier declares `options` and the env value matches none of them, the env
267280
layer contributes nothing at all — no value and no `cascadeChain` entry — and

‎packages/services/service-settings/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ Such fields are returned with `source: 'env', locked: true`. Every write
3434
(service or REST) but a clear (`null`) fails with HTTP 409. A clear removes
3535
the value stored beneath the override and changes nothing in force: the key
3636
still answers the env value, and falls back to its default once the variable
37-
is removed.
37+
is removed. The namespace read (`getNamespace`, `GET /api/settings/:namespace`)
38+
lists the stored entries beneath such a field's `env` entry in `cascadeChain`,
39+
so a client can tell whether a clear has a value to remove (a secret's entry
40+
shows the mask, never the value); `get()` and `getMany()` answer it from the
41+
env alone.
3842

3943
## Encryption
4044

‎packages/services/service-settings/src/settings-getmany.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,24 @@ describe('[#10826] SettingsService.getMany', () => {
242242
expect((await svc.get('localization', 'timezone', ctx)).value).toBe('America/New_York');
243243
});
244244
});
245+
246+
describe('[#30823] getNamespace lists a pinned key\'s stored rows from the same grouped load', () => {
247+
it('a pin over a global row lists that row beneath its env entry, at no more finds than the read without the pin', async () => {
248+
const { svc, engine } = await makeService();
249+
const ctx = { userId: 'u1' };
250+
engine.find.mockClear();
251+
await svc.getNamespace('localization', ctx);
252+
const unpinnedFinds = engine.find.mock.calls.length;
253+
254+
process.env.OS_LOCALIZATION_TIMEZONE = 'Asia/Tokyo';
255+
engine.find.mockClear();
256+
const ns = await svc.getNamespace('localization', ctx);
257+
expect(engine.find).toHaveBeenCalledTimes(unpinnedFinds);
258+
expect(ns.values.timezone).toMatchObject({ value: 'Asia/Tokyo', source: 'env', locked: true });
259+
expect(ns.values.timezone.cascadeChain).toEqual([
260+
expect.objectContaining({ scope: 'env', value: 'Asia/Tokyo', effective: true }),
261+
{ scope: 'global', value: 'America/New_York', locked: false, lockedReason: undefined },
262+
{ scope: 'default', value: 'UTC' },
263+
]);
264+
});
265+
});

‎packages/services/service-settings/src/settings-routes.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,8 +614,10 @@ describe('settings-routes — #7522 encrypted values are redacted at the REST bo
614614
expect(r1.state.body.data.values.smtp_password.source).toBe('env');
615615
expect(r1.state.body.data.values.smtp_password.locked).toBe(true);
616616
expect(r1.state.body.data.values.smtp_password.lockedReason).toContain('OS_SECRETSNS_SMTP_PASSWORD');
617+
// Beneath the env entry, the stored cascade: nothing stored, so the default.
617618
expect(r1.state.body.data.values.smtp_password.cascadeChain).toEqual([
618619
expect.objectContaining({ scope: 'env', value: SETTINGS_SECRET_MASK, locked: true }),
620+
{ scope: 'default', value: null },
619621
]);
620622

621623
const write = http.routes.get('PUT /api/settings/:namespace')!;
@@ -921,3 +923,78 @@ describe('settings-routes — the two retired Branding keys (#24766)', () => {
921923
expect(await storedRetiredRows()).toEqual(RETIRED_BRANDING_ROWS);
922924
});
923925
});
926+
927+
describe('settings-routes — GET /:ns lists what is stored beneath an env pin', () => {
928+
/** The secret stack with an env the test pins after storing. */
929+
function pinnableSecretStack() {
930+
const env: Record<string, string | undefined> = {};
931+
const secretRows = new Map<string, any>();
932+
const cryptoProvider = new LocalCryptoProvider();
933+
const svc = new SettingsService({
934+
env,
935+
cryptoProvider,
936+
secretStore: {
937+
async insert(row) { secretRows.set(row.id, row); return { id: row.id }; },
938+
async get(id) { return secretRows.get(id) ?? null; },
939+
async update(id, patch) { secretRows.set(id, { ...secretRows.get(id), ...patch }); },
940+
},
941+
});
942+
svc.registerManifest(secretsManifest);
943+
const http = new MockHttp();
944+
registerSettingsRoutes(http, svc, { contextFromRequest: secretAdmin });
945+
const read = async () => {
946+
const r = makeReqRes({ params: { namespace: 'secretsns' } });
947+
await http.routes.get('GET /api/settings/:namespace')!(r.req, r.res);
948+
return r.state;
949+
};
950+
return { env, svc, http, cryptoProvider, read };
951+
}
952+
953+
it('a pinned key\'s stored row rides beneath its env entry; a pinned secret\'s shows the mask, never the value', async () => {
954+
const { env, svc, cryptoProvider, read } = pinnableSecretStack();
955+
await svc.set('secretsns', 'smtp_host', 'stored.example.com', { userId: 'usr_1' });
956+
await svc.set('secretsns', 'smtp_password', SMTP_PLAINTEXT, { userId: 'usr_1' });
957+
env.OS_SECRETSNS_SMTP_HOST = 'env.example.com';
958+
env.OS_SECRETSNS_SMTP_PASSWORD = 'env-supplied-secret';
959+
env.OS_SECRETSNS_WEBHOOK_TOKEN = 'env-supplied-token';
960+
const decrypt = vi.spyOn(cryptoProvider, 'decrypt');
961+
962+
const state = await read();
963+
expect(state.status).toBe(200);
964+
const values = state.body.data.values;
965+
expect(values.smtp_host).toMatchObject({ value: 'env.example.com', source: 'env', locked: true });
966+
expect(values.smtp_host.cascadeChain).toEqual([
967+
expect.objectContaining({ scope: 'env', value: 'env.example.com', effective: true }),
968+
{ scope: 'user', value: 'stored.example.com' },
969+
{ scope: 'default', value: 'smtp.example.com' },
970+
]);
971+
expect(values.smtp_password.cascadeChain).toEqual([
972+
expect.objectContaining({ scope: 'env', value: SETTINGS_SECRET_MASK, effective: true }),
973+
{ scope: 'user', value: SETTINGS_SECRET_MASK },
974+
{ scope: 'default', value: null },
975+
]);
976+
// A pinned secret with nothing stored lists no stored entry.
977+
expect(values.webhook_token.cascadeChain.slice(1)).toEqual([{ scope: 'default', value: null }]);
978+
const wire = JSON.stringify(state.body);
979+
expect(wire).not.toContain(SMTP_PLAINTEXT);
980+
expect(wire).not.toContain('env-supplied-secret');
981+
expect(decrypt).not.toHaveBeenCalled();
982+
});
983+
984+
it('after PUT clears the stored value through the pin, the next read lists it null', async () => {
985+
const { env, svc, http, read } = pinnableSecretStack();
986+
await svc.set('secretsns', 'smtp_host', 'stored.example.com', { userId: 'usr_1' });
987+
env.OS_SECRETSNS_SMTP_HOST = 'env.example.com';
988+
989+
const put = makeReqRes({ params: { namespace: 'secretsns' }, body: { smtp_host: null } });
990+
await http.routes.get('PUT /api/settings/:namespace')!(put.req, put.res);
991+
expect(put.state.status).toBe(200);
992+
993+
const values = (await read()).body.data.values;
994+
expect(values.smtp_host).toMatchObject({ value: 'env.example.com', source: 'env', locked: true });
995+
expect(values.smtp_host.cascadeChain.slice(1)).toEqual([
996+
{ scope: 'user', value: null },
997+
{ scope: 'default', value: 'smtp.example.com' },
998+
]);
999+
});
1000+
});

‎packages/services/service-settings/src/settings-service.test.ts‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { companySettingsManifest } from './manifests/company.manifest.js';
1818
import { brandingSettingsManifest } from './manifests/branding.manifest.js';
1919
import { builtinSettingsManifests } from './manifests/index.js';
2020
import { evaluateVisibility, visibilitySource } from './visibility-eval.js';
21+
import { SETTINGS_SECRET_MASK } from './settings-secret-redaction.js';
2122
import { SettingsManifestSchema } from '@objectstack/spec/system';
2223

2324
describe('reference manifests are spec-valid', () => {
@@ -2969,3 +2970,161 @@ describe('SettingsService — a reset clears the value stored beneath an env pin
29692970
expect(r.message).toBe('Cleared 1 saved value(s); environment/default configuration is back in effect.');
29702971
});
29712972
});
2973+
2974+
describe('SettingsService — the namespace read lists what is stored beneath an env pin', () => {
2975+
// A pin on each rung a clear can land at, a secret, and a JSON key whose env
2976+
// value can coerce to null.
2977+
const BENEATH = {
2978+
namespace: 'beneath',
2979+
version: 1,
2980+
label: 'Beneath',
2981+
scope: 'tenant',
2982+
specifiers: [
2983+
{ type: 'text', key: 'motto', label: 'Motto', scope: 'tenant', default: 'none' },
2984+
{ type: 'text', key: 'banner', label: 'Banner', scope: 'global', default: 'none' },
2985+
{ type: 'text', key: 'nickname', label: 'Nickname', scope: 'user', default: 'none' },
2986+
{ type: 'password', key: 'api_secret', label: 'API secret', scope: 'tenant' },
2987+
{ type: 'password', key: 'spare_secret', label: 'Spare secret', scope: 'tenant' },
2988+
{ type: 'json', key: 'rules', label: 'Rules', scope: 'tenant', default: [] },
2989+
],
2990+
} as any;
2991+
const envEntry = (value: unknown, envName: string) => ({
2992+
scope: 'env', value, locked: true, lockedReason: `Set via env: ${envName}`, effective: true,
2993+
});
2994+
2995+
/** A crypto adapter whose decrypt is watched, so a read can prove it never opened a secret. */
2996+
function watchedCrypto(): CryptoAdapter & { decrypt: ReturnType<typeof vi.fn> } {
2997+
return {
2998+
confidential: true,
2999+
encrypt: async (plain: string) => 'kms:' + Buffer.from(plain, 'utf8').toString('base64'),
3000+
decrypt: vi.fn(async (cipher: string) => Buffer.from(cipher.replace(/^kms:/, ''), 'base64').toString('utf8')),
3001+
digest: () => 'fnv32:deadbeef',
3002+
};
3003+
}
3004+
3005+
/** `motto` stored then pinned: the operator's recovery, before the clear. */
3006+
async function pinnedOverStored() {
3007+
const env: Record<string, string | undefined> = {};
3008+
const crypto = watchedCrypto();
3009+
const svc = new SettingsService({ env, crypto });
3010+
svc.registerManifest(BENEATH);
3011+
await svc.set('beneath', 'motto', 'Stored motto');
3012+
env.OS_BENEATH_MOTTO = 'Pinned motto';
3013+
return { env, svc, crypto };
3014+
}
3015+
3016+
it('a pinned key over a stored row lists that row beneath its env entry; the answer is otherwise the pin\'s', async () => {
3017+
const { svc } = await pinnedOverStored();
3018+
const { values } = await svc.getNamespace('beneath');
3019+
3020+
expect(values.motto).toEqual({
3021+
value: 'Pinned motto',
3022+
source: 'env',
3023+
locked: true,
3024+
lockedReason: 'Set via env: OS_BENEATH_MOTTO',
3025+
cascadeChain: [
3026+
envEntry('Pinned motto', 'OS_BENEATH_MOTTO'),
3027+
{ scope: 'tenant', value: 'Stored motto', locked: false, lockedReason: undefined },
3028+
{ scope: 'default', value: 'none' },
3029+
],
3030+
});
3031+
// The env entry is the only effective one, and the first non-null one.
3032+
expect(values.motto.cascadeChain!.filter((e) => e.effective)).toHaveLength(1);
3033+
});
3034+
3035+
it('after the clear through the pin the stored entry reads null; a pin with no row lists none', async () => {
3036+
const { env, svc } = await pinnedOverStored();
3037+
await svc.set('beneath', 'motto', null);
3038+
env.OS_BENEATH_BANNER = 'Pinned banner';
3039+
3040+
const { values } = await svc.getNamespace('beneath');
3041+
expect(values.motto.cascadeChain).toEqual([
3042+
envEntry('Pinned motto', 'OS_BENEATH_MOTTO'),
3043+
{ scope: 'tenant', value: null, locked: false, lockedReason: undefined },
3044+
{ scope: 'default', value: 'none' },
3045+
]);
3046+
expect(values.banner.cascadeChain).toEqual([
3047+
envEntry('Pinned banner', 'OS_BENEATH_BANNER'),
3048+
{ scope: 'default', value: 'none' },
3049+
]);
3050+
});
3051+
3052+
it('a global key lists its global row, and an upper row\'s lock rides along, so a refused clear is visible', async () => {
3053+
const { env, svc } = await pinnedOverStored();
3054+
await svc.set('beneath', 'banner', 'Stored banner');
3055+
env.OS_BENEATH_BANNER = 'Pinned banner';
3056+
await (svc as any).upsertRow({
3057+
namespace: 'beneath', key: 'motto', scope: 'global', user_id: null, value: 'Platform motto',
3058+
value_enc: null, encrypted: false, locked: true, locked_reason: 'Set by the platform.',
3059+
});
3060+
3061+
const { values } = await svc.getNamespace('beneath');
3062+
expect(values.banner.cascadeChain).toEqual([
3063+
envEntry('Pinned banner', 'OS_BENEATH_BANNER'),
3064+
{ scope: 'global', value: 'Stored banner', locked: false, lockedReason: undefined },
3065+
{ scope: 'default', value: 'none' },
3066+
]);
3067+
expect(values.motto.cascadeChain!.slice(1, 3)).toEqual([
3068+
{ scope: 'global', value: 'Platform motto', locked: true, lockedReason: 'Set by the platform.' },
3069+
{ scope: 'tenant', value: 'Stored motto', locked: false, lockedReason: undefined },
3070+
]);
3071+
// The pin still answers: an upper row's lock does not take the lock over.
3072+
expect(values.motto).toMatchObject({ source: 'env', lockedReason: 'Set via env: OS_BENEATH_MOTTO' });
3073+
});
3074+
3075+
it('a user key lists the caller\'s own row only', async () => {
3076+
const env: Record<string, string | undefined> = {};
3077+
const svc = new SettingsService({ env });
3078+
svc.registerManifest(BENEATH);
3079+
await svc.set('beneath', 'nickname', 'Ada', { userId: 'usr_a' });
3080+
await svc.set('beneath', 'nickname', 'Bob', { userId: 'usr_b' });
3081+
env.OS_BENEATH_NICKNAME = 'Pinned';
3082+
3083+
const forA = (await svc.getNamespace('beneath', { userId: 'usr_a' })).values.nickname.cascadeChain!;
3084+
expect(forA.slice(1)).toEqual([{ scope: 'user', value: 'Ada' }, { scope: 'default', value: 'none' }]);
3085+
const forNobody = (await svc.getNamespace('beneath')).values.nickname.cascadeChain!;
3086+
expect(forNobody.slice(1)).toEqual([{ scope: 'default', value: 'none' }]);
3087+
});
3088+
3089+
it('a secret beneath a pin shows that a value is stored, never the value, and is never decrypted', async () => {
3090+
const { env, svc, crypto } = await pinnedOverStored();
3091+
await svc.set('beneath', 'api_secret', 'stored-secret-plaintext');
3092+
env.OS_BENEATH_API_SECRET = 'env-secret';
3093+
env.OS_BENEATH_SPARE_SECRET = 'env-spare';
3094+
crypto.decrypt.mockClear();
3095+
3096+
const { values } = await svc.getNamespace('beneath');
3097+
expect(values.api_secret.cascadeChain).toEqual([
3098+
envEntry('env-secret', 'OS_BENEATH_API_SECRET'),
3099+
{ scope: 'tenant', value: SETTINGS_SECRET_MASK, locked: false, lockedReason: undefined },
3100+
{ scope: 'default', value: null },
3101+
]);
3102+
expect(values.spare_secret.cascadeChain!.slice(1)).toEqual([{ scope: 'default', value: null }]);
3103+
expect(JSON.stringify(values)).not.toContain('stored-secret-plaintext');
3104+
expect(crypto.decrypt).not.toHaveBeenCalled();
3105+
3106+
// Cleared through the pin, the secret's stored entry reads null.
3107+
await svc.set('beneath', 'api_secret', null);
3108+
const after = (await svc.getNamespace('beneath')).values.api_secret.cascadeChain!;
3109+
expect(after[1]).toEqual({ scope: 'tenant', value: null, locked: false, lockedReason: undefined });
3110+
});
3111+
3112+
it('an override whose value coerces to null keeps its single env entry', async () => {
3113+
const env: Record<string, string | undefined> = {};
3114+
const svc = new SettingsService({ env });
3115+
svc.registerManifest(BENEATH);
3116+
await svc.set('beneath', 'rules', ['stored']);
3117+
env.OS_BENEATH_RULES = 'null';
3118+
3119+
const { values } = await svc.getNamespace('beneath');
3120+
expect(values.rules).toMatchObject({ value: null, source: 'env', locked: true });
3121+
expect(values.rules.cascadeChain).toEqual([envEntry(null, 'OS_BENEATH_RULES')]);
3122+
});
3123+
3124+
it('get() and getMany() still answer a pin from the env alone', async () => {
3125+
const { svc } = await pinnedOverStored();
3126+
const pinOnly = [envEntry('Pinned motto', 'OS_BENEATH_MOTTO')];
3127+
expect((await svc.get('beneath', 'motto')).cascadeChain).toEqual(pinOnly);
3128+
expect((await svc.getMany('beneath', ['motto'])).motto.cascadeChain).toEqual(pinOnly);
3129+
});
3130+
});

0 commit comments

Comments
 (0)