/** * Server-side relay routes for the desktop client (T22a routes, T22b * streaming, T32 event forwarding, T41b plain-HTTP passthrough): * authentication, ping, handshake, the single invoke passthrough, the * `relay/v1/http` GET dispatch through the host's shared `/api` fetch * handler, the NDJSON stream subscription route with share-change * synchronization, and the forwarded-event half — the * `$zr/events` subscription over the gateway's `$events` wire stream plus * the `relay/v1/event-result` answer route. Activity stats remain a later * task. * * Why the secret: the desktop client is a Node process on another machine — * it has no DSH login cookie, so the gateway authenticates it with a Bearer * pairing token and marks the forwarded request with `x-zen-remote-*` headers. * But DSH only listens on 127.0.0.1, where any local process could bypass the * gateway and forge those headers. The plugin therefore mints a per-apply * secret, hands it to the gateway child through `LAN_GATE_RELAY_SECRET`, the * gateway stamps every device-authenticated forward with it, and these routes * re-verify it on every request with `timingSafeEqual`. * * Path dispatch never decodes the request path. The gateway only admits relay * paths whose raw and URL-normalized forms are byte-identical, which lets * `/_dsh/zen-remote/relay/..%2f..%2fapi` through — decoding it here before * matching would reopen the traversal the gateway just closed. Every route is * an exact comparison on `new URL(req.url).pathname` as-is; anything else is * a 404. */ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { ShareStore } from './share-store.js'; import type { ServerPluginEntry, BridgeRegistry } from './plugin-bridge.js'; import { type PluginTunnelRoutes } from './plugin-tunnel.js'; import type { PluginLoopback } from './plugin-loopback.js'; import { MAX_UPLOAD_BYTES } from './upload-limits.js'; import type { NormalizedAdapter } from './plugin-adapter-core.js'; /** * Registration prefix on the host webServer. Deliberately WITHOUT the * trailing slash: the webserver matches a prefix route as `pathname === p || * pathname.startsWith(p + '/')`, so a trailing slash here would match only * `…/relay//…` shapes and never `…/relay/ping`. */ export declare const RELAY_PREFIX = "/_dsh/zen-remote/relay"; export { MAX_UPLOAD_BYTES }; /** The `/api` route the upload channel dispatches into, verbatim from the * host's own registration (RT dsh-client-file-upload lib/index.js:73). */ export declare const UPLOAD_HTTP_PATH = "/api/session/uploadFileBinary"; /** Shape of the `typertGateway` service this route needs (measured live, * docs/spike-relay.md §2.1: invoke returns the unwrapped business value and * throws errors carrying a string `code`; stream opens one `mode: 'stream'` * method and resolves to an async iterable of its frames). Declared * structurally instead of augmenting `Context`: the providing package is not * a devDependency here, and a local augmentation could collide with its own * once that changes. * * T32 adds the two event surfaces, both OPTIONAL: only `$zr/events` and * `relay/v1/event-result` touch them, so a composition (or test fake) without * them keeps every other route working and the event routes answer * `gateway/service-unavailable`. * * - `wireStream.open('$events', …)` is the ONLY way to the forwarded-event * stream — `gw.stream({namespace:'$events'})` refuses with * `gateway/invocation-unavailable` (docs/spike-relay.md §2.1 坑 1). It * resolves to an async iterable whose first frame is the `ready` frame. * - `dispatchRpc('$events/result', payload, signal, peer)` is the only way to * answer a forwarded waterfall — the gateway special-cases the endpoint * before its typert dispatch — and returns the `{ok,…}` envelope itself. */ export interface RelayGateway { invoke(call: { namespace: string; method: string; args: unknown; signal?: AbortSignal; peer?: unknown; }): Promise; stream(call: { namespace: string; method: string; args: unknown; signal?: AbortSignal; peer?: unknown; }): Promise>; /** The host's wire adapter, shared by the WebSocket mux and the local * transports. `open` is the async 0.2.0 method. */ wireStream?: { open(endpoint: string, payload: unknown, uplink: unknown, peer: unknown, signal: AbortSignal | undefined): unknown; }; /** The host's `/api` dispatch, owning the `$events/result` special case. */ dispatchRpc?(endpoint: string, payload: unknown, signal: AbortSignal | undefined, peer: unknown): Promise; } /** What the handshake reports about this server. `serverName` is a CALLBACK * on purpose: the row value is a volatile (`{ get() }` wrapped) setting that * can change without restarting the plugin row, so it must be recomputed at * handshake time, not snapshotted at registration. `fingerprints` is a * callback too (T42), awaited per handshake so a just-restarted server never * serves a stale map; a THROWN compute degrades to the empty map here so a * broken fingerprint source can never fail the handshake itself — the * client's comparison judges every group `unavailable`, never `different`. * `plugins` is the same discipline for the plugin bridge (PB-2): computed * FRESH per handshake (the local registry and the row's consent map are both * live), a THROWN compute degrades to the empty table — the handshake still * succeeds and the client reads every local plugin `not-on-server`. */ export interface RelayServerInfo { serverId: string; serverName: () => string; dshVersion: string; fingerprints?: () => Record | Promise>; plugins?: () => Record; } export interface RelayHandlerOptions { /** The per-apply shared secret; an empty one refuses every request. */ secret: string; /** The shared-session table deciding reachability. */ store: ShareStore; /** The host gateway service invokes and streams go through. */ gateway: RelayGateway; /** Handshake facts. */ serverInfo: RelayServerInfo; /** Ancestor lookup for subagent reachability; defaults to "no parent". */ parentOf?: (id: string) => string | undefined; /** The host's `/api` shared-fetch dispatcher (T41b), probed lazily per * request: `undefined` — no connection service, or no shared handler — * answers 501 `unsupported` instead of touching the network. The wiring * builds it from `connection.createSharedFetchHandler('/api').fetch` * (dsh-client-connection), the in-process entry that dispatches a * synthetic Request to the same exact-fetch route table the browser's * `/api` transport uses — no loopback HTTP, no login state. */ getApiFetch?: () => ((request: Request) => Promise) | undefined; /** The owner Peer every gateway call is made as (DSH 0.2.1-alpha.3+ * requires one; `$events` and `$events/result` need an owner Peer with * `interactions: 'answer'`, the SAME one for both). Built by * {@link createOwnerPeerSource}; absent or answering undefined keeps the * pre-alpha.3 behavior (the host defaults the Peer). */ getPeer?: () => unknown; /** Stream heartbeat interval in ms (a `{"type":"ping"}` line that keeps * reverse proxies from timing the idle stream away); defaults to 15000. * Tests inject a small value. */ heartbeatMs?: number; /** How long a stream's finish may wait for a backed-up write buffer to * drain before the response is destroyed (T43-A: a client that stopped * reading must not pin the viewer count and the device budget forever); * defaults to 5000. Tests inject a small value. */ endDrainTimeoutMs?: number; /** The binary upload channel's byte cap (T51); defaults to * {@link MAX_UPLOAD_BYTES} (500 MiB). Tests inject a small value so the * Content-Length and streaming-count refusals stay cheap to drive. */ uploadCapBytes?: number; /** How long an upload refusal's tail drain may run before the socket is * destroyed (the {@link DRAIN_DEADLINE_MS} bound); defaults to 10000. * Tests inject a small value so the deadline path stays cheap to drive. */ drainDeadlineMs?: number; /** The plugin bridge's server half (PB-3). Absent — a wiring that never * mounted it, old tests — and `POST relay/v1/plugin` answers 501 * `unsupported`, exactly like the `/api` passthrough without a dispatcher. */ pluginBridge?: { /** THIS process's bridge declaration registry (the `zenRemote` service's * backing table) — the source of truth for "which plugin, which version, * which routes". */ registry: BridgeRegistry; /** The row's per-plugin whole-machine-data consent, read LIVE per request * (a volatile row field — never snapshotted). */ getConsent: () => Record; /** The loopback executor performing the actual plugin GET. */ loopback: PluginLoopback; /** The WebSocket byte tunnel's server half (PB-10). Absent — a wiring * that predates the tunnels — and the three `plugin-tunnel` routes * answer 501 `unsupported` like an unwired bridge. */ tunnel?: PluginTunnelRoutes; /** The validated adapters (PB-11), as the wiring registered them. * Absent/empty — the pluggable default — and `v1/plugin` serves * cooperative declarations only, exactly as before. */ adapters?: readonly NormalizedAdapter[]; }; } /** * The relay route handler plus its introspection surface. A function WITH * properties on purpose: the webServer registration wants exactly a request * handler, and the "who is looking" badge (a later UI task) wants the * per-session viewer counts — while the row-reload path (T22b-fix) needs to * tear every open stream down with the handler that owns it. */ export interface RelayHandler { (req: IncomingMessage, res: ServerResponse): Promise; /** How many session-scoped relay streams currently reference the session * (across all devices). */ viewerCount(sessionId: string): number; /** End every currently open stream: each client gets one * `error{code:'server-restart'}` line, then the response ends (a client * that stopped reading is destroyed after {@link RelayHandlerOptions.endDrainTimeoutMs}), * the upstream subscription aborts and every counter/listener cleans up — * including the handler's own share-table subscriptions, so a table change * after the close never reaches this handler again (T43-B). A plugin row * reload builds a new handler and share table; without this the streams of * the OLD handler would keep pushing, unreachable by any unshare. */ closeAll(reason: string): void; } /** * Read the persisted server id, creating (and persisting) one on first use. * A missing file is the normal first run; an unreadable or damaged file must * never take the host down, so every failure degrades to a fresh per-process * id that simply is not remembered across restarts. */ export declare function loadServerId(home: string): string; /** * The DSH version reported in the handshake: `DSH_CLIENT_VERSION` when the * host sets it, else the version of the `@deepseek-ai/dsh` package resolved * from here (compositions without that package installed — tests, Electron — * report `'unknown'`). Never throws. */ export declare function resolveDshVersion(): string; /** * Build the relay route handler mounted under {@link RELAY_PREFIX}. Exported * as a factory so the route tests can drive it against a plain node:http * server with a fake gateway and a real share store — no harness required. * * Every request passes the same gate first: gateway secret, then the two * marking headers. Failures answer a uniform 401 that does not say WHICH * check failed — the difference would only help someone probing the wall. * After the gate, `x-zen-remote-device` is the caller's device id (the * per-device stream budget hangs off it). * * @param options - secret, share store, gateway service and server facts. * @returns the handler owning the full response lifecycle of one request, * with `viewerCount` alongside for the "who is looking" surface. */ export declare function createRelayHandler(options: RelayHandlerOptions): RelayHandler; export { SESSION_REFERENCE_URI, collectReferenceTexts, decodeSessionReferenceUri, encodeSessionReferenceUri, mapReferenceTexts, } from './session-reference.js'; //# sourceMappingURL=relay-server.d.ts.map