/** * Service-vault accessor — store-level CRUD + policy-before-decrypt egress. * * EP-UNIVERSAL-SERVICE-VAULT (epic T11765 · saga SG-VAULT-CORE T10409 · M2 W1a · * task T11937 · AC3). The store layer for the universal SERVICE-credential vault * (`service_connections` / `service_configs` / `agent_service_grants`). * * ## DB Open Guard (Gate 3 — strict) * * EVERY open goes through {@link openDualScopeDb}`('global')` (or its path-aware * sibling {@link openDualScopeDbAtPath} for an injected test DB) — there is ZERO * raw `new DatabaseSync(`. The typed Drizzle handle drives all CRUD; the accessor * never constructs a native handle itself. * * ## Crypto reuse (DRY — no new crypto · T11710) * * `credentials_enc` holds the {@link encryptGlobal} ciphertext of the * `{access_token, refresh_token}` JSON, keyed `id = service:${provider}:${label}`. * The accessor calls {@link encryptGlobal}/{@link decryptGlobal} from * `crypto/credentials.ts` — it adds no crypto of its own. The crypto functions are * injectable ({@link ServiceVaultDeps}) so a test can spy on `decryptGlobal` and * PROVE it is never invoked on a denied access (policy-before-decrypt, AC4). * * ## Policy-before-decrypt egress (AC4) * * {@link resolveSealedConnection} evaluates the {@link evaluateServiceAccess} * trust gate FIRST (a pure decision from the agent's grant rows). It calls * `decryptGlobal` ONLY when the gate returns `allowed: true`. A denied agent * returns `null` BEFORE the decrypt call — the decrypt closure never runs. On * allow, the plaintext is not surfaced inline: it is wrapped in a * {@link makeSealedCredential} handle whose `resolveToken` thunk performs the * decrypt at the wire (the same egress the LLM credential pool uses). * * ## OAuth seam (T11939) * * The OAuth build/exchange/refresh dance is the FOLLOW-UP (T11939). This accessor * leaves the clean seam: {@link connectService} accepts an already-obtained token * blob and writes its `encryptGlobal` ciphertext into `credentials_enc`; the OAuth * flow simply calls `connectService` (or `updateConnectionCredentials`) with the * tokens it negotiates. No OAuth logic lives here. * * @module store/service-connections-accessor * @task T11937 * @epic T11765 * @saga T10409 * @see ./schema/cleo-global/services.ts — the three tables * @see ./service-trust-gate.ts — the policy-before-decrypt decision * @see ../crypto/credentials.ts — `encryptGlobal` / `decryptGlobal` (reused, not re-implemented) * @see ../llm/sealed-credential.ts — `makeSealedCredential` (the shared egress handle) */ import type { SealedCredential } from '@cleocode/contracts'; import { type CleoGlobalDb } from './dual-scope-db.js'; import { type ServiceConnectionStatus } from './schema/cleo-global/services.js'; import { type SessionPolicy } from './service-trust-gate.js'; /** * The token blob persisted (encrypted) in `service_connections.credentials_enc`. * * Serialized to JSON, encrypted via {@link encryptGlobal}. The OAuth flow (T11939) * produces this; this accessor only stores/loads it. * * @task T11937 */ export interface ServiceTokenBlob { /** The bearer access token. */ readonly accessToken: string; /** The refresh token, when the provider issued one. */ readonly refreshToken?: string; } /** * Injectable crypto + DB dependencies — defaults bind the real implementations. * * The crypto functions are injectable so a test can supply a SPY for * `decryptGlobal` and assert it is never called on a denied access (AC4 * policy-before-decrypt proof). * * @task T11937 */ export interface ServiceVaultDeps { /** Encrypt a plaintext with the global KDF keyed by `id`. Defaults to {@link encryptGlobal}. */ readonly encrypt?: (plaintext: string, id: string) => Promise; /** Decrypt ciphertext with the global KDF keyed by `id`. Defaults to {@link decryptGlobal}. */ readonly decrypt?: (ciphertext: string, id: string) => Promise; /** * An already-open global Drizzle handle. When omitted the accessor opens via * {@link openDualScopeDb}`('global')`. Tests pass a temp-DB handle (opened via * {@link openServiceVaultAtPath}) to stay off `.cleo/*.db`. */ readonly db?: CleoGlobalDb; } /** Parameters for {@link connectService}. */ export interface ConnectServiceParams { /** Stable provider key (e.g. `github`). */ readonly provider: string; /** Connection label, unique within the provider (e.g. `personal`). */ readonly label: string; /** The token blob to encrypt + store (from the OAuth flow, T11939). */ readonly tokens: ServiceTokenBlob; /** Granted scopes (non-secret) — stored as JSON. */ readonly scopes?: readonly string[]; /** ISO-8601 access-token expiry, if known. */ readonly expiresAt?: string; /** Non-secret metadata (e.g. `{ username, email }`) — stored as JSON. */ readonly metadata?: Readonly>; } /** A non-secret view of a `service_connections` row (NEVER carries the token). */ export interface ServiceConnectionView { readonly id: number; readonly provider: string; readonly label: string; readonly status: ServiceConnectionStatus; readonly scopes: readonly string[]; readonly expiresAt: string | null; readonly metadata: Readonly>; readonly connectedAt: string; readonly updatedAt: string; /** Whether a credential blob has been written (the OAuth flow ran). */ readonly hasCredentials: boolean; } /** * Open the global service-vault handle at an EXPLICIT path (test seam). * * Production callers MUST use {@link openDualScopeDb}`('global')` (resolved * canonical path). This path-aware variant exists so tests open a temp-dir * `cleo.db` — never `.cleo/*.db`. Returns the typed global Drizzle handle. * * @param dbPath - Absolute path to the temp `cleo.db`. * @returns The typed {@link CleoGlobalDb} handle. * @task T11937 */ export declare function openServiceVaultAtPath(dbPath: string): Promise; /** * CONNECT a service: encrypt the token blob and upsert the connection row. * * The token blob is JSON-serialized and {@link encryptGlobal}-encrypted under * `id = service:${provider}:${label}` — only the ciphertext is written. On a * repeat `connect` for the same `(provider, label)` the row's credentials/scopes/ * metadata/expiry are REFRESHED in place and `status` is reset to `active` (an * expired/revoked connection is re-activated by re-connecting). * * @returns The connection id. * @task T11937 */ export declare function connectService(params: ConnectServiceParams, deps?: ServiceVaultDeps): Promise; /** * UPDATE a connection's encrypted token blob + expiry in place (OAuth refresh * persistence seam — T11939). * * The self-heal path ({@link import('./service-oauth.js').selfHealConnection}) * calls this after a refresh: the new `{accessToken, refreshToken}` blob is * {@link encryptGlobal}-encrypted under the SAME `id = service:${provider}:${label}` * and written back, with `expires_at` bumped and `status` kept `active`. Only the * ciphertext is persisted — exactly like {@link connectService}. * * @returns `true` if a row was updated, `false` when no such `(provider, label)`. * @task T11939 */ export declare function updateConnectionTokens(params: { readonly provider: string; readonly label: string; readonly tokens: ServiceTokenBlob; readonly expiresAt?: string | null; }, deps?: ServiceVaultDeps): Promise; /** The decrypted token blob + expiry returned by {@link loadDecryptedTokenBlob}. */ export interface DecryptedConnection { /** The connection id. */ readonly id: number; /** The decrypted `{accessToken, refreshToken}` blob (SECRET — never log/serialize). */ readonly blob: ServiceTokenBlob; /** ISO-8601 access-token expiry, or `null` when non-expiring/unknown. */ readonly expiresAt: string | null; } /** * RESOLVE a connection's DECRYPTED token blob — **policy-before-decrypt** (T11939). * * The self-heal seam: the OAuth flow needs the REFRESH token (inside the encrypted * blob) plus `expires_at` to decide whether to refresh. Like * {@link resolveSealedConnection}, the trust gate runs FIRST and `decryptGlobal` * is invoked ONLY on allow — a denied agent gets `null` with NO decrypt. Unlike * the sealed resolve, this returns the plaintext blob inline (the caller is the * refresh machinery, which re-encrypts immediately) so it MUST NOT cross a * logging/serialization boundary. * * @returns The decrypted blob + expiry on allow; `null` when denied / missing / * not `active` / no stored credential. * @task T11939 */ export declare function loadDecryptedTokenBlob(params: ResolveServiceParams, deps?: ServiceVaultDeps): Promise; /** * GET one connection as a non-secret view (NEVER decrypts the token). * * @returns The view, or `null` when no such `(provider, label)` exists. * @task T11937 */ export declare function getConnection(provider: string, label: string, deps?: ServiceVaultDeps): Promise; /** * LIST connections as non-secret views (NEVER decrypts), optionally by provider. * * @param provider - When set, restrict to one provider; otherwise all. * @task T11937 */ export declare function listConnections(provider?: string, deps?: ServiceVaultDeps): Promise; /** * A NON-SECRET, dashboard-facing view of one `agent_service_grants` row joined * to its connection identity. Carries the granted agent, the per-session access * policy (`mode` / `manualApproval`), and the connection's `provider`/`label` * so a read-only surface (the Studio vault dashboard, T11943) can render "which * agent may use which connection" WITHOUT touching the encrypted token blob. * * @task T11943 */ export interface AgentGrantView { /** The granted agent's id. */ readonly agentId: string; /** The connection this grant authorizes (FK to `service_connections.id`). */ readonly serviceConnectionId: number; /** The connection's provider key, when the connection still exists. */ readonly provider: string | null; /** The connection's label, when the connection still exists. */ readonly label: string | null; /** The session policy mode (`allow` | `block`). */ readonly mode: SessionPolicy['mode']; /** Whether the policy requires an out-of-band manual approval per session. */ readonly manualApproval: boolean; /** ISO-8601 grant-creation instant. */ readonly createdAt: string; /** ISO-8601 last-update instant. */ readonly updatedAt: string; } /** * LIST every agent grant as a NON-SECRET {@link AgentGrantView}, joined to its * connection's `provider`/`label` for display. NEVER decrypts and never carries * a token — the dashboard read path (T11943). * * The join is performed in-memory (one `agent_service_grants` scan + one * `service_connections` scan, both already redacted) so the accessor stays a * pure read with no second round-trip per grant. A grant whose connection was * deleted out-of-band resolves `provider`/`label` to `null` (defensive — the * cascade should prevent this, but the view must not throw). * * @param agentId - When set, restrict to one agent's grants; otherwise all. * @task T11943 */ export declare function listAllGrants(agentId?: string, deps?: ServiceVaultDeps): Promise; /** * REVOKE a connection: flip `status` to `revoked` and CLEAR the credential blob. * * Clearing `credentials_enc` makes the secret unrecoverable (the ciphertext is * gone) — a revoke is permanent until a fresh `connect`. The row is kept (not * deleted) so grants/audit referencing it remain coherent. * * @returns `true` if a row was revoked, `false` if no such connection. * @task T11937 */ export declare function revokeConnection(provider: string, label: string, deps?: ServiceVaultDeps): Promise; /** The non-secret result of {@link deleteConnectionCascade}. */ export interface DeleteConnectionResult { /** Whether a `service_connections` row was deleted (`false` ⇒ no such connection). */ readonly deleted: boolean; /** The deleted connection id, or `null` when no row matched. */ readonly connectionId: number | null; /** How many `agent_service_grants` rows were cascaded-deleted alongside it. */ readonly grantsRemoved: number; } /** * DELETE a connection and CASCADE its agent grants (the hard `service revoke`). * * Unlike {@link revokeConnection} (a soft status flip that keeps the row), this * REMOVES the `service_connections` row entirely and first deletes every * `agent_service_grants` row that references it. The cascade is performed * EXPLICITLY in application code — the in-file FK on `service_connection_id` is * declared without `ON DELETE CASCADE`, and `foreign_keys` is not assumed ON — * so grants are deleted FIRST, then the connection, leaving no dangling grant. * * @returns A {@link DeleteConnectionResult} reporting the cascade outcome. * @task T11941 */ export declare function deleteConnectionCascade(provider: string, label: string, deps?: ServiceVaultDeps): Promise; /** * GRANT an agent access to a connection with a session policy. * * @param agentId - The agent to grant. * @param serviceConnectionId - The connection id (from {@link connectService}). * @param policy - The {@link SessionPolicy}; defaults to a bare allow. * @task T11937 */ export declare function grantAgentAccess(agentId: string, serviceConnectionId: number, policy?: SessionPolicy, deps?: ServiceVaultDeps): Promise; /** Parameters for {@link resolveSealedConnection}. */ export interface ResolveServiceParams { /** The agent requesting access (trust-gated). */ readonly agentId: string; /** The provider key. */ readonly provider: string; /** The connection label. */ readonly label: string; /** Whether an out-of-band manual approval was granted for this session. */ readonly approved?: boolean; } /** * RESOLVE a connection to a sealed credential — **policy-before-decrypt** egress. * * The trust gate runs FIRST (a pure {@link evaluateServiceAccess} decision over * the agent's grants). `decryptGlobal` is invoked ONLY when the gate returns * `allowed: true` — and even then NOT inline: the plaintext is materialized lazily * inside the {@link makeSealedCredential} `resolveToken` thunk at the wire. A * denied agent returns `null` BEFORE the decrypt closure is ever built or run, so * a spy on `decryptGlobal` records ZERO calls on a deny (AC4 proof). * * @returns A {@link SealedCredential} on allow; `null` when denied or when the * connection is missing / not `active` / has no stored credential. * @task T11937 */ export declare function resolveSealedConnection(params: ResolveServiceParams, deps?: ServiceVaultDeps): Promise; //# sourceMappingURL=service-connections-accessor.d.ts.map