fix(settings): expose third-party settings namespaces to the web config client
The web configuration surface (apiproxy) served only model-provider plus
explicit allowlist namespaces, so a third-party plugin's settings card (e.g.
the web-ui image-understanding plugin's describe-image) showed "namespace not
exposed" and was uneditable. Let a plugin opt its namespace in via
`settings.register(..., { configurable: true })`, and let a deployment expose
any shipped third-party namespace via the gateway's new `exposeSettings`
config (the web-app bundle lists describe-image). Default stays not-exposed.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ User-settings Service Definition (`ctx.settings`). One provider holds a raw docu
|
||||
|
||||
- `documentPath` — absolute path of the provider's user-editable file when it has one; non-file providers leave it `undefined`. Host configuration adapters derive availability from it, while browser protocols expose only a boolean capability and never a filesystem target.
|
||||
- `prepareDocument()` — return that path after making the document ready for a native editor. The base implementation returns `documentPath`; a file provider may materialize an absent document first.
|
||||
- `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.
|
||||
- `register(ns, schema, { base?, applies?, configurable? })` — 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. `configurable: true` opts the namespace into the web configuration client (see the apiproxy README); without it a registration stays host-only.
|
||||
- `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. Patches may contain only JSON-compatible data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently change such values on reload). 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.
|
||||
|
||||
@@ -39,6 +39,12 @@ export interface SettingsRegisterOptions<T> {
|
||||
base?: Partial<T>
|
||||
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
|
||||
applies?: SettingsApplies
|
||||
/**
|
||||
* Whether the web configuration client may read and write this namespace.
|
||||
* Defaults to `false` so a registration never becomes remotely editable by
|
||||
* accident; a plugin that ships a settings card opts in here.
|
||||
*/
|
||||
configurable?: boolean
|
||||
/**
|
||||
* Reject a resolved section the owner could not act on, for constraints its
|
||||
* schema cannot express — a cross-field requirement, or one field's validity
|
||||
@@ -85,6 +91,8 @@ export interface SettingsDescriptor {
|
||||
user?: unknown
|
||||
/** Owner's declared effect timing. */
|
||||
applies: SettingsApplies
|
||||
/** Whether the web configuration client may read and write this namespace. */
|
||||
configurable: boolean
|
||||
/** Schema-declared secret positions; present only under `redactSecrets`. */
|
||||
secrets?: RedactedSecret[]
|
||||
}
|
||||
@@ -326,6 +334,8 @@ interface SettingsRegistration {
|
||||
schema: z<unknown>
|
||||
base: unknown
|
||||
applies: SettingsApplies
|
||||
/** Whether the web configuration client may read and write this namespace. */
|
||||
configurable: boolean
|
||||
/** Owner-supplied check for constraints the schema cannot express. */
|
||||
validate?: (value: unknown) => void
|
||||
resolved: unknown
|
||||
@@ -441,6 +451,7 @@ export abstract class SettingsProvider extends Service {
|
||||
schema: schema as z<unknown>,
|
||||
base: options?.base,
|
||||
applies: options?.applies ?? 'live',
|
||||
configurable: options?.configurable ?? false,
|
||||
...options?.validate === undefined
|
||||
? {}
|
||||
: { validate: options.validate as (value: unknown) => void },
|
||||
@@ -497,6 +508,7 @@ export abstract class SettingsProvider extends Service {
|
||||
...base === undefined ? {} : { base },
|
||||
...detachedUser === undefined ? {} : { user: detachedUser },
|
||||
applies: registration.applies,
|
||||
configurable: registration.configurable,
|
||||
}
|
||||
if (options?.redactSecrets !== true) return descriptor
|
||||
const schema = registration.schema as z<never>
|
||||
@@ -511,6 +523,19 @@ export abstract class SettingsProvider extends Service {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The namespaces a plugin opted into web configuration via `configurable:
|
||||
* true` on registration. The web config boundary serves these alongside the
|
||||
* explicit harness allowlists, so a plugin that ships a settings card is
|
||||
* editable without hardcoding its namespace into the proxy.
|
||||
* @returns the configurable namespace ids, in registration order.
|
||||
*/
|
||||
configurableNamespaces(): string[] {
|
||||
return [...this.registrations.values()]
|
||||
.filter(registration => registration.configurable)
|
||||
.map(registration => String(registration.ns))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one registered namespace's resolved value.
|
||||
* @param ns - the namespace to read.
|
||||
|
||||
@@ -140,6 +140,21 @@ describe('registration', () => {
|
||||
.toThrow(/already registered/)
|
||||
})
|
||||
|
||||
it('exposes only namespaces opted in via configurable on registration', async () => {
|
||||
const { ctx } = await boot()
|
||||
const theme = settingsNamespace('ui-theme')
|
||||
const shell = settingsNamespace('shell')
|
||||
// Default: not remotely configurable.
|
||||
ctx.settings.register(theme, ThemeSchema)
|
||||
// Explicit opt-in: remotely configurable.
|
||||
ctx.settings.register(shell, ThemeSchema, { configurable: true })
|
||||
|
||||
expect(ctx.settings.configurableNamespaces()).toEqual(['shell'])
|
||||
const byNs = new Map(ctx.settings.describe().map(descriptor => [String(descriptor.ns), descriptor]))
|
||||
expect(byNs.get('ui-theme')?.configurable).toBe(false)
|
||||
expect(byNs.get('shell')?.configurable).toBe(true)
|
||||
})
|
||||
|
||||
it('fails registration when the stored section is invalid for the schema', async () => {
|
||||
const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
|
||||
expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()
|
||||
|
||||
Reference in New Issue
Block a user