/** * `/api/v1/projects/:projectId/interactive/*` — crew reverse-proxies the wicked-interactive * bridge (DES-MERGE-001 §5.3, §7.2; slice 1). * * PURE TRANSPORT. Nothing user-visible moves in this slice: what changes is that interactive's * HTTP surface becomes reachable through crew's OWN origin, so the studio client never learns a * second origin, never needs CORS, and never carries a bridge port literal — ADR-0022's dynamic * port is honored rather than worked around, and the bridge stops being browser-reachable at all. * * The path encodes the project (§7.2) because the ROOT is per project — an explicit setting, or * the project's own partition of the default root (crew#472, `bridge-root.ts`): the same proxy * mount serves N interactive instances, one bridge per resolved root. * * Two things are deliberately hand-rolled over `node:http` rather than delegated to `fetch`: * * 1. UNBUFFERED STREAMING BOTH WAYS. Interactive's generation surface is SSE; a proxy that * buffers turns a live narration into a single blob delivered at stream close. Piping raw * sockets keeps every chunk flowing as it arrives, in both directions. * 2. VERBATIM FORWARDING. The path remainder and query string are taken from `req.raw.url`, * not from Fastify's decoded wildcard param, so percent-encoding survives the hop intact. * * THE ONE EXCEPTION TO PURE TRANSPORT — the doc CREATE (acceptance finding F-046). `POST * /api/docs` is read before it is forwarded, because two things the bridge cannot do have * to happen at the daemon: (a) a document may name the repository (or repositories) it is ABOUT * (`repo_ref` / `repo_refs`), which only crew can validate against the project's members and * only crew can remember — the bridge's create wire builds `doc.created` explicitly and never * echoes a field it does not know; and (b) a print/A4 brief with no `style` must reach the * bridge's print instructions, so an absent style is inferred from the brief's format words. A * named repo the project does not have is a 400 with nothing created (the bridge never sees the * request); the refs are stripped from the forwarded body; the binding is recorded from the * bridge's create answer (`name`) as a `crew-grounding.json` sidecar beside the new doc's * `versions.json` under this project's docs root, for the draft/demo seams (`doc-grounding.ts`). * The create response is small JSON, so it is buffered — SSE never rides this route. */ import type { FastifyInstance } from 'fastify'; import type { InteractiveDocCreateRequest } from 'wicked-crew-api-types'; import type { CoreAdapter } from '../core/adapter.js'; import type { ProjectSettingsStore } from '../projects/settings.js'; import { InteractiveBridgePool, type LiveBridge } from './bridge-pool.js'; import { type DocGroundingStore } from './doc-grounding.js'; /** The bridge route whose request crew reads before forwarding (F-046) — exact path, any query. */ export declare const DOC_CREATE_PATH = "/api/docs"; /** Create bodies are a brief plus a few fields; anything bigger is not a create crew should buffer. */ export declare const DOC_CREATE_BODY_MAX: number; /** Shared with `api/testing.ts` — same message, different entry point (#631). */ export declare const PROXY_SEAT_UNAVAILABLE_REASON = "clisJson names a council seat that is not available in the current roster \u2014 check GET /roster for available seats and omit clisJson to use the default"; /** * Point a `Location` back at the proxy. The bridge answers on its own origin and knows nothing * about the mount prefix, so both spellings it can emit have to be re-anchored: an absolute URL * on the bridge origin, and a root-relative path. Anything else (a foreign origin, a relative * path) is left exactly as-is — rewriting those would be inventing a redirect target. */ export declare function rewriteLocation(location: string, bridge: LiveBridge, prefix: string): string; export interface InteractiveProxyDeps { settings: ProjectSettingsStore; pool: InteractiveBridgePool; env?: NodeJS.ProcessEnv; /** The daemon state home the DEFAULT docs root hangs off (crew ≥ 0.7.35, D-L7-1; tests point it at a scratch dir). */ stateHome?: string; /** The HOME a leading `~` in an EXPLICIT `interactiveRoot` expands against. */ home?: string; /** The create-time doc → subject-repo binding store (F-046). Absent = the create is pure * transport like every other route (a directly-driven route set with no grounding). */ grounding?: DocGroundingStore; /** The current council roster — when supplied, a doc/demo create body with a `clisJson` that * names a seat not in the roster is refused 400 before the bridge sees the request (#631). */ roster?: () => unknown[]; log?: (msg: string) => void; } /** The create body as `prepareDocCreate` reads it — the published `InteractiveDocCreateRequest` * (wire-contract.test.ts pins the two against each other). */ export type DocCreateBody = InteractiveDocCreateRequest; /** Crew's 400 on a create that names a repository the document cannot be grounded on (F-046); * wire: crew-api-types `InteractiveDocCreateRefusal`. */ export interface DocCreateRefusal { error: string; code: 'repo_not_in_project' | 'unfiled_doc_repo' | 'invalid_repo_ref' | 'ambiguous_repo_ref' | 'project_mismatch'; requested: string[]; missing?: string[]; /** `ambiguous_repo_ref`: the refs that name several repos, each with its candidates. */ ambiguous?: Array<{ ref: string; candidates: Array<{ id: string; name: string; }>; }>; available?: Array<{ id: string; name: string; }>; } /** The 502 a create earns when the bridge dropped the connection AFTER the request was sent: the * doc may or may not exist, so the proxy never replays a non-idempotent POST (codex on crew#506). */ export declare const CREATE_UNDETERMINED: { readonly code: "create_undetermined"; readonly error: string; }; /** The 502 a UI emit earns when the bridge dropped the connection AFTER the body was sent (F-RECON-013 * gate path): the event may or may not have landed on the bus — the thread will show it if it did. */ export declare const EMIT_UNDETERMINED: { readonly code: "emit_undetermined"; readonly error: string; }; /** A create read and validated at the daemon, ready to forward (or to refuse). */ export interface PreparedCreate { /** The bytes to forward — `normalized` serialized, or the original bytes when they were not a JSON object. */ body: Buffer; contentType: string; /** The create as the bridge receives it — EXACTLY the published `InteractiveDocCreateRequest` * (codex on crew#506: the wire type at the real boundary). Fields the contract does not know * are not forwarded; `repo_ref`/`repo_refs` are consumed here and never ride. Absent when the * body was not a JSON object (pure passthrough — the bridge answers its own 400). */ normalized?: DocCreateBody; /** Recorded under the doc name the bridge answers with, when the request named repositories. */ binding?: { projectId: string; repoRefs: string[]; style?: string | undefined; clisJson?: string | undefined; channel?: 'studio' | 'cli' | 'api' | undefined; actor?: string | undefined; }; refusal?: DocCreateRefusal; } /** `true` for the bridge's doc-create request (method + exact path, query ignored). */ export declare function isDocCreate(method: string | undefined, target: string): boolean; /** The bridge's UI-emit route — where the studio posts `chat.posted` (an ask) and the like. */ export declare const DOC_EVENTS_PATH = "/api/events"; /** An emit body is small JSON; anything bigger is not a chat message. */ export declare const DOC_EVENTS_BODY_MAX: number; /** `true` for the bridge's UI-emit request (method + exact path, query ignored). */ export declare function isDocEventsPost(method: string | undefined, target: string): boolean; /** * Spike 3: the document a WRITE names in its path (`/d/:doc/…`, `/api/docs/:doc…`), or null. A run's working document * takes no write through the proxy at all: crew writes it, from the run's block. */ export declare function pathDocumentId(method: string | undefined, target: string): string | null; /** * Crew's 422 on an ask no seam can answer (F-RECON-013): a `chat.posted` USER ask on a document * whose manifest kind has no answering seam (a `demo` doc's asks are the demo seam's since * crew#501, so today only a kind no seam knows). The bridge * would have accepted the emit (200) and the chat seam would have declined it in a log line, so * the thread showed "generating" until its silence budget blamed the service. Refused HERE, before * the bridge sees it, so the sender learns at once — the studio's own send-failure path renders * `error`; `code` and `remedy` are for a skin that wants to say more. */ export interface InteractiveAskRefusal { error: string; code: 'ask_unsupported_for_doc_kind'; document_id: string; doc_kind: string; remedy: string; } /** The refusal for an ask on a doc of `kind`, or `null` when a seam answers that kind * (chat-events.ts `isAnswerableDocKind`). */ /** The `payload.document_id` of a doc events POST body, or null. */ export declare function eventDocumentId(raw: Buffer): string | null; export declare function askRefusalFor(documentId: string, kind: string): InteractiveAskRefusal | null; /** The body a `chat.posted` emit must carry to be an ASK this gate judges (anything else passes). */ export declare function askOf(raw: Buffer): { documentId: string; text: string; } | null; /** * The create's daemon-side half (F-046): read the body, validate `repo_ref(s)` against the * project's repositories, infer a missing `style` from the brief, strip what the bridge must not * see, and say what to record once the bridge names the doc. Pure over its inputs — the route * decides what to do with a refusal; exported so the rule is unit-testable without a bridge. */ export declare function prepareDocCreate(raw: Buffer, contentType: string | undefined, adapter: CoreAdapter, projectId: string, log?: (msg: string) => void): Promise; export declare function registerInteractiveProxy(app: FastifyInstance, adapter: CoreAdapter, deps: InteractiveProxyDeps): void; /** * Errors that mean "the upstream tore down its side after the CLIENT already left" — normal * teardown noise when a browser tab closes mid-stream. Only used when `clientGone` is already * true so we are sure the client left first and the upstream is reacting to the destroyed socket. */ export declare function isClientGoneError(err: unknown): boolean; //# sourceMappingURL=proxy-routes.d.ts.map