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.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user