diff --git a/apps/web-console/app/api/openapi.yaml/route.ts b/apps/web-console/app/api/openapi.yaml/route.ts new file mode 100644 index 000000000..b35d3998d --- /dev/null +++ b/apps/web-console/app/api/openapi.yaml/route.ts @@ -0,0 +1,41 @@ +// Serves the generated OpenAPI contract so an integrator can point their own +// tooling at it instead of reading a rendered table. +// +// Deliberately outside /console: middleware.ts redirects unauthenticated +// requests under /console to sign-in, and a spec a code generator cannot fetch +// without a session cookie is not a spec anyone will use. +// +// The file is read from packages/openai-contract/ rather than bundled: it is +// 1.1 MB of YAML and no loader in this app turns that into a module. That +// makes the read a real runtime dependency on the image containing the +// package, which is why both deploy/docker/Dockerfile.web-console and +// deploy/docker/Dockerfile.web-console.prod COPY it in. A missing file answers +// 500 with a plain message rather than an empty 200, because an empty spec +// silently generates an empty client. +// +// Node runtime only. `npm run build:cf` (the retired Workers path) has no +// filesystem and this route would fail there. +import { readFile } from "node:fs/promises"; + +import { OPENAPI_SPEC_PATH } from "@/lib/api-contract"; + +export async function GET(): Promise { + let spec: string; + try { + spec = await readFile(OPENAPI_SPEC_PATH, "utf8"); + } catch (error) { + console.error("openapi spec unreadable", { path: OPENAPI_SPEC_PATH, error }); + return new Response( + "The OpenAPI specification is not available in this deployment.\n", + { status: 500, headers: { "content-type": "text/plain; charset=utf-8" } }, + ); + } + + return new Response(spec, { + headers: { + "content-type": "application/yaml; charset=utf-8", + "content-disposition": 'inline; filename="hive-openapi.yaml"', + "cache-control": "public, max-age=300", + }, + }); +} diff --git a/apps/web-console/app/console/docs/page.tsx b/apps/web-console/app/console/docs/page.tsx new file mode 100644 index 000000000..0a538800b --- /dev/null +++ b/apps/web-console/app/console/docs/page.tsx @@ -0,0 +1,337 @@ +import { redirect } from "next/navigation"; + +import { + getAccountProfile, + getCatalogModels, + getViewer, + type CatalogModel, +} from "@/lib/control-plane/client"; +import { + API_BASE_URL, + OPENAPI_ROUTE, + type EndpointSection, + buildEndpointSections, + diffSpecAgainstMatrix, + loadSpecOperations, + loadSupportMatrix, +} from "@/lib/api-contract"; +import { ConsoleShell } from "@/components/app-shell/console-shell"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { PageHeader } from "@/components/ui/page-header"; + +/** + * The console's own API reference. + * + * Every endpoint, status and count below is read from + * `packages/openai-contract/` at request time, never typed in here. That is + * the whole point: a hand written endpoint list is stale the first time a + * route changes, and a docs page that is quietly wrong is worse than no docs + * page. The unsupported and out-of-scope endpoints are listed too, because an + * integrator needs to know what will not work before building against it. + */ + +const BADGE_TONE: Record = { + supported_now: "success", + planned_for_launch: "accent", + explicitly_unsupported_at_launch: "outline", + out_of_scope: "neutral", +}; + +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ); +} + +export default async function DocsPage() { + const viewer = await getViewer(); + if (viewer.user.email_verified === false) { + redirect("/console/settings/profile"); + } + + const [profile, models] = await Promise.all([ + getAccountProfile().catch((): { owner_name: string } => ({ owner_name: "" })), + // The quickstart names a model that actually exists on this deployment. + // If the catalog cannot be read the snippets still have to be runnable, so + // they fall back to the alias seeded by supabase/migrations. + getCatalogModels().catch((): CatalogModel[] => []), + ]); + + // Prefer one of Hive's own routing aliases: they are the documented entry + // points, and an upstream model id happening to sort first would read as a + // recommendation to bypass them. + const alias = + models.find((model) => model.id.startsWith("hive-"))?.id ?? + models[0]?.id ?? + "hive-default"; + const matrix = loadSupportMatrix(); + const specOperations = loadSpecOperations(); + const disagreements = diffSpecAgainstMatrix(specOperations, matrix); + const countKind = (kind: string) => + disagreements.filter((entry) => entry.kind === kind).length; + const missingFromSpec = countKind("missing_from_spec"); + const missingFromMatrix = countKind("missing_from_matrix"); + const statusMismatch = countKind("status_mismatch"); + const sections = buildEndpointSections(matrix); + + const curl = `curl ${API_BASE_URL}/chat/completions \\ + -H "Authorization: Bearer $HIVE_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "${alias}", + "messages": [{"role": "user", "content": "Say hello in one sentence."}] + }'`; + + const python = `# pip install openai +import os + +from openai import OpenAI + +client = OpenAI( + base_url="${API_BASE_URL}", + api_key=os.environ["HIVE_API_KEY"], +) + +response = client.chat.completions.create( + model="${alias}", + messages=[{"role": "user", "content": "Say hello in one sentence."}], +) +print(response.choices[0].message.content)`; + + return ( + + API documentation + + } + > + + +
+ + + Quickstart + + Create a key under API keys, export it as{" "} + HIVE_API_KEY, then call the + gateway exactly as you would call OpenAI. + + + +
+
+
+ Base URL +
+
+ {API_BASE_URL} +
+
+
+
+ Authentication +
+
+ Authorization: Bearer <your key> +
+
+
+ +
+

curl

+ {curl} +
+ +
+

+ Python, official OpenAI SDK +

+ {python} +
+ +

+ {alias} is a routing alias, not + a single upstream model. See the model catalog for what each alias + resolves to and what it costs. +

+
+
+ + + + Machine-readable spec + + The OpenAPI document this page is generated from. Point your own + tooling at it rather than reading this page. + + + +

+ Served unauthenticated at{" "} + + {OPENAPI_ROUTE} + {" "} + on this console's own origin · {specOperations.length}{" "} + operations described · support matrix version {matrix.version}, + generated {matrix.generated} +

+

+ The generation date is printed because it is the honest measure of + how current this page is. Nothing here is hand maintained, so if + that date is old, the classifications below are that old too. +

+
+
+ + {disagreements.length > 0 ? ( + + + + Where the spec and the support matrix disagree + + + {disagreements.length} operations are described differently by + the two generated files: {missingFromSpec} the matrix + classifies but the spec never describes, {statusMismatch} the + two give different support statuses, {missingFromMatrix}{" "} + the spec declares but the matrix never classified. All of them + are listed rather than one file being silently preferred, + because either side can be the stale one. + + + +
+ + Show all {disagreements.length} + +
    + {disagreements.map((disagreement) => ( +
  • + + {disagreement.operation} + {" "} + — {disagreement.detail} +
  • + ))} +
+
+
+
+ ) : null} + + {sections.map((section) => ( + + +
+ {section.meta.label} + + {section.count} + +
+ {section.meta.meaning} +
+ + + +
+ ))} +
+
+ ); +} + +function EndpointFamilies({ + families, + collapsed, + count, +}: { + families: EndpointSection["families"]; + collapsed: boolean; + count: number; +}) { + const tables = ( +
+ {families.map((family) => ( +
+

+ {family.name} +

+
+ + + + + + + + + + {family.endpoints.map((endpoint) => ( + + + + + + ))} + +
MethodPathNotes
+ {endpoint.method} + + {endpoint.path} + + {endpoint.notes} +
+
+
+ ))} +
+ ); + + if (!collapsed) { + return tables; + } + + return ( +
+ + Show all {count} + +
{tables}
+
+ ); +} diff --git a/apps/web-console/app/console/page.tsx b/apps/web-console/app/console/page.tsx index 7eecac7e5..d0937d449 100644 --- a/apps/web-console/app/console/page.tsx +++ b/apps/web-console/app/console/page.tsx @@ -273,7 +273,7 @@ export default async function ConsolePage() {
Need the API reference?{" "} Read the docs diff --git a/apps/web-console/components/app-shell/console-shell.tsx b/apps/web-console/components/app-shell/console-shell.tsx index 5124c4114..7896e9d13 100644 --- a/apps/web-console/components/app-shell/console-shell.tsx +++ b/apps/web-console/components/app-shell/console-shell.tsx @@ -204,8 +204,13 @@ export function ConsoleShell({ {topbar}
+ {/* + In-product docs, not hivegpt.io. That host is off-product and + this link is in the shell on every console page, so it was the + most-seen dead end in the console (issues #883, #1179). + */} {tShell("docs")} diff --git a/apps/web-console/lib/api-contract.ts b/apps/web-console/lib/api-contract.ts new file mode 100644 index 000000000..3f9b2a2a1 --- /dev/null +++ b/apps/web-console/lib/api-contract.ts @@ -0,0 +1,375 @@ +/** + * api-contract.ts + * + * Reads the generated OpenAPI contract and its support matrix so the console's + * docs page is generated from them rather than hand written. + * + * The contract lives in `packages/openai-contract/`, outside this app. It is + * read from disk instead of imported for two reasons: the spec is 1.1 MB of + * YAML, which no bundler loader in this app can turn into a module, and + * copying either file into `apps/web-console/` would create a second version + * of a generated artefact, which is exactly the drift that generating from a + * spec exists to prevent. Both `deploy/docker/Dockerfile.web-console` and + * `deploy/docker/Dockerfile.web-console.prod` COPY the package into the image + * for this reason; a route that reads it will 500 in any image that does not. + * + * Every fact rendered on `/console/docs` comes from one of these two files. + * Where they disagree, the disagreement is reported on the page rather than + * resolved silently in favour of one of them. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +const CONTRACT_ROOT = path.resolve( + process.cwd(), + "..", + "..", + "packages", + "openai-contract", +); + +export const OPENAPI_SPEC_PATH = path.join( + CONTRACT_ROOT, + "generated", + "hive-openapi.yaml", +); + +export const SUPPORT_MATRIX_PATH = path.join( + CONTRACT_ROOT, + "matrix", + "support-matrix.json", +); + +/** + * Public base URL of the gateway's OpenAI-compatible API. + * + * `api-hive.scubed.co` is edge-api in `deploy/cloudflare/tunnel-ingress.json`, + * which that file's own header calls the single source of truth for which hive + * hostnames are public. `/v1` is the `servers[0].url` the generated spec + * declares. `tests/unit/console-docs-contract.test.ts` asserts both halves + * still hold, so a hostname move fails a check instead of leaving a quickstart + * that quietly points at nothing. + */ +export const API_BASE_URL = "https://api-hive.scubed.co/v1"; + +/** Public, unauthenticated route this app serves the raw spec on. */ +export const OPENAPI_ROUTE = "/api/openapi.yaml"; + +export interface MatrixEndpoint { + method: string; + path: string; + status: string; + phase: number | null; + notes: string; +} + +export interface SupportMatrix { + version: string; + /** The matrix's own generation date, printed on the page so staleness shows. */ + generated: string; + endpoints: MatrixEndpoint[]; +} + +export interface StatusMeta { + status: string; + label: string; + meaning: string; +} + +/** + * Render order, and what each status actually means to an integrator. The + * unsupported and out-of-scope statuses are on the page deliberately: docs + * that list only the happy path let somebody build against an endpoint that + * was never implemented. + */ +export const STATUS_META: readonly StatusMeta[] = [ + { + status: "supported_now", + label: "Supported now", + meaning: "Implemented and served by the gateway today.", + }, + { + status: "planned_for_launch", + label: "Planned for launch", + meaning: "Not implemented yet. On the launch path, so do not build against it now.", + }, + { + status: "explicitly_unsupported_at_launch", + label: "Not supported at launch", + meaning: + "Part of the OpenAI contract this gateway mirrors, deliberately not implemented here.", + }, + { + status: "out_of_scope", + label: "Out of scope", + meaning: "Not part of this gateway's surface and not planned.", + }, +]; + +const HTTP_METHODS = new Set([ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Parses the support matrix, failing loudly on a shape it does not recognise. + * A silently empty table would read as "this gateway supports nothing", which + * is worse than an error page. + */ +export function parseSupportMatrix(raw: string): SupportMatrix { + const parsed: unknown = JSON.parse(raw); + if (!isRecord(parsed) || !Array.isArray(parsed.endpoints)) { + throw new Error("support matrix has no endpoints array"); + } + + const endpoints: MatrixEndpoint[] = parsed.endpoints.map((entry, index) => { + if ( + !isRecord(entry) || + typeof entry.method !== "string" || + typeof entry.path !== "string" || + typeof entry.status !== "string" + ) { + throw new Error(`support matrix endpoint ${index} is missing method, path or status`); + } + return { + method: entry.method.toUpperCase(), + path: entry.path, + status: entry.status, + phase: typeof entry.phase === "number" ? entry.phase : null, + notes: typeof entry.notes === "string" ? entry.notes : "", + }; + }); + + return { + version: typeof parsed.version === "string" ? parsed.version : "unknown", + generated: typeof parsed.generated === "string" ? parsed.generated : "unknown", + endpoints, + }; +} + +export function loadSupportMatrix(): SupportMatrix { + return parseSupportMatrix(readFileSync(SUPPORT_MATRIX_PATH, "utf8")); +} + +/** + * The prefix every path in the spec is served under, read from the spec's own + * `servers` block rather than assumed. Matrix paths already carry it, spec + * paths do not, and comparing them without it produces 164 phantom + * disagreements. + */ +export function extractServerPrefix(spec: string): string { + const match = /^servers:\n-\s*url:\s*(\S+)/m.exec(spec); + return match ? match[1].replace(/\/$/, "") : ""; +} + +export interface SpecOperation { + /** `METHOD /v1/path`. */ + operation: string; + /** The `x-hive-status` the generator stamped on it, when it stamped one. */ + status: string | null; +} + +/** + * Every operation the generated spec declares, with the support status the + * generator annotated it with. + * + * A line scan rather than a YAML parse: the only facts needed here are which + * method/path pairs exist and what `x-hive-status` each carries, the file is + * 29k lines, and adding a YAML dependency to render a table is not worth it. + * Inside the `paths:` block a path key is the only thing indented two spaces, + * a method key the only thing indented four, and the generator writes + * `x-hive-status` at six. `tests/unit/console-docs-contract.test.ts` pins the + * extracted count against the real file, which is what catches a future + * regeneration that changes the formatting rather than letting the table + * quietly empty out. + */ +export function extractSpecOperations(spec: string): SpecOperation[] { + const prefix = extractServerPrefix(spec); + const operations: SpecOperation[] = []; + let inPaths = false; + let currentPath: string | null = null; + let current: SpecOperation | null = null; + + for (const line of spec.split("\n")) { + if (!inPaths) { + if (line === "paths:") { + inPaths = true; + } + continue; + } + if (line.trim() === "") { + continue; + } + if (/^\S/.test(line)) { + break; // next top-level key; the paths block is over + } + + const pathKey = /^ {2}(\/\S*):\s*$/.exec(line); + if (pathKey) { + currentPath = pathKey[1]; + current = null; + continue; + } + + const methodKey = /^ {4}([a-z]+):\s*$/.exec(line); + if (methodKey && currentPath !== null && HTTP_METHODS.has(methodKey[1])) { + current = { + operation: `${methodKey[1].toUpperCase()} ${prefix}${currentPath}`, + status: null, + }; + operations.push(current); + continue; + } + + const statusKey = /^ {6}x-hive-status:\s*(\S+)\s*$/.exec(line); + if (statusKey && current !== null) { + current.status = statusKey[1]; + } + } + + return operations; +} + +export function loadSpecOperations(): SpecOperation[] { + return extractSpecOperations(readFileSync(OPENAPI_SPEC_PATH, "utf8")); +} + +export type DisagreementKind = + /** The matrix classifies it; the spec does not describe it at all. */ + | "missing_from_spec" + /** The spec describes it; the matrix does not classify it. */ + | "missing_from_matrix" + /** Both have it, and they disagree about its support status. */ + | "status_mismatch"; + +export interface ContractDisagreement { + /** `METHOD /v1/path`. */ + operation: string; + kind: DisagreementKind; + /** What the two files each say about it. */ + detail: string; +} + +/** + * Where the matrix and the generated spec do not agree. Reported on the page + * instead of picked between, because every direction is a real finding: a + * matrix row with no spec operation means the spec does not describe an + * endpoint somebody may be told to call, a spec operation with no matrix row + * means an endpoint nobody has classified, and a status mismatch means one of + * the two is stale. + */ +export function diffSpecAgainstMatrix( + specOperations: readonly SpecOperation[], + matrix: SupportMatrix, +): ContractDisagreement[] { + const inSpec = new Map(specOperations.map((entry) => [entry.operation, entry.status])); + const inMatrix = new Map( + matrix.endpoints.map((endpoint) => [ + `${endpoint.method} ${endpoint.path}`, + endpoint.status, + ]), + ); + + const disagreements: ContractDisagreement[] = []; + for (const [operation, matrixStatus] of inMatrix) { + if (!inSpec.has(operation)) { + disagreements.push({ + operation, + kind: "missing_from_spec", + detail: `classified "${matrixStatus}" in the support matrix, absent from the OpenAPI spec`, + }); + continue; + } + const specStatus = inSpec.get(operation) ?? null; + if (specStatus !== null && specStatus !== matrixStatus) { + disagreements.push({ + operation, + kind: "status_mismatch", + detail: `the spec annotates it "${specStatus}", the support matrix classifies it "${matrixStatus}"`, + }); + } + } + for (const operation of inSpec.keys()) { + if (!inMatrix.has(operation)) { + disagreements.push({ + operation, + kind: "missing_from_matrix", + detail: "declared in the OpenAPI spec, unclassified in the support matrix", + }); + } + } + + return disagreements.sort((a, b) => a.operation.localeCompare(b.operation)); +} + +export interface EndpointFamily { + name: string; + endpoints: MatrixEndpoint[]; +} + +export interface EndpointSection { + meta: StatusMeta; + count: number; + families: EndpointFamily[]; +} + +/** `/v1/chat/completions` -> `chat`. The first segment after the base path. */ +export function endpointFamily(endpointPath: string): string { + const segments = endpointPath.split("/").filter((segment) => segment !== ""); + return segments.length > 1 ? segments[1] : (segments[0] ?? "root"); +} + +/** + * Groups the matrix into one section per status, each grouped by resource + * family. A status the matrix carries but `STATUS_META` does not describe gets + * its own section rather than being dropped, so an unrecognised classification + * is visible on the page instead of vanishing from the counts. + */ +export function buildEndpointSections(matrix: SupportMatrix): EndpointSection[] { + const known = new Set(STATUS_META.map((meta) => meta.status)); + const extra = [...new Set(matrix.endpoints.map((endpoint) => endpoint.status))] + .filter((status) => !known.has(status)) + .sort() + .map((status): StatusMeta => ({ + status, + label: status, + meaning: "Status present in the support matrix but not described by the console.", + })); + + return [...STATUS_META, ...extra] + .map((meta) => { + const matching = matrix.endpoints.filter( + (endpoint) => endpoint.status === meta.status, + ); + const byFamily = new Map(); + for (const endpoint of matching) { + const family = endpointFamily(endpoint.path); + const bucket = byFamily.get(family); + if (bucket) { + bucket.push(endpoint); + } else { + byFamily.set(family, [endpoint]); + } + } + const families = [...byFamily.entries()] + .map(([name, endpoints]) => ({ + name, + endpoints: endpoints.sort( + (a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method), + ), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + return { meta, count: matching.length, families }; + }) + .filter((section) => section.count > 0); +} diff --git a/apps/web-console/tests/unit/console-docs-contract.test.ts b/apps/web-console/tests/unit/console-docs-contract.test.ts new file mode 100644 index 000000000..b73f913bd --- /dev/null +++ b/apps/web-console/tests/unit/console-docs-contract.test.ts @@ -0,0 +1,247 @@ +/** + * console-docs-contract.test.ts + * + * Guards the console's API reference (`/console/docs`, issue #1179) against + * the three ways a generated docs page goes quietly wrong. + * + * 1. The page is generated from `packages/openai-contract/`, which lives + * outside this app. If the line scan that reads the spec stops matching + * after a regeneration, the page renders an empty table and still returns + * 200. Pinning the extracted operation count against the spec's own + * `x-hive-status:` annotation count makes that a red test rather than an + * empty page nobody notices. + * 2. The quickstart names a host and a base path. Both are written down + * elsewhere as ground truth, and both have moved before, so they are + * asserted against those files instead of trusted. + * 3. The whole reason the page exists is that the shell's Documentation link + * pointed off-product at hivegpt.io on every console page. Nothing stops + * that from being reintroduced except a check. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { + API_BASE_URL, + OPENAPI_SPEC_PATH, + STATUS_META, + buildEndpointSections, + diffSpecAgainstMatrix, + endpointFamily, + extractServerPrefix, + extractSpecOperations, + loadSpecOperations, + loadSupportMatrix, + parseSupportMatrix, +} from "@/lib/api-contract"; + +const APP_ROOT = resolve(__dirname, "../.."); +const REPO_ROOT = resolve(APP_ROOT, "../.."); + +describe("support matrix", () => { + it("parses the real matrix and carries its own generation date", () => { + const matrix = loadSupportMatrix(); + + expect(matrix.endpoints.length).toBeGreaterThan(0); + // Printed on the page, so a matrix that lost its date would silently + // render "generated unknown" instead of failing here. + expect(matrix.generated).toMatch(/^\d{4}-\d{2}-\d{2}$/); + for (const endpoint of matrix.endpoints) { + expect(endpoint.path.startsWith("/v1/")).toBe(true); + expect(endpoint.method).toBe(endpoint.method.toUpperCase()); + } + }); + + it("rejects a matrix whose endpoints are unusable rather than rendering an empty table", () => { + expect(() => parseSupportMatrix('{"version":"1"}')).toThrow(/endpoints/); + expect(() => + parseSupportMatrix('{"endpoints":[{"method":"GET"}]}'), + ).toThrow(/method, path or status/); + }); + + it("keeps every status in a section, including one the console does not describe", () => { + const sections = buildEndpointSections({ + version: "test", + generated: "2026-01-01", + endpoints: [ + { method: "POST", path: "/v1/chat/completions", status: "supported_now", phase: 1, notes: "" }, + { method: "GET", path: "/v1/models", status: "supported_now", phase: 1, notes: "" }, + { method: "GET", path: "/v1/moonshot", status: "invented_status", phase: null, notes: "" }, + ], + }); + + const counted = sections.reduce((total, section) => total + section.count, 0); + expect(counted).toBe(3); + expect(sections.map((section) => section.meta.status)).toContain("invented_status"); + + const supported = sections.find((s) => s.meta.status === "supported_now"); + expect(supported?.families.map((family) => family.name)).toEqual(["chat", "models"]); + }); + + it("derives a family from the first segment after the base path", () => { + expect(endpointFamily("/v1/chat/completions")).toBe("chat"); + expect(endpointFamily("/v1/models")).toBe("models"); + expect(endpointFamily("/v1")).toBe("v1"); + }); +}); + +describe("openapi spec extraction", () => { + it("finds exactly the operations the generator annotated", () => { + const spec = readFileSync(OPENAPI_SPEC_PATH, "utf8"); + // The generator stamps x-hive-status on every operation it publishes + // (packages/openai-contract/scripts/sync_hive_contract.py), so counting + // those annotations is an independent count of the same set. + const annotated = spec.split("\n").filter((line) => + /^ {6}x-hive-status:/.test(line), + ).length; + + const operations = loadSpecOperations(); + + expect(annotated).toBeGreaterThan(0); + expect(operations.length).toBe(annotated); + expect(operations.every((op) => op.status !== null)).toBe(true); + expect(operations.every((op) => op.operation.includes(" /v1/"))).toBe(true); + }); + + it("reads the server prefix from the spec instead of assuming it", () => { + expect(extractServerPrefix(readFileSync(OPENAPI_SPEC_PATH, "utf8"))).toBe("/v1"); + expect(extractServerPrefix("servers:\n- url: /v2\n")).toBe("/v2"); + expect(extractServerPrefix("openapi: 3.0.0\n")).toBe(""); + }); + + it("scans a path block without being fooled by nested keys", () => { + const spec = [ + "servers:", + "- url: /v1", + "paths:", + " /chat/completions:", + " post:", + " operationId: createChatCompletion", + " x-hive-status: supported_now", + " responses:", + " get: not-a-method", + " /models:", + " get:", + " x-hive-status: supported_now", + "components:", + " schemas:", + " get:", + "", + ].join("\n"); + + expect(extractSpecOperations(spec)).toEqual([ + { operation: "POST /v1/chat/completions", status: "supported_now" }, + { operation: "GET /v1/models", status: "supported_now" }, + ]); + }); +}); + +describe("spec against matrix", () => { + it("reports each kind of disagreement rather than picking a side", () => { + const matrix = { + version: "test", + generated: "2026-01-01", + endpoints: [ + { method: "POST", path: "/v1/chat/completions", status: "supported_now", phase: 1, notes: "" }, + { method: "POST", path: "/v1/rag/chat", status: "supported_now", phase: 1, notes: "" }, + { method: "GET", path: "/v1/models", status: "out_of_scope", phase: null, notes: "" }, + ], + }; + const spec = [ + { operation: "POST /v1/chat/completions", status: "supported_now" }, + { operation: "GET /v1/models", status: "supported_now" }, + { operation: "DELETE /v1/files/{file_id}", status: "supported_now" }, + ]; + + expect(diffSpecAgainstMatrix(spec, matrix)).toEqual([ + { + operation: "DELETE /v1/files/{file_id}", + kind: "missing_from_matrix", + detail: "declared in the OpenAPI spec, unclassified in the support matrix", + }, + { + operation: "GET /v1/models", + kind: "status_mismatch", + detail: + 'the spec annotates it "supported_now", the support matrix classifies it "out_of_scope"', + }, + { + operation: "POST /v1/rag/chat", + kind: "missing_from_spec", + detail: + 'classified "supported_now" in the support matrix, absent from the OpenAPI spec', + }, + ]); + }); + + it("has no unclassified spec operation, and no status mismatch in an unexpected direction", () => { + const disagreements = diffSpecAgainstMatrix( + loadSpecOperations(), + loadSupportMatrix(), + ); + + // Matrix-only entries are expected and are rendered on the page: the + // generator drops out-of-scope operations from the spec on purpose, and + // Hive's own endpoints were never in the upstream OpenAI document the + // spec is built from. + expect( + disagreements.filter((entry) => entry.kind === "missing_from_matrix"), + ).toEqual([]); + + // Status mismatches are not expected, and today there is exactly one + // kind: generated/hive-openapi.yaml was produced from an older matrix and + // still annotates a set of live endpoints planned_for_launch. That is a + // contract-package defect, reported on the docs page and in issue #1179, + // not something the console can fix. A mismatch in any other direction is + // new and must not arrive silently, so the direction is pinned rather + // than the count. + for (const entry of disagreements) { + if (entry.kind === "status_mismatch") { + expect(entry.detail).toBe( + 'the spec annotates it "planned_for_launch", the support matrix classifies it "supported_now"', + ); + } + } + }); +}); + +describe("quickstart facts", () => { + it("names a host the tunnel actually serves, on the spec's own base path", () => { + const base = new URL(API_BASE_URL); + const ingress: { config: { ingress: { hostname?: string }[] } } = JSON.parse( + readFileSync(resolve(REPO_ROOT, "deploy/cloudflare/tunnel-ingress.json"), "utf8"), + ); + const hostnames = ingress.config.ingress + .map((rule) => rule.hostname) + .filter((hostname): hostname is string => typeof hostname === "string"); + + expect(hostnames).toContain(base.hostname); + expect(base.pathname).toBe( + extractServerPrefix(readFileSync(OPENAPI_SPEC_PATH, "utf8")), + ); + expect(base.protocol).toBe("https:"); + }); + + it("describes every status the console renders", () => { + expect(new Set(STATUS_META.map((meta) => meta.status)).size).toBe( + STATUS_META.length, + ); + for (const meta of STATUS_META) { + expect(meta.label.length).toBeGreaterThan(0); + expect(meta.meaning.length).toBeGreaterThan(0); + } + }); +}); + +describe("documentation links stay in product", () => { + it("no console surface links out to hivegpt.io", () => { + for (const file of [ + "components/app-shell/console-shell.tsx", + "app/console/page.tsx", + ]) { + const source = readFileSync(resolve(APP_ROOT, file), "utf8"); + expect(source).not.toMatch(/href=["']https:\/\/hivegpt\.io/); + expect(source).toContain('"/console/docs"'); + } + }); +}); diff --git a/deploy/docker/Dockerfile.web-console b/deploy/docker/Dockerfile.web-console index d864f5fb8..41cb1b2e6 100644 --- a/deploy/docker/Dockerfile.web-console +++ b/deploy/docker/Dockerfile.web-console @@ -40,6 +40,17 @@ COPY deploy/ /app/deploy/ COPY docs/proof/chat-interaction-coverage-2026-08-10/coverage.run.json /app/docs/proof/chat-interaction-coverage-2026-08-10/coverage.run.json COPY supabase/migrations/ /app/supabase/migrations/ +# The console's /console/docs page and its /api/openapi.yaml route read the +# generated OpenAPI contract and its support matrix straight out of this +# package at request time (apps/web-console/lib/api-contract.ts). It is read +# rather than imported because the spec is 1.1 MB of YAML that no loader in +# that app turns into a module, and it is not copied into apps/web-console +# because a second copy of a generated artefact is exactly the drift that +# generating it exists to prevent. Without this COPY the route answers 500 and +# the docs page fails to render, in the image only, which is the failure this +# line exists to prevent. +COPY packages/openai-contract/ /app/packages/openai-contract/ + EXPOSE 3000 CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0", "--port", "3000"] diff --git a/deploy/docker/Dockerfile.web-console.prod b/deploy/docker/Dockerfile.web-console.prod index 884e10759..31318009a 100644 --- a/deploy/docker/Dockerfile.web-console.prod +++ b/deploy/docker/Dockerfile.web-console.prod @@ -37,6 +37,17 @@ RUN set -eux; \ COPY apps/web-console/ ./ +# The console's /console/docs page and its /api/openapi.yaml route read the +# generated OpenAPI contract and its support matrix straight out of this +# package at request time (apps/web-console/lib/api-contract.ts). It is read +# rather than imported because the spec is 1.1 MB of YAML that no loader in +# that app turns into a module, and it is not copied into apps/web-console +# because a second copy of a generated artefact is exactly the drift that +# generating it exists to prevent. Without this COPY the route answers 500 and +# the docs page fails to render, in the image only, which is the failure this +# line exists to prevent. +COPY packages/openai-contract/ /app/packages/openai-contract/ + ARG NEXT_PUBLIC_SUPABASE_URL ARG NEXT_PUBLIC_SUPABASE_ANON_KEY ARG NEXT_PUBLIC_APP_URL diff --git a/docs/proof/console-hosted-docs-2026-08-25/capture.log b/docs/proof/console-hosted-docs-2026-08-25/capture.log new file mode 100644 index 000000000..6270bd66b --- /dev/null +++ b/docs/proof/console-hosted-docs-2026-08-25/capture.log @@ -0,0 +1,34 @@ +hive PR #1187, issue #1179 -- console API reference, captured 2026-08-25. + +Substrate: the image built from this branch's deploy/docker/Dockerfile.web-console.prod +(next build + next start, the same Dockerfile that builds console-hive.scubed.co), +running against a live self-hosted Supabase and a live control-plane, signed in with a +session minted through tests/e2e/support/live-auth.mjs. No password was set or rotated. +The signed-in address is covered by Playwright's screenshot mask in every capture and +never appears in this log. + +GET /console/catalog -> 200 +shell header Documentation link href = /console/docs +screenshot console-docs-01-shell-link.png url=http://localhost:3111/console/catalog +clicking the shell Documentation link landed on http://localhost:3111/console/docs +screenshot console-docs-02-quickstart.png url=http://localhost:3111/console/docs +quickstart alias line = hive-auto is a routing alias, not a single upstream model. See the model catalog for what each alias resolves to and wha +page h1 = API reference +provenance line = Served unauthenticated at /api/openapi.yaml on this console's own origin · 97 operations described · support matrix version 0.1.0, generated 2026-03-28 +screenshot console-docs-03-disagreements.png url=http://localhost:3111/console/docs +screenshot console-docs-04-unsupported.png url=http://localhost:3111/console/docs +section present: Supported now -> true +section present: Planned for launch -> true +section present: Not supported at launch -> true +section present: Out of scope -> true +endpoint rows rendered = 164 +GET /api/openapi.yaml (no session) -> 200 application/yaml; charset=utf-8 +spec bytes served = 1165460 +first spec line = openapi: 3.0.0 + +clean load, no capture-side DOM mutation: + /console -> 200, no page errors + /console/catalog -> 200, no page errors + /console/docs -> 200, no page errors + +browser console during the capture: