/** * Shared contract for secret providers. * * Dockhand is provider-agnostic about where secrets come from. A secret * provider knows how to authenticate against one backend (1Password, Infisical, * HashiCorp Vault, ...), and supports one or both resolution modes: * * 1. Reference interpolation — a variable's value is an inline reference in * that backend's own syntax (e.g. 1Password's `op://vault/item/field`), * which the provider replaces with the real secret. Detection of what * counts as a reference is provider-specific (see `isReference`). * 2. Bulk pull — fetch every secret under a provider-defined selector (a * 1Password Environment id, an Infisical project/environment/path, a Vault * kv path, ...) as a flat key/value map. * * Concrete providers live in sibling files and are registered in ./index. * Decrypted config (tokens, hosts) is passed in by the caller and never * persisted here — providers hold it only for the duration of a call. */ import { isSafeNotificationUrl } from '../url-safety'; /** * Identifies which backend a stored provider row talks to. Matches the * `secret_providers.type` column and the API. Kept as a widenable string union * so new backends can be added without a breaking type change. */ export type SecretProviderType = | 'op-service-account' | 'op-connect' | 'infisical' | 'vault' | 'doppler' | 'bitwarden' // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | (string & {}); /** * SSRF guard for a provider's user-supplied host/address. A provider makes * server-side HTTP requests to whatever host the user configured, so an * unchecked value lets an authenticated user pivot at Dockhand's network * (cloud-metadata 169.254.169.254, loopback, internal services) and read the * response back through testConnection / the bulk pull. Same policy as * notifications: block loopback + cloud metadata, allow ordinary LAN ranges so a * self-hosted Vault/Infisical/Connect on the local network still works. Throws on * an unsafe host; call it before the first request in every REST provider. */ export function assertSafeProviderHost(rawUrl: string, label: string): void { const safe = isSafeNotificationUrl(rawUrl); if (!safe.ok) { throw new Error(`${label}: host not allowed (${safe.reason})`); } } /** * Sanitises a user-supplied selector/path before it is concatenated into a * provider's API URL (Vault KV path, Infisical secretPath). The selector comes * from a stack env var an operator controls, so an unencoded value could inject * `..` traversal or `?`/`#`/scheme characters and forge a request to a different * endpoint on the same server. Rejects `..` segments and percent-encodes each * path segment; a leading slash is stripped by the caller as before. */ export function sanitizeSelectorPath(selector: string, label: string): string { const trimmed = selector.trim().replace(/^\/+/, ''); const segments = trimmed.split('/'); for (const seg of segments) { if (seg === '..' || seg === '.') { throw new Error(`${label}: selector must not contain path traversal ("${selector}")`); } } return segments.map((s) => encodeURIComponent(s)).join('/'); } /** * Extracts a short, human error message from a provider's error RESPONSE BODY, but * only when the body parses as that provider's own documented error JSON shape. * Used by testConnection (interactive setup) to show an actionable message like * "permission denied" or "Invalid Auth token" WITHOUT reflecting arbitrary upstream * bytes: a non-provider host (SSRF probe of a LAN service) returns HTML/other text * that does not match the shape, so nothing leaks. Returns null when the body is * absent, unparseable, or not the expected shape - the caller then shows status only. * * Shapes: Vault `{errors:[...]}`, Doppler/Infisical `{messages:[...]}` / `{message}`. */ export function parseProviderError(rawBody: string | undefined | null): string | null { if (!rawBody) return null; let parsed: unknown; try { parsed = JSON.parse(rawBody); } catch { return null; // not JSON -> not a known provider error shape } if (!parsed || typeof parsed !== 'object') return null; const obj = parsed as Record; const pick = (v: unknown): string | null => { if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 200); if (Array.isArray(v)) { const strs = v.filter((x) => typeof x === 'string' && x.trim()) as string[]; if (strs.length) return strs.join('; ').slice(0, 200); } return null; }; return pick(obj.errors) ?? pick(obj.messages) ?? pick(obj.message) ?? null; } /** * True when `body` parses as a JSON object or array. A 2xx status alone does not prove a * host is the expected backend - a parked domain / captive portal / reverse proxy answers * 200 with an HTML page. Every provider backend answers JSON, so testConnection requires * this before reporting success. Rejects empty bodies and bare scalars (`5`, `true`). */ export function isJsonResponse(body: string | undefined | null): boolean { if (!body || !body.trim()) return false; try { const v = JSON.parse(body); return typeof v === 'object' && v !== null; } catch { return false; } } /** 1Password service account: a single bearer token. */ export interface ServiceAccountConfig { token: string; } /** 1Password Connect: a self-hosted host URL plus an access token. */ export interface ConnectConfig { host: string; token: string; } /** * Infisical: an API host (self-hosted or cloud), a machine-identity or service * token, and the project/environment coordinates a bulk pull targets. `path` * and `environment` may be overridden per stack via the bulk selector. */ export interface InfisicalConfig { host: string; token: string; projectId: string; environment?: string; path?: string; } /** * HashiCorp Vault: a server address and token, plus the KV v2 mount to read * from (defaults to `secret`). `namespace` is only meaningful on Enterprise/HCP. */ export interface VaultConfig { address: string; token: string; namespace?: string; mount?: string; } /** * Doppler: bulk-only. A SERVICE token (dp.st.) is scoped to one config, so the * token alone identifies it and `project`/`config` are not needed. A PERSONAL * token (dp.pt.) is account-wide, so it MUST be told which project + config to * download (the API returns 400 "You must specify a project" otherwise). `host` * defaults to Doppler's SaaS API; it is overridable only for testing against a * stub (Doppler has no self-hosted server). */ export interface DopplerConfig { token: string; host?: string; /** Required for a personal token (dp.pt.); ignored for a service token. */ project?: string; config?: string; } /** Bitwarden Secrets Manager: a Machine Account access token. */ export interface BitwardenConfig { token: string; /** Optional Bitwarden server base URL for EU or self-hosted instances. */ serverUrl?: string; } /** Persisted (encrypted) config, discriminated by the provider `type`. */ export type SecretProviderConfig = | ServiceAccountConfig | ConnectConfig | InfisicalConfig | VaultConfig | DopplerConfig | BitwardenConfig; /** * Config keys that hold a SECRET across every provider type. Only these are stripped * when a provider config is sent to the client (the edit form); everything else * (host, address, projectId, environment, path, namespace, mount, project, config) is * a non-secret coordinate the user needs to see and edit. Keep in sync with the * `type: 'password'` fields in ProviderModal.svelte's PROVIDER_FIELDS. */ export const SECRET_CONFIG_KEYS = new Set(['token']); /** * Merges an incoming (edit-form) config OVER the stored one for a write/test: the incoming * non-secret coordinates win, but any SECRET_CONFIG_KEY (the token) that is blank/absent in * the incoming falls back to the STORED value - because the edit form leaves the token blank * to mean "keep the stored secret". Used by BOTH the update (persist) and the edit-mode Test * so a Test validates exactly what a Save would persist. Keep them on this one helper so they * can never diverge. */ export function mergeProviderConfigForWrite( incoming: Record, stored: Record ): Record { const merged: Record = { ...incoming }; for (const key of SECRET_CONFIG_KEYS) { const v = incoming[key]; if (v === undefined || v === '') { if (stored[key] !== undefined) merged[key] = stored[key]; } } return merged; } /** * Returns a copy of a provider config with secret values removed, so the edit form * can pre-fill the non-secret coordinates without the token ever leaving the server. * A stripped secret is simply absent (the form treats a blank secret field as * "keep the stored value"). */ export function redactProviderConfig(config: SecretProviderConfig): Record { const out: Record = {}; for (const [key, value] of Object.entries(config)) { if (!SECRET_CONFIG_KEYS.has(key)) out[key] = value; } return out; } export interface TestConnectionResult { ok: boolean; error?: string; } /** * Thrown by a provider when it is asked to do something its backend cannot do * (e.g. resolving a 1Password Environment over Connect, or inline reference * interpolation on a bulk-only backend). Callers can catch this to surface a * precise "not supported on this provider" message instead of a generic failure. */ export class UnsupportedOperationError extends Error { constructor(message: string) { super(message); this.name = 'UnsupportedOperationError'; } } /** * The behaviour every provider implements. `C` is the provider's own config * shape; the registry in ./index narrows it per type. * * A provider that does not support a given mode advertises it via * `supportsReferences` / `supportsBulk` and throws * {@link UnsupportedOperationError} from the corresponding method. */ export interface SecretProvider { /** Stable type tag matching the `secret_providers.type` column. */ readonly type: SecretProviderType; /** Human-readable name for logs and UI (e.g. "1Password service account"). */ readonly label: string; /** Whether this backend supports inline reference interpolation. */ readonly supportsReferences: boolean; /** Whether this backend supports bulk pull of a selector. */ readonly supportsBulk: boolean; /** * Detects whether a variable value is an inline reference in this backend's * syntax. Bulk-only providers return false for everything. */ isReference(value: unknown): value is string; /** * Validates the config against the backend. Returns `{ ok: true }` on * success, `{ ok: false, error }` otherwise. Must not throw for ordinary * auth failures. */ testConnection(config: C): Promise; /** * Resolves a batch of inline references. Returns a map containing only the * references that resolved successfully; individual lookup failures are * logged and skipped so the caller can leave those values as literals. * Transport / auth failures propagate as thrown errors. Bulk-only providers * throw {@link UnsupportedOperationError}. */ resolveSecretReferences( config: C, refs: string[], logPrefix?: string ): Promise>; /** * Fetches every secret under a provider-defined selector as a flat key/value * map. The selector is opaque and provider-specific (a 1Password Environment * id, an Infisical path, a Vault kv path, ...). Providers without a bulk * concept throw {@link UnsupportedOperationError}. */ resolveBulk(config: C, selector: string): Promise>; }