import { type KeyPair } from './crypto.js'; import { type KeepAliveOptions } from './keepalive.js'; import { type ClientSecretSource } from './config.js'; export interface Timers { setInterval: (fn: () => void, ms: number) => unknown; clearInterval: (handle: unknown) => void; } export type TimerHandle = NodeJS.Timeout; export interface Backoff { initial: number; max: number; } export declare const DEFAULT_BACKOFF: Backoff; export interface MsgFrame { op: 'msg'; /** Set on DM deliveries — hub deliverDM echoes the recipient id. */ to?: string; channel?: string; from: string; id: string; ts: number; ciphertext: string; } /** One agent in a presence snapshot (hub registry view). */ export interface PresenceAgent { agentId: string; name?: string; online: boolean; lastSeen?: number; } /** Hub `presence` frame: an ANSWER echoes the presence_query request id as * `replyTo` (additive hub field); broadcast pushes (peer join/offline to * channel-mates) never carry it. */ export interface PresenceFrame { op: 'presence'; agents: PresenceAgent[]; /** Echo of the request `id` — present only on answers. */ replyTo?: string; } export interface AgentInfo { agentId: string; pubkey: string; /** Agent's X25519 public key (b64); empty string when the peer did not send one. */ x25519: string; name?: string; online: boolean; } export interface WelcomeInfo { agentId: string; } export interface DapOptions { url: string; keys: KeyPair; name?: string; backoff?: Partial; timers?: Timers; /** Client keepalive: ping the hub while idle, terminate on missed pong. */ keepAlive?: Partial; /** Hub-issued client secret (config/env-resolved upstream: DAP_CLIENT_SECRET * > config.json). When absent but DAP_MASTER_SECRET is set, the dial * carries the master secret and enrolls after welcome; the issued secret * is persisted and every later dial uses it. */ clientSecret?: string; /** Where `clientSecret` was resolved from (resolveDapSettings sets it): * 'config' = persisted cache — a hub 401 may recover it via ONE * enroll-mode retry; 'env' = DAP_CLIENT_SECRET / explicit override — * user intent, never wiped; unset (no secret, or caller-resolved * elsewhere) never escalates. */ clientSecretSource?: ClientSecretSource; /** Identity handle for secret persistence: the resolved keyPath (one key * file = one identity = one name). The hub binds an issued secret to the * hello name it enrolled under, so enroll/wipe persist into THAT * identity's own clientSecrets slot — a second identity on the same * host never dials with (or overwrites) this one's secret. */ identityKey?: string; } type Listener = (value: unknown) => void; /** * DAP/1 wire client: hello handshake, signed send frames, flush after * welcome, and a setInterval-driven reconnect loop with exponential * backoff (1s doubling, 30s cap, reset on welcome). */ export declare class DapClient { private readonly opts; /** Identity follows the keypair: agentId = hex(sha256(pub))[:16]. */ get agentId(): string; connected: boolean; helloCount: number; welcomeCount: number; /** Delays handed to setInterval for each reconnect — spec-visible backoff. */ readonly backoffSchedule: number[]; private readonly backoff; private readonly timers; private readonly watchdog; private ws; private delay; private timer; private stopped; /** Master-secret dial in flight: send {"t":"enroll"} after welcome. */ private enrollPending; /** 401 streak marker: 'denied' fires once per failure streak (no spam). */ private denied; /** What the CURRENT dial's bearer was: 'client' secret, 'master' secret * (enroll-mode), or 'none' — decides whether a 401 is recoverable. */ private dialToken; private readonly listeners; private readonly emitCounts; private readonly frameListeners; private readonly whoisCache; private readonly whoisWaiters; /** Pending presence_query calls, keyed by request id (see presence()). */ private readonly presenceWaiters; /** ponytail: sticky client-lifetime latch — a replyTo echo proves the hub * is echo-capable, so id-less presence frames are broadcasts (never * answers) and must not complete waiters. NOT reset on reconnect: echo * capability doesn't flip with our socket, and resetting reopens the * every-other-restart reconnect race. Armed proactively by the * welcome-time warm-up (see warmUpPresence). */ private echoSeen; /** Warm-up attempts spent on THIS connection (1 try + 2 retries). */ private warmupAttempts; /** Subscribe to inbound msg frames. Every subscriber receives each frame * (one client is shared by every omp session in the process — delivery * must fan out, not go to the last subscriber). Returns an unsubscriber. */ onFrame(fn: (frame: MsgFrame) => void | Promise): () => void; constructor(opts: DapOptions); connect(): void; /** Runtime retarget (dap_connect): stop everything, swap url and/or * identity keys and display name, then connect fresh. A new name means * a new identity (name-derived key file) — a different agentId — so the * caller re-resolves and swaps the client secret (and identityKey) for * the NEW identity: the hub rejects a hello whose name does not match * the enrolled secret, and the previous identity's secret is bound to * the previous name. */ retarget(next: { url?: string; keys?: KeyPair; name?: string; /** Re-resolved secret for the (possibly new) identity; applied when the * field is present (undefined = enroll-mode dial via master secret). */ clientSecret?: string; clientSecretSource?: ClientSecretSource; identityKey?: string; }): void; stop(): void; send(frame: Record): void; /** Build, sign and send a `send` frame; `id` binds the E2E payload (HKDF salt / AAD). */ signedSend(payload: { channel?: string; to?: string; id: string; ciphertext: string; }): number; /** Channel membership (spec § join): first join creates the channel and * registers chanPubkey; re-join is idempotent — safe on every reconnect. */ join(channel: string, chanPubkeyB64: string): void; /** Presence snapshot: every agent the hub knows (id, name, online, lastSeen). * The query carries a unique request id; a current hub echoes it back as * `replyTo` on the ANSWER, so only our own answer completes the wait — a * concurrent broadcast can never satisfy it with a partial roster. */ presence(): Promise; /** Welcome-time warm-up (once per connection, re-run on reconnect): one * throwaway presence_query whose replyTo echo arms `echoSeen` BEFORE any * consumer query — a fresh connection's own join broadcast lands in the * unarmed window and would otherwise steal the first query's waiter via * the one-completes-all legacy path. The result is discarded. A legacy * hub answers without replyTo: the latch stays unarmed, the chain gives * up after 1 try + 2 retries, and the legacy path remains. */ private warmUpPresence; /** Pubkey directory lookup (needed for DM key agreement). The cache is * fine for key material (a pubkey never changes under an agentId) but * `online`/`lastSeen` are volatile — callers surfacing presence (the * dap_whois tool) must pass `{ fresh: true }` or they may serve a * stale online verdict forever (the cache only clears on our own * re-key). */ whois(agentId: string, opts?: { fresh?: boolean; }): Promise; on(event: string, listener: Listener): () => void; /** Emission counter for an event (monotonic; enables wait-for-the-Nth). */ eventCount(event: string): number; /** Resolve with the first emission of `event` AFTER `prev` emissions * already happened. Registering before the trigger avoids races. */ waitForAfter(event: string, prev: number, timeoutMs?: number): Promise; private sendHello; private handleFrame; private onWelcome; /** {"t":"enrolled","secret"}: bind the issued secret to this identity, * persist it (config file; the master secret is never stored or logged) * and use it for every later dial — this process (opts mutation) and * future launches (config.json). Persisted into THIS identity's * clientSecrets slot when identityKey is set: a shared field made every * other identity on the host dial with (and get rejected by) a secret * bound to this identity's name. */ private onEnrolled; /** Stale persisted cache (live incident: a hub restart wiped server-side * secrets, so every previously enrolled client 401-loops on its config * clientSecret). A 401 on a CONFIG-sourced bearer is recoverable when * DAP_MASTER_SECRET is available: drop the stale secret from this * process AND config.json (the identity's own slot plus the legacy * shared field); the scheduled reconnect dials ONCE in enroll-mode * (master bearer -> hello -> enroll -> onEnrolled re-persists the * issued secret). Env-sourced and master dials never escalate: env is * explicit user intent, and a rejection on the enroll-mode dial itself * is fatal — exactly one retry, no loops. Identity keys are never * touched: re-enrollment binds to the same hello identity. The * guard on an already-cleared secret keeps repeated rejections on one * dial (401 + in-band access_denied twins) one single escalation. */ private escalateStaleSecret; private onClose; private scheduleReconnect; private emit; } export {};