mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(api): serve settings through Remote controllers
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-settings-controller",
|
||||
"description": "Remote owner for the configuration surfaces over the settings-domain seams",
|
||||
"version": "0.1.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/api/settings-controller"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Host owner of the `credentials` Remote namespace: the reference half of
|
||||
* `ctx.credentials` as a browser configuration page reads and writes it.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-api-settings-controller/src/credentials.ts
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
|
||||
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Fan-out bound on one remote `describe` batch. A settings page asks about the
|
||||
* references its own rows name, so this is far above any real page and still
|
||||
* keeps one authenticated request from starting unbounded provider work.
|
||||
*/
|
||||
const MAX_DESCRIBE_REFS = 64
|
||||
|
||||
const credentialRefSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
||||
const describeRequestSchema = z.object({
|
||||
refs: z.array(credentialRefSchema).max(MAX_DESCRIBE_REFS),
|
||||
})
|
||||
const setRequestSchema = z.object({ ref: credentialRefSchema, value: z.string().min(1) })
|
||||
const unsetRequestSchema = z.object({ ref: credentialRefSchema })
|
||||
|
||||
/** Parse the domain constraints that are more specific than generated TypeScript codecs. */
|
||||
function parseRequest<T>(method: string, schema: z.ZodType<T>, value: unknown): T {
|
||||
const parsed = schema.safeParse(value)
|
||||
if (!parsed.success) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'bad-request',
|
||||
message: `invalid payload for ${method}`,
|
||||
details: { issues: parsed.error.issues },
|
||||
})
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy exactly the fields {@link CredentialInfo} declares. The Gateway returns
|
||||
* a business result without decoding it, so a provider whose `describe` carried
|
||||
* extra enumerable properties would otherwise serialize them to the caller.
|
||||
* @param info - the provider's answer for one reference.
|
||||
* @returns the same facts with nothing else attached.
|
||||
*/
|
||||
function projectCredentialInfo(info: CredentialInfo): CredentialInfo {
|
||||
return {
|
||||
configured: info.configured,
|
||||
...info.source === undefined ? {} : { source: info.source },
|
||||
writable: info.writable,
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host owner of the `credentials` Remote namespace. */
|
||||
credentialsController: CredentialsController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host service backing the generated `ctx.remote.credentials` namespace. It
|
||||
* carries every wire obligation the credential seam itself does not: the batch
|
||||
* fan-out bound, the field-by-field view projection, the reference-grammar
|
||||
* guard, and the refusal mapping. Secret values cross in one direction only —
|
||||
* no method here returns one.
|
||||
*/
|
||||
export class CredentialsController extends TypertRemoteService {
|
||||
/** @param ctx - Host context where a credential provider may be mounted. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'credentialsController', { namespace: 'credentials' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe several references for one configuration surface. Batched because
|
||||
* a settings page describes every reference its rows name at once, and one
|
||||
* round trip keeps those rows from settling separately.
|
||||
* @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`.
|
||||
* @returns one view per requested name, keyed by that name.
|
||||
* @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted.
|
||||
*/
|
||||
@Remote
|
||||
async describe(refs: string[]): Promise<Record<string, CredentialInfo>> {
|
||||
const request = parseRequest('credentials.describe', describeRequestSchema, { refs })
|
||||
const branded = request.refs.map(ref => [ref, credentialRef(ref)] as const)
|
||||
const credentials = this.provider()
|
||||
const entries = await Promise.all(branded.map(async ([ref, key]) =>
|
||||
[ref, projectCredentialInfo(await credentials.describe(key))] as const))
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store one value from a configuration surface. The value crosses the wire in
|
||||
* this direction only: no read path returns it.
|
||||
* @param ref - reference name to store under.
|
||||
* @param value - the non-empty secret value.
|
||||
* @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
|
||||
*/
|
||||
@Remote
|
||||
async set(ref: string, value: string): Promise<void> {
|
||||
const request = parseRequest('credentials.set', setRequestSchema, { ref, value })
|
||||
const branded = credentialRef(request.ref)
|
||||
const credentials = this.provider()
|
||||
await this.write(request.ref, () => credentials.set(branded, request.value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one reference from a configuration surface.
|
||||
* @param ref - reference name to remove.
|
||||
* @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
|
||||
*/
|
||||
@Remote
|
||||
async unset(ref: string): Promise<void> {
|
||||
const request = parseRequest('credentials.unset', unsetRequestSchema, { ref })
|
||||
const branded = credentialRef(request.ref)
|
||||
const credentials = this.provider()
|
||||
await this.write(request.ref, () => credentials.unset(branded))
|
||||
}
|
||||
|
||||
/** Resolve the optional provider or report how to supply it. */
|
||||
private provider(): CredentialProvider {
|
||||
const credentials = this.ctx.get('credentials')
|
||||
if (credentials === undefined) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'internal',
|
||||
message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return credentials
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one remote write and report every refusal as `credential-rejected`
|
||||
* carrying the seam's own message: a read-only source shadowing the reference
|
||||
* is what a configuration surface must show verbatim. Callers brand the
|
||||
* reference before entering, so a name outside the grammar never reaches this
|
||||
* path and fails the same way it does on the read side. The details name only
|
||||
* the reference, so no failure path can carry the value back out.
|
||||
*/
|
||||
private async write(ref: string, write: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await write()
|
||||
} catch (error: unknown) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'credential-rejected',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { ref },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CredentialsController
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Host Remote owner for the configuration surfaces over the settings-domain
|
||||
* seams. Two namespaces: `settings`, the redacted reads and writes of
|
||||
* `ctx.settings`, owned by the class below; and `credentials`, mounted from
|
||||
* here as its own plugin.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-api-settings-controller
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings'
|
||||
import type {
|
||||
SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView,
|
||||
} from '@deepseek-ai/dsh-settings/types'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session/types'
|
||||
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { z } from 'zod'
|
||||
import { CredentialsController } from './credentials.ts'
|
||||
|
||||
export { CredentialsController } from './credentials.ts'
|
||||
export type * from './types.ts'
|
||||
|
||||
const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) })
|
||||
|
||||
/**
|
||||
* Project one redacted descriptor onto its wire view, field by field. The
|
||||
* Gateway returns a business result without decoding it, so a provider whose
|
||||
* descriptor carried extra enumerable properties would otherwise serialize them
|
||||
* to the caller.
|
||||
* @param descriptor - one descriptor read under `redactSecrets`.
|
||||
* @returns the same facts with nothing else attached.
|
||||
*/
|
||||
function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
|
||||
return {
|
||||
ns: String(descriptor.ns),
|
||||
schema: descriptor.schema as JsonValue,
|
||||
value: descriptor.value as JsonValue,
|
||||
...descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue },
|
||||
...descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue },
|
||||
applies: descriptor.applies,
|
||||
secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
|
||||
revision: descriptor.revision,
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host owner of the `settings` Remote namespace. */
|
||||
settingsController: SettingsController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host service backing the generated `ctx.remote.settings` namespace. Every
|
||||
* remote read uses `redactSecrets: true`, so a `role('secret')` field cannot
|
||||
* ride a response. Writes expose the settings service's merge, replacement,
|
||||
* and path-addressed operations, and classify every provider refusal as
|
||||
* `settings-conflict` or `settings-rejected` with the service's message.
|
||||
*/
|
||||
export class SettingsController extends TypertRemoteService {
|
||||
/**
|
||||
* Register the settings namespace and mount the credentials namespace beside
|
||||
* it. Both namespaces stay registered when a provider is absent so calls can
|
||||
* return the configuration API's actionable missing-provider diagnostic.
|
||||
* @param ctx - Host context where settings and credential providers may be mounted.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'settingsController', { namespace: 'settings' })
|
||||
ctx.plugin(CredentialsController)
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe every registered namespace for a configuration page: redacted
|
||||
* layered values plus the serialized schema the page renders its form from.
|
||||
* @returns provider writability, local-document presence, and one view per namespace.
|
||||
* @throws TypertRemoteFailure when no settings provider is mounted.
|
||||
*/
|
||||
@Remote
|
||||
describe(): SettingsDescribeValue {
|
||||
const settings = this.provider()
|
||||
return {
|
||||
writable: settings.writable,
|
||||
hasDocument: settings.documentPath !== undefined,
|
||||
namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch into one namespace's stored user section.
|
||||
* @param ns - namespace key to write.
|
||||
* @param patch - fields to merge into the user section.
|
||||
* @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
|
||||
* @returns the namespace's redacted view after the write.
|
||||
* @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
|
||||
*/
|
||||
@Remote
|
||||
update(
|
||||
ns: string,
|
||||
patch: Record<string, JsonValue>,
|
||||
expectedRevision: number | undefined,
|
||||
): Promise<SettingsNamespaceView> {
|
||||
return this.write(ns, 'update', patch, expectedRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one namespace's stored user section wholesale.
|
||||
* @param ns - namespace key to write.
|
||||
* @param section - complete replacement user section.
|
||||
* @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
|
||||
* @returns the namespace's redacted view after the write.
|
||||
* @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
|
||||
*/
|
||||
@Remote
|
||||
replace(
|
||||
ns: string,
|
||||
section: Record<string, JsonValue>,
|
||||
expectedRevision: number | undefined,
|
||||
): Promise<SettingsNamespaceView> {
|
||||
return this.write(ns, 'replace', section, expectedRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply path-addressed edits to one namespace's user section, resolved against
|
||||
* the section as stored rather than against whatever the caller last read,
|
||||
* then answer with that namespace's new redacted view.
|
||||
* @param ns - namespace key to write.
|
||||
* @param ops - the edits to apply, in order.
|
||||
* @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
|
||||
* @returns the namespace's redacted view after the write.
|
||||
* @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
|
||||
*/
|
||||
@Remote
|
||||
async mutate(
|
||||
ns: string,
|
||||
ops: SettingsPathOpView[],
|
||||
expectedRevision: number | undefined,
|
||||
): Promise<SettingsNamespaceView> {
|
||||
return this.write(ns, 'mutate', ops, expectedRevision)
|
||||
}
|
||||
|
||||
private async write(
|
||||
ns: string,
|
||||
mode: 'update' | 'replace' | 'mutate',
|
||||
input: Record<string, JsonValue> | SettingsPathOpView[],
|
||||
expectedRevision: number | undefined,
|
||||
): Promise<SettingsNamespaceView> {
|
||||
const parsed = settingsNamespaceRequestSchema.safeParse({ ns })
|
||||
if (!parsed.success) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'bad-request',
|
||||
message: `invalid payload for settings.${mode}`,
|
||||
details: { issues: parsed.error.issues },
|
||||
})
|
||||
}
|
||||
const settings = this.provider()
|
||||
let branded
|
||||
try {
|
||||
// A malformed name can address no registration, so it fails exactly as an
|
||||
// unregistered one does.
|
||||
branded = settingsNamespace(parsed.data.ns)
|
||||
} catch (error: unknown) {
|
||||
throw rejected(ns, error)
|
||||
}
|
||||
try {
|
||||
if (mode === 'update') await settings.update(branded, input, expectedRevision)
|
||||
else if (mode === 'replace') await settings.replace(branded, input, expectedRevision)
|
||||
else await settings.mutate(branded, input as SettingsPathOp[], expectedRevision)
|
||||
} catch (error: unknown) {
|
||||
throw rejected(ns, error)
|
||||
}
|
||||
const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
|
||||
if (descriptor === undefined) {
|
||||
// The write committed but the namespace vanished before this read: only a
|
||||
// concurrent registrant disposal can produce it.
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'internal',
|
||||
message: `settings namespace "${ns}" was disposed after the ${mode}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return namespaceView(descriptor)
|
||||
}
|
||||
|
||||
/** Resolve the optional provider or report how to supply it. */
|
||||
private provider(): SettingsProvider {
|
||||
const settings = this.ctx.get('settings')
|
||||
if (settings === undefined) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'internal',
|
||||
message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return settings
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one seam refusal. A stale writer is its own outcome, not a malformed
|
||||
* request: the client must re-read and re-apply rather than treat the write as
|
||||
* invalid.
|
||||
* @param ns - the namespace the write addressed.
|
||||
* @param error - whatever the seam threw.
|
||||
* @returns the failure to raise for that refusal.
|
||||
*/
|
||||
function rejected(ns: string, error: unknown): TypertRemoteFailure {
|
||||
if (error instanceof SettingsConflictError) {
|
||||
return new TypertRemoteFailure({
|
||||
code: 'settings-conflict',
|
||||
message: error.message,
|
||||
details: { ns, expected: error.expected, actual: error.actual },
|
||||
})
|
||||
}
|
||||
return new TypertRemoteFailure({
|
||||
code: 'settings-rejected',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { ns },
|
||||
})
|
||||
}
|
||||
|
||||
export default SettingsController
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-settings-controller/invariant */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-api-settings-controller'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'api-settings-controller-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the settings and credential seams own storage and
|
||||
* update events, while this package only projects their methods onto the wire.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Browser-safe failure vocabulary of the configuration surfaces this package
|
||||
* serves. The redacted views themselves live with their seam in
|
||||
* `@deepseek-ai/dsh-settings/types`, whose Cordis event declarations already
|
||||
* register that file for the Client compilation face.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-api-settings-controller/types
|
||||
*/
|
||||
|
||||
/** Stable settings failure details returned by the `settings` namespace. */
|
||||
export interface SettingsErrorDetailsMap {
|
||||
/**
|
||||
* Every seam refusal that is not a stale write: an unregistered or malformed
|
||||
* namespace, a read-only provider, schema validation, storage.
|
||||
*/
|
||||
'settings-rejected': { readonly ns: string }
|
||||
/**
|
||||
* The stored revision moved after the caller read it. Its own outcome rather
|
||||
* than an invalid request: the caller must re-read and re-apply.
|
||||
*/
|
||||
'settings-conflict': { readonly ns: string; readonly expected: number; readonly actual: number }
|
||||
}
|
||||
|
||||
/** Settings business failure carried by a rejected Remote call. */
|
||||
export type SettingsError = {
|
||||
[Code in keyof SettingsErrorDetailsMap]: {
|
||||
readonly code: Code
|
||||
readonly message: string
|
||||
readonly details: SettingsErrorDetailsMap[Code]
|
||||
}
|
||||
}[keyof SettingsErrorDetailsMap]
|
||||
|
||||
/** Stable credential failure details returned by the `credentials` namespace. */
|
||||
export interface CredentialErrorDetailsMap {
|
||||
/**
|
||||
* The provider refused a valid write, for example because a read-only source
|
||||
* shadows the reference. The details name only the reference, never the value.
|
||||
*/
|
||||
'credential-rejected': { readonly ref: string }
|
||||
}
|
||||
|
||||
/** Credential business failure carried by a rejected Remote call. */
|
||||
export type CredentialError = {
|
||||
[Code in keyof CredentialErrorDetailsMap]: {
|
||||
readonly code: Code
|
||||
readonly message: string
|
||||
readonly details: CredentialErrorDetailsMap[Code]
|
||||
}
|
||||
}[keyof CredentialErrorDetailsMap]
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
|
||||
import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import CredentialsController from '../src/credentials.ts'
|
||||
import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts'
|
||||
|
||||
/** A store whose `describe` carries more than the view declares, as a foreign provider might. */
|
||||
class LeakyCredentials extends MemoryCredentials {
|
||||
override describe(): Promise<CredentialInfo> {
|
||||
return Promise.resolve(
|
||||
{ configured: true, source: 'memory', writable: true, value: 'sk-leaked' } as CredentialInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A store whose write rejects with a bare string, the way some client libraries do. */
|
||||
class LiteralRejectingCredentials extends MemoryCredentials {
|
||||
override async set(): Promise<void> {
|
||||
throw 'the store refused'
|
||||
}
|
||||
}
|
||||
|
||||
/** A store whose provider-owned policy rejects an otherwise valid write. */
|
||||
class RejectingCredentials extends MemoryCredentials {
|
||||
override set(): Promise<void> {
|
||||
return Promise.reject(new Error('a read-only source shadows this reference'))
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(
|
||||
seed: Record<string, string> = {},
|
||||
provider: typeof MemoryCredentials = MemoryCredentials,
|
||||
): Promise<CredentialsController> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(provider, seed)
|
||||
await ctx.plugin(CredentialsController)
|
||||
return ctx.credentialsController
|
||||
}
|
||||
|
||||
describe('the credentials Remote namespace a configuration surface calls', () => {
|
||||
it('publishes the credentials namespace from its own service key', async () => {
|
||||
const controller = await boot()
|
||||
const binding = controller.typertRemote
|
||||
expect(binding.serviceKey).toBe('credentialsController')
|
||||
expect(binding.namespace).toBe('credentials')
|
||||
expect(remoteMethods(controller)).toEqual([
|
||||
{ method: 'describe', invocation: { kind: 'direct' } },
|
||||
{ method: 'set', invocation: { kind: 'direct' } },
|
||||
{ method: 'unset', invocation: { kind: 'direct' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports the actionable configuration error while no credential provider is mounted', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(CredentialsController)
|
||||
for (const call of [
|
||||
() => ctx.credentialsController.describe(['DEEPSEEK_API_KEY']),
|
||||
() => ctx.credentialsController.set('DEEPSEEK_API_KEY', 'sk-live'),
|
||||
() => ctx.credentialsController.unset('DEEPSEEK_API_KEY'),
|
||||
]) {
|
||||
const failure = await call().catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
expect((failure as TypertRemoteFailure).failure).toEqual({
|
||||
code: 'internal',
|
||||
message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('describes a batch of references as one map, values excluded', async () => {
|
||||
const controller = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' })
|
||||
const described = await controller.describe(['DEEPSEEK_API_KEY', 'OPENAI_API_KEY'])
|
||||
expect(described).toEqual({
|
||||
DEEPSEEK_API_KEY: { configured: true, source: 'memory', writable: true },
|
||||
OPENAI_API_KEY: { configured: false, writable: true },
|
||||
})
|
||||
expect(JSON.stringify(described)).not.toContain('sk-seeded')
|
||||
})
|
||||
|
||||
it('reports an invalid reference as bad-request', async () => {
|
||||
const controller = await boot()
|
||||
for (const call of [
|
||||
() => controller.describe(['DEEPSEEK_API_KEY', 'not a var']),
|
||||
() => controller.set('not a var', 'sk-live'),
|
||||
() => controller.unset('not a var'),
|
||||
]) {
|
||||
const failure = await call().catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
|
||||
}
|
||||
})
|
||||
|
||||
it('answers the largest batch it accepts and reports one reference more as bad-request', async () => {
|
||||
const controller = await boot()
|
||||
const accepted = Array.from({ length: 64 }, (_unused, index) => `REF_${String(index)}`)
|
||||
expect(Object.keys(await controller.describe(accepted))).toHaveLength(64)
|
||||
const failure = await controller.describe([...accepted, 'REF_64']).catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
|
||||
})
|
||||
|
||||
it('answers only the fields the view declares, whatever a provider returns', async () => {
|
||||
const controller = await boot({}, LeakyCredentials)
|
||||
const described = await controller.describe(['DEEPSEEK_API_KEY'])
|
||||
expect(described.DEEPSEEK_API_KEY).toEqual({ configured: true, source: 'memory', writable: true })
|
||||
expect(JSON.stringify(described)).not.toContain('sk-leaked')
|
||||
})
|
||||
|
||||
it('stores and removes through the same references the batch describes', async () => {
|
||||
const controller = await boot()
|
||||
await controller.set('DEEPSEEK_API_KEY', 'sk-live')
|
||||
expect(await controller.describe(['DEEPSEEK_API_KEY']))
|
||||
.toEqual({ DEEPSEEK_API_KEY: { configured: true, source: 'memory', writable: true } })
|
||||
await controller.unset('DEEPSEEK_API_KEY')
|
||||
expect(await controller.describe(['DEEPSEEK_API_KEY']))
|
||||
.toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } })
|
||||
})
|
||||
|
||||
it('reports a refused write as credential-rejected naming only the reference', async () => {
|
||||
const controller = await boot({}, RejectingCredentials)
|
||||
const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
const { code, message, details } = (failure as TypertRemoteFailure).failure
|
||||
expect(code).toBe('credential-rejected')
|
||||
expect(message).toContain('read-only source')
|
||||
expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' })
|
||||
})
|
||||
|
||||
it('reports an empty value as bad-request', async () => {
|
||||
const controller = await boot()
|
||||
const failure = await controller.set('DEEPSEEK_API_KEY', '').catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
|
||||
})
|
||||
|
||||
it('stringifies a refusal that is not an Error', async () => {
|
||||
const controller = await boot({}, LiteralRejectingCredentials)
|
||||
const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure.message).toBe('the store refused')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import * as SettingsControllerInvariant from '../src/invariant.ts'
|
||||
|
||||
describe('api-settings-controller invariant companion', () => {
|
||||
it('reserves the package name against duplicate registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin(SettingsControllerInvariant)
|
||||
|
||||
expect(() => {
|
||||
ctx.invariants.register('@deepseek-ai/dsh-api-settings-controller', () => {})
|
||||
}).toThrow(/already registered/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import SettingsController from '../src/index.ts'
|
||||
import { MemorySettings } from '../../../settings/settings/tests/memory.ts'
|
||||
|
||||
const NS = settingsNamespace('ui-test')
|
||||
|
||||
const Profile = z.object({
|
||||
preference: z.union(['light', 'dark']).default('light'),
|
||||
apiKey: z.string().role('secret'),
|
||||
})
|
||||
|
||||
/** A provider that reports a local document, for the `hasDocument` fact. */
|
||||
class DocumentSettings extends MemorySettings {
|
||||
override get documentPath(): string | undefined {
|
||||
return '/deployment/settings.yaml'
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider whose read forgets the namespace its write just committed. */
|
||||
class VanishingSettings extends MemorySettings {
|
||||
override describe(): SettingsDescriptor[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider whose descriptor omits the secret-slot list. `secrets` is optional
|
||||
* on the descriptor, so a foreign provider may leave it out even under
|
||||
* `redactSecrets`, and the view still has to declare an empty list.
|
||||
*/
|
||||
class SlotlessSettings extends MemorySettings {
|
||||
override describe(): SettingsDescriptor[] {
|
||||
return [{
|
||||
ns: NS,
|
||||
schema: Profile.toJSON(),
|
||||
value: { preference: 'light' },
|
||||
applies: 'live',
|
||||
revision: 0,
|
||||
} as unknown as SettingsDescriptor]
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider that refuses every write the way a read-only backing store would. */
|
||||
class RefusingSettings extends MemorySettings {
|
||||
override mutate(ns: SettingsNamespace): Promise<void> {
|
||||
return Promise.reject(new Error(`settings "${ns}" is read-only in this deployment`))
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider that refuses with a bare string, the way some storage clients do. */
|
||||
class LiteralRefusingSettings extends MemorySettings {
|
||||
override async mutate(): Promise<void> {
|
||||
throw 'the document is locked'
|
||||
}
|
||||
}
|
||||
|
||||
async function boot(
|
||||
provider: typeof MemorySettings = MemorySettings,
|
||||
options: { doc?: Record<string, unknown>; base?: { preference: 'light' | 'dark' } } = {},
|
||||
): Promise<{ controller: SettingsController; ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(provider, options.doc === undefined ? {} : { doc: options.doc })
|
||||
ctx.settings.register(NS, Profile, options.base === undefined ? {} : { base: options.base })
|
||||
await ctx.plugin(SettingsController)
|
||||
return { controller: ctx.settingsController, ctx }
|
||||
}
|
||||
|
||||
describe('the settings Remote namespace a configuration page calls', () => {
|
||||
it('publishes the settings namespace from its own service key', async () => {
|
||||
const { controller } = await boot()
|
||||
expect(controller.typertRemote.serviceKey).toBe('settingsController')
|
||||
expect(controller.typertRemote.namespace).toBe('settings')
|
||||
expect(remoteMethods(controller)).toEqual([
|
||||
{ method: 'describe', invocation: { kind: 'direct' } },
|
||||
{ method: 'update', invocation: { kind: 'direct' } },
|
||||
{ method: 'replace', invocation: { kind: 'direct' } },
|
||||
{ method: 'mutate', invocation: { kind: 'direct' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports the actionable configuration error while no settings provider is mounted', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SettingsController)
|
||||
for (const call of [
|
||||
() => ctx.settingsController.describe(),
|
||||
() => ctx.settingsController.update('ui-test', {}, undefined),
|
||||
() => ctx.settingsController.replace('ui-test', {}, undefined),
|
||||
() => ctx.settingsController.mutate('ui-test', [], undefined),
|
||||
]) {
|
||||
const failure = await Promise.resolve().then(call).catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
expect((failure as TypertRemoteFailure).failure).toEqual({
|
||||
code: 'internal',
|
||||
message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('mounts the credentials namespace beside its own', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings)
|
||||
ctx.settings.register(NS, Profile)
|
||||
const fiber = ctx.plugin(SettingsController)
|
||||
await fiber.await()
|
||||
expect(ctx.get('credentialsController')).toBeDefined()
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('settingsController')).toBeUndefined()
|
||||
expect(ctx.get('credentialsController')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('describes every namespace redacted, with the deployment facts around them', async () => {
|
||||
const { controller } = await boot(DocumentSettings, { doc: { 'ui-test': { apiKey: 'sk-stored' } } })
|
||||
const value = controller.describe()
|
||||
expect(value).toMatchObject({ writable: true, hasDocument: true })
|
||||
const [view] = value.namespaces
|
||||
expect(view?.ns).toBe('ui-test')
|
||||
// The secret never rides; its slot reports only that one is stored.
|
||||
expect(JSON.stringify(value)).not.toContain('sk-stored')
|
||||
expect(view?.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
||||
// Redaction removes the field rather than replacing it, so the layer that
|
||||
// stored a secret comes back empty instead of carrying a placeholder.
|
||||
expect(view?.user).toEqual({})
|
||||
})
|
||||
|
||||
it('reports a read-only provider and omits the layers it has none of', async () => {
|
||||
const { controller } = await boot(class extends MemorySettings {
|
||||
override get writable(): boolean {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const value = controller.describe()
|
||||
expect(value).toMatchObject({ writable: false, hasDocument: false })
|
||||
const [view] = value.namespaces
|
||||
// No composition base was declared and no user section is stored, so
|
||||
// neither optional layer appears at all.
|
||||
expect(view && 'base' in view).toBe(false)
|
||||
expect(view && 'user' in view).toBe(false)
|
||||
})
|
||||
|
||||
it('declares an empty slot list when the provider names no secrets', async () => {
|
||||
const { controller } = await boot(SlotlessSettings)
|
||||
const [view] = controller.describe().namespaces
|
||||
expect(view?.secrets).toEqual([])
|
||||
})
|
||||
|
||||
it('carries the composition base layer when the registrant declared one', async () => {
|
||||
const { controller } = await boot(MemorySettings, { base: { preference: 'dark' } })
|
||||
const [view] = controller.describe().namespaces
|
||||
expect(view?.base).toEqual({ preference: 'dark' })
|
||||
})
|
||||
|
||||
it('applies path-addressed edits and answers with the namespace it just wrote', async () => {
|
||||
const { controller } = await boot()
|
||||
const view = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined)
|
||||
expect(view).toMatchObject({ ns: 'ui-test', user: { preference: 'dark' } })
|
||||
expect(view.revision).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('supports merge updates and wholesale replacement on the Remote namespace', async () => {
|
||||
const { controller } = await boot(MemorySettings, {
|
||||
doc: { 'ui-test': { preference: 'dark', apiKey: 'sk-stored' } },
|
||||
})
|
||||
const updated = await controller.update('ui-test', { preference: 'light' }, undefined)
|
||||
expect(updated.user).toEqual({ preference: 'light' })
|
||||
expect(updated.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
||||
|
||||
const replaced = await controller.replace('ui-test', {}, updated.revision)
|
||||
expect(replaced.value).toEqual({ preference: 'light' })
|
||||
expect(replaced.user).toEqual({})
|
||||
expect(replaced.secrets).toEqual([{ path: ['apiKey'], set: false }])
|
||||
})
|
||||
|
||||
it('refuses a stale write as settings-conflict carrying both revisions', async () => {
|
||||
const { controller } = await boot()
|
||||
const held = controller.describe().namespaces[0]!.revision
|
||||
await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], held)
|
||||
const failure = await controller
|
||||
.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'light' }], held)
|
||||
.catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
const { code, details } = (failure as TypertRemoteFailure).failure
|
||||
expect(code).toBe('settings-conflict')
|
||||
expect(details).toMatchObject({ ns: 'ui-test', expected: held })
|
||||
})
|
||||
|
||||
it('answers a malformed namespace exactly as an unregistered one', async () => {
|
||||
const { controller } = await boot()
|
||||
for (const ns of ['Not A Namespace', 'unregistered']) {
|
||||
const failure = await controller.mutate(ns, [{ op: 'unset', path: ['preference'] }], undefined)
|
||||
.catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({
|
||||
code: 'settings-rejected',
|
||||
details: { ns },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('reports an empty namespace as bad-request', async () => {
|
||||
const { controller } = await boot()
|
||||
for (const call of [
|
||||
() => controller.update('', {}, undefined),
|
||||
() => controller.replace('', {}, undefined),
|
||||
() => controller.mutate('', [], undefined),
|
||||
]) {
|
||||
const failure = await call().catch((error: unknown) => error)
|
||||
expect(failure).toBeInstanceOf(TypertRemoteFailure)
|
||||
expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a refused write as settings-rejected carrying the seam message', async () => {
|
||||
const { controller } = await boot(RefusingSettings)
|
||||
const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
|
||||
.catch((error: unknown) => error)
|
||||
const { code, message } = (failure as TypertRemoteFailure).failure
|
||||
expect(code).toBe('settings-rejected')
|
||||
expect(message).toContain('read-only in this deployment')
|
||||
})
|
||||
|
||||
it('stringifies a refusal that is not an Error', async () => {
|
||||
const { controller } = await boot(LiteralRefusingSettings)
|
||||
const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
|
||||
.catch((error: unknown) => error)
|
||||
expect((failure as TypertRemoteFailure).failure.message).toBe('the document is locked')
|
||||
})
|
||||
|
||||
it('reports a namespace disposed between the write and its read-back', async () => {
|
||||
const { controller } = await boot(VanishingSettings)
|
||||
const failure = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined)
|
||||
.catch((error: unknown) => error)
|
||||
const { code, message } = (failure as TypertRemoteFailure).failure
|
||||
expect(code).toBe('internal')
|
||||
expect(message).toContain('was disposed after the mutate')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -86,6 +86,11 @@
|
||||
- id: session-controller
|
||||
name: '@deepseek-ai/dsh-api-session-controller'
|
||||
|
||||
# Configuration-surface reads and writes over Typert Remote. Each method
|
||||
# reports an actionable error when its settings-domain provider is absent.
|
||||
- id: settings-controller
|
||||
name: '@deepseek-ai/dsh-api-settings-controller'
|
||||
|
||||
# Workspace commands and reconnect-safe projection over Typert Remote.
|
||||
- id: workspace-controller
|
||||
name: '@deepseek-ai/dsh-api-workspace-controller'
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"@deepseek-ai/dsh-session-log-export": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-settings-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { CredentialKey, CredentialRecord, CredentialRef } from './types.ts'
|
||||
import type { CredentialInfo, CredentialKey, CredentialRecord, CredentialRef } from './types.ts'
|
||||
|
||||
export type { ApiKeyRecord, CredentialKey, CredentialRecord, CredentialRef, GrantRecord } from './types.ts'
|
||||
export type {
|
||||
ApiKeyRecord, CredentialInfo, CredentialKey, CredentialRecord, CredentialRef, GrantRecord,
|
||||
} from './types.ts'
|
||||
|
||||
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
@@ -119,16 +121,6 @@ export interface ResolvedCredential {
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Source and writability facts for one reference, safe for configuration UIs — never the value. */
|
||||
export interface CredentialInfo {
|
||||
/** Whether {@link CredentialProvider.resolve} would currently return a value. */
|
||||
configured: boolean
|
||||
/** Source layer currently supplying the value; absent while unconfigured. */
|
||||
source?: string
|
||||
/** Whether {@link CredentialProvider.set} would currently succeed for this reference. */
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
/** Presence and writability facts for one record, safe for configuration UIs — never the value. */
|
||||
export interface CredentialRecordInfo {
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Client-safe type surface of the credential seam: the two key brands, the
|
||||
* stored-record union, and the seam's Cordis event declarations. Types only —
|
||||
* no runtime code, and nothing here reaches a Host-only symbol, so a Client
|
||||
* compilation face reads exactly the signature the Host emits.
|
||||
* stored-record union, the reference view crossing the Remote wire, and the
|
||||
* seam's Cordis event declarations. Types only — no runtime code, and nothing
|
||||
* here reaches a Host-only symbol, so a Client compilation face reads exactly
|
||||
* the signature the Host emits.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-credentials/types
|
||||
*/
|
||||
@@ -58,6 +59,20 @@ export interface GrantRecord {
|
||||
/** One durable credential record, tagged by what the seam may do with it. */
|
||||
export type CredentialRecord = ApiKeyRecord | GrantRecord
|
||||
|
||||
/**
|
||||
* Source and writability facts for one reference, safe for configuration UIs —
|
||||
* never the value. The view has no slot a value could ride in, which is what
|
||||
* lets the whole read half cross the Remote wire.
|
||||
*/
|
||||
export interface CredentialInfo {
|
||||
/** Whether resolving the reference would currently return a value. */
|
||||
configured: boolean
|
||||
/** Source layer currently supplying the value; absent while unconfigured. */
|
||||
source?: string
|
||||
/** Whether the active provider can write this reference. */
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
|
||||
@@ -37,15 +37,17 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* Client-safe type surface of the user-settings seam: the namespace brand, the
|
||||
* commit-origin union, and the seam's Cordis event declarations. Types only —
|
||||
* no runtime code, and nothing here reaches a Host-only symbol, so a Client
|
||||
* commit-origin union, the redacted views a configuration surface reads over
|
||||
* the Remote wire, and the seam's Cordis event declarations. Types only — no
|
||||
* runtime code, and nothing here reaches a Host-only symbol, so a Client
|
||||
* compilation face reads exactly the signatures the Host emits.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-settings/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Nominal id of one registered settings namespace. */
|
||||
export type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
@@ -15,6 +17,61 @@ export type SettingsNamespace = Branded<'SettingsNamespace'>
|
||||
/** Origin of one committed settings change. */
|
||||
export type SettingsUpdateSource = 'update' | 'provider'
|
||||
|
||||
/** One schema-declared secret slot inside a redacted namespace value. */
|
||||
export interface SettingsSecretView {
|
||||
/** Path from the section root to the removed field. */
|
||||
path: string[]
|
||||
/** Whether the slot currently holds a value; the value itself never rides. */
|
||||
set: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire view of one registered namespace, always read under `redactSecrets`. The
|
||||
* JSON-valued fields are `JsonValue` rather than the descriptor's `unknown`
|
||||
* because the Remote boundary admits no unconstrained data.
|
||||
*/
|
||||
export interface SettingsNamespaceView {
|
||||
/** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */
|
||||
ns: string
|
||||
/** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */
|
||||
schema: JsonValue
|
||||
/** Redacted resolved value (schema defaults → composition base → user layer). */
|
||||
value: JsonValue
|
||||
/** Redacted composition base layer, when the registrant declared one. */
|
||||
base?: JsonValue
|
||||
/** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */
|
||||
user?: JsonValue
|
||||
/** When the owner applies changes. */
|
||||
applies: 'live' | 'restart'
|
||||
/** Every schema-declared secret slot with its configured state. */
|
||||
secrets: SettingsSecretView[]
|
||||
/**
|
||||
* Monotonic revision of the raw user section this view was read at. Send it
|
||||
* back as `expectedRevision` on a write so a stale editor is refused rather
|
||||
* than silently overwriting a concurrent change.
|
||||
*/
|
||||
revision: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One path-addressed edit carried by a remote settings write. `set` writes the
|
||||
* value at the path, creating intermediate objects; `unset` removes it. The
|
||||
* empty path addresses the section root.
|
||||
*/
|
||||
export type SettingsPathOpView =
|
||||
| { op: 'set'; path: string[]; value: JsonValue }
|
||||
| { op: 'unset'; path: string[] }
|
||||
|
||||
/** Every registered namespace with the deployment facts a configuration page renders around them. */
|
||||
export interface SettingsDescribeValue {
|
||||
/** Whether the provider accepts writes; `false` disables every write control. */
|
||||
writable: boolean
|
||||
/** Whether a file-backed provider owns a local document, without exposing its Host path. */
|
||||
hasDocument: boolean
|
||||
/** One view per registered namespace. */
|
||||
namespaces: SettingsNamespaceView[]
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
|
||||
Generated
+40
-9
@@ -683,6 +683,9 @@ importers:
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../session-controller
|
||||
'@deepseek-ai/dsh-api-settings-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../settings-controller
|
||||
'@deepseek-ai/dsh-api-workspace-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../workspace-controller
|
||||
@@ -820,6 +823,31 @@ importers:
|
||||
specifier: workspace:^
|
||||
version: link:../../workspace/workspace
|
||||
|
||||
packages/api/settings-controller:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-credentials':
|
||||
specifier: workspace:^
|
||||
version: link:../../credentials/credentials
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
'@deepseek-ai/dsh-typert-protocol':
|
||||
specifier: workspace:^
|
||||
version: link:../../typert/protocol
|
||||
|
||||
packages/api/workspace-controller:
|
||||
dependencies:
|
||||
zod:
|
||||
@@ -1376,6 +1404,9 @@ importers:
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/session-controller
|
||||
'@deepseek-ai/dsh-api-settings-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/settings-controller
|
||||
'@deepseek-ai/dsh-api-workspace-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/workspace-controller
|
||||
@@ -1630,6 +1661,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-settings':
|
||||
specifier: workspace:^
|
||||
version: link:../../settings/settings
|
||||
'@deepseek-ai/dsh-tool-todo':
|
||||
specifier: workspace:^
|
||||
version: link:../../todo/tool-todo
|
||||
@@ -2644,9 +2678,6 @@ importers:
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/session-controller
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
@@ -3096,9 +3127,6 @@ importers:
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/remotes
|
||||
'@deepseek-ai/dsh-client-connection':
|
||||
specifier: workspace:^
|
||||
version: link:../connection
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
@@ -5703,9 +5731,6 @@ importers:
|
||||
'@deepseek-ai/dsh-commands':
|
||||
specifier: workspace:^
|
||||
version: link:../../interaction/commands
|
||||
'@deepseek-ai/dsh-credentials':
|
||||
specifier: workspace:^
|
||||
version: link:../../credentials/credentials
|
||||
'@deepseek-ai/dsh-host-directory-picker':
|
||||
specifier: workspace:^
|
||||
version: link:../directory-picker
|
||||
@@ -5752,6 +5777,9 @@ importers:
|
||||
'@deepseek-ai/dsh-agent-presets':
|
||||
specifier: workspace:^
|
||||
version: link:../../preset/agent-presets
|
||||
'@deepseek-ai/dsh-credentials':
|
||||
specifier: workspace:^
|
||||
version: link:../../credentials/credentials
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
@@ -7531,6 +7559,9 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
|
||||
packages/settings/settings-file:
|
||||
dependencies:
|
||||
|
||||
@@ -70,6 +70,8 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
cordisInspect: 'extensions.md',
|
||||
authorization: 'credentials.md',
|
||||
credentials: 'credentials.md',
|
||||
credentialsController: 'credentials.md',
|
||||
settingsController: 'settings.md',
|
||||
directoryPicker: 'workspace.md',
|
||||
deepseekLlmApiExtensions: 'llm-streaming.md',
|
||||
dynamicCordisRunner: 'extensions.md',
|
||||
@@ -526,6 +528,10 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SettingsRegisterOptions: 'settings.md',
|
||||
SettingsScope: 'settings.md',
|
||||
SettingsDescriptor: 'settings.md',
|
||||
SettingsDescribeValue: 'settings.md',
|
||||
SettingsNamespaceView: 'settings.md',
|
||||
SettingsPathOpView: 'settings.md',
|
||||
SettingsSecretView: 'settings.md',
|
||||
SettingsPathOp: 'settings.md',
|
||||
SettingsDescribeOptions: 'settings.md',
|
||||
SettingsUpdateSource: 'settings.md',
|
||||
|
||||
@@ -157,6 +157,20 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['host-apiproxy'],
|
||||
note: 'Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains.',
|
||||
},
|
||||
{
|
||||
key: 'credentialsController',
|
||||
pkg: 'api-settings-controller',
|
||||
title: 'Host credential-surface Remote controller',
|
||||
mode: 'core',
|
||||
note: 'Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition.',
|
||||
},
|
||||
{
|
||||
key: 'settingsController',
|
||||
pkg: 'api-settings-controller',
|
||||
title: 'Host settings-surface Remote controller',
|
||||
mode: 'core',
|
||||
note: 'Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition.',
|
||||
},
|
||||
{
|
||||
key: 'workspaceController',
|
||||
pkg: 'api-workspace-controller',
|
||||
|
||||
@@ -1664,7 +1664,7 @@
|
||||
{
|
||||
"doc": "docs/subsystems/credentials.md",
|
||||
"symbol": "CredentialInfo",
|
||||
"source": "packages/credentials/credentials/src/index.ts"
|
||||
"source": "packages/credentials/credentials/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/settings.md",
|
||||
|
||||
@@ -162,6 +162,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
|
||||
'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' },
|
||||
'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' },
|
||||
'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' },
|
||||
'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' },
|
||||
'packages/typert/protocol': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers nothing model-facing.' },
|
||||
'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' },
|
||||
|
||||
@@ -52,6 +52,9 @@
|
||||
"@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-api-session-controller/types": ["./packages/api/session-controller/src/types.ts"],
|
||||
"@deepseek-ai/dsh-api-session-controller/remote-events": ["./packages/api/session-controller/src/remote-events.ts"],
|
||||
"@deepseek-ai/dsh-api-settings-controller": ["./packages/api/settings-controller/src/index.ts"],
|
||||
"@deepseek-ai/dsh-api-settings-controller/invariant": ["./packages/api/settings-controller/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-api-settings-controller/types": ["./packages/api/settings-controller/src/types.ts"],
|
||||
"@deepseek-ai/dsh-api-workspace-controller": ["./packages/api/workspace-controller/src/index.ts"],
|
||||
"@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"],
|
||||
"@deepseek-ai/dsh-api-workspace-controller/invariant": ["./packages/api/workspace-controller/src/invariant.ts"],
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
{ "path": "./packages/api/gateway/tsconfig.host.json" },
|
||||
{ "path": "./packages/api/remotes/tsconfig.host.json" },
|
||||
{ "path": "./packages/api/session-controller/tsconfig.host.json" },
|
||||
{ "path": "./packages/api/settings-controller" },
|
||||
{ "path": "./packages/api/workspace-controller/tsconfig.host.json" },
|
||||
{ "path": "./packages/typert/loader" },
|
||||
{ "path": "./packages/session/session-persistence" },
|
||||
|
||||
Reference in New Issue
Block a user