From 46b4c1b076072b03c71bca8d4dde7a2dcd2504d2 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 21 Jul 2026 18:53:16 -0500 Subject: [PATCH] feat: point Try It panels at localhost with editable port, format all API output - OpenAPI servers: http://localhost:{port}/api/v1 with editable port variable (default 3000, the documented coven daemon serve --tcp standard) so hosted docs never display/resolve docs.opencoven.ai - Proxy bridge: socket-first dial with loopback TCP fallback on the chosen port; validates absolute targets (http + loopback + /api/ paths), keeps legacy /api/coven-proxy URLs, self-dial guard returns a clear 503 hint - Pretty-print all JSON responses through the bridge; code samples pipe curl through jq and pretty-print in TS/Python/Rust - Prose: port selector + TCP fallback + standard port 3000 in openapi index, socket-api, and reference/api pages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/api/coven-proxy/[...path]/route.ts | 7 +- app/api/coven-proxy/route.ts | 67 ++++++++-- content/docs/daemon/socket-api.mdx | 4 +- content/docs/openapi/index.mdx | 10 +- content/docs/reference/api.mdx | 2 +- lib/coven-proxy-dial.ts | 165 ++++++++++++++++++++----- openapi/coven.daemon.v1.yaml | 124 ++++++++++--------- scripts/build-openapi-samples.mjs | 14 ++- 8 files changed, 277 insertions(+), 116 deletions(-) diff --git a/app/api/coven-proxy/[...path]/route.ts b/app/api/coven-proxy/[...path]/route.ts index f28d4bd..5c7e104 100644 --- a/app/api/coven-proxy/[...path]/route.ts +++ b/app/api/coven-proxy/[...path]/route.ts @@ -1,6 +1,7 @@ // Path-prefix bridge for direct daemon calls (status banner, curl tests). // Sibling route at app/api/coven-proxy/route.ts handles the fumadocs-openapi -// playground's `?url=` style. Dialing logic lives in lib/coven-proxy-dial.ts. +// playground's `?url=` style. Dialing logic lives in lib/coven-proxy-dial.ts +// (Unix socket first, loopback TCP fallback on the standard port). import { dialDaemon } from '@/lib/coven-proxy-dial'; @@ -19,7 +20,9 @@ async function proxy( const hasBody = method !== 'GET' && method !== 'HEAD'; const body = hasBody ? Buffer.from(await req.arrayBuffer()) : undefined; - return dialDaemon(targetPath, method, req.headers, body); + return dialDaemon(targetPath, method, req.headers, body, { + selfHost: req.headers.get('host'), + }); } export const GET = proxy; diff --git a/app/api/coven-proxy/route.ts b/app/api/coven-proxy/route.ts index 78daddd..296b05a 100644 --- a/app/api/coven-proxy/route.ts +++ b/app/api/coven-proxy/route.ts @@ -3,16 +3,24 @@ // // GET /api/coven-proxy?url=&cookie= // -// We strip the docs-site prefix from the target URL's pathname and forward -// the remaining daemon-side path to the Unix socket via the shared -// dialDaemon() helper. +// The spec's server URL is `http://localhost:{port}/api/v1` (port variable, +// default 3000), so the target is an absolute loopback URL. We validate it is +// loopback-only, extract the port for the TCP fallback, and forward the +// daemon-side path via the shared dialDaemon() helper (Unix socket first, +// loopback TCP fallback). Legacy `/api/coven-proxy/...` relative targets from +// cached pages are still accepted. -import { dialDaemon, envelope } from '@/lib/coven-proxy-dial'; +import { + DEFAULT_DAEMON_TCP_PORT, + dialDaemon, + envelope, +} from '@/lib/coven-proxy-dial'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -const PROXY_PREFIX = '/api/coven-proxy'; +const LEGACY_PROXY_PREFIX = '/api/coven-proxy'; +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); async function proxy(req: Request): Promise { const reqUrl = new URL(req.url); @@ -28,7 +36,7 @@ async function proxy(req: Request): Promise { let targetUrl: URL; try { - targetUrl = new URL(target); + targetUrl = new URL(target, reqUrl.origin); } catch { return envelope( 'invalid_request', @@ -39,25 +47,58 @@ async function proxy(req: Request): Promise { } // The playground composes URLs like: - // spec server: /api/coven-proxy/api/v1 + // spec server: http://localhost:{port}/api/v1 (port default 3000) // operation: /health - // final URL: http://localhost:3000/api/coven-proxy/api/v1/health - // Strip the docs-site prefix to recover the daemon-side path (/api/v1/...). - if (!targetUrl.pathname.startsWith(PROXY_PREFIX)) { + // final URL: http://localhost:3000/api/v1/health + // Legacy cached specs used the relative server `/api/coven-proxy/api/v1`, + // which resolves against the docs-site origin — strip that prefix instead. + let targetPath: string; + let tcpPort = DEFAULT_DAEMON_TCP_PORT; + + if (targetUrl.pathname.startsWith(LEGACY_PROXY_PREFIX)) { + targetPath = + targetUrl.pathname.slice(LEGACY_PROXY_PREFIX.length) + targetUrl.search; + } else { + if (targetUrl.protocol !== 'http:') { + return envelope( + 'invalid_request', + `Target URL must use http: ${target}`, + { source: 'playground' }, + 400, + ); + } + if (!LOOPBACK_HOSTNAMES.has(targetUrl.hostname.toLowerCase())) { + return envelope( + 'invalid_request', + `Target URL host must be loopback (localhost / 127.0.0.1 / [::1]): ${target}`, + { source: 'playground' }, + 400, + ); + } + const parsedPort = Number(targetUrl.port); + if (Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535) { + tcpPort = parsedPort; + } + targetPath = targetUrl.pathname + targetUrl.search; + } + + if (!targetPath.startsWith('/api/')) { return envelope( 'invalid_request', - `Target URL pathname must start with ${PROXY_PREFIX}: ${targetUrl.pathname}`, + `Target URL path must start with /api/: ${targetPath}`, { source: 'playground' }, 400, ); } - const targetPath = targetUrl.pathname.slice(PROXY_PREFIX.length) + targetUrl.search; const method = req.method.toUpperCase(); const hasBody = method !== 'GET' && method !== 'HEAD'; const body = hasBody ? Buffer.from(await req.arrayBuffer()) : undefined; - return dialDaemon(targetPath, method, req.headers, body); + return dialDaemon(targetPath, method, req.headers, body, { + tcpPort, + selfHost: req.headers.get('host'), + }); } export const GET = proxy; diff --git a/content/docs/daemon/socket-api.mdx b/content/docs/daemon/socket-api.mdx index 4fdafc3..f6984fe 100644 --- a/content/docs/daemon/socket-api.mdx +++ b/content/docs/daemon/socket-api.mdx @@ -13,14 +13,14 @@ The socket is local by default: ```bash curl --unix-socket "$HOME/.coven/coven.sock" \ - http://localhost/api/v1/health + http://localhost/api/v1/health | jq . ``` For the full endpoint reference, see [Coven local socket API](/docs/reference/api). For diagrams of the handshake, event cursors, launch lifecycle, and authority boundary, see [API architecture diagrams](/docs/reference/api-architecture). ## Transports -The Unix socket at `$COVEN_HOME/coven.sock` is the default and recommended transport. The daemon can additionally bind a TCP listener with `coven daemon serve --tcp `, guarded so it stays local: +The Unix socket at `$COVEN_HOME/coven.sock` is the default and recommended transport. The daemon can additionally bind a TCP listener with `coven daemon serve --tcp ` — the standard loopback address is `127.0.0.1:3000`, which is also the default port the docs-site Try It panels fall back to — guarded so it stays local: - The bind address must resolve to loopback (`127.0.0.1` / `::1`); the daemon refuses non-loopback addresses. - Each TCP request's `Host` and `Origin` headers must be loopback, or exactly match a host passed via the repeatable `--allow-host ` flag (for a trusted reverse proxy that forwards a fixed hostname, such as a Tailscale-served FQDN). diff --git a/content/docs/openapi/index.mdx b/content/docs/openapi/index.mdx index 6a1ace8..684f794 100644 --- a/content/docs/openapi/index.mdx +++ b/content/docs/openapi/index.mdx @@ -11,7 +11,7 @@ The Coven daemon serves a small HTTP/1.1 API over a Unix domain socket. This sec ## Start a daemon -The Try It panels on each endpoint page bridge browser requests through a Next.js route handler to your local `$COVEN_HOME/coven.sock`. They only work when this docs site is running on the same machine as a live daemon. +The Try It panels on each endpoint page target `http://localhost:{port}/api/v1` — click the server URL above any panel to change the `port` variable (default `3000`). Requests are bridged through a Next.js route handler that dials your local `$COVEN_HOME/coven.sock` first and falls back to a loopback TCP listener on the chosen port (`coven daemon serve --tcp 127.0.0.1:3000`). JSON responses are pretty-printed by the bridge. The panels only work when this docs site is running on the same machine as a live daemon (or a daemon tunneled to a local port). ```bash coven daemon start @@ -23,9 +23,10 @@ coven daemon status # verify pid + socket Without leaving the shell: ```bash - curl --unix-socket "$HOME/.coven/coven.sock" http://localhost/api/v1/health + curl --unix-socket "$HOME/.coven/coven.sock" \ + http://localhost/api/v1/health | jq . ``` - A healthy daemon returns `{"ok":true,"apiVersion":"coven.daemon.v1",...}`. + A healthy daemon returns `{ "ok": true, "apiVersion": "coven.daemon.v1", ... }`. ## Transport and trust model @@ -34,6 +35,7 @@ coven daemon status # verify pid + socket | --- | --- | | Protocol | HTTP/1.1 over Unix domain socket | | Default socket | `~/.coven/coven.sock` (override via `$COVEN_HOME`) | +| Optional TCP | Loopback-only listener via `coven daemon serve --tcp 127.0.0.1:3000` (standard port `3000`) | | Auth | File-system permissions on the socket — no Bearer / JWT / API-key / cookie | | Contract | `coven.daemon.v1` served under `/api/v1` | @@ -75,4 +77,4 @@ Additive fields are allowed inside `v1`; breaking changes require a new route-pr ## Code samples -Every endpoint includes runnable samples in **curl** (with `--unix-socket`), **TypeScript** (undici `Agent({ connect: { socketPath } })`), **Python** (`httpx.HTTPTransport(uds=...)`), and **Rust** (`hyperlocal::UnixConnector`). Each sample reads `$COVEN_HOME` with a `~/.coven` fallback, so they work as-is once a daemon is running. +Every endpoint includes runnable samples in **curl** (with `--unix-socket`, piped through `jq` for formatted output), **TypeScript** (undici `Agent({ connect: { socketPath } })`), **Python** (`httpx.HTTPTransport(uds=...)`), and **Rust** (`hyperlocal::UnixConnector`). Each sample reads `$COVEN_HOME` with a `~/.coven` fallback and pretty-prints the JSON response, so they work as-is once a daemon is running. diff --git a/content/docs/reference/api.mdx b/content/docs/reference/api.mdx index fe216be..2dd180e 100644 --- a/content/docs/reference/api.mdx +++ b/content/docs/reference/api.mdx @@ -18,7 +18,7 @@ _Last updated: 2026-07-21_ Coven exposes an HTTP API over the local Unix socket at `/coven.sock`. The Rust daemon is the authority boundary: clients may validate for UX, but the daemon still validates project roots, cwd, harness ids, session ids, input, and live-session state before acting. -The Unix socket is the default transport. The daemon can also bind a TCP listener via `coven daemon serve --tcp `: the bind address must be loopback, and each request's `Host`/`Origin` headers must be loopback or exactly match a host added with the repeatable `--allow-host ` flag (for a trusted reverse proxy such as a Tailscale-served FQDN). The API is unauthenticated on both transports, so any non-loopback reach must be fronted by an authenticated transport. +The Unix socket is the default transport. The daemon can also bind a TCP listener via `coven daemon serve --tcp ` (standard loopback address: `127.0.0.1:3000`): the bind address must be loopback, and each request's `Host`/`Origin` headers must be loopback or exactly match a host added with the repeatable `--allow-host ` flag (for a trusted reverse proxy such as a Tailscale-served FQDN). The API is unauthenticated on both transports, so any non-loopback reach must be fronted by an authenticated transport. For a consolidated visual map of route topology, compatibility handshake, error flow, event cursors, live control, launch lifecycle, and authority boundaries, see [API architecture diagrams](/docs/reference/api-architecture). diff --git a/lib/coven-proxy-dial.ts b/lib/coven-proxy-dial.ts index 0b408b9..0976817 100644 --- a/lib/coven-proxy-dial.ts +++ b/lib/coven-proxy-dial.ts @@ -4,10 +4,15 @@ // - app/api/coven-proxy/route.ts (?url= style; used by the // fumadocs-openapi Try It playground) // +// Transport order: the Unix socket at $COVEN_HOME/coven.sock is dialed first; +// if it does not answer, we fall back to the daemon's loopback TCP listener +// (`coven daemon serve --tcp 127.0.0.1:`, standard port 3000). The +// fallback only ever dials 127.0.0.1 — never a caller-supplied host. +// // Trust model: the daemon's only access control is file-system permissions on -// the socket. This module must NOT forward auth headers, cookies, or anything -// that could let a cross-origin payload smuggle credentials in — it forwards -// only content-type and accept. +// the socket (and the loopback/Host guard on TCP). This module must NOT +// forward auth headers, cookies, or anything that could let a cross-origin +// payload smuggle credentials in — it forwards only content-type and accept. import http from 'node:http'; import os from 'node:os'; @@ -18,6 +23,30 @@ const SKIP_RESPONSE_HEADERS = /^(content-length|transfer-encoding|connection|keep-alive)$/i; const REQUEST_TIMEOUT_MS = 5000; +/** Standard loopback TCP port for `coven daemon serve --tcp 127.0.0.1:3000`. */ +export const DEFAULT_DAEMON_TCP_PORT = 3000; + +const DIAL_FAILURE_CODES = new Set([ + 'ENOENT', + 'ECONNREFUSED', + 'ETIMEDOUT', + 'EACCES', +]); + +export interface DialOptions { + /** + * Loopback TCP port to fall back to when the Unix socket does not answer. + * Comes from the playground's server-URL `port` variable. + */ + tcpPort?: number; + /** + * The docs site's own `Host` header (e.g. `localhost:3000`). When the + * fallback port would dial the docs site itself, the TCP attempt is + * skipped so a missing daemon can't be masked by a self-response. + */ + selfHost?: string | null; +} + export function resolveSocketPath(): string { const home = process.env.COVEN_HOME?.trim() || path.join(os.homedir(), '.coven'); @@ -91,10 +120,47 @@ function dial( }); } +function isLoopbackHost(host: string): boolean { + const bare = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase(); + return bare === 'localhost' || bare === '127.0.0.1' || bare === '::1'; +} + +function hostPort(host: string): number { + const match = /:(\d+)$/.exec(host); + return match ? Number(match[1]) : 80; +} + /** - * Dial the daemon's Unix socket with the given path/method/body and return - * a `Response` mirroring the daemon's reply. On connect failure or timeout, - * returns a 503 `daemon_unreachable` envelope. + * Skip the TCP fallback when it would dial the docs site itself (same + * loopback port), which would surface a confusing Next.js 404 instead of a + * clear `daemon_unreachable`. + */ +function isSelfDial(tcpPort: number, selfHost: string | null | undefined): boolean { + if (!selfHost) return false; + return isLoopbackHost(selfHost) && hostPort(selfHost) === tcpPort; +} + +/** + * Pretty-print JSON bodies so the Try It panels and curl output are readable. + * Non-JSON and unparseable payloads pass through untouched. + */ +function formatBody(body: Buffer, contentType: string | undefined): Buffer { + const mime = (contentType ?? '').split(';')[0].trim().toLowerCase(); + if (mime !== 'application/json' && !mime.endsWith('+json')) return body; + try { + const text = body.toString('utf-8'); + const formatted = JSON.stringify(JSON.parse(text), null, 2) + '\n'; + return Buffer.from(formatted, 'utf-8'); + } catch { + return body; + } +} + +/** + * Dial the daemon (Unix socket first, loopback TCP fallback) with the given + * path/method/body and return a `Response` mirroring the daemon's reply, with + * JSON bodies pretty-printed. On connect failure or timeout on both + * transports, returns a 503 `daemon_unreachable` envelope. * * `targetPath` is the daemon-side path (e.g., `/api/v1/health`), NOT the * docs-site bridge path. @@ -104,6 +170,7 @@ export async function dialDaemon( method: string, requestHeaders: Headers, body: Buffer | undefined, + options: DialOptions = {}, ): Promise { // Hosted-detection short-circuit: a Vercel deployment can't reach the // reader's local socket. Fail fast with a distinct cause so the status @@ -126,46 +193,76 @@ export async function dialDaemon( ); } + const tcpPort = options.tcpPort ?? DEFAULT_DAEMON_TCP_PORT; + const headers: Record = { host: 'localhost' }; for (const [key, value] of requestHeaders) { if (FORWARDED_REQUEST_HEADERS.has(key.toLowerCase())) headers[key] = value; } if (body) headers['content-length'] = String(body.byteLength); + let result: DialResult; try { - const { status, headers: respHeaders, body: respBody } = await dial( + result = await dial( { socketPath, method, path: targetPath, headers }, body, ); + } catch (err: unknown) { + const socketCause = (err as NodeJS.ErrnoException)?.code; + if (socketCause === undefined || !DIAL_FAILURE_CODES.has(socketCause)) { + return envelope( + 'internal_error', + err instanceof Error ? err.message : 'Unexpected proxy error', + { code: socketCause ?? 'unknown' }, + 502, + ); + } - const out = new Headers(); - for (const [k, v] of Object.entries(respHeaders)) { - if (SKIP_RESPONSE_HEADERS.test(k)) continue; - if (v === undefined) continue; - if (Array.isArray(v)) for (const item of v) out.append(k, item); - else out.set(k, v); + // Socket didn't answer — fall back to the loopback TCP listener. + if (isSelfDial(tcpPort, options.selfHost)) { + return daemonUnreachable(socketPath, socketCause, { + tcp: { + port: tcpPort, + cause: 'self', + hint: `Port ${tcpPort} is this docs site itself. Point the server-URL port variable at your daemon's --tcp port.`, + }, + }); } - // Buffer is a Uint8Array at runtime — valid BodyInit — but @types/node's - // ArrayBufferLike generic confuses TS's BodyInit union, so cast. - return new Response(respBody as unknown as BodyInit, { - status, - headers: out, - }); - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException)?.code; - if ( - code === 'ENOENT' || - code === 'ECONNREFUSED' || - code === 'ETIMEDOUT' || - code === 'EACCES' - ) { - return daemonUnreachable(socketPath, code); + try { + result = await dial( + { host: '127.0.0.1', port: tcpPort, method, path: targetPath, headers }, + body, + ); + } catch (tcpErr: unknown) { + const tcpCause = (tcpErr as NodeJS.ErrnoException)?.code; + if (tcpCause !== undefined && DIAL_FAILURE_CODES.has(tcpCause)) { + return daemonUnreachable(socketPath, socketCause, { + tcp: { port: tcpPort, cause: tcpCause }, + }); + } + return envelope( + 'internal_error', + tcpErr instanceof Error ? tcpErr.message : 'Unexpected proxy error', + { code: tcpCause ?? 'unknown' }, + 502, + ); } - return envelope( - 'internal_error', - err instanceof Error ? err.message : 'Unexpected proxy error', - { code: code ?? 'unknown' }, - 502, - ); } + + const { status, headers: respHeaders, body: respBody } = result; + + const out = new Headers(); + for (const [k, v] of Object.entries(respHeaders)) { + if (SKIP_RESPONSE_HEADERS.test(k)) continue; + if (v === undefined) continue; + if (Array.isArray(v)) for (const item of v) out.append(k, item); + else out.set(k, v); + } + const formatted = formatBody(respBody, respHeaders['content-type']); + // Buffer is a Uint8Array at runtime — valid BodyInit — but @types/node's + // ArrayBufferLike generic confuses TS's BodyInit union, so cast. + return new Response(formatted as unknown as BodyInit, { + status, + headers: out, + }); } diff --git a/openapi/coven.daemon.v1.yaml b/openapi/coven.daemon.v1.yaml index 4473dc2..92a56ad 100644 --- a/openapi/coven.daemon.v1.yaml +++ b/openapi/coven.daemon.v1.yaml @@ -47,11 +47,16 @@ info: ### Try It panels - The interactive Try It buttons on this site forward requests through a - Next.js route handler to your local `$COVEN_HOME/coven.sock`. They only - work when you are running this docs site on the same machine as a live - daemon. Start one with `coven daemon start` and verify with - `coven daemon status`. + The interactive Try It buttons on this site target + `http://localhost:{port}/api/v1` (default port `3000`, editable via the + server-URL selector on any endpoint page). Requests are bridged through a + Next.js route handler on the docs site, which dials your local + `$COVEN_HOME/coven.sock` first and falls back to a loopback TCP listener + on the chosen port (`coven daemon serve --tcp 127.0.0.1:3000`). JSON + responses are pretty-printed by the bridge. The panels only work when you + are running this docs site on the same machine as a live daemon (or a + daemon tunneled to a local port). Start one with `coven daemon start` and + verify with `coven daemon status`. contact: name: OpenCoven url: https://github.com/OpenCoven @@ -60,8 +65,12 @@ info: identifier: MIT servers: - - url: /api/coven-proxy/api/v1 - description: Local daemon via the docs-site Unix socket bridge. + - url: http://localhost:{port}/api/v1 + description: Local daemon. The docs-site bridge dials $COVEN_HOME/coven.sock first, then falls back to a loopback TCP listener on the chosen port. + variables: + port: + default: "3000" + description: Loopback TCP port of the daemon (standard is 3000, from `coven daemon serve --tcp 127.0.0.1:3000`). Change it here if your daemon or tunnel listens elsewhere. Ignored when the Unix socket answers first. security: [] @@ -185,9 +194,10 @@ paths: description: | Returns all active, non-archived sessions known to this daemon. - Archived sessions are excluded; surface them through the CLI via - `coven sessions --all` or via tooling that calls this endpoint twice - (with and without archived filtering). + This endpoint accepts no filter parameters: archived sessions are + always excluded, and there is no `archived` query flag. Archived + records are surfaced through the CLI (`coven sessions --all`) and + TUI, which read the session store directly. responses: '200': description: List of active sessions. @@ -587,33 +597,13 @@ paths: - name: sessionId in: query required: true - description: Session id whose events should be returned. + description: Session id whose events should be returned. Requests without it fail with `400 invalid_request`. schema: type: string examples: [session-1] - - name: afterSeq - in: query - required: false - description: Preferred cursor. Returns events with `seq > afterSeq`. - schema: - type: integer - minimum: 0 - examples: [41] - - name: afterEventId - in: query - required: false - description: Compatibility cursor, resolved by the daemon to a sequence position. - schema: - type: string - examples: [event-41] - - name: limit - in: query - required: false - description: Maximum number of events to return, clamped to 1–1000. When omitted, all events after the cursor are returned. - schema: - type: integer - minimum: 1 - maximum: 1000 + - $ref: '#/components/parameters/AfterSeq' + - $ref: '#/components/parameters/AfterEventId' + - $ref: '#/components/parameters/EventsLimit' responses: '200': description: A page of events. @@ -640,29 +630,9 @@ paths: cursor parameters and response shape, with the session id taken from the path instead of a `sessionId` query parameter. parameters: - - name: afterSeq - in: query - required: false - description: Preferred cursor. Returns events with `seq > afterSeq`. - schema: - type: integer - minimum: 0 - examples: [41] - - name: afterEventId - in: query - required: false - description: Compatibility cursor, resolved by the daemon to a sequence position. - schema: - type: string - examples: [event-41] - - name: limit - in: query - required: false - description: Maximum number of events to return, clamped to 1–1000. When omitted, all events after the cursor are returned. - schema: - type: integer - minimum: 1 - maximum: 1000 + - $ref: '#/components/parameters/AfterSeq' + - $ref: '#/components/parameters/AfterEventId' + - $ref: '#/components/parameters/EventsLimit' responses: '200': description: A page of events. @@ -687,6 +657,46 @@ components: schema: type: string examples: [session-1] + AfterSeq: + name: afterSeq + in: query + required: false + description: | + Preferred cursor. Returns events with `seq > afterSeq`; pass + `nextCursor.afterSeq` from the previous page to resume reading. + Takes precedence over `afterEventId` when both are supplied. + Non-integer values fail with `400 invalid_request`. + schema: + type: integer + minimum: 0 + examples: [41] + AfterEventId: + name: afterEventId + in: query + required: false + description: | + Compatibility cursor for clients that stored event ids instead of + sequence numbers. The daemon resolves the id to its sequence position + and returns events after it. Ignored when `afterSeq` is also + supplied; unknown ids read from the start of the log. + schema: + type: string + examples: [event-41] + EventsLimit: + name: limit + in: query + required: false + description: | + Maximum number of events to return. Out-of-range values are clamped + to 1–1000 rather than rejected; non-integer values fail with + `400 invalid_request`. When omitted, all events after the cursor are + returned in one page. `hasMore` in the response is only meaningful + when a limit is set — it is `true` when the page filled the limit. + schema: + type: integer + minimum: 1 + maximum: 1000 + examples: [200] responses: InvalidRequest: diff --git a/scripts/build-openapi-samples.mjs b/scripts/build-openapi-samples.mjs index 25fdc15..8eaccda 100644 --- a/scripts/build-openapi-samples.mjs +++ b/scripts/build-openapi-samples.mjs @@ -121,7 +121,7 @@ function sampleCurl({ method, fullPath, body }) { lines.push(` -H 'content-type: application/json' \\`); lines.push(` --data '${JSON.stringify(body)}' \\`); } - lines.push(` "http://localhost${API_PREFIX}${fullPath}"`); + lines.push(` "http://localhost${API_PREFIX}${fullPath}" | jq .`); return lines.join('\n'); } @@ -154,13 +154,15 @@ function sampleTypeScript({ method, fullPath, body }) { lines.push(` },`); lines.push(`);`); lines.push(``); - lines.push(`console.log(res.status, await res.json());`); + lines.push(`console.log(res.status);`); + lines.push(`console.log(JSON.stringify(await res.json(), null, 2));`); return lines.join('\n'); } function samplePython({ method, fullPath, body }) { const lines = [ `# pip install httpx`, + `import json`, `import os`, `import httpx`, ``, @@ -176,7 +178,8 @@ function samplePython({ method, fullPath, body }) { const args = [`"${fullPath}"`]; if (body !== undefined) args.push(`json=${pyLiteral(body, 8)}`); lines.push(` response = client.${method.toLowerCase()}(${args.join(', ')})`); - lines.push(` print(response.status_code, response.json())`); + lines.push(` print(response.status_code)`); + lines.push(` print(json.dumps(response.json(), indent=2))`); return lines.join('\n'); } @@ -187,6 +190,7 @@ function sampleRust({ method, fullPath, body }) { `// hyperlocal = "0.8"`, `// tokio = { version = "1", features = ["full"] }`, `// dirs = "5"`, + `// serde_json = "1"`, ``, `use hyper::{Body, Client, Method, Request};`, `use hyperlocal::{UnixConnector, Uri};`, @@ -220,6 +224,10 @@ function sampleRust({ method, fullPath, body }) { lines.push(``); lines.push(` let resp = client.request(req).await?;`); lines.push(` println!("{}", resp.status());`); + lines.push(``); + lines.push(` let bytes = hyper::body::to_bytes(resp.into_body()).await?;`); + lines.push(` let json: serde_json::Value = serde_json::from_slice(&bytes)?;`); + lines.push(` println!("{}", serde_json::to_string_pretty(&json)?);`); lines.push(` Ok(())`); lines.push(`}`); return lines.join('\n');