/** * The closed, versioned wire contract of the local daemon endpoint: one JSON object per line. * * A request names `{ v, id, op }` plus exactly the fields its op declares. Any other key, any other * version, an unknown op, a field of the wrong shape, a line that is not a JSON object, or a line * over {@link MAX_LINE_BYTES} is refused before any effect, with a typed error. Responses echo the * request id when one could be read. An oversized line closes the connection after its refusal, * including an incomplete tail after earlier valid requests. The limit includes the newline. * * Sign-in streams: `login-start` answers with one `login-prompt` event, then exactly one * `login-result` event, both tagged with the request id and the attempt id. No frame carries a * session, token, JWT or device code; the verification URL and user code are the only IdP values * the requester sees, because a person must type them. */ import { computerName } from "./name-validation.js"; import type { AccountComputerStatus } from "./account-state.js"; import { normalizeIdpUrl } from "@cotal-ai/auth"; import { DAEMON_PROTOCOL_VERSION, MAX_LINE_BYTES } from "./endpoint.js"; export const DAEMON_OPS = ["name", "daemon-stop", "space-intent", "status", "spaces", "manager-start", "manager-status", "managers-stop", "managers-prepare-restart", "managers-abort-restart", "login-start", "login-cancel", "connect-check", "register", "service-connect", "service-disconnect", "service-status"] as const; export type DaemonOp = (typeof DAEMON_OPS)[number]; /** The fields each op takes besides `v`, `id` and `op`. Required ones must be present. */ const OP_FIELDS: Record = { "daemon-stop": { required: [], optional: ["commit"] }, "space-intent": { required: ["space", "enabled"], optional: [] }, name: { required: [], optional: ["name"] }, status: { required: [], optional: [] }, "manager-start": { required: ["space"], optional: [] }, "manager-status": { required: ["space"], optional: [] }, "managers-stop": { required: [], optional: [] }, "managers-prepare-restart": { required: [], optional: [] }, "managers-abort-restart": { required: [], optional: [] }, spaces: { required: [], optional: ["refresh"] }, "login-start": { required: ["idp"], optional: ["clientId"] }, "login-cancel": { required: ["attempt"], optional: [] }, "connect-check": { required: ["space"], optional: [] }, register: { required: ["space"], optional: [] }, "service-connect": { required: ["space"], optional: [] }, "service-disconnect": { required: ["space"], optional: [] }, "service-status": { required: ["space"], optional: [] }, }; export type DaemonRequest = | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "name"; readonly name?: string } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "daemon-stop"; readonly commit?: boolean } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "space-intent"; readonly space: string; readonly enabled: boolean } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "manager-start"; readonly space: string } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "manager-status"; readonly space: string } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "managers-stop" } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "managers-prepare-restart" } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "managers-abort-restart" } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "spaces"; readonly refresh?: boolean } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "status" } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "login-start"; readonly idp: string; readonly clientId?: string } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "login-cancel"; readonly attempt: string } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "connect-check"; readonly space: string } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "register"; readonly space: string } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "service-connect"; readonly space: string } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "service-disconnect"; readonly space: string } | { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string; readonly op: "service-status"; readonly space: string }; export type ProtocolErrorCode = | "BAD_JSON" | "NOT_OBJECT" | "BAD_VERSION" | "UNKNOWN_FIELD" | "BAD_ID" | "UNKNOWN_OP" | "LINE_TOO_LONG" | "BAD_FIELD" | "LOGIN_IN_PROGRESS" | "TOO_MANY_REQUESTS" | "DEPENDENCY_UNAVAILABLE" | "SPACES_UNAVAILABLE" | "MANAGER_UNAVAILABLE" | "SEATS_ACTIVE" | "GENERATION_PENDING"; export interface DaemonUpdateStatus { readonly restartEvidence?: { readonly prepared: boolean; readonly managers: readonly { readonly space: string; readonly seats: number; readonly fenced: boolean }[] }; readonly channel?: "desktop" | "npm"; readonly current: string; readonly latest?: string; readonly lastCheck?: string; readonly lastResult: string; readonly nextAttempt?: string; readonly automatic: boolean; readonly failures: number; } export interface DaemonStatus { readonly computer?: string; readonly computerNameWarning?: string; /** Optional for older daemon clients. Shared with desktop status readers. */ readonly update?: DaemonUpdateStatus; readonly account?: AccountComputerStatus; readonly generation?: { readonly installed: string; readonly running: string; readonly waiting: string }; readonly managers?: readonly HostedManager[]; /** The local control listener is bound and verified. It does NOT mean connected to a mesh or Cloud. */ readonly state: "ready"; readonly protocol: typeof DAEMON_PROTOCOL_VERSION; readonly version: string; readonly pid: number; readonly startedAt: string; readonly uptimeMs: number; } export interface HostedManager { readonly connectors?: readonly { readonly agent: string; readonly state: string; readonly reason?: string }[]; readonly space: string; readonly state: "starting" | "answering" | "restarting" | "refused" | "stopping" | "stopped"; readonly instanceId?: string; /** A guarded registration removal may have run. Inspect it before another request. */ readonly registrationCleanup?: "unknown"; readonly pid?: number; readonly restarts: number; readonly reason?: string; readonly lastRefusal?: string; readonly next?: string; readonly lastStop?: "graceful" | "terminated" | "killed"; readonly observedAt?: string; readonly lastExit?: string; readonly logError?: string; } /** What a person needs to approve the sign-in, and when the code stops working. */ export interface LoginPrompt { readonly verificationUri: string; readonly verificationUriComplete: string; readonly userCode: string; readonly expiresAt: string; } /** The single terminal outcome of one sign-in attempt. */ export type LoginResult = | { readonly outcome: "signed-in"; readonly idp: string; readonly sub: string; readonly label?: string; readonly previousSub?: string } | { readonly outcome: "cancelled" } | { readonly outcome: "failed"; readonly reason: "local-state-error"; readonly message: string; readonly remedy?: string } | { readonly outcome: "failed"; readonly reason: "denied" | "expired" | "unreachable" | "authorization-refused" | "refused"; readonly message: string }; /** The answer to `login-cancel`. It is sent only after the named attempt has settled. */ export type CancelResult = | { readonly outcome: "cancelled"; readonly attempt: string } | { readonly outcome: "completed"; readonly attempt: string } | { readonly outcome: "not-found"; readonly attempt: string }; /** The answer to `connect-check`: whether this daemon could ask the space to register it. It never * means registered or connected. Every `message` is a fixed sentence; `remedy` may name the * registered provider and selected daemon home, never provider response text. */ export type ConnectCheckResult = | { readonly outcome: "not-registered" | "not-user-auth" | "not-signed-in" | "unsupported-by-space" | "unreachable" | "refused" | "local-state-error" | "credential-expired"; readonly space: string; readonly message: string; readonly remedy?: string } | { readonly outcome: "account-mismatch"; readonly space: string; readonly pinnedAccountPublicKey: string; readonly observedAccountPublicKey: string; readonly message: string } | { readonly outcome: "ready-to-register"; readonly space: string; readonly endpoint: string; readonly instanceId: string; readonly accountPublicKey: string; readonly message: string }; /** The answer to `register`: the stock registration answer for the daemon's own service. None of these * means connected; `registered` is not activated or serving. A readiness outcome other than * ready-to-register is passed through unchanged, with no request sent. */ type RegisterIds = { readonly space: string; readonly requestId: string; readonly endpoint: string; readonly instanceId: string; readonly message: string }; export type RegisterResult = | Exclude | (RegisterIds & { readonly outcome: "approval-required"; readonly proposalId: string }) | (RegisterIds & { readonly outcome: "pending"; readonly phase: string }) | (RegisterIds & { readonly outcome: "registered"; readonly witness: { readonly accountPublicKey: string; readonly space: string; readonly endpoint: string; readonly instanceId: string; readonly registrationRevision: number; readonly processEpoch: number } }) | (RegisterIds & { readonly outcome: "refused"; readonly reason: string }) | (RegisterIds & { readonly outcome: "unconfirmed" }) | { readonly outcome: "conflict"; readonly space: string; readonly requestId: string; readonly message: string }; /** The daemon's own service connection to a space. Only `connected` claims a connection: the broker * accepted the service connection AND the space accepted it as ready. Every `message` is fixed text, * and `reason` is a fixed stock code (or `unrecognized`). */ export type ServiceConnectionState = | "not-requested" | "not-registered" | "not-signed-in" | "unsupported" | "approval-pending" | "refused" | "account-mismatch" | "conflict" | "connecting" | "connected" | "unavailable" | "ended" | "disconnected" | "local-state-error" | "credential-not-yet-valid" | "credential-expired"; export interface ServiceConnectionResult { readonly remedy?: string; readonly manager?: HostedManager; readonly space: string; readonly state: ServiceConnectionState; readonly message: string; readonly instanceId?: string; readonly reason?: string; } export interface DaemonSpaces { readonly kind: "spaces"; readonly refresh: "not-requested" | "not-signed-in" | "complete" | "partial" | "failed"; readonly unreadable: number; readonly collisions: number; readonly failedProviders?: readonly string[]; readonly signedInProviders?: readonly string[]; readonly spaces: readonly { readonly space: string; readonly label: string; readonly catalogId?: string; readonly kind?: string; readonly role?: string; readonly mode: "auth" | "open" | "user"; readonly idp?: string }[]; } type Envelope = { readonly v: typeof DAEMON_PROTOCOL_VERSION; readonly id: string | null }; export type DaemonResponse = | (Envelope & { readonly ok: true; readonly result: HostedManager | DaemonSpaces | DaemonStatus | CancelResult | ConnectCheckResult | RegisterResult | ServiceConnectionResult }) | (Envelope & { readonly ok: false; readonly error: { readonly code: ProtocolErrorCode; readonly message: string; readonly remedy?: string } }) | (Envelope & { readonly ok: true; readonly event: "login-prompt"; readonly attempt: string; readonly prompt: LoginPrompt }) | (Envelope & { readonly ok: true; readonly event: "login-result"; readonly attempt: string; readonly result: LoginResult }); const ENVELOPE_KEYS = new Set(["v", "id", "op"]); const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; /** An attempt id is daemon-minted: 22 characters of base64url. */ export const ATTEMPT_PATTERN = /^[A-Za-z0-9_-]{22}$/; const MAX_CLIENT_ID = 128; /** A space name as it appears in a subject token. */ export const SPACE_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; /** Whether a registry name can be addressed by a scoped daemon request. */ export function isDaemonSpaceName(space: unknown): space is string { return typeof space === "string" && SPACE_PATTERN.test(space); } export type ParsedRequest = { readonly ok: true; readonly request: DaemonRequest } | { readonly ok: false; readonly id: string | null; readonly code: ProtocolErrorCode; readonly message: string }; const bad = (id: string | null, message: string): ParsedRequest => ({ ok: false, id, code: "BAD_FIELD", message }); /** Parse one line (without its newline). Pure: it decides, it does nothing. */ export function parseRequest(line: string): ParsedRequest { if (Buffer.byteLength(line, "utf8") + 1 > MAX_LINE_BYTES) return { ok: false, id: null, code: "LINE_TOO_LONG", message: `request exceeds ${MAX_LINE_BYTES} bytes` }; let raw: unknown; try { raw = JSON.parse(line); } catch { return { ok: false, id: null, code: "BAD_JSON", message: "request is not valid JSON" }; } if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, id: null, code: "NOT_OBJECT", message: "request must be a JSON object" }; const obj = raw as Record; const id = typeof obj.id === "string" && ID_PATTERN.test(obj.id) ? obj.id : null; const op = typeof obj.op === "string" && (DAEMON_OPS as readonly string[]).includes(obj.op) ? (obj.op as DaemonOp) : undefined; const fields = op ? OP_FIELDS[op] : { required: [], optional: [] }; for (const k of Object.keys(obj)) { if (!ENVELOPE_KEYS.has(k) && !fields.required.includes(k) && !fields.optional.includes(k)) return { ok: false, id, code: "UNKNOWN_FIELD", message: `unknown request field ${JSON.stringify(k)}` }; } if (obj.v !== DAEMON_PROTOCOL_VERSION) return { ok: false, id, code: "BAD_VERSION", message: `unsupported protocol version ${JSON.stringify(obj.v)}; this daemon speaks ${DAEMON_PROTOCOL_VERSION}` }; if (id === null) return { ok: false, id: null, code: "BAD_ID", message: "request id must be 1-64 characters of [A-Za-z0-9_-]" }; if (!op) return { ok: false, id, code: "UNKNOWN_OP", message: `unknown op ${JSON.stringify(obj.op)}` }; const v = DAEMON_PROTOCOL_VERSION; if (op === "name") { try { return { ok: true, request: { v, id, op, ...(obj.name !== undefined ? { name: computerName(obj.name) } : {}) } }; } catch (error) { return bad(id, error instanceof Error ? error.message : "invalid computer name"); } } if (op === "space-intent") { if (!isDaemonSpaceName(obj.space) || typeof obj.enabled !== "boolean") return bad(id, "space and enabled must name a space and a boolean"); return { ok: true, request: { v, id, op, space: obj.space, enabled: obj.enabled } }; } if (op === "spaces") { if (obj.refresh !== undefined && typeof obj.refresh !== "boolean") return bad(id, "refresh must be a boolean"); return { ok: true, request: { v, id, op, ...(obj.refresh !== undefined ? { refresh: obj.refresh } : {}) } }; } if (op === "daemon-stop") { if (obj.commit !== undefined && typeof obj.commit !== "boolean") return bad(id, "commit must be a boolean"); return { ok: true, request: { v, id, op, ...(obj.commit !== undefined ? { commit: obj.commit } : {}) } }; } if (op === "status" || op === "managers-stop" || op === "managers-prepare-restart" || op === "managers-abort-restart") return { ok: true, request: { v, id, op } }; if (op === "login-cancel") { if (typeof obj.attempt !== "string" || !ATTEMPT_PATTERN.test(obj.attempt)) return bad(id, "attempt must be the 22-character attempt id from login-prompt"); return { ok: true, request: { v, id, op, attempt: obj.attempt } }; } if (op === "manager-start" || op === "manager-status" || op === "connect-check" || op === "register" || op === "service-connect" || op === "service-disconnect" || op === "service-status") { if (!isDaemonSpaceName(obj.space)) return bad(id, "space must be 1-64 characters of [A-Za-z0-9_-]"); return { ok: true, request: { v, id, op, space: obj.space } }; } if (typeof obj.idp !== "string") return bad(id, "idp must be the provider's auth base URL"); let idp: string; try { idp = normalizeIdpUrl(obj.idp); } catch { return bad(id, "idp must be an HTTPS auth URL, or an HTTP loopback IP URL, without embedded credentials, a query or a fragment"); } if (obj.clientId !== undefined && (typeof obj.clientId !== "string" || !obj.clientId.trim() || obj.clientId.length > MAX_CLIENT_ID)) return bad(id, `clientId must be a non-empty string of at most ${MAX_CLIENT_ID} characters`); return { ok: true, request: { v, id, op, idp, ...(obj.clientId !== undefined ? { clientId: obj.clientId as string } : {}) } }; } export function encode(msg: DaemonResponse | DaemonRequest): string { return `${JSON.stringify(msg)}\n`; }