From a5c8136cb3890d5175261c27d94db5fe2cc94daa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 29 Jul 2026 16:50:05 +0800 Subject: [PATCH] feat(settings): layered descriptors and structural secret redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit describe() now carries each namespace's detached composition base and raw user section beside the resolved value — presence in the user layer is how a form marks a field user-overridden — and describe({redactSecrets:true}) strips role('secret') fields from every layer while enumerating their {path,set} slots, so a wire surface has no slot that can carry a secret. The pure redactSecrets(schema,value) walker (object/dict/array containers, secret-role subtree as opaque leaf, inputs never mutated) is exported for any other wire; the README's no-redaction Known Limitation is discharged. --- packages/settings/settings/README.md | 3 +- packages/settings/settings/src/index.ts | 68 ++++++- packages/settings/settings/src/redact.ts | 106 +++++++++++ .../settings/settings/tests/redact.spec.ts | 168 ++++++++++++++++++ 4 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 packages/settings/settings/src/redact.ts create mode 100644 packages/settings/settings/tests/redact.spec.ts diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index ff6cdeb57a..de04c1260c 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -7,7 +7,7 @@ Abstract user-settings seam (`ctx.settings`). One provider holds a raw document ## Service API - `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud. -- `describe()` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, `applies`) for configuration surfaces. +- `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires. - `get(ns)` — resolved value, `undefined` while unregistered. - `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order. - `replace(ns, section)` — sets the user section wholesale: the removal/reset path a merge cannot express (`replace({})` re-inherits `base` and schema defaults). @@ -34,4 +34,3 @@ No direct invalidation; a consumer that folds a settings value into the request - **Single user layer** — resolution knows schema defaults, one composition `base`, and one user document; there is no project/managed layering or per-value provenance yet. - **Cross-process concurrency is provider-defined** — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider is last-write-wins). -- **No secret-field redaction** — `describe()` returns resolved values verbatim; a wire surface (RPC/UI) must redact `role('secret')` fields before exposure. diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 2df73528e8..e3922f41e4 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -9,6 +9,11 @@ import { Context, Service } from 'cordis' import type z from 'schemastery' import type { Branded } from '@deepseek-ai/dsh-brand' +import { redactSecrets } from './redact.ts' +import type { RedactedSecret } from './redact.ts' + +export { redactSecrets } from './redact.ts' +export type { RedactedSecret, RedactedValue } from './redact.ts' /** Nominal id of one registered settings namespace. */ export type SettingsNamespace = Branded<'SettingsNamespace'> @@ -49,8 +54,27 @@ export interface SettingsDescriptor { schema: unknown /** Current resolved value. */ value: unknown + /** Registrant's composition `base` layer (detached), when one was declared. */ + base?: unknown + /** + * Raw user section from the stored document (detached), when one exists and + * is well-formed; a field's presence here is what marks it user-overridden. + */ + user?: unknown /** Owner's declared effect timing. */ applies: SettingsApplies + /** Schema-declared secret positions; present only under `redactSecrets`. */ + secrets?: RedactedSecret[] +} + +/** Options for {@link Settings.describe}. */ +export interface SettingsDescribeOptions { + /** + * Strip `role('secret')` fields from `value`/`base`/`user` and enumerate + * them in each descriptor's `secrets`. Every wire surface MUST pass this; + * the verbatim default exists for same-process configuration UIs only. + */ + redactSecrets?: boolean } /** Owner-facing handle for one registered namespace. */ @@ -262,16 +286,44 @@ export abstract class Settings extends Service { } /** - * Describe every registered namespace for configuration surfaces. + * Describe every registered namespace for configuration surfaces, including + * the composition `base` and raw user layers so a form can mark which fields + * the user overrode (presence in `user`) and what a reset returns to. + * @param options - redaction switch; wire surfaces must redact. * @returns one descriptor per registered namespace, in registration order. */ - describe(): SettingsDescriptor[] { - return [...this.registrations.values()].map(registration => ({ - ns: registration.ns, - schema: registration.schema.toJSON(), - value: registration.resolved, - applies: registration.applies, - })) + describe(options?: SettingsDescribeOptions): SettingsDescriptor[] { + return [...this.registrations.values()].map((registration) => { + let user: Record | undefined + try { + user = this.section(registration.ns) + } catch { + // A malformed stored section already warned at publish and kept the + // last good resolved value; only that malformed shape can throw here, + // and describing it as "no user layer" keeps this read total. + user = undefined + } + const base = registration.base === undefined ? undefined : structuredClone(registration.base) + const detachedUser = user === undefined ? undefined : structuredClone(user) + const descriptor: SettingsDescriptor = { + ns: registration.ns, + schema: registration.schema.toJSON(), + value: registration.resolved, + ...base === undefined ? {} : { base }, + ...detachedUser === undefined ? {} : { user: detachedUser }, + applies: registration.applies, + } + if (options?.redactSecrets !== true) return descriptor + const schema = registration.schema as z + const redacted = redactSecrets(schema, registration.resolved) + return { + ...descriptor, + value: redacted.value, + ...base === undefined ? {} : { base: redactSecrets(schema, base).value }, + ...detachedUser === undefined ? {} : { user: redactSecrets(schema, detachedUser).value }, + secrets: redacted.secrets, + } + }) } /** diff --git a/packages/settings/settings/src/redact.ts b/packages/settings/settings/src/redact.ts new file mode 100644 index 0000000000..68cb034e05 --- /dev/null +++ b/packages/settings/settings/src/redact.ts @@ -0,0 +1,106 @@ +/** + * Structural secret redaction for settings values. `role('secret')` fields are + * removed from a value before it crosses a wire boundary; a sidecar records + * each schema-declared secret position and whether it currently holds a value, + * so a configuration surface can render a write-only input without ever + * receiving the secret itself. + * @module @deepseek-ai/dsh-settings/redact + */ + +import type z from 'schemastery' + +/** + * Minimal structural view of a live schemastery node. Only the relations the + * redactor walks are named; everything else on the instance is ignored. + */ +interface SchemaNode { + type?: string + meta?: { role?: unknown } + /** `object` properties, keyed by property name. */ + dict?: Record + /** `dict`/`array` element schema. */ + inner?: SchemaNode +} + +/** One schema-declared secret position inside a redacted value. */ +export interface RedactedSecret { + /** Path from the section root to the removed field (concrete dict keys and array indexes included). */ + path: string[] + /** Whether the field held a value before redaction. */ + set: boolean +} + +/** A value with every `role('secret')` field removed, plus the removal record. */ +export interface RedactedValue { + /** Detached copy of the input with secret fields absent. */ + value: unknown + /** + * Every reachable secret position: object properties always (even unset, so + * a form knows the slot exists), dict entries and array items only where the + * value has them. + */ + secrets: RedactedSecret[] +} + +/** Whether a value is a plain data object the walker may recurse into. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function walk(node: SchemaNode | undefined, value: unknown, path: string[], secrets: RedactedSecret[]): unknown { + if (node === undefined) return value + if (node.meta?.role === 'secret') { + secrets.push({ path, set: value !== undefined }) + return undefined + } + switch (node.type) { + case 'object': { + const properties = node.dict ?? {} + const source = isRecord(value) ? value : undefined + const rebuilt: Record = {} + if (source !== undefined) { + for (const [key, entry] of Object.entries(source)) { + if (key in properties) continue + rebuilt[key] = entry + } + } + for (const [key, child] of Object.entries(properties)) { + const stripped = walk(child, source?.[key], [...path, key], secrets) + if (stripped !== undefined) rebuilt[key] = stripped + } + return source === undefined && Object.keys(rebuilt).length === 0 ? value : rebuilt + } + case 'dict': { + if (!isRecord(value)) return value + const rebuilt: Record = {} + for (const [key, entry] of Object.entries(value)) { + const stripped = walk(node.inner, entry, [...path, key], secrets) + if (stripped !== undefined) rebuilt[key] = stripped + } + return rebuilt + } + case 'array': { + if (!Array.isArray(value)) return value + return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets)) + } + default: + return value + } +} + +/** + * Remove every `role('secret')` field a schema declares from a value. The + * walker follows `object`, `dict`, and `array` containers; a secret must be + * declared directly on a field reachable through those containers (a secret + * buried inside a union branch or transform is not reachable and must not be + * modeled that way). The input is never mutated. + * @param schema - live schemastery schema describing the value. + * @param value - the value to strip; `undefined` yields an empty record with + * object-property secret slots still enumerated. + * @returns the stripped detached value and the ordered secret positions. + */ +export function redactSecrets(schema: z, value: unknown): RedactedValue { + const secrets: RedactedSecret[] = [] + const stripped = walk(schema, value, [], secrets) + return { value: stripped, secrets } +} diff --git a/packages/settings/settings/tests/redact.spec.ts b/packages/settings/settings/tests/redact.spec.ts new file mode 100644 index 0000000000..fff6902ea6 --- /dev/null +++ b/packages/settings/settings/tests/redact.spec.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import z from 'schemastery' +import { redactSecrets, settingsNamespace } from '../src/index.ts' +import { MemorySettings } from './memory.ts' + +const Profile = z.object({ + apiKey: z.string().role('secret'), + apiKeyEnv: z.string().role('credential-ref'), + baseURL: z.string(), +}) + +const Adapter: z = z.object({ + apiKey: z.string().role('secret'), + providers: z.dict(Profile), + fallbacks: z.array(Profile), + nested: z.object({ + token: z.string().role('secret'), + }), +}) + +describe('redactSecrets', () => { + it('strips secrets from object, dict, and array containers and records each position', () => { + const { value, secrets } = redactSecrets(Adapter as z, { + apiKey: 'top-secret', + providers: { + openai: { apiKey: 'sk-live', apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://x' }, + anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' }, + }, + fallbacks: [{ apiKey: 'fb', baseURL: 'https://y' }], + nested: {}, + }) + expect(value).toEqual({ + providers: { + openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://x' }, + anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' }, + }, + fallbacks: [{ baseURL: 'https://y' }], + nested: {}, + }) + expect(secrets).toEqual([ + { path: ['apiKey'], set: true }, + { path: ['providers', 'openai', 'apiKey'], set: true }, + { path: ['providers', 'anthropic', 'apiKey'], set: false }, + { path: ['fallbacks', '0', 'apiKey'], set: true }, + { path: ['nested', 'token'], set: false }, + ]) + }) + + it('enumerates unset object-property slots without inventing containers', () => { + const { value, secrets } = redactSecrets(Adapter as z, undefined) + expect(value).toBeUndefined() + expect(secrets).toEqual([ + { path: ['apiKey'], set: false }, + { path: ['nested', 'token'], set: false }, + ]) + }) + + it('never mutates the input and preserves keys outside the schema', () => { + const input = Object.freeze({ + apiKey: 'frozen', + extra: Object.freeze({ keep: true }), + }) + const { value } = redactSecrets(Adapter as z, input) + expect(input.apiKey).toBe('frozen') + expect(value).toEqual({ extra: { keep: true }, nested: undefined } as never) + expect((value as { extra: unknown }).extra).toEqual({ keep: true }) + }) + + it('passes malformed container values through untouched', () => { + const { value, secrets } = redactSecrets(Adapter as z, { + providers: 'not-a-dict', + fallbacks: 'not-an-array', + }) + expect(value).toEqual({ providers: 'not-a-dict', fallbacks: 'not-an-array' }) + expect(secrets).toEqual([ + { path: ['apiKey'], set: false }, + { path: ['nested', 'token'], set: false }, + ]) + }) + + it('treats a secret-role container as one opaque secret leaf', () => { + const Weird = z.object({ blob: z.object({ inner: z.string() }).role('secret') }) + const { value, secrets } = redactSecrets(Weird as z, { blob: { inner: 'x' } }) + expect(value).toEqual({}) + expect(secrets).toEqual([{ path: ['blob'], set: true }]) + }) + + it('drops a dict entry whose entire value is the secret', () => { + const Tokens = z.object({ tokens: z.dict(z.string().role('secret')) }) + const { value, secrets } = redactSecrets(Tokens as z, { tokens: { a: 'x', b: 'y' } }) + expect(value).toEqual({ tokens: {} }) + expect(secrets).toEqual([ + { path: ['tokens', 'a'], set: true }, + { path: ['tokens', 'b'], set: true }, + ]) + }) + + it('tolerates structural nodes missing their relation maps', () => { + expect(redactSecrets({ type: 'dict' } as never, { k: 'v' })).toEqual({ value: { k: 'v' }, secrets: [] }) + expect(redactSecrets({ type: 'object' } as never, { k: 'v' })).toEqual({ value: { k: 'v' }, secrets: [] }) + expect(redactSecrets({ type: 'array' } as never, ['v'])).toEqual({ value: ['v'], secrets: [] }) + }) +}) + +describe('describe() layers and redaction', () => { + const NS = settingsNamespace('adapter') + + async function boot(doc?: Record) { + const ctx = new Context() + await ctx.plugin(MemorySettings, doc === undefined ? undefined : { doc }) + return ctx + } + + it('exposes detached base and user layers beside the resolved value', async () => { + const ctx = await boot({ adapter: { baseURL: 'https://user' } }) + const base = { apiKey: 'entry-key', baseURL: 'https://base' } + ctx.settings.register(NS, Profile, { base }) + const [descriptor] = ctx.settings.describe() + expect(descriptor?.base).toEqual(base) + expect(descriptor?.base).not.toBe(base) + expect(descriptor?.user).toEqual({ baseURL: 'https://user' }) + expect(descriptor?.value).toEqual({ apiKey: 'entry-key', baseURL: 'https://user' }) + ;(descriptor?.user as Record).baseURL = 'mutated' + expect(ctx.settings.describe()[0]?.user).toEqual({ baseURL: 'https://user' }) + expect(descriptor?.secrets).toBeUndefined() + }) + + it('omits the layers when neither a base nor a user section exists', async () => { + const ctx = await boot() + ctx.settings.register(NS, Profile) + const [descriptor] = ctx.settings.describe() + expect(descriptor).not.toHaveProperty('base') + expect(descriptor).not.toHaveProperty('user') + }) + + it('describes a section that became malformed after registration as having no user layer', async () => { + const ctx = await boot({ adapter: { baseURL: 'https://user' } }) + const provider = ctx.get('settings') as MemorySettings + ctx.settings.register(NS, Profile, { base: { baseURL: 'https://base' } }) + provider.pushExternal({ adapter: 5 }) + const [descriptor] = ctx.settings.describe() + expect(descriptor).not.toHaveProperty('user') + // The malformed publish kept the last good resolved value. + expect(descriptor?.value).toEqual({ baseURL: 'https://user' }) + }) + + it('redacts a descriptor that has neither base nor user layer', async () => { + const ctx = await boot() + ctx.settings.register(NS, Profile) + const [descriptor] = ctx.settings.describe({ redactSecrets: true }) + expect(descriptor).not.toHaveProperty('base') + expect(descriptor).not.toHaveProperty('user') + expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: false }]) + }) + + it('redacts every layer and enumerates secret slots under redactSecrets', async () => { + const ctx = await boot({ adapter: { apiKey: 'user-key', baseURL: 'https://user' } }) + ctx.settings.register(NS, Profile, { base: { apiKey: 'entry-key' } }) + const [descriptor] = ctx.settings.describe({ redactSecrets: true }) + expect(descriptor?.value).toEqual({ baseURL: 'https://user' }) + expect(descriptor?.base).toEqual({}) + expect(descriptor?.user).toEqual({ baseURL: 'https://user' }) + expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: true }]) + const [verbatim] = ctx.settings.describe() + expect(verbatim?.value).toEqual({ apiKey: 'user-key', baseURL: 'https://user' }) + }) +})