Просмотр исходного кода

feat(settings): layered descriptors and structural secret redaction

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.
Yichen Jiang 1 месяц назад
Родитель
Сommit
a5c8136cb3

+ 1 - 2
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.

+ 60 - 8
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<string, unknown> | 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<never>
+      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,
+      }
+    })
   }
 
   /**

+ 106 - 0
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<string, SchemaNode>
+  /** `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<string, unknown> {
+  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<string, unknown> = {}
+      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<string, unknown> = {}
+      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<never>, value: unknown): RedactedValue {
+  const secrets: RedactedSecret[] = []
+  const stripped = walk(schema, value, [], secrets)
+  return { value: stripped, secrets }
+}

+ 168 - 0
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<object> = 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<never>, {
+      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<never>, 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<never>, 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<never>, {
+      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<never>, { 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<never>, { 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<string, unknown>) {
+    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<string, unknown>).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' })
+  })
+})