/** * Host-side RPC facade for the dsh-connect web settings pane. * * Mirrors dsh-im's host RPC pattern: a plugin registers a single channel on * `ctx.connection.rpc.handle(...)` and a handler dispatches dotted endpoints, * returning `{ ok: true, value }` on success or `{ ok: false, error }` on * failure (see dsh-im `plugin-src/host/update-rpc.mjs`). * * Endpoints: * - `settings.get` — current non-secret config + enabled channels + credential presence. * - `settings.save` — persist non-secret config (payload must be non-empty). * - `credentials.save` — persist secrets to the DSH credential store (payload * `{ channel, values }`, values are {configKey: secret}). * - `settings.status` — fresh status snapshot. * - `onboarding.start` / `onboarding.status` / `onboarding.cancel` — the * one-click bot creation flow. `start` answers with the *current* state and * the flow goes on living in the host process; the pane polls `status` until * a terminal phase, so a page refresh cannot abort a single-use * device-authorization link. See `./feishu-onboarding`. * * @module dsh-connect/settings/settings-rpc */ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OnboardingStatus } from "./feishu-onboarding.js"; import type { ChannelConnectionStatus } from "./channel-status.js"; /** RPC channel the settings pane calls. */ export declare const SETTINGS_RPC_CHANNEL = "/dsh-connect"; /** Endpoints the settings pane can invoke on the channel. */ export declare const SETTINGS_ENDPOINTS: readonly string[]; /** A settings pane `settings.get`/status snapshot. */ export interface SettingsSnapshot { /** The current plugin config (non-secret fields). */ config: Record; /** The channel names currently enabled. */ enabled: string[]; /** Per-channel credential presence (true = stored & set). */ credentials: Record; /** * Per-channel, per-config-key secret **presence** (true = a value is stored * for that key) — never the values themselves. The pane uses it to tell an * untouched input («已配置») from an empty one. * * This used to echo the values, which put an `appSecret` into browser state * and every screenshot of the settings page. What leaves the host now is * either this boolean or the masked {@link secretPreviews} below — a usable * secret still leaves exactly once, when the adapter consumes it * (`injectSecrets`). */ secrets?: Record>; /** * Per-channel, per-config-key **display preview**, for the user to confirm * what they configured without being able to copy it back out. * * Already masked on the host by `maskSecret` (`./secret-disclosure.js`), so * it is lossy by construction: identifiers such as an `appId` come through * whole, an `appSecret` keeps only its first and last four characters, and a * `webhookUrl` keeps its origin and path with the `access_token` in the query * masked. A key is present here only when a value is stored for it, which * makes an empty entry the same statement as `secrets[key] === false`. * * **Display-only.** The pane renders it as text; it must never be written * back as a credential, and `buildCredentialSaves` ignores it — the secret * inputs are still write-only and still start blank. */ secretPreviews?: Record>; /** * True when the `dsh-connect` settings namespace is live, i.e. this config is * stored in `$DSH_HOME/settings.yaml` and a save takes effect immediately. * False means the legacy JSON state file is behind this pane: the settings * path control still applies, and a save needs a restart. */ live?: boolean; /** * Non-fatal problems with the call that produced this snapshot. Absent when * there is nothing to say. * * These are the cases where the save *did* happen but did not (yet) have the * effect the user asked for — a rotated secret stored in the credential store * that the running adapter did not pick up, say. Neither answer available to * the service is honest on its own: reporting a failed save would be a lie * (the value *is* stored, and re-sending it changes nothing), and reporting a * clean save is the lie the user then acts on — they see 「已保存」 over a bot * that stays broken until restart. * * Codes come from {@link SettingsWarningCode}; the pane owns the wording, so a * new code needs a `w.` locale key in both languages (the locale test * enforces that) rather than an English sentence shipped from the host. */ warnings?: SettingsWarningCode[]; /** * Channels whose stored credentials could not be **read**. * * Distinct from `credentials[ch] === false`, and the distinction is the whole * point: `false` asserts "nothing is stored", while an IO error means the * answer is *unknown*. Reporting absence over a `EACCES` is how a working bot * comes to be described as 「未配置凭据」, which sends the user off to * re-enter a credential that was never broken. A channel listed here renders * as unknown — it is deliberately not `credentials[ch] === undefined`, because * a missing key already means "not a channel this pane knows". */ credentialErrors?: string[]; /** * Channels whose adapter failed to **start** with the config just saved, as * `{channel: reason}`. * * A channel that throws on start is logged and the rest still come up (see * `ChannelRuntime`), so the save itself succeeds and the pane would otherwise * report 「已保存」 over a channel that is silently not running. The reason is * the adapter's own message — passed through rather than mapped to a code, * because it is the only part that identifies *which* credential is wrong. */ channelErrors?: Record; /** * Per-channel **access** state — whether the channel is switched on, up, or * actually connected — as `{channel: {state, attempts?}}`. * * Kept separate from `credentials`, which only says a secret exists: a channel * with perfect credentials and a socket that keeps dropping is 「已配置凭据」 + * 「重连中」, and collapsing the two would describe it as working. * * **Attached whenever the host wired the option, which deliberately breaks the * "spread only when non-empty" convention above.** The other optional fields * are adds — an empty list carries no information — but here 「未启用」 *is* an * answer and a channel with no probe is honestly 「运行中」. So an absent key * has to keep its stronger meaning: *this host cannot report access state at * all* (an older host, or a boot where the runtime was never wired), and the * pane renders no badge rather than inventing one. */ channelStatus?: Record; /** * The DSH-wide default model, for the pane's read-only display row. Absent * when the host could not answer (no `agentDefaultModel` service, nothing * selected), in which case the pane omits the row — an empty control would * imply the user could set it here, and they cannot: the selection belongs to * DSH and changing it would move every other session with it. */ agentModel?: { provider: string; model: string; }; /** * General-settings keys a `dsh.shared.config.json` on this machine overrides. * Absent when there is no shared config (the common case), so the rows render * as ordinary editable fields with no provenance note. */ sharedOverrideKeys?: string[]; } /** * Codes a snapshot's {@link SettingsSnapshot.warnings} can carry. Each one needs * a `w.` key in `client/locale.mjs` (both languages). A real array rather * than a bare union so the locale test can *derive* that list instead of keeping * a second copy of it — a copy is exactly what goes stale. */ export declare const SETTINGS_WARNING_CODES: readonly ["credentialsStoredNotApplied"]; export type SettingsWarningCode = (typeof SETTINGS_WARNING_CODES)[number]; /** The service backing the RPC; supplied by the web-settings integration. */ export interface SettingsService { get(): Promise; save(config: Record): Promise; /** Persist channel secrets ({configKey: secret}) to the credential store. */ saveCredentials?(channel: string, values: Record): Promise; /** * One-click bot creation. Optional like `saveCredentials` — a host that wired * no onboarding registry reports `unsupported` instead of failing to compile. * Returns the flow's own status shape, not a `SettingsSnapshot`: a * `SettingsSnapshot` is deep-compared field by field in several tests and is * only spread when non-empty, so a permanent new field there would cost far * more than this feature needs. */ onboardingStart?(channel: string): Promise; onboardingStatus?(): Promise; onboardingCancel?(): Promise; status(): Promise; } /** * Build the channel handler for a settings service. * * `log` is how a failure that is *not* in {@link PUBLIC_ERRORS} stays * diagnosable. The pane is only told a code, and an unrecognised error is * flattened to the generic `settings-failed` — deliberately, since the raw * message of a host-side failure may name plugin internals the pane cannot act * on. That flattening is only acceptable while the real cause is recorded * *somewhere*: without a line here, a refused write (a missing profile entry, a * non-volatile path) reaches the user as "保存失败" and leaves nothing at all in * the host log to work from. */ export declare function createSettingsRpcHandler(service: SettingsService, log?: (message: string) => void): (endpoint: string, payload: unknown, signal?: { aborted?: boolean; }) => Promise<{ ok: boolean; value: OnboardingStatus | SettingsSnapshot; error?: undefined; } | { ok: boolean; error: { code: any; message: any; }; value?: undefined; }>; /** The Host/Origin + browser-auth fence exposed by the host `connection` service. */ interface ConnectionFence { /** HTTP status to reject with, or `undefined` to allow the request through. */ requestRejection?(request: IncomingMessage): number | undefined; } /** * Build the raw HTTP handler for {@link SETTINGS_RPC_CHANNEL}. Mirrors the * host's own Connection RPC adapter (method/content-type/envelope checks plus * the Host/Origin and browser-auth fence) so the pane sees the same protocol * and the same failure codes as a first-party channel. */ export declare function createSettingsHttpHandler(service: SettingsService, connection?: ConnectionFence, log?: (message: string) => void): (req: IncomingMessage, res: ServerResponse) => Promise; /** * Mount the settings RPC channel on the host webserver. * * Mounts a `prefix` route for {@link SETTINGS_RPC_CHANNEL} directly on the * `webServer` host service — the same thing the host's own * `connection.rpc.handle()` does internally (`owner.webServer.register(route)` * with the `/api` fence), and the same thing `dsh-api-gateway` does for its * WebSocket route. * * Why not `connection.rpc.handle()`: the host's `HostConnectionService` is * context-tracked, so its `get rpc()` captures `this.ctx`. cordis resolves that * read to a *shadow* of the service's construction context, and service * resolution then walks **that** fiber chain — which never contains the * `webServer` provider (a sibling fiber). The call therefore dies with * `cannot get property "webServer" without inject`, the route is never mounted, * and every `settings.*` call falls through to the static fallback (HTTP 405), * leaving the pane stuck on "加载中". Injecting `webServer` into the calling * scope — or declaring it in the plugin's `inject` — does not help, because the * shadow overrides the caller's fiber. * * @param ctx - an injection scope holding `connection` and `webServer`; its * `logger` is used, when present, to record a refused call (see * {@link createSettingsRpcHandler}). * @param options - the settings service backing the channel. * @returns a disposer removing the route (no-op if the host has no webserver). */ export declare function installSettingsRpc(ctx: unknown, options: { service: SettingsService; }): () => void; export {}; //# sourceMappingURL=settings-rpc.d.ts.map