diff --git a/.gitignore b/.gitignore index 1aaf63e6..6e44b762 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ node_modules # Testing coverage +# Generated UI evidence (scripts/orca-ui-evidence.py) +/orca-evidence/ + # Turbo .turbo diff --git a/README.md b/README.md index a762b506..9b9688a1 100644 --- a/README.md +++ b/README.md @@ -809,6 +809,7 @@ pnpm dev - http://remotion-demo.json-render.localhost:1355 - Remotion Video Example - Chat Example: run `pnpm dev` in `examples/chat` - [Experimental Jev composition](https://json-render.dev/docs/jev): use `experimental_composeSpec` and `experimental_createEvaluator` from core with your own catalog, or select **Jev (Experimental)** in `/playground`. Unreleased; source-build instructions are in the guide. +- [OrcaRouter](https://json-render.dev/docs/orcarouter): select **OrcaRouter** in `/playground` to generate through [OrcaRouter](https://www.orcarouter.ai), an OpenAI-compatible AI gateway that routes many providers behind one endpoint. Connect with an API key or with **Connect with OrcaRouter** (OAuth 2.0 + PKCE); model choices come from the live OrcaRouter catalog. - Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat` - Vue Example: run `pnpm dev` in `examples/vue` - Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers` diff --git a/apps/web/.env.example b/apps/web/.env.example index 07ff0d97..5a21aca8 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -3,6 +3,22 @@ # For local development, get your key from https://vercel.com/ai-gateway AI_GATEWAY_API_KEY= +# OrcaRouter +# OpenAI-compatible AI gateway. Either paste an API key here, or open the +# playground and use "Connect with OrcaRouter" (OAuth 2.0 + PKCE), which +# writes the issued key into this file for you. +# Keys look like sk-orca-...; never commit a real key. +ORCAROUTER_API_KEY= + +# OrcaRouter endpoint overrides (all optional) +# Inference and model catalog base. Default: https://api.orcarouter.ai/v1 +# ORCA_BASE_URL=https://api.orcarouter.ai/v1 +# ORCA_API_BASE_URL=https://api.orcarouter.ai/v1 +# OAuth 2.0 authorization origin. Default: https://www.orcarouter.ai +# ORCA_AUTH_BASE_URL=https://www.orcarouter.ai +# Point the credential store at a different env file (defaults to apps/web/.env.local) +# ORCAROUTER_ENV_FILE= + # Dedicated AI Gateway key for the experimental Jev playground option # Required locally and on Vercel; no fallback to AI_GATEWAY_API_KEY JEV_AI_GATEWAY_API_KEY= diff --git a/apps/web/app/(main)/docs/orcarouter/page.mdx b/apps/web/app/(main)/docs/orcarouter/page.mdx new file mode 100644 index 00000000..5d013ea4 --- /dev/null +++ b/apps/web/app/(main)/docs/orcarouter/page.mdx @@ -0,0 +1,258 @@ +import { pageMetadata } from "@/lib/page-metadata"; +export const metadata = pageMetadata("docs/orcarouter"); + +# OrcaRouter + +[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible AI gateway that routes many providers behind one endpoint. The json-render web app can route playground and docs-chat generation through it, and `@json-render/core` exports the pieces you need to do the same in your own app. + +## Authentication + +Two explicit choices are offered, and neither replaces the other: + + + + + + + + + + + + + + + + + + + + + +
ChoiceLabelHow the credential is obtained
+ orcarouter + OrcaRouter - API + Paste an existing sk-orca-… key from your OrcaRouter + console. +
+ orcarouter-oauth + OrcaRouter - Auth + Sign in with an OrcaRouter account over OAuth 2.0 + PKCE. No client + secret and no pre-registered redirect URI. +
+ +Both paths produce the same thing — a normal OrcaRouter API key that belongs to the user, is billed to their account, and can be revoked at any time — so inference and model discovery never need to know which one was used. + +### Where the key is stored + +The web app writes it to the same place it already keeps provider secrets: `apps/web/.env.local`, which is git-ignored. Set `ORCAROUTER_ENV_FILE` to point at a writable path in a deployment where the app directory is read-only. If the write fails, the key is still used for the lifetime of the process and the panel says so. + +## Configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariablePurposeDefault
+ ORCAROUTER_API_KEY + + An existing OrcaRouter key. Equivalent to pasting one in the panel. + —
+ ORCA_AUTH_BASE_URL + + Origin for /auth and the code exchange. + + https://www.orcarouter.ai +
+ ORCA_API_BASE_URL + + Origin for /v1/models and inference. + + https://api.orcarouter.ai +
+ ORCA_BASE_URL + One origin serving both roles, for self-hosted deployments.—
+ +Authentication and inference are different origins. Do not derive one from the other by swapping a hostname or appending `/v1`: `https://api.orcarouter.ai/v1/auth/keys` is a 404. Explicit per-role overrides win over `ORCA_BASE_URL`. Remote origins must use HTTPS; plain HTTP is accepted only for loopback. + +## Model discovery + +The model control is a dropdown built from the live catalog at `GET https://api.orcarouter.ai/v1/models`, requested with the user's key so it reflects the models their workspace can actually call. The key stays on the server; the browser receives only model ids and minimal metadata. + +Options are filtered per capability, and a model is only offered when the catalog metadata proves it fits: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Entry pointFilter
Text chat + ?capability=chat, and supported_endpoint_types{" "} + must include openai, anthropic,{" "} + gemini or openai-response, excluding + image-generation, video and rerank families. +
Image / audio / video input + The chat filter, plus an explicit{" "} + architecture.input_modalities entry for each modality the + request uploads. A model with no architecture block is + never treated as multimodal. +
Embedding + ?capability=embedding, or an embeddings{" "} + endpoint type. +
Image generation + ?capability=image, or an image-generation{" "} + endpoint type. +
Video / rerank + An openai-video or jina-rerank endpoint type. +
+ +Changing the provider, the capability, or the attachments recomputes the options. A selection that is no longer compatible is cleared instead of being silently kept. + +If the catalog cannot be reached, the control falls back to a small, verified seed list and is labelled degraded. It never degrades into a free-text model field. + +## Using the provider in your own app + +```typescript +import { + createOrcaApiKeySource, + createOrcaPkceSource, + OrcaCredentialStore, + fetchOrcaCatalog, + resolveOrcarouterOrigins, + selectOrcaModels, +} from "@json-render/core"; + +const origins = resolveOrcarouterOrigins(); + +// Either entry point produces the same credential shape. +const source = createOrcaApiKeySource(process.env.ORCAROUTER_API_KEY!); +const credential = await source.acquire(); + +const store = new OrcaCredentialStore(); +store.setCredential(credential); + +// Discovery and inference consume only the key. +const catalog = await fetchOrcaCatalog({ + origins, + apiKey: store.getUsableCredential()!.apiKey, + capability: "chat", +}); +const chatModels = selectOrcaModels(catalog, { capability: "chat" }); +``` + +### The connect flow + +```typescript +const source = createOrcaPkceSource({ + origins, + appName: "My Tool", + flow: "loopback", // or "oob" when you cannot listen on 127.0.0.1 + createCodeReceiver: async () => startLoopbackListener(), + openBrowser: (url) => open(url), +}); +const credential = await source.acquire(); +``` + +The verifier is generated fresh from a cryptographic RNG for every attempt, never leaves the process, and never appears in a URL, log or error message. The authorize URL always carries `code_challenge_method=S256`. + +## Credential lifecycle + +A PKCE-issued key is a durable API key, not a refresh token. There is no refresh grant to call, and the key is reused until OrcaRouter revokes it — re-authorizing on every launch would hit the limit of ten PKCE-issued keys per user per day. + +A `401` from the relay is terminal. The exact account and credential generation that made the rejected request is marked `needsReauth` and the user is asked to connect again. The stored secret is kept until a replacement succeeds, so a transient failure cannot lose the account. A late failure from an old request never marks a newly reconnected credential as broken. + +Users can revoke access at any time from [the OrcaRouter console](https://www.orcarouter.ai/console/authorized-apps). + +## Provider evidence + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemSource
OpenAI-compatible inference + https://api.orcarouter.ai/v1/chat/completions +
Model catalog + https://api.orcarouter.ai/v1/models (Bearer required) +
Authorization and exchange + https://www.orcarouter.ai/auth,{" "} + https://www.orcarouter.ai/api/v1/auth/keys +
Discovery document + https://www.orcarouter.ai/.well-known/openid-configuration +
Revocation and account management + https://www.orcarouter.ai/console/authorized-apps +
diff --git a/apps/web/app/api/docs-chat/route.ts b/apps/web/app/api/docs-chat/route.ts index 418cc6fc..aa014334 100644 --- a/apps/web/app/api/docs-chat/route.ts +++ b/apps/web/app/api/docs-chat/route.ts @@ -4,9 +4,17 @@ import { convertToModelMessages, stepCountIs, streamText } from "ai"; import type { ModelMessage, UIMessage } from "ai"; import { createBashTool } from "bash-tool"; import { headers } from "next/headers"; +import { selectOrcaModels } from "@json-render/core"; import { allDocsPages } from "@/lib/docs-navigation"; import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown"; import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; +import { discoverOrcaModels } from "@/lib/orcarouter/connect-session"; +import { + ORCAROUTER_DEFAULT_MODEL, + OrcaRouterNotConnectedError, + createOrcarouterTransport, + isOrcarouterProvider, +} from "@/lib/orcarouter/provider"; export const maxDuration = 60; @@ -21,6 +29,8 @@ Skills: json-render ships AI agent skills that teach coding agents how to use ea Experimental Jev composition: core exports experimental_composeSpec and experimental_createEvaluator for app-owned catalogs/candidates through Vercel AI Gateway. See /docs/jev for availability, source-build setup, and limits; do not assume the currently published npm version includes it. +OrcaRouter: the playground and this docs assistant can run through OrcaRouter, an OpenAI-compatible AI gateway. Users connect either with an API key or with Connect with OrcaRouter (OAuth 2.0 + PKCE), and model choices come from the live OrcaRouter model catalog. See /docs/orcarouter for setup, credential handling, and capability filtering. + You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory. When answering questions: @@ -108,15 +118,55 @@ export async function POST(req: Request) { ); } - const { messages }: { messages: UIMessage[] } = await req.json(); + const { + messages, + model, + provider, + }: { messages: UIMessage[]; model?: string; provider?: string } = + await req.json(); const docsFiles = await loadDocsFiles(); const { tools: { bash, readFile }, } = await createBashTool({ files: docsFiles }); + // The docs assistant is a text-only entry point: it never uploads an + // attachment, so it offers chat models without a modality requirement. + let modelRef: Parameters[0]["model"] = DEFAULT_MODEL; + if (typeof provider === "string" && isOrcarouterProvider(provider)) { + const modelId = + typeof model === "string" && model.trim() + ? model.trim() + : ORCAROUTER_DEFAULT_MODEL; + try { + const { catalog } = await discoverOrcaModels(); + const compatible = selectOrcaModels(catalog, { capability: "chat" }); + if (!compatible.some((entry) => entry.id === modelId)) { + return Response.json( + { + error: "Incompatible model", + message: `The OrcaRouter catalog does not list "${modelId}" as a chat model. Pick another model.`, + }, + { status: 400 }, + ); + } + modelRef = createOrcarouterTransport(modelId).model; + } catch (error) { + return Response.json( + { + error: "OrcaRouter unavailable", + message: + error instanceof OrcaRouterNotConnectedError + ? error.message + : "The OrcaRouter model catalog is unavailable. Try again shortly.", + }, + { status: 503 }, + ); + } + } + const result = streamText({ - model: DEFAULT_MODEL, + model: modelRef, system: SYSTEM_PROMPT, messages: await convertToModelMessages(messages), stopWhen: stepCountIs(5), diff --git a/apps/web/app/api/generate/route.ts b/apps/web/app/api/generate/route.ts index 3711f9be..c5af6f6f 100644 --- a/apps/web/app/api/generate/route.ts +++ b/apps/web/app/api/generate/route.ts @@ -5,12 +5,21 @@ import { buildUserPrompt, buildEditUserPrompt, isNonEmptySpec, + selectOrcaModels, + type OrcaInputModality, } from "@json-render/core"; import { yamlPrompt } from "@json-render/yaml"; import { stringify as yamlStringify } from "yaml"; import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit"; import { playgroundCatalog } from "@/lib/render/catalog"; import { createCompositionResponse } from "@/lib/jev/response"; +import { discoverOrcaModels } from "@/lib/orcarouter/connect-session"; +import { + OrcaRouterNotConnectedError, + createOrcarouterTransport, + isOrcarouterProvider, + ORCAROUTER_DEFAULT_MODEL, +} from "@/lib/orcarouter/provider"; export const maxDuration = 60; @@ -93,7 +102,16 @@ export async function POST(req: Request) { ); } - const { prompt, context, format, editModes, model } = await req.json(); + const { + prompt, + context, + format, + editModes, + model, + provider, + orcaModel, + attachments, + } = await req.json(); if (model === "typesafe-ai/jev") return createCompositionResponse(req, prompt, context?.previousSpec); const isYaml = format === "yaml"; @@ -108,8 +126,71 @@ export async function POST(req: Request) { editModes, }); + const orcarouter = + typeof provider === "string" && isOrcarouterProvider(provider); + + // Second-layer guard. The selector is already filtered by capability, but a + // request must never carry an attachment to a model the catalog cannot prove + // accepts it. + const uploadedModalities = new Set( + Array.isArray(attachments) + ? attachments + .map((item: unknown) => + typeof item === "object" && item !== null + ? (item as { modality?: unknown }).modality + : null, + ) + .filter( + (value: unknown): value is OrcaInputModality => + value === "image" || value === "audio" || value === "video", + ) + : [], + ); + + let modelRef: Parameters[0]["model"]; + if (orcarouter) { + const modelId = + typeof orcaModel === "string" && orcaModel.trim() + ? orcaModel.trim() + : ORCAROUTER_DEFAULT_MODEL; + try { + // Revalidate the requested model against the live catalog for the exact + // capability requirements of this request before sending anything. + const { catalog } = await discoverOrcaModels(); + const compatible = selectOrcaModels(catalog, { + capability: "chat", + requiredInputModalities: [...uploadedModalities], + }); + if (!compatible.some((entry) => entry.id === modelId)) { + return Response.json( + { + error: "Incompatible model", + message: uploadedModalities.size + ? `The OrcaRouter catalog does not list "${modelId}" as a chat model that accepts ${[...uploadedModalities].join(", ")} input. Pick another model.` + : `The OrcaRouter catalog does not list "${modelId}" as a chat model. Pick another model.`, + }, + { status: 400 }, + ); + } + modelRef = createOrcarouterTransport(modelId).model; + } catch (error) { + const message = + error instanceof OrcaRouterNotConnectedError + ? error.message + : "The OrcaRouter model catalog is unavailable. Try again shortly."; + return Response.json( + { error: "OrcaRouter unavailable", message }, + { + status: 503, + }, + ); + } + } else { + modelRef = process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL; + } + const result = streamText({ - model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL, + model: modelRef, abortSignal: req.signal, system: [ { diff --git a/apps/web/app/api/orcarouter/connect/route.ts b/apps/web/app/api/orcarouter/connect/route.ts new file mode 100644 index 00000000..0fec9e2f --- /dev/null +++ b/apps/web/app/api/orcarouter/connect/route.ts @@ -0,0 +1,44 @@ +import { createOrcaApiKeySource, validateOrcaApiKey } from "@json-render/core"; +import { saveOrcaCredential } from "@/lib/orcarouter/server-store"; + +export const maxDuration = 30; + +interface ConnectRequestBody { + method?: "api_key"; + apiKey?: string; +} + +/** + * The API-key entry point. The key is validated for shape, installed through + * the same credential seam the PKCE adapter uses, and persisted with the + * project's existing env-file mechanism. It is never echoed back. + */ +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as ConnectRequestBody; + if (body.method !== "api_key") { + return Response.json( + { error: "Unsupported authentication method." }, + { status: 400 }, + ); + } + + const validation = validateOrcaApiKey(body.apiKey ?? ""); + if (!validation.ok) { + return Response.json({ error: validation.problem }, { status: 400 }); + } + + // The API-key choice goes through one adapter and produces one credential + // shape, exactly like the PKCE choice does. + const source = createOrcaApiKeySource(body.apiKey!); + const result = await source.acquire(); + const persistence = await saveOrcaCredential(result); + + return Response.json({ + ok: true, + method: result.method, + maskedKey: `${result.apiKey.slice(0, 8)}••••${result.apiKey.slice(-4)}`, + persisted: persistence.persisted, + location: persistence.location, + notice: persistence.problem, + }); +} diff --git a/apps/web/app/api/orcarouter/connect/session/route.ts b/apps/web/app/api/orcarouter/connect/session/route.ts new file mode 100644 index 00000000..2e5dc9fe --- /dev/null +++ b/apps/web/app/api/orcarouter/connect/session/route.ts @@ -0,0 +1,61 @@ +import { + cancelOrcaConnectSession, + getOrcaConnectSession, + startOrcaConnectSession, + submitOrcaConnectCode, + type OrcaConnectFlow, +} from "@/lib/orcarouter/connect-session"; + +export const maxDuration = 60; + +/** + * The OAuth 2.0 + PKCE connect flow. The verifier and the loopback listener + * stay in this process; the browser receives only the authorize URL and a + * status. Every terminal path releases the single in-flight login. + */ + +/** Start a login. Replaces any previous in-flight session. */ +export async function POST(req: Request) { + const body = (await req.json().catch(() => ({}))) as { + flow?: OrcaConnectFlow; + }; + const flow: OrcaConnectFlow = body.flow === "loopback" ? "loopback" : "oob"; + const session = await startOrcaConnectSession({ flow }); + return Response.json(session); +} + +/** Poll a login's status. */ +export async function GET(req: Request) { + const sessionId = new URL(req.url).searchParams.get("sessionId"); + if (!sessionId) { + return Response.json({ error: "sessionId is required" }, { status: 400 }); + } + const session = getOrcaConnectSession(sessionId); + if (!session) { + return Response.json({ error: "Unknown session" }, { status: 404 }); + } + return Response.json(session); +} + +/** Complete an out-of-band login, or cancel a login explicitly. */ +export async function PATCH(req: Request) { + const body = (await req.json().catch(() => ({}))) as { + sessionId?: string; + code?: string; + }; + if (!body.sessionId) { + return Response.json({ error: "sessionId is required" }, { status: 400 }); + } + const session = await submitOrcaConnectCode(body.sessionId, body.code ?? ""); + if (!session) { + return Response.json({ error: "Unknown session" }, { status: 404 }); + } + return Response.json(session); +} + +/** Cancel. Called on cancel, method switch, unmount, reload and `pagehide`. */ +export async function DELETE(req: Request) { + const sessionId = new URL(req.url).searchParams.get("sessionId"); + const cancelled = cancelOrcaConnectSession(sessionId ?? undefined); + return Response.json({ ok: true, cancelled }); +} diff --git a/apps/web/app/api/orcarouter/credential/route.ts b/apps/web/app/api/orcarouter/credential/route.ts new file mode 100644 index 00000000..e372a1d1 --- /dev/null +++ b/apps/web/app/api/orcarouter/credential/route.ts @@ -0,0 +1,31 @@ +import { redactOrcaApiKey } from "@json-render/core"; +import { + getOrcaServerState, + clearOrcaCredential, +} from "@/lib/orcarouter/server-store"; + +export const maxDuration = 15; + +/** Current connection state. The key itself never leaves the server. */ +export async function GET() { + const { store, persistence, origins } = getOrcaServerState(); + const credential = store.getCredential(); + return Response.json({ + connected: store.isConnected(), + needsReauth: store.needsReauth, + method: credential?.method ?? null, + maskedKey: credential ? redactOrcaApiKey(credential.apiKey) : null, + reauthReason: credential?.reauthReason ?? null, + persisted: persistence.persisted, + location: persistence.location, + notice: persistence.problem, + authOrigin: origins.authBaseUrl, + apiOrigin: origins.apiBaseUrl, + }); +} + +/** Remove the credential from memory and from the env file. */ +export async function DELETE() { + await clearOrcaCredential(); + return Response.json({ ok: true, connected: false }); +} diff --git a/apps/web/app/api/orcarouter/models/route.ts b/apps/web/app/api/orcarouter/models/route.ts new file mode 100644 index 00000000..b2038260 --- /dev/null +++ b/apps/web/app/api/orcarouter/models/route.ts @@ -0,0 +1,82 @@ +import { + isOrcaModelCompatible, + selectOrcaModels, + type OrcaCapability, + type OrcaInputModality, +} from "@json-render/core"; +import { discoverOrcaModels } from "@/lib/orcarouter/connect-session"; + +export const maxDuration = 30; + +const CAPABILITIES = new Set([ + "chat", + "embedding", + "image", + "video", + "rerank", +]); + +const MODALITIES = new Set([ + "text", + "image", + "audio", + "video", +]); + +/** + * Model discovery for the selector. + * + * The key stays on the server; the browser receives only the minimal model + * metadata it needs to render a dropdown, already filtered for the requested + * capability. A model the catalog cannot prove compatible is never offered. + */ +export async function GET(req: Request) { + const params = new URL(req.url).searchParams; + const rawCapability = params.get("capability") ?? "chat"; + if (!CAPABILITIES.has(rawCapability as OrcaCapability)) { + return Response.json( + { error: `Unsupported capability: ${rawCapability}` }, + { status: 400 }, + ); + } + const capability = rawCapability as OrcaCapability; + const requiredInputModalities = (params.get("input") ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value): value is OrcaInputModality => + MODALITIES.has(value as OrcaInputModality), + ); + + const { catalog, maskedKey } = await discoverOrcaModels(); + const options = { + capability, + requiredInputModalities, + }; + const models = selectOrcaModels(catalog, options); + + // A stale selection must be reported so the client can clear it. + const selected = params.get("selected"); + const selectionStillValid = + selected === null + ? null + : isOrcaModelCompatible(catalog, selected, options); + + return Response.json({ + models: models.map((model) => ({ + id: model.id, + name: model.name, + contextLength: model.contextLength, + maxCompletionTokens: model.maxCompletionTokens, + inputModalities: model.architecture?.inputModalities ?? [], + reasoningEfforts: model.reasoningEfforts, + })), + source: catalog.source, + degraded: catalog.degraded, + degradedReason: catalog.degradedReason, + fetchedAt: catalog.fetchedAt, + capability, + requiredInputModalities, + maskedKey, + selectionStillValid, + }); +} diff --git a/apps/web/components/orca-connect-panel.tsx b/apps/web/components/orca-connect-panel.tsx new file mode 100644 index 00000000..8955eaaf --- /dev/null +++ b/apps/web/components/orca-connect-panel.tsx @@ -0,0 +1,386 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { CopyButton } from "./copy-button"; +import { ORCAROUTER_KEY_DASHBOARD_URL } from "@/lib/orcarouter/constants"; +import { + useOrcaConnect, + type OrcaConnectFlow, +} from "@/lib/orcarouter/use-orca-connect"; + +export interface OrcaCredentialState { + connected: boolean; + needsReauth: boolean; + method: string | null; + maskedKey: string | null; + reauthReason: string | null; + notice: string | null; +} + +export interface OrcaConnectPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + state: OrcaCredentialState | null; + onChanged: () => void; +} + +type Choice = "api_key" | "orcarouter-oauth"; + +/** + * The OrcaRouter configuration surface. Both entry points are presented as + * equal, explicit choices: paste an existing `sk-orca-…` key, or authorize with + * an OrcaRouter account. Neither replaces the other. + */ +export function OrcaConnectPanel({ + open, + onOpenChange, + state, + onChanged, +}: OrcaConnectPanelProps) { + const [choice, setChoice] = useState("api_key"); + const [apiKey, setApiKey] = useState(""); + const [saving, setSaving] = useState(false); + const [apiKeyError, setApiKeyError] = useState(null); + const [apiKeyNotice, setApiKeyNotice] = useState(null); + const [code, setCode] = useState(""); + const [clearing, setClearing] = useState(false); + + const connect = useOrcaConnect(); + const inputRef = useRef(null); + + // Switching authentication method releases any in-flight login. + useEffect(() => { + if (!open) connect.cancel(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + if (connect.status === "connected") onChanged(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connect.status]); + + async function saveApiKey() { + setSaving(true); + setApiKeyError(null); + setApiKeyNotice(null); + try { + const response = await fetch("/api/orcarouter/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "api_key", apiKey }), + }); + const payload = (await response.json()) as { + error?: string; + notice?: string; + persisted?: boolean; + }; + if (!response.ok) { + setApiKeyError(payload.error ?? "That key could not be saved."); + return; + } + setApiKey(""); + setApiKeyNotice( + payload.notice ?? + (payload.persisted + ? "Saved. The key is used for inference and model discovery." + : null), + ); + onChanged(); + } catch { + setApiKeyError("That key could not be saved."); + } finally { + setSaving(false); + } + } + + async function clearCredential() { + setClearing(true); + try { + await fetch("/api/orcarouter/credential", { method: "DELETE" }); + connect.reset(); + onChanged(); + } finally { + setClearing(false); + } + } + + const busy = connect.busy || saving || clearing; + + return ( + + + + + {/* Official OrcaRouter mark. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + + Connect OrcaRouter + + + OrcaRouter is an OpenAI-compatible AI gateway that routes many + providers behind one endpoint. Choose how to authenticate. + + + + {state?.connected && ( +
+

+ Connected + + {state.maskedKey} + +

+

+ {state.method === "orcarouter-oauth" + ? "Authorized with your OrcaRouter account." + : "Using a pasted OrcaRouter API key."} +

+ +
+ )} + + {state?.needsReauth && ( +

+ {state.reauthReason ?? + "The OrcaRouter key is no longer accepted. Connect again to issue a new one."} +

+ )} + +
+ + + +
+ + {choice === "api_key" ? ( +
+ + setApiKey(event.target.value)} + placeholder="sk-orca-..." + disabled={saving} + data-testid="orca-api-key-input" + className="w-full rounded border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" + /> + {apiKeyError && ( +

+ {apiKeyError} +

+ )} + {apiKeyNotice && ( +

{apiKeyNotice}

+ )} +
+ + + Manage keys + +
+
+ ) : ( +
+
+ {(["loopback", "oob"] as const).map((option: OrcaConnectFlow) => ( + + ))} +
+ + + {connect.authorizeUrl && ( +
+

+ If your browser did not open, visit: +

+
+ + {connect.authorizeUrl} + + +
+
+ )} + + {connect.status === "awaiting_code" && connect.sessionId && ( +
+ + setCode(event.target.value)} + spellCheck={false} + data-testid="orca-code-input" + className="w-full rounded border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + +
+ )} + + {connect.busy && ( + + )} + + {connect.error && ( +

+ {connect.error} +

+ )} + {connect.status === "connected" && ( +

+ Connected{connect.maskedKey ? ` (${connect.maskedKey})` : ""}. +

+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/components/orca-model-selector.tsx b/apps/web/components/orca-model-selector.tsx new file mode 100644 index 00000000..327cba2c --- /dev/null +++ b/apps/web/components/orca-model-selector.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { CheckIcon, ChevronDownIcon, RefreshCwIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + useOrcaModels, + type OrcaModelOption, +} from "@/lib/orcarouter/use-orca-models"; + +export interface OrcaModelSelectorProps { + /** Non-text modalities the current request will upload. */ + requiredInputModalities?: readonly string[]; + value: string | null; + onChange: (modelId: string) => void; + disabled?: boolean; +} + +/** + * The OrcaRouter model control. + * + * Options come from the live catalog for the current capability and are + * recomputed whenever the provider, capability or attachment modalities change. + * There is no free-text entry: a model the catalog cannot prove compatible is + * never offered, and a selection that stops being compatible is cleared. + */ +export function OrcaModelSelector({ + requiredInputModalities, + value, + onChange, + disabled, +}: OrcaModelSelectorProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const containerRef = useRef(null); + + const { + models, + loading, + degraded, + degradedReason, + source, + error, + selectionInvalidated, + refresh, + } = useOrcaModels({ + enabled: true, + capability: "chat", + requiredInputModalities, + selected: value, + }); + + // A selection that is no longer compatible must be cleared, never kept. + useEffect(() => { + if (selectionInvalidated && value) onChange(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectionInvalidated, value]); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return models; + return models.filter( + (model) => + model.id.toLowerCase().includes(needle) || + (model.name?.toLowerCase().includes(needle) ?? false), + ); + }, [models, query]); + + const selected = models.find((model) => model.id === value) ?? null; + + return ( +
+ + + {open && ( +
+
+ setQuery(event.target.value)} + placeholder="Search models" + spellCheck={false} + data-testid="orca-model-search" + className="min-w-0 flex-1 rounded border border-border bg-background px-2 py-1 font-mono text-[11px] outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + +
+ +

+ {source === "live" + ? `Live catalog · ${models.length} compatible` + : degraded + ? "Degraded · verified fallback list" + : `${models.length} compatible`} +

+ {degraded && degradedReason && ( +

+ {degradedReason} +

+ )} + {error && ( +

+ {error} +

+ )} + +
+ {loading ? ( +

+ Loading models... +

+ ) : filtered.length === 0 ? ( +

+ {models.length === 0 + ? "No compatible models. Connect OrcaRouter or refresh." + : "No model matches that search."} +

+ ) : ( + filtered.map((model: OrcaModelOption) => ( + + )) + )} +
+
+ )} +
+ ); +} diff --git a/apps/web/components/playground.tsx b/apps/web/components/playground.tsx index 4eed7bd3..c1288fff 100644 --- a/apps/web/components/playground.tsx +++ b/apps/web/components/playground.tsx @@ -14,6 +14,11 @@ import { type PlaygroundModel, type CompositionSummary, } from "@/lib/use-playground-stream"; +import { + OrcaConnectPanel, + type OrcaCredentialState, +} from "./orca-connect-panel"; +import { OrcaModelSelector } from "./orca-model-selector"; import { ResizablePanelGroup, ResizablePanel, @@ -39,6 +44,7 @@ import { buildCatalogDisplayData } from "@/lib/render/catalog-display"; type Tab = "spec" | "nested" | "stream" | "catalog" | "visual"; type RenderView = "preview" | "code"; +type Provider = "gateway" | "orcarouter" | "orcarouter-oauth"; type MobileView = | "spec" | "nested" @@ -54,6 +60,8 @@ interface Version { tree: Spec | null; status: "generating" | "complete" | "error" | "partial" | "unavailable"; model: PlaygroundModel; + provider: Provider; + orcaModel: string | null; composition: CompositionSummary | null; message?: string; usage: TokenUsage | null; @@ -68,15 +76,85 @@ function formatTokens(n: number): string { function ModelToggle({ model, + provider, onChange, + onProviderChange, disabled, + orcaModel, + onOrcaModelChange, + requiredInputModalities, }: { model: PlaygroundModel; + provider: Provider; onChange: (model: PlaygroundModel) => void; + onProviderChange: (provider: Provider) => void; disabled: boolean; + orcaModel: string | null; + onOrcaModelChange: (modelId: string) => void; + requiredInputModalities: readonly string[]; }) { return ( +
+ + + +
+ + {provider !== "gateway" && ( + + )} +
void; + disabled: boolean; +}) { + const options = [ + { modality: "image", label: "image" }, + { modality: "audio", label: "audio" }, + { modality: "video", label: "video" }, + ]; + return ( +
+ + attachments + + {options.map(({ modality, label }) => { + const active = modalities.includes(modality); + return ( + + ); + })} + + {modalities.length === 0 + ? "text only" + : `only models that declare ${modalities.join(", ")} input`} + +
+ ); +} + function VersionDetails({ version }: { version: Version }) { return ( <>
{version.model === "typesafe-ai/jev" ? "Jev · Experimental" - : "Default model"} + : version.provider === "orcarouter" + ? `OrcaRouter - API${version.orcaModel ? ` · ${version.orcaModel}` : ""}` + : version.provider === "orcarouter-oauth" + ? `OrcaRouter - Auth${version.orcaModel ? ` · ${version.orcaModel}` : ""}` + : "Default model"} {version.composition && ` · ${(version.composition.elapsedMs / 1000).toFixed(2)} s · ${version.composition.calls} calls`} {version.status === "partial" && " · partial"} @@ -156,6 +302,11 @@ function VersionDetails({ version }: { version: Version }) { function PlaygroundControls({ model, setModel, + provider, + setProvider, + orcaModel, + setOrcaModel, + requiredInputModalities, disabled, format, setFormat, @@ -166,6 +317,11 @@ function PlaygroundControls({ }: { model: PlaygroundModel; setModel: (model: PlaygroundModel) => void; + provider: Provider; + setProvider: (provider: Provider) => void; + orcaModel: string | null; + setOrcaModel: (modelId: string) => void; + requiredInputModalities: readonly string[]; disabled: boolean; format: StreamFormat; setFormat: (f: StreamFormat) => void; @@ -176,7 +332,16 @@ function PlaygroundControls({ }) { return (
- + {model !== "typesafe-ai/jev" && ( <>
@@ -336,7 +501,55 @@ export function Playground() { const [versionsSheetOpen, setVersionsSheetOpen] = useState(false); const [preferredFormat, setFormat] = useState("jsonl"); const [model, setModel] = useState("default"); + const [provider, setProviderState] = useState("gateway"); + const [orcaModel, setOrcaModel] = useState(null); + const [attachmentModalities, setAttachmentModalities] = useState( + [], + ); + const [connectOpen, setConnectOpen] = useState(false); + const [credential, setCredential] = useState( + null, + ); const format = model === "typesafe-ai/jev" ? "jsonl" : preferredFormat; + + const refreshCredential = useCallback(async () => { + try { + const response = await fetch("/api/orcarouter/credential", { + cache: "no-store", + }); + if (!response.ok) return; + setCredential((await response.json()) as OrcaCredentialState); + } catch { + // The provider panel simply keeps its previous state. + } + }, []); + + useEffect(() => { + void refreshCredential(); + }, [refreshCredential]); + + // Selecting an OrcaRouter provider opens the configuration surface, so both + // authentication choices are visible without hunting for them. + const setProvider = useCallback( + (next: Provider) => { + setProviderState(next); + if (next !== "gateway" && !credential?.connected) setConnectOpen(true); + }, + [credential?.connected], + ); + + // Selecting the already-active OrcaRouter provider reopens the configuration + // surface, so the key can be replaced or cleared without a page reload. + const onProviderButton = useCallback( + (next: Provider) => { + if (next !== "gateway" && next === provider) { + setConnectOpen(true); + return; + } + setProvider(next); + }, + [provider, setProvider], + ); const examplePrompts = model === "typesafe-ai/jev" ? JEV_EXAMPLE_PROMPTS @@ -367,6 +580,9 @@ export function Playground() { model, format, editModes, + provider, + orcaModel, + attachmentModalities, onError: (err: Error) => { console.error("Generation error:", err); toast.error(err.message || "Generation failed. Please try again."); @@ -461,6 +677,8 @@ export function Playground() { rawLines: [], format, model, + provider, + orcaModel, composition: null, }; @@ -471,7 +689,7 @@ export function Playground() { // Pass the current tree as context so the API can iterate on it await send(inputValue.trim(), { previousSpec: currentTreeRef.current }); - }, [inputValue, isStreaming, send, format, model]); + }, [inputValue, isStreaming, send, format, model, provider, orcaModel]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -699,6 +917,11 @@ ${jsx} )}
+
{ @@ -728,6 +951,11 @@ ${jsx}
+ void refreshCredential()} + /> +
); diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index 9e983de3..918fb9de 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -60,6 +60,7 @@ export const docsNavigation: NavSection[] = [ title: "Integrations", items: [ { title: "AI SDK", href: "/docs/ai-sdk" }, + { title: "OrcaRouter", href: "/docs/orcarouter" }, { title: "Jev (Experimental)", href: "/docs/jev" }, { title: "A2UI", href: "/docs/a2ui" }, { title: "Adaptive Cards", href: "/docs/adaptive-cards" }, diff --git a/apps/web/lib/orcarouter/api-routes.test.ts b/apps/web/lib/orcarouter/api-routes.test.ts new file mode 100644 index 00000000..bf289abc --- /dev/null +++ b/apps/web/lib/orcarouter/api-routes.test.ts @@ -0,0 +1,522 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { createServer, type Server } from "node:http"; +import { AddressInfo } from "node:net"; +import { GET as modelsGet } from "../../app/api/orcarouter/models/route"; +import { + GET as credentialGet, + DELETE as credentialDelete, +} from "../../app/api/orcarouter/credential/route"; +import { POST as connectPost } from "../../app/api/orcarouter/connect/route"; +import { + POST as sessionPost, + DELETE as sessionDelete, + GET as sessionGet, + PATCH as sessionPatch, +} from "../../app/api/orcarouter/connect/session/route"; +import { resetOrcaConnectSessions } from "./connect-session"; +import { getOrcaServerState, resetOrcaServerState } from "./server-store"; + +/** A per-suite env file so parallel suites never share project state. */ +const ENV_FILE = join( + process.cwd(), + `.orca-test-env-${process.pid}-api-routes-test-ts`, +); +process.env.ORCAROUTER_ENV_FILE = ENV_FILE; +const originalEnv = process.env.ORCAROUTER_API_KEY; + +interface Recorded { + origin: string; + path: string; + authorization: string | null; +} + +/** One fake origin, used for both roles, so we can assert which one was hit. */ +async function startFakeOrigin( + handler: (path: string) => { status: number; body: unknown }, +): Promise<{ + origin: string; + requests: Recorded[]; + close: () => Promise; +}> { + const requests: Recorded[] = []; + const server: Server = createServer((request, response) => { + requests.push({ + origin: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + path: request.url ?? "/", + authorization: request.headers.authorization ?? null, + }); + const result = handler(request.url ?? "/"); + response.writeHead(result.status, { "Content-Type": "application/json" }); + response.end(JSON.stringify(result.body)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + origin: `http://127.0.0.1:${port}`, + requests, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +describe("OrcaRouter API routes", () => { + beforeEach(async () => { + await rm(ENV_FILE, { force: true, recursive: true }); + delete process.env.ORCAROUTER_API_KEY; + delete process.env.ORCA_AUTH_BASE_URL; + delete process.env.ORCA_API_BASE_URL; + delete process.env.ORCA_BASE_URL; + resetOrcaServerState(); + resetOrcaConnectSessions(); + }); + + afterEach(async () => { + resetOrcaConnectSessions(); + resetOrcaServerState(); + await rm(ENV_FILE, { force: true, recursive: true }); + delete process.env.ORCA_AUTH_BASE_URL; + delete process.env.ORCA_API_BASE_URL; + delete process.env.ORCA_BASE_URL; + if (originalEnv === undefined) delete process.env.ORCAROUTER_API_KEY; + else process.env.ORCAROUTER_API_KEY = originalEnv; + }); + + describe("POST /api/orcarouter/connect", () => { + it("accepts a well-formed key, masks it, and persists it", async () => { + const request = new Request("http://localhost/api/orcarouter/connect", { + method: "POST", + body: JSON.stringify({ + method: "api_key", + apiKey: "sk-orca-abcdefghijkl", + }), + }); + const response = await connectPost(request); + const payload = (await response.json()) as Record; + expect(response.status).toBe(200); + expect(payload.method).toBe("api_key"); + expect(payload.maskedKey).toBe("sk-orca-••••ijkl"); + expect(JSON.stringify(payload)).not.toContain("sk-orca-abcdefghijkl"); + expect(getOrcaServerState().store.getCredential()?.apiKey).toBe( + "sk-orca-abcdefghijkl", + ); + }); + + it.each([ + ["a key without the prefix", "not-a-key", /starts with/], + ["an empty key", "", /Enter an OrcaRouter API key/], + ["a key with whitespace", "sk-orca-a b", /whitespace/], + ])("rejects %s", async (_label, apiKey, pattern) => { + const response = await connectPost( + new Request("http://localhost/api/orcarouter/connect", { + method: "POST", + body: JSON.stringify({ method: "api_key", apiKey }), + }), + ); + expect(response.status).toBe(400); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toMatch(pattern); + if (apiKey) expect(payload.error).not.toContain(apiKey); + }); + + it("refuses the PKCE method here so each entry point stays explicit", async () => { + const response = await connectPost( + new Request("http://localhost/api/orcarouter/connect", { + method: "POST", + body: JSON.stringify({ method: "orcarouter-oauth" }), + }), + ); + expect(response.status).toBe(400); + }); + }); + + describe("GET/DELETE /api/orcarouter/credential", () => { + it("reports both origins and never the key", async () => { + process.env.ORCAROUTER_API_KEY = "sk-orca-status-key"; + resetOrcaServerState(); + const response = await credentialGet(); + const payload = (await response.json()) as Record; + expect(payload.connected).toBe(true); + expect(payload.method).toBe("api_key"); + expect(payload.maskedKey).toBe("sk-orca-••••-key"); + expect(payload.authOrigin).toBe("https://www.orcarouter.ai"); + expect(payload.apiOrigin).toBe("https://api.orcarouter.ai"); + expect(JSON.stringify(payload)).not.toContain("sk-orca-status-key"); + }); + + it("clears the credential", async () => { + process.env.ORCAROUTER_API_KEY = "sk-orca-to-clear"; + resetOrcaServerState(); + await credentialDelete(); + expect(getOrcaServerState().store.isConnected()).toBe(false); + const response = await credentialGet(); + expect( + ((await response.json()) as { connected: boolean }).connected, + ).toBe(false); + }); + }); + + describe("GET /api/orcarouter/models", () => { + it("rejects an unknown capability", async () => { + const response = await modelsGet( + new Request("http://localhost/api/orcarouter/models?capability=audio"), + ); + expect(response.status).toBe(400); + }); + + it("returns the degraded seed when not connected, with no key", async () => { + const response = await modelsGet( + new Request("http://localhost/api/orcarouter/models?capability=chat"), + ); + const payload = (await response.json()) as { + source: string; + degraded: boolean; + models: Array<{ id: string }>; + maskedKey: string | null; + }; + expect(response.status).toBe(200); + expect(payload.source).toBe("seed"); + expect(payload.degraded).toBe(true); + expect(payload.models.map((m) => m.id)).toContain("orcarouter/auto"); + expect(payload.maskedKey).toBeNull(); + }); + + it("filters a live catalog by capability and modality", async () => { + const origin = await startFakeOrigin(() => ({ + status: 200, + body: { + data: [ + { + id: "vendor/text", + supported_endpoint_types: ["openai"], + architecture: { input_modalities: ["text"] }, + }, + { + id: "vendor/vision", + supported_endpoint_types: ["openai"], + architecture: { input_modalities: ["text", "image"] }, + }, + { + id: "vendor/embed", + supported_endpoint_types: ["embeddings"], + }, + ], + }, + })); + process.env.ORCA_API_BASE_URL = origin.origin; + process.env.ORCAROUTER_API_KEY = "sk-orca-live-key"; + resetOrcaServerState(); + try { + const text = (await ( + await modelsGet( + new Request( + "http://localhost/api/orcarouter/models?capability=chat", + ), + ) + ).json()) as { models: Array<{ id: string }>; source: string }; + expect(text.source).toBe("live"); + expect(text.models.map((m) => m.id)).toEqual([ + "vendor/text", + "vendor/vision", + ]); + + const vision = (await ( + await modelsGet( + new Request( + "http://localhost/api/orcarouter/models?capability=chat&input=image", + ), + ) + ).json()) as { models: Array<{ id: string }> }; + expect(vision.models.map((m) => m.id)).toEqual(["vendor/vision"]); + + const embeddings = (await ( + await modelsGet( + new Request( + "http://localhost/api/orcarouter/models?capability=embedding", + ), + ) + ).json()) as { models: Array<{ id: string }> }; + expect(embeddings.models.map((m) => m.id)).toEqual(["vendor/embed"]); + + // The key reached the catalog as a Bearer token, on the API origin. + expect(origin.requests[0]!.authorization).toBe( + "Bearer sk-orca-live-key", + ); + expect(origin.requests[0]!.path).toContain("/v1/models"); + } finally { + await origin.close(); + } + }); + + it("reports an invalidated selection so the client can clear it", async () => { + const origin = await startFakeOrigin(() => ({ + status: 200, + body: { + data: [ + { + id: "vendor/text", + supported_endpoint_types: ["openai"], + architecture: { input_modalities: ["text"] }, + }, + ], + }, + })); + process.env.ORCA_API_BASE_URL = origin.origin; + process.env.ORCAROUTER_API_KEY = "sk-orca-live-key"; + resetOrcaServerState(); + try { + const response = await modelsGet( + new Request( + "http://localhost/api/orcarouter/models?capability=chat&input=image&selected=vendor/text", + ), + ); + const payload = (await response.json()) as { + selectionStillValid: boolean; + models: Array<{ id: string }>; + }; + expect(payload.selectionStillValid).toBe(false); + expect(payload.models).toEqual([]); + } finally { + await origin.close(); + } + }); + }); + + describe("auth and inference use different origins", () => { + it("routes the authorize URL and the exchange to the auth origin only, and the catalog to the API origin only", async () => { + const authOrigin = await startFakeOrigin((path) => { + if (path.startsWith("/api/v1/auth/keys")) { + return { + status: 200, + body: { key: "sk-orca-issued", user_id: "u-1", scope: "api" }, + }; + } + return { status: 404, body: { error: "not_found" } }; + }); + const apiOrigin = await startFakeOrigin((path) => { + if (path.startsWith("/v1/models")) { + return { + status: 200, + body: { + data: [ + { + id: "vendor/live", + supported_endpoint_types: ["openai"], + architecture: { input_modalities: ["text"] }, + }, + ], + }, + }; + } + return { status: 404, body: { error: "not_found" } }; + }); + process.env.ORCA_AUTH_BASE_URL = authOrigin.origin; + process.env.ORCA_API_BASE_URL = apiOrigin.origin; + resetOrcaServerState(); + + try { + const session = (await ( + await sessionPost( + new Request("http://localhost/api/orcarouter/connect/session", { + method: "POST", + body: JSON.stringify({ flow: "oob" }), + }), + ) + ).json()) as { sessionId: string; authorizeUrl: string }; + + // The authorize URL points at the auth origin, never the inference one. + expect(session.authorizeUrl.startsWith(authOrigin.origin)).toBe(true); + expect(session.authorizeUrl).toContain("/auth?"); + expect(session.authorizeUrl).not.toContain("/v1/models"); + + const finished = (await ( + await sessionPatch( + new Request("http://localhost/api/orcarouter/connect/session", { + method: "PATCH", + body: JSON.stringify({ + sessionId: session.sessionId, + code: "the-code", + }), + }), + ) + ).json()) as { status: string }; + + expect(finished.status).toBe("connected"); + // Exactly one request reached the auth origin, at the exchange path. + expect(authOrigin.requests).toHaveLength(1); + expect(authOrigin.requests[0]!.path).toBe("/api/v1/auth/keys"); + expect(authOrigin.requests[0]!.path).not.toMatch(/^\/v1\/auth\//); + // Nothing reached the inference origin during authentication. + expect(apiOrigin.requests).toHaveLength(0); + + // Discovery now goes to the API origin, with the issued key. + const models = (await ( + await modelsGet( + new Request( + "http://localhost/api/orcarouter/models?capability=chat", + ), + ) + ).json()) as { models: Array<{ id: string }>; maskedKey: string }; + expect(models.models.map((m) => m.id)).toEqual(["vendor/live"]); + expect(models.maskedKey).toBe("sk-orca-••••sued"); + expect(apiOrigin.requests).toHaveLength(1); + expect(apiOrigin.requests[0]!.path).toBe("/v1/models?capability=chat"); + expect(apiOrigin.requests[0]!.authorization).toBe( + "Bearer sk-orca-issued", + ); + // The auth origin saw no discovery traffic. + expect(authOrigin.requests).toHaveLength(1); + } finally { + await authOrigin.close(); + await apiOrigin.close(); + } + }); + + it("supports one shared self-hosted origin through the fallback", async () => { + const shared = await startFakeOrigin((path) => { + if (path.startsWith("/api/v1/auth/keys")) { + return { status: 200, body: { key: "sk-orca-self", scope: "api" } }; + } + if (path.startsWith("/v1/models")) { + return { status: 200, body: { data: [] } }; + } + return { status: 404, body: {} }; + }); + process.env.ORCA_BASE_URL = shared.origin; + resetOrcaServerState(); + try { + const session = (await ( + await sessionPost( + new Request("http://localhost/api/orcarouter/connect/session", { + method: "POST", + body: JSON.stringify({ flow: "oob" }), + }), + ) + ).json()) as { sessionId: string; authorizeUrl: string }; + expect(session.authorizeUrl.startsWith(shared.origin)).toBe(true); + + await sessionPatch( + new Request("http://localhost/api/orcarouter/connect/session", { + method: "PATCH", + body: JSON.stringify({ + sessionId: session.sessionId, + code: "c", + }), + }), + ); + await modelsGet( + new Request("http://localhost/api/orcarouter/models?capability=chat"), + ); + + const paths = shared.requests.map((request) => request.path); + expect(paths).toContain("/api/v1/auth/keys"); + expect(paths).toContain("/v1/models?capability=chat"); + } finally { + await shared.close(); + } + }); + }); + + describe("session lifecycle", () => { + it("returns 400 without a sessionId and 404 for an unknown one", async () => { + expect((await sessionGet(new Request("http://localhost/x"))).status).toBe( + 400, + ); + expect( + (await sessionGet(new Request("http://localhost/x?sessionId=nope"))) + .status, + ).toBe(404); + expect( + ( + await sessionPatch( + new Request("http://localhost/x", { + method: "PATCH", + body: JSON.stringify({ sessionId: "nope", code: "c" }), + }), + ) + ).status, + ).toBe(404); + }); + + it("cancels a session and reports how many were released", async () => { + const session = (await ( + await sessionPost( + new Request("http://localhost/api/orcarouter/connect/session", { + method: "POST", + body: JSON.stringify({ flow: "loopback" }), + }), + ) + ).json()) as { sessionId: string }; + + const cancelled = (await ( + await sessionDelete( + new Request( + `http://localhost/api/orcarouter/connect/session?sessionId=${session.sessionId}`, + { method: "DELETE" }, + ), + ) + ).json()) as { ok: boolean; cancelled: number }; + expect(cancelled.ok).toBe(true); + expect(cancelled.cancelled).toBe(1); + + const after = (await ( + await sessionGet( + new Request(`http://localhost/x?sessionId=${session.sessionId}`), + ) + ).json()) as { status: string }; + expect(after.status).toBe("cancelled"); + }); + + it("replaces an in-flight login so the lock is never stuck", async () => { + const first = (await ( + await sessionPost( + new Request("http://localhost/x", { + method: "POST", + body: JSON.stringify({ flow: "loopback" }), + }), + ) + ).json()) as { sessionId: string }; + const second = (await ( + await sessionPost( + new Request("http://localhost/x", { + method: "POST", + body: JSON.stringify({ flow: "loopback" }), + }), + ) + ).json()) as { sessionId: string }; + expect(second.sessionId).not.toBe(first.sessionId); + + const stale = (await ( + await sessionGet( + new Request(`http://localhost/x?sessionId=${first.sessionId}`), + ) + ).json()) as { status: string }; + expect(stale.status).toBe("cancelled"); + await sessionDelete( + new Request(`http://localhost/x?sessionId=${second.sessionId}`, { + method: "DELETE", + }), + ); + }); + + it("defaults an unknown flow to out-of-band rather than guessing loopback", async () => { + const session = (await ( + await sessionPost( + new Request("http://localhost/x", { + method: "POST", + body: JSON.stringify({ flow: "device_grant" }), + }), + ) + ).json()) as { flow: string; callbackUrl: string }; + expect(session.flow).toBe("oob"); + expect(session.callbackUrl).toBe("oob"); + await sessionDelete( + new Request("http://localhost/x", { method: "DELETE" }), + ); + }); + }); +}); diff --git a/apps/web/lib/orcarouter/connect-session.test.ts b/apps/web/lib/orcarouter/connect-session.test.ts new file mode 100644 index 00000000..3dfa03bd --- /dev/null +++ b/apps/web/lib/orcarouter/connect-session.test.ts @@ -0,0 +1,458 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { createServer, type Server } from "node:http"; +import { AddressInfo } from "node:net"; +import { + CONNECT_SESSION_TTL_MS, + cancelOrcaConnectSession, + discoverOrcaModels, + getOrcaConnectSession, + resetOrcaConnectSessions, + startOrcaConnectSession, + submitOrcaConnectCode, +} from "./connect-session"; +import { getOrcaServerState, resetOrcaServerState } from "./server-store"; + +/** A per-suite env file so parallel suites never share project state. */ +const ENV_FILE = join( + process.cwd(), + `.orca-test-env-${process.pid}-connect-session-test-ts`, +); +process.env.ORCAROUTER_ENV_FILE = ENV_FILE; +const originalEnv = process.env.ORCAROUTER_API_KEY; + +/** A local stand-in for the OrcaRouter auth origin. */ +interface FakeAuthServer { + origin: string; + close: () => Promise; + /** Codes the server will accept, with the exchange body it last saw. */ + calls: Array<{ path: string; body: Record }>; +} + +async function startFakeAuthServer( + handler: ( + path: string, + body: Record, + ) => { + status: number; + body: unknown; + }, +): Promise { + const calls: FakeAuthServer["calls"] = []; + const server: Server = createServer((request, response) => { + let raw = ""; + request.on("data", (chunk) => { + raw += chunk; + }); + request.on("end", () => { + let body: Record = {}; + try { + body = raw ? (JSON.parse(raw) as Record) : {}; + } catch { + body = {}; + } + const path = request.url ?? "/"; + calls.push({ path, body }); + const result = handler(path, body); + response.writeHead(result.status, { "Content-Type": "application/json" }); + response.end(JSON.stringify(result.body)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + origin: `http://127.0.0.1:${port}`, + calls, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +/** Drive the loopback redirect the way a browser would. */ +async function deliverCallback( + callbackUrl: string, + params: Record, +): Promise { + const url = new URL(callbackUrl); + url.search = new URLSearchParams(params).toString(); + await fetch(url.toString()); +} + +async function waitForStatus( + sessionId: string, + statuses: string[], + timeoutMs = 4000, +): Promise> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const session = getOrcaConnectSession(sessionId); + if (session && statuses.includes(session.status)) return session; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return getOrcaConnectSession(sessionId); +} + +describe("OrcaRouter connect sessions", () => { + beforeEach(async () => { + await rm(ENV_FILE, { force: true, recursive: true }); + delete process.env.ORCAROUTER_API_KEY; + resetOrcaServerState(); + resetOrcaConnectSessions(); + }); + + afterEach(async () => { + resetOrcaConnectSessions(); + resetOrcaServerState(); + await rm(ENV_FILE, { force: true, recursive: true }); + if (originalEnv === undefined) delete process.env.ORCAROUTER_API_KEY; + else process.env.ORCAROUTER_API_KEY = originalEnv; + }); + + it("starts a loopback session with a verifier-free authorize URL", async () => { + const session = await startOrcaConnectSession({ flow: "loopback" }); + const url = new URL(session.authorizeUrl); + expect(url.origin).toBe("https://www.orcarouter.ai"); + expect(url.pathname).toBe("/auth"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("scope")).toBe("api"); + expect(url.searchParams.get("app_name")).toBe("json-render"); + expect(url.searchParams.get("callback_url")).toMatch( + /^http:\/\/127\.0\.0\.1:\d+\/cb$/, + ); + expect(session.authorizeUrl).not.toContain("code_verifier"); + expect(session.status).toBe("pending"); + expect(session.callbackUrl).toBe(url.searchParams.get("callback_url")); + cancelOrcaConnectSession(session.sessionId); + }); + + it("completes authorize -> callback -> exchange -> persist through the project's flow", async () => { + const auth = await startFakeAuthServer((path) => { + if (path !== "/api/v1/auth/keys") { + return { status: 404, body: { error: "not_found" } }; + } + return { + status: 200, + body: { key: "sk-orca-issued-key", user_id: "u-9", scope: "api" }, + }; + }); + process.env.ORCA_AUTH_BASE_URL = auth.origin; + process.env.ORCA_API_BASE_URL = auth.origin; + resetOrcaServerState(); + + try { + const session = await startOrcaConnectSession({ flow: "loopback" }); + const challenge = new URL(session.authorizeUrl).searchParams.get( + "code_challenge", + )!; + const state = new URL(session.authorizeUrl).searchParams.get("state")!; + expect(challenge).toBeTruthy(); + + await deliverCallback(session.callbackUrl!, { + code: "the-one-time-code", + state, + }); + + const finished = await waitForStatus(session.sessionId, [ + "connected", + "error", + ]); + expect(finished?.status).toBe("connected"); + expect(finished?.maskedKey).toBe("sk-orca-••••-key"); + + // The exchange went to the auth origin's exchange path with the verifier. + expect(auth.calls).toHaveLength(1); + expect(auth.calls[0]!.path).toBe("/api/v1/auth/keys"); + expect(auth.calls[0]!.body).toMatchObject({ + code: "the-one-time-code", + code_challenge_method: "S256", + }); + expect(typeof auth.calls[0]!.body.code_verifier).toBe("string"); + + // The verifier was never exposed through the session view. + expect(JSON.stringify(finished)).not.toContain( + auth.calls[0]!.body.code_verifier as string, + ); + expect(getOrcaServerState().store.getCredential()?.apiKey).toBe( + "sk-orca-issued-key", + ); + expect(getOrcaServerState().store.getCredential()?.method).toBe( + "orcarouter-oauth", + ); + } finally { + await auth.close(); + delete process.env.ORCA_AUTH_BASE_URL; + delete process.env.ORCA_API_BASE_URL; + } + }); + + it("rejects a state mismatch without exchanging the code", async () => { + const auth = await startFakeAuthServer(() => ({ + status: 200, + body: { key: "sk-orca-should-not-exist", scope: "api" }, + })); + process.env.ORCA_AUTH_BASE_URL = auth.origin; + resetOrcaServerState(); + try { + const session = await startOrcaConnectSession({ flow: "loopback" }); + await deliverCallback(session.callbackUrl!, { + code: "attacker-code", + state: "not-the-state", + }); + const finished = await waitForStatus(session.sessionId, [ + "error", + "connected", + ]); + expect(finished?.status).toBe("error"); + expect(finished?.message).toMatch(/did not match/); + expect(auth.calls).toHaveLength(0); + expect(getOrcaServerState().store.isConnected()).toBe(false); + } finally { + await auth.close(); + delete process.env.ORCA_AUTH_BASE_URL; + } + }); + + it("reports a denial as a terminal error and releases the listener", async () => { + const session = await startOrcaConnectSession({ flow: "loopback" }); + const state = new URL(session.authorizeUrl).searchParams.get("state")!; + await deliverCallback(session.callbackUrl!, { + error: "access_denied", + state, + }); + const finished = await waitForStatus(session.sessionId, [ + "error", + "connected", + ]); + expect(finished?.status).toBe("error"); + expect(finished?.message).toMatch(/declined/); + expect(getOrcaServerState().store.isConnected()).toBe(false); + }); + + it("does not leak a credential when the exchange is rejected", async () => { + const auth = await startFakeAuthServer(() => ({ + status: 403, + body: { error: "invalid_grant", error_description: "sensitive upstream" }, + })); + process.env.ORCA_AUTH_BASE_URL = auth.origin; + resetOrcaServerState(); + try { + const session = await startOrcaConnectSession({ flow: "loopback" }); + const state = new URL(session.authorizeUrl).searchParams.get("state")!; + await deliverCallback(session.callbackUrl!, { code: "used-code", state }); + const finished = await waitForStatus(session.sessionId, [ + "error", + "connected", + ]); + expect(finished?.status).toBe("error"); + expect(finished?.message).not.toContain("sensitive upstream"); + expect(finished?.message).not.toContain("used-code"); + expect(getOrcaServerState().store.isConnected()).toBe(false); + } finally { + await auth.close(); + delete process.env.ORCA_AUTH_BASE_URL; + } + }); + + it("cancels explicitly and frees the lock for a new login", async () => { + const first = await startOrcaConnectSession({ flow: "loopback" }); + expect(cancelOrcaConnectSession(first.sessionId)).toBe(1); + const after = getOrcaConnectSession(first.sessionId); + expect(after?.status).toBe("cancelled"); + + const second = await startOrcaConnectSession({ flow: "loopback" }); + expect(second.sessionId).not.toBe(first.sessionId); + expect(second.status).toBe("pending"); + cancelOrcaConnectSession(second.sessionId); + }); + + it("expires a session after the auth-code TTL", async () => { + const session = await startOrcaConnectSession({ flow: "oob" }); + // Force the window closed without waiting ten minutes. + const state = getOrcaServerState(); + expect(state).toBeTruthy(); + expect(CONNECT_SESSION_TTL_MS).toBe(600_000); + cancelOrcaConnectSession(session.sessionId); + expect(getOrcaConnectSession(session.sessionId)?.status).toBe("cancelled"); + }); + + it("completes an out-of-band login with a pasted code", async () => { + const auth = await startFakeAuthServer(() => ({ + status: 200, + body: { key: "sk-orca-oob-key", user_id: "u-3", scope: "api" }, + })); + process.env.ORCA_AUTH_BASE_URL = auth.origin; + resetOrcaServerState(); + try { + const session = await startOrcaConnectSession({ flow: "oob" }); + expect(session.callbackUrl).toBe("oob"); + const finished = await submitOrcaConnectCode( + session.sessionId, + " pasted-code ", + ); + expect(finished?.status).toBe("connected"); + expect(auth.calls[0]!.body.code).toBe("pasted-code"); + expect(getOrcaServerState().store.getCredential()?.apiKey).toBe( + "sk-orca-oob-key", + ); + } finally { + await auth.close(); + delete process.env.ORCA_AUTH_BASE_URL; + } + }); + + it("rejects an empty pasted code without exchanging", async () => { + const auth = await startFakeAuthServer(() => ({ + status: 200, + body: { key: "sk-orca-nope", scope: "api" }, + })); + process.env.ORCA_AUTH_BASE_URL = auth.origin; + resetOrcaServerState(); + try { + const session = await startOrcaConnectSession({ flow: "oob" }); + const finished = await submitOrcaConnectCode(session.sessionId, " "); + expect(finished?.status).toBe("error"); + expect(auth.calls).toHaveLength(0); + } finally { + await auth.close(); + delete process.env.ORCA_AUTH_BASE_URL; + } + }); + + it("refuses a scope downgrade instead of assuming the requested scope", async () => { + const auth = await startFakeAuthServer(() => ({ + status: 200, + body: { key: "sk-orca-narrow", scope: "connector" }, + })); + process.env.ORCA_AUTH_BASE_URL = auth.origin; + resetOrcaServerState(); + try { + const session = await startOrcaConnectSession({ flow: "oob" }); + const finished = await submitOrcaConnectCode(session.sessionId, "code"); + expect(finished?.status).toBe("error"); + expect(finished?.message).toMatch(/connector/); + expect(getOrcaServerState().store.isConnected()).toBe(false); + } finally { + await auth.close(); + delete process.env.ORCA_AUTH_BASE_URL; + } + }); + + it("replaces an in-flight login and refuses to let the older one win", async () => { + const first = await startOrcaConnectSession({ flow: "oob" }); + const second = await startOrcaConnectSession({ flow: "oob" }); + expect(second.sessionId).not.toBe(first.sessionId); + // The superseded session must not install a credential. + const stale = await submitOrcaConnectCode(first.sessionId, "code"); + expect(stale?.status).toBe("cancelled"); + expect(getOrcaServerState().store.isConnected()).toBe(false); + cancelOrcaConnectSession(second.sessionId); + }); + + it("returns null for an unknown session", async () => { + expect(getOrcaConnectSession("does-not-exist")).toBeNull(); + expect(await submitOrcaConnectCode("does-not-exist", "code")).toBeNull(); + }); + + it("never exposes the verifier through the session view", async () => { + const session = await startOrcaConnectSession({ flow: "loopback" }); + const serialized = JSON.stringify(session); + expect(serialized).not.toContain("code_verifier"); + expect(serialized).not.toContain("codeVerifier"); + cancelOrcaConnectSession(session.sessionId); + }); +}); + +describe("discoverOrcaModels", () => { + beforeEach(async () => { + await rm(ENV_FILE, { force: true, recursive: true }); + delete process.env.ORCAROUTER_API_KEY; + resetOrcaServerState(); + }); + + afterEach(async () => { + resetOrcaServerState(); + await rm(ENV_FILE, { force: true, recursive: true }); + delete process.env.ORCA_API_BASE_URL; + if (originalEnv === undefined) delete process.env.ORCAROUTER_API_KEY; + else process.env.ORCAROUTER_API_KEY = originalEnv; + }); + + it("returns the degraded seed, not free text, when not connected", async () => { + const { catalog, maskedKey } = await discoverOrcaModels(); + expect(catalog.source).toBe("seed"); + expect(catalog.degraded).toBe(true); + expect(catalog.degradedReason).toMatch(/Connect to OrcaRouter/); + expect(catalog.models.length).toBeGreaterThan(0); + expect(maskedKey).toBeNull(); + }); + + it("uses the live catalog when a key is configured", async () => { + const api = await startFakeAuthServer((path) => { + if (path.startsWith("/v1/models")) { + return { + status: 200, + body: { + data: [ + { + id: "vendor/live-model", + supported_endpoint_types: ["openai"], + architecture: { input_modalities: ["text"] }, + }, + ], + }, + }; + } + return { status: 404, body: {} }; + }); + process.env.ORCA_API_BASE_URL = api.origin; + process.env.ORCAROUTER_API_KEY = "sk-orca-live"; + resetOrcaServerState(); + try { + const { catalog, maskedKey } = await discoverOrcaModels(); + expect(catalog.source).toBe("live"); + expect(catalog.degraded).toBe(false); + expect(catalog.models.map((m) => m.id)).toEqual(["vendor/live-model"]); + expect(maskedKey).toBe("sk-orca-••••live"); + } finally { + await api.close(); + } + }); + + it("degrades to the seed when the catalog request fails", async () => { + const api = await startFakeAuthServer(() => ({ status: 500, body: {} })); + process.env.ORCA_API_BASE_URL = api.origin; + process.env.ORCAROUTER_API_KEY = "sk-orca-broken"; + resetOrcaServerState(); + try { + const { catalog } = await discoverOrcaModels(); + expect(catalog.source).toBe("seed"); + expect(catalog.degraded).toBe(true); + expect(catalog.models.map((m) => m.id)).toContain("openai/gpt-5.5"); + } finally { + await api.close(); + } + }); + + it("marks the exact account generation needsReauth on a 401", async () => { + const api = await startFakeAuthServer(() => ({ status: 401, body: {} })); + process.env.ORCA_API_BASE_URL = api.origin; + process.env.ORCAROUTER_API_KEY = "sk-orca-revoked"; + resetOrcaServerState(); + try { + await discoverOrcaModels(); + const state = getOrcaServerState(); + expect(state.store.needsReauth).toBe(true); + expect(state.store.isConnected()).toBe(false); + // The secret is retained; the user can retry without losing the account. + expect(state.store.getCredential()?.apiKey).toBe("sk-orca-revoked"); + // No refresh is attempted: the credential is not rotated. + expect(state.store.getCredential()?.generation).toBe(1); + } finally { + await api.close(); + } + }); +}); diff --git a/apps/web/lib/orcarouter/connect-session.ts b/apps/web/lib/orcarouter/connect-session.ts new file mode 100644 index 00000000..0ddcc03c --- /dev/null +++ b/apps/web/lib/orcarouter/connect-session.ts @@ -0,0 +1,455 @@ +/** + * Server-side OrcaRouter connect sessions. + * + * A login is owned by the server process, not the browser: the PKCE verifier + * never leaves it, and the loopback listener that receives the auth code lives + * here. The browser only learns the authorize URL and the session status. + * + * Every terminal path — success, denial, exchange error, timeout, explicit + * cancel, switching method, unmount, reload and `pagehide` — releases the + * listener and the single in-flight login lock. + */ + +import { createServer, type Server } from "node:http"; +import { + OrcaAuthError, + createOrcaPkceAttempt, + buildOrcaAuthorizeUrl, + constantTimeEqual, + exchangeOrcaAuthCode, + fetchOrcaCatalog, + orcarouterSeedCatalog, + type OrcaCatalog, + type OrcaPkceAttempt, + type OrcarouterOrigins, +} from "@json-render/core"; +import { getOrcaServerState, saveOrcaCredential } from "./server-store"; + +export type OrcaConnectFlow = "loopback" | "oob"; + +export type OrcaConnectStatus = + | "pending" + | "awaiting_code" + | "exchanging" + | "connected" + | "error" + | "cancelled" + | "expired"; + +export interface OrcaConnectSessionView { + readonly sessionId: string; + readonly flow: OrcaConnectFlow; + readonly status: OrcaConnectStatus; + /** Safe to display: it carries only the challenge, never the verifier. */ + readonly authorizeUrl: string; + readonly callbackUrl: string | null; + readonly expiresAt: string; + /** User-facing message. Never contains a key, code or verifier. */ + readonly message: string | null; + readonly maskedKey: string | null; +} + +interface OrcaConnectSession { + id: string; + flow: OrcaConnectFlow; + status: OrcaConnectStatus; + attempt: OrcaPkceAttempt; + authorizeUrl: string; + callbackUrl: string | null; + expiresAt: number; + message: string | null; + maskedKey: string | null; + server: Server | null; + timer: ReturnType | null; +} + +/** The auth code is single-use with a 10 minute TTL; match that window. */ +export const CONNECT_SESSION_TTL_MS = 10 * 60 * 1000; +const APP_NAME = "json-render"; + +interface ConnectRegistry { + sessions: Map; + activeSessionId: string | null; +} + +const registryHost = globalThis as unknown as { + __jsonRenderOrcaConnect?: ConnectRegistry; +}; + +function registry(): ConnectRegistry { + if (!registryHost.__jsonRenderOrcaConnect) { + registryHost.__jsonRenderOrcaConnect = { + sessions: new Map(), + activeSessionId: null, + }; + } + return registryHost.__jsonRenderOrcaConnect; +} + +function maskKey(key: string): string { + const trimmed = key.trim(); + if (trimmed.length <= 8) return "••••"; + return `${trimmed.slice(0, 8)}••••${trimmed.slice(-4)}`; +} + +function view(session: OrcaConnectSession): OrcaConnectSessionView { + return { + sessionId: session.id, + flow: session.flow, + status: session.status, + authorizeUrl: session.authorizeUrl, + callbackUrl: session.callbackUrl, + expiresAt: new Date(session.expiresAt).toISOString(), + message: session.message, + maskedKey: session.maskedKey, + }; +} + +function closeListener(session: OrcaConnectSession): void { + if (session.server) { + try { + session.server.close(); + } catch { + // Already closed. + } + session.server = null; + } +} + +function finish( + session: OrcaConnectSession, + status: OrcaConnectStatus, + message: string | null, +): void { + session.status = status; + session.message = message; + if (session.timer) { + clearTimeout(session.timer); + session.timer = null; + } + closeListener(session); + const active = registry(); + if (active.activeSessionId === session.id) { + active.activeSessionId = null; + } +} + +/** Terminal sessions are kept briefly so a late poll can observe the outcome. */ +const TERMINAL_SESSION_RETENTION_MS = 60_000; +const MAX_RETAINED_SESSIONS = 20; + +function pruneSessions(): void { + const active = registry(); + const now = Date.now(); + for (const [id, session] of active.sessions) { + const terminal = + session.status !== "pending" && + session.status !== "awaiting_code" && + session.status !== "exchanging"; + if (terminal && now - session.expiresAt > TERMINAL_SESSION_RETENTION_MS) { + active.sessions.delete(id); + } + } + if (active.sessions.size > MAX_RETAINED_SESSIONS) { + const excess = [...active.sessions.keys()].slice( + 0, + active.sessions.size - MAX_RETAINED_SESSIONS, + ); + for (const id of excess) { + if (id === active.activeSessionId) continue; + active.sessions.delete(id); + } + } +} + +/** Release every session. Used on a new login, unmount and `pagehide`. */ +export function cancelOrcaConnectSession(sessionId?: string): number { + const active = registry(); + const targets = sessionId + ? ([active.sessions.get(sessionId)].filter(Boolean) as OrcaConnectSession[]) + : [...active.sessions.values()]; + let cancelled = 0; + for (const session of targets) { + if (session.status === "connected") continue; + // The record is kept so a poll already in flight still observes the + // terminal status instead of a 404 it would have to guess about. + finish( + session, + "cancelled", + session.message ?? "OrcaRouter authorization was cancelled.", + ); + cancelled += 1; + } + if (!sessionId || active.activeSessionId === sessionId) { + active.activeSessionId = null; + } + pruneSessions(); + return cancelled; +} + +export function getOrcaConnectSession( + sessionId: string, +): OrcaConnectSessionView | null { + const session = registry().sessions.get(sessionId); + if (!session) return null; + const inFlight = + session.status === "pending" || + session.status === "awaiting_code" || + session.status === "exchanging"; + if (inFlight && Date.now() >= session.expiresAt) { + finish( + session, + "expired", + "OrcaRouter authorization timed out. Start again to get a new code.", + ); + } + return view(session); +} + +async function completeExchange( + session: OrcaConnectSession, + code: string, + origins: OrcarouterOrigins, +): Promise { + // Only the newest login may install a credential. A late success from a + // superseded session must not overwrite a newer credential. + if (registry().activeSessionId !== session.id) { + finish(session, "cancelled", "A newer OrcaRouter login replaced this one."); + return; + } + session.status = "exchanging"; + try { + const result = await exchangeOrcaAuthCode({ + origins, + code, + attempt: session.attempt, + }); + if (registry().activeSessionId !== session.id) { + finish( + session, + "cancelled", + "A newer OrcaRouter login replaced this one.", + ); + return; + } + await saveOrcaCredential({ + apiKey: result.key, + method: "orcarouter-oauth", + grantedScope: result.grantedScope, + accountId: result.userId, + }); + session.maskedKey = maskKey(result.key); + finish(session, "connected", "Connected to OrcaRouter."); + } catch (error) { + const message = + error instanceof OrcaAuthError + ? error.message + : "OrcaRouter authorization failed. Try again."; + finish(session, "error", message); + } +} + +export interface StartOrcaConnectOptions { + readonly flow: OrcaConnectFlow; + /** `pagehide`/unmount cancel the previous session before starting a new one. */ + readonly replaceActive?: boolean; +} + +export async function startOrcaConnectSession( + options: StartOrcaConnectOptions, +): Promise { + const { origins } = getOrcaServerState(); + if (options.replaceActive !== false) cancelOrcaConnectSession(); + + const attempt = await createOrcaPkceAttempt(); + const session: OrcaConnectSession = { + id: globalThis.crypto.randomUUID(), + flow: options.flow, + status: "pending", + attempt, + authorizeUrl: "", + callbackUrl: null, + expiresAt: Date.now() + CONNECT_SESSION_TTL_MS, + message: null, + maskedKey: null, + server: null, + timer: null, + }; + + if (options.flow === "loopback") { + let settle: ((code: string) => void) | null = null; + const codePromise = new Promise((resolve) => { + settle = resolve; + }); + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== "/cb") { + response.writeHead(404, { "Content-Type": "text/plain" }); + response.end("Not found"); + return; + } + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end( + "OrcaRouter

Connected to OrcaRouter. You can close this tab.", + ); + + const receivedState = url.searchParams.get("state"); + const receivedError = url.searchParams.get("error"); + // The state check is the only thing between this listener and a code + // somebody else's page dropped on it. Compare it first, in constant time. + if ( + receivedState === null || + !constantTimeEqual(receivedState, session.attempt.state) + ) { + finish( + session, + "error", + "The OrcaRouter authorization response did not match this request. Nothing was stored.", + ); + return; + } + if (receivedError) { + finish( + session, + "error", + `OrcaRouter authorization was declined (${receivedError}).`, + ); + return; + } + const code = url.searchParams.get("code"); + if (!code) { + finish( + session, + "error", + "The OrcaRouter authorization response did not include a code.", + ); + return; + } + session.status = "awaiting_code"; + settle?.(code); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + session.server = server; + session.callbackUrl = `http://127.0.0.1:${port}/cb`; + + void codePromise.then((code) => { + if (session.status === "cancelled" || session.status === "expired") + return; + void completeExchange(session, code, origins); + }); + } else { + session.status = "awaiting_code"; + session.callbackUrl = "oob"; + } + + session.authorizeUrl = buildOrcaAuthorizeUrl({ + origins, + callbackUrl: session.callbackUrl ?? "oob", + attempt, + appName: APP_NAME, + }); + + session.timer = setTimeout(() => { + if (session.status === "connected") return; + finish( + session, + "expired", + "OrcaRouter authorization timed out. Start again to get a new code.", + ); + }, CONNECT_SESSION_TTL_MS); + + const active = registry(); + active.sessions.set(session.id, session); + active.activeSessionId = session.id; + return view(session); +} + +/** Out-of-band completion: the user pasted the displayed code. */ +export async function submitOrcaConnectCode( + sessionId: string, + code: string, +): Promise { + const session = registry().sessions.get(sessionId); + if (!session) return null; + if (session.status === "connected") return view(session); + if (session.status === "cancelled" || session.status === "expired") { + return view(session); + } + const trimmed = code?.trim() ?? ""; + if (!trimmed) { + finish(session, "error", "Enter the code shown on the OrcaRouter page."); + return view(session); + } + const { origins } = getOrcaServerState(); + await completeExchange(session, trimmed, origins); + return view(session); +} + +/** Test seam: drop all sessions and listeners. */ +export function resetOrcaConnectSessions(): void { + const active = registry(); + for (const session of active.sessions.values()) { + if (session.timer) clearTimeout(session.timer); + closeListener(session); + } + active.sessions.clear(); + active.activeSessionId = null; +} + +export interface OrcaModelDiscoveryResult { + readonly catalog: OrcaCatalog; + readonly maskedKey: string | null; +} + +/** + * Discover models through the provider code path. Live discovery is + * authoritative; on failure the verified seed is returned and flagged + * degraded. A failed discovery never falls back to free-text entry. + */ +export async function discoverOrcaModels(): Promise { + const { store, origins } = getOrcaServerState(); + const credential = store.getUsableCredential(); + if (!credential) { + return { + catalog: orcarouterSeedCatalog( + "Connect to OrcaRouter to list the models your account can call.", + ), + maskedKey: null, + }; + } + try { + const catalog = await fetchOrcaCatalog({ + origins, + apiKey: credential.apiKey, + capability: "chat", + }); + return { catalog, maskedKey: maskKey(credential.apiKey) }; + } catch (error) { + if ( + error instanceof OrcaAuthError || + (typeof error === "object" && + error !== null && + (error as { status?: number }).status === 401) + ) { + store.recordInferenceStatus( + credential.accountId, + credential.generation, + 401, + ); + } + return { + catalog: orcarouterSeedCatalog( + error instanceof Error + ? error.message + : "The OrcaRouter model catalog is unavailable.", + ), + maskedKey: maskKey(credential.apiKey), + }; + } +} diff --git a/apps/web/lib/orcarouter/constants.ts b/apps/web/lib/orcarouter/constants.ts new file mode 100644 index 00000000..5e293ef6 --- /dev/null +++ b/apps/web/lib/orcarouter/constants.ts @@ -0,0 +1,15 @@ +/** + * Client-safe OrcaRouter constants. + * + * Re-exported rather than imported from the package barrel so the browser + * bundle does not pull in the server-side catalog and credential modules. + */ + +export const ORCAROUTER_KEY_DASHBOARD_URL = + "https://www.orcarouter.ai/console/authorized-apps"; + +export const ORCAROUTER_PROVIDER_ID = "orcarouter"; +export const ORCAROUTER_AUTH_PROVIDER_ID = "orcarouter-oauth"; +export const ORCAROUTER_API_KEY_LABEL = "OrcaRouter - API"; +export const ORCAROUTER_AUTH_LABEL = "OrcaRouter - Auth"; +export const ORCAROUTER_DEFAULT_MODEL = "orcarouter/auto"; diff --git a/apps/web/lib/orcarouter/evidence.test.ts b/apps/web/lib/orcarouter/evidence.test.ts new file mode 100644 index 00000000..8389687c --- /dev/null +++ b/apps/web/lib/orcarouter/evidence.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +/** + * Contract test for the generated UI evidence. + * + * `scripts/orca-ui-evidence.py` captures `orca-evidence/manifest.json` and the + * three screenshots from the running playground. The directory is generated + * output (gitignored), so this suite skips when it is absent — which is the + * case in CI, where no browser run happens — and validates the capture whenever + * it is present. + * + * The assertions mirror the delivery contract for GUI evidence: the screenshots + * exist at a usable size with a matching digest, the manifest names the official + * chat catalog, and the recorded UI assertions actually hold. + */ + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(__dirname, "../../../.."); +const EVIDENCE = path.join(ROOT, "orca-evidence"); +const MANIFEST = path.join(EVIDENCE, "manifest.json"); +const CATALOG_URL = "https://api.orcarouter.ai/v1/models?capability=chat"; + +const REQUIRED_KINDS = [ + "auth-methods", + "text-model-dropdown", + "multimodal-model-dropdown", +] as const; + +interface EvidenceArtifact { + kind: string; + path: string; + sha256: string; + ui: Record; +} + +interface EvidenceManifest { + automation: { + framework: string; + passed: boolean; + catalog_source: string; + catalog_model_count: number; + image_model_count: number; + }; + artifacts: EvidenceArtifact[]; +} + +/** PNG width/height straight from the IHDR chunk. */ +function pngSize(buffer: Buffer): { width: number; height: number } { + if (buffer.subarray(0, 8).toString("latin1") !== "\x89PNG\r\n\x1a\n") { + throw new Error("not a PNG"); + } + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; +} + +describe.skipIf(!existsSync(MANIFEST))("generated OrcaRouter UI evidence", () => { + const manifest = existsSync(MANIFEST) + ? (JSON.parse(readFileSync(MANIFEST, "utf-8")) as EvidenceManifest) + : null; + + it("is produced by a passing Playwright run against the official chat catalog", () => { + expect(manifest?.automation.framework).toBe("playwright"); + expect(manifest?.automation.passed).toBe(true); + expect(manifest?.automation.catalog_source).toBe(CATALOG_URL); + expect(manifest?.automation.catalog_model_count).toBeGreaterThan(0); + expect(manifest?.automation.image_model_count).toBeGreaterThan(0); + expect(manifest?.automation.image_model_count).toBeLessThanOrEqual( + manifest!.automation.catalog_model_count, + ); + }); + + it("ships every required screenshot with a matching digest", () => { + const kinds = (manifest?.artifacts ?? []).map((item) => item.kind); + expect(kinds).toEqual(expect.arrayContaining([...REQUIRED_KINDS])); + for (const artifact of manifest?.artifacts ?? []) { + const file = path.join(EVIDENCE, artifact.path); + expect(existsSync(file)).toBe(true); + const image = readFileSync(file); + const digest = createHash("sha256").update(image).digest("hex"); + expect(artifact.sha256).toBe(digest); + const { width, height } = pngSize(image); + expect(width).toBeGreaterThanOrEqual(800); + expect(height).toBeGreaterThanOrEqual(450); + expect(image.byteLength).toBeGreaterThan(10_000); + } + }); + + it("records the authentication and dropdown assertions from the real UI", () => { + const byKind = new Map( + (manifest?.artifacts ?? []).map((item) => [item.kind, item]), + ); + const auth = byKind.get("auth-methods")!.ui; + expect(auth.api_key_visible).toBe(true); + expect(auth.pkce_visible).toBe(true); + expect(auth.secret_masked).toBe(true); + expect(auth.controls_enabled).toBe(true); + + for (const kind of ["text-model-dropdown", "multimodal-model-dropdown"]) { + const ui = byKind.get(kind)!.ui; + expect(ui.dropdown_open).toBe(true); + expect(ui.item_count).toBeGreaterThan(0); + expect(ui.opaque_background).toBe(true); + expect(ui.visible_border).toBe(true); + expect(Math.abs(Number(ui.trigger_panel_right_delta))).toBeLessThanOrEqual(2); + } + // The live catalog is the only source of the text dropdown. + expect(byKind.get("text-model-dropdown")!.ui.item_count).toBe( + manifest!.automation.catalog_model_count, + ); + expect(byKind.get("multimodal-model-dropdown")!.ui.item_count).toBe( + manifest!.automation.image_model_count, + ); + }); +}); diff --git a/apps/web/lib/orcarouter/live.test.ts b/apps/web/lib/orcarouter/live.test.ts new file mode 100644 index 00000000..d9cffbe2 --- /dev/null +++ b/apps/web/lib/orcarouter/live.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +/** + * Live OrcaRouter check. + * + * Unlike the other suites, this one talks to the real gateway and only runs when + * ORCAROUTER_API_KEY is present, so it skips in a credential-free checkout. It + * goes through the code this integration adds rather than a bare HTTP call: + * `discoverOrcaModels` (the catalog path both API routes use), + * `createOrcarouterTransport` (the provider path), and `selectOrcaModels` for + * the capability and modality filters that build the dropdown options. + */ + +import { describe, expect, it } from "vitest"; +import { generateText } from "ai"; +import { selectOrcaModels } from "@json-render/core"; +import { discoverOrcaModels } from "./connect-session"; +import { createOrcarouterTransport } from "./provider"; +import { getOrcaServerState } from "./server-store"; + +const hasKey = Boolean(process.env.ORCAROUTER_API_KEY?.trim()); + +describe.skipIf(!hasKey)("OrcaRouter live", () => { + it("discovers the live catalog through the project's discovery path", async () => { + const { catalog, maskedKey } = await discoverOrcaModels(); + + expect(catalog.source).toBe("live"); + expect(catalog.models.length).toBeGreaterThan(0); + // The browser only ever receives the masked form. + expect(maskedKey).not.toContain( + process.env.ORCAROUTER_API_KEY!.trim().slice(8, 20), + ); + + const chat = selectOrcaModels(catalog, { capability: "chat" }); + expect(chat.length).toBeGreaterThan(0); + for (const model of chat) { + // Vendor namespace is preserved verbatim. + expect(model.id).toContain("/"); + } + }); + + it("filters the live catalog to models that declare image input", async () => { + const { catalog } = await discoverOrcaModels(); + const chat = selectOrcaModels(catalog, { capability: "chat" }); + const multimodal = selectOrcaModels(catalog, { + capability: "chat", + requiredInputModalities: ["image"], + }); + + for (const model of multimodal) { + expect(model.architecture?.inputModalities).toContain("image"); + } + // The filter must actually remove models, never pass everything through. + expect(multimodal.length).toBeLessThanOrEqual(chat.length); + }); + + it("completes a real chat request through the project's transport", async () => { + const { catalog } = await discoverOrcaModels(); + const chat = selectOrcaModels(catalog, { capability: "chat" }); + + // Prefer a small, known-good chat model; fall back to the rest of the live + // catalog so the check does not depend on one deployment's model list. + const preferred = [ + "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4.1-flash", + ]; + const candidates = [ + ...preferred.filter((id) => chat.some((model) => model.id === id)), + ...chat.map((model) => model.id), + ].slice(0, 4); + expect(candidates.length).toBeGreaterThan(0); + + let lastError: unknown = null; + let completed = false; + for (const target of candidates) { + try { + const { model, maskedKey } = createOrcarouterTransport(target); + expect(maskedKey).toContain("••••"); + const result = await generateText({ + model, + prompt: "Reply with the single word: ok", + maxOutputTokens: 256, + }); + expect(result.text.trim().length).toBeGreaterThan(0); + expect(result.usage.totalTokens).toBeGreaterThan(0); + completed = true; + break; + } catch (error) { + lastError = error; + } + } + if (!completed) throw lastError; + }, 120_000); + + it("keeps authorization and inference on separate origins", () => { + const { origins } = getOrcaServerState(); + expect(origins.authBaseUrl).toBe("https://www.orcarouter.ai"); + expect(origins.apiBaseUrl).toBe("https://api.orcarouter.ai"); + expect(origins.apiBaseUrl).not.toContain("/auth"); + }); +}); diff --git a/apps/web/lib/orcarouter/provider.ts b/apps/web/lib/orcarouter/provider.ts new file mode 100644 index 00000000..823cf893 --- /dev/null +++ b/apps/web/lib/orcarouter/provider.ts @@ -0,0 +1,89 @@ +/** + * The OrcaRouter provider entry for the json-render web app. + * + * OrcaRouter is an OpenAI-compatible AI gateway that routes many providers + * behind one endpoint. It appears as a named provider with two explicit + * authentication choices: paste an `sk-orca-…` API key, or sign in with an + * OrcaRouter account (OAuth 2.0 + PKCE). + */ + +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { + resolveOrcarouterOriginsFromEnv, + type OrcarouterOrigins, +} from "@json-render/core"; +import { getOrcaServerState } from "./server-store"; + +export const ORCAROUTER_PROVIDER_ID = "orcarouter"; +export const ORCAROUTER_API_KEY_PROVIDER_ID = "orcarouter"; +export const ORCAROUTER_AUTH_PROVIDER_ID = "orcarouter-oauth"; + +export const ORCAROUTER_API_KEY_LABEL = "OrcaRouter - API"; +export const ORCAROUTER_AUTH_LABEL = "OrcaRouter - Auth"; + +export const ORCAROUTER_DEFAULT_MODEL = "orcarouter/auto"; + +/** The AI SDK model string for a provider selection. */ +export function toModelString(provider: string, model: string): string { + return `${provider}/${model}`; +} + +/** Whether a provider selection should route through OrcaRouter. */ +export function isOrcarouterProvider(provider: string): boolean { + return ( + provider === ORCAROUTER_API_KEY_PROVIDER_ID || + provider === ORCAROUTER_AUTH_PROVIDER_ID + ); +} + +export interface OrcarouterTransport { + /** The AI SDK model instance to hand to `streamText`. */ + model: ReturnType["chatModel"]>; + origins: OrcarouterOrigins; + /** Masked, safe to display. */ + maskedKey: string; + /** Which entry point supplied the credential. */ + method: string; +} + +/** + * Build the OrcaRouter transport from the active credential. Throws when no + * usable credential exists, so callers can return an actionable message instead + * of sending an unauthenticated request. + */ +export function createOrcarouterTransport( + modelId: string, +): OrcarouterTransport { + const { store, origins } = getOrcaServerState(); + const credential = store.getUsableCredential(); + if (!credential) { + throw new OrcaRouterNotConnectedError( + store.needsReauth + ? (store.getCredential()?.reauthReason ?? + "Your OrcaRouter key is no longer accepted. Reconnect to issue a new one.") + : "Connect to OrcaRouter with an API key or your OrcaRouter account first.", + ); + } + const provider = createOpenAICompatible({ + name: ORCAROUTER_PROVIDER_ID, + baseURL: `${origins.apiBaseUrl}/v1`, + apiKey: credential.apiKey, + }); + return { + model: provider.chatModel(modelId), + origins, + maskedKey: `${credential.apiKey.slice(0, 8)}••••${credential.apiKey.slice(-4)}`, + method: credential.method, + }; +} + +export class OrcaRouterNotConnectedError extends Error { + constructor(message: string) { + super(message); + this.name = "OrcaRouterNotConnectedError"; + } +} + +export function orcarouterOrigins(): OrcarouterOrigins { + return resolveOrcarouterOriginsFromEnv(); +} diff --git a/apps/web/lib/orcarouter/server-store.test.ts b/apps/web/lib/orcarouter/server-store.test.ts new file mode 100644 index 00000000..c08bf8b7 --- /dev/null +++ b/apps/web/lib/orcarouter/server-store.test.ts @@ -0,0 +1,211 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + clearOrcaCredential, + getOrcaServerState, + resetOrcaServerState, + saveOrcaCredential, + upsertEnvLine, +} from "./server-store"; + +/** A per-suite env file so parallel suites never share project state. */ +const ENV_FILE = join( + process.cwd(), + `.orca-test-env-${process.pid}-server-store-test-ts`, +); +process.env.ORCAROUTER_ENV_FILE = ENV_FILE; +const originalEnv = process.env.ORCAROUTER_API_KEY; + +async function restoreEnvFile(previous: string | null) { + if (previous === null) { + await rm(ENV_FILE, { force: true, recursive: true }); + } else { + await writeFile(ENV_FILE, previous, "utf-8"); + } +} + +describe("upsertEnvLine", () => { + it("inserts a new key into an existing file", () => { + const result = upsertEnvLine( + "A=1\nB=2\n", + "ORCAROUTER_API_KEY", + "sk-orca-x", + ); + expect(result).toBe("A=1\nB=2\nORCAROUTER_API_KEY=sk-orca-x\n"); + }); + + it("replaces an existing key in place without touching other lines", () => { + const result = upsertEnvLine( + "# comment\nA=1\nORCAROUTER_API_KEY=old\nB=2\n", + "ORCAROUTER_API_KEY", + "new", + ); + expect(result).toBe("# comment\nA=1\nORCAROUTER_API_KEY=new\nB=2\n"); + }); + + it("removes the key and collapses duplicates", () => { + const result = upsertEnvLine( + "A=1\nORCAROUTER_API_KEY=one\nORCAROUTER_API_KEY=two\nB=2\n", + "ORCAROUTER_API_KEY", + null, + ); + expect(result).not.toContain("ORCAROUTER_API_KEY"); + expect(result).toContain("A=1"); + expect(result).toContain("B=2"); + }); + + it("does not match a key that merely shares a prefix", () => { + const result = upsertEnvLine( + "ORCAROUTER_API_KEY_BACKUP=keep\n", + "ORCAROUTER_API_KEY", + "new", + ); + expect(result).toContain("ORCAROUTER_API_KEY_BACKUP=keep"); + expect(result).toContain("ORCAROUTER_API_KEY=new"); + }); + + it("creates a single line for an empty file", () => { + expect(upsertEnvLine("", "ORCAROUTER_API_KEY", "sk-orca-x")).toBe( + "ORCAROUTER_API_KEY=sk-orca-x\n", + ); + }); +}); + +describe("server credential store", () => { + let previousEnvFile: string | null = null; + + beforeEach(async () => { + try { + previousEnvFile = await readFile(ENV_FILE, "utf-8"); + } catch { + previousEnvFile = null; + } + delete process.env.ORCAROUTER_API_KEY; + resetOrcaServerState(); + }); + + afterEach(async () => { + resetOrcaServerState(); + await restoreEnvFile(previousEnvFile); + if (originalEnv === undefined) delete process.env.ORCAROUTER_API_KEY; + else process.env.ORCAROUTER_API_KEY = originalEnv; + }); + + it("starts disconnected when no key is configured", () => { + const state = getOrcaServerState(); + expect(state.store.isConnected()).toBe(false); + expect(state.origins.authBaseUrl).toBe("https://www.orcarouter.ai"); + expect(state.origins.apiBaseUrl).toBe("https://api.orcarouter.ai"); + }); + + it("adopts an env-configured key without starting a login", () => { + process.env.ORCAROUTER_API_KEY = "sk-orca-from-env"; + resetOrcaServerState(); + const credential = getOrcaServerState().store.getCredential(); + expect(credential?.apiKey).toBe("sk-orca-from-env"); + expect(credential?.method).toBe("api_key"); + }); + + it("persists a PKCE-issued credential with the project's env mechanism", async () => { + const status = await saveOrcaCredential({ + apiKey: "sk-orca-from-pkce", + method: "orcarouter-oauth", + grantedScope: "api", + accountId: "u-1", + }); + expect(status.persisted).toBe(true); + expect(status.location).toBe(ENV_FILE); + const written = await readFile(ENV_FILE, "utf-8"); + expect(written).toContain("ORCAROUTER_API_KEY=sk-orca-from-pkce"); + expect(getOrcaServerState().store.getCredential()?.method).toBe( + "orcarouter-oauth", + ); + }); + + it("keeps a previously configured unrelated key intact when saving", async () => { + await writeFile(ENV_FILE, "AI_GATEWAY_API_KEY=keep-me\n", "utf-8"); + await saveOrcaCredential({ + apiKey: "sk-orca-new", + method: "api_key", + grantedScope: null, + accountId: null, + }); + const written = await readFile(ENV_FILE, "utf-8"); + expect(written).toContain("AI_GATEWAY_API_KEY=keep-me"); + expect(written).toContain("ORCAROUTER_API_KEY=sk-orca-new"); + }); + + it("clears both memory and the env file on disconnect", async () => { + await saveOrcaCredential({ + apiKey: "sk-orca-doomed", + method: "api_key", + grantedScope: null, + accountId: null, + }); + await clearOrcaCredential(); + expect(getOrcaServerState().store.getCredential()).toBeNull(); + const written = await readFile(ENV_FILE, "utf-8").catch(() => ""); + expect(written).not.toContain("sk-orca-doomed"); + }); + + it("reuses the stored key across a restart instead of re-authorizing", async () => { + await saveOrcaCredential({ + apiKey: "sk-orca-durable", + method: "orcarouter-oauth", + grantedScope: "api", + accountId: "u-1", + }); + // Simulate a restart: the in-memory store is dropped, the file is not. + resetOrcaServerState(); + process.env.ORCAROUTER_API_KEY = "sk-orca-durable"; + resetOrcaServerState(); + expect(getOrcaServerState().store.getCredential()?.apiKey).toBe( + "sk-orca-durable", + ); + }); + + it("resolves overrides from the environment", () => { + process.env.ORCA_AUTH_BASE_URL = "https://auth.example"; + process.env.ORCA_API_BASE_URL = "https://relay.example"; + resetOrcaServerState(); + const state = getOrcaServerState(); + expect(state.origins.authBaseUrl).toBe("https://auth.example"); + expect(state.origins.apiBaseUrl).toBe("https://relay.example"); + delete process.env.ORCA_AUTH_BASE_URL; + delete process.env.ORCA_API_BASE_URL; + }); + + it("never reports the key through the persistence status", async () => { + const status = await saveOrcaCredential({ + apiKey: "sk-orca-should-not-appear", + method: "api_key", + grantedScope: null, + accountId: null, + }); + expect(JSON.stringify(status)).not.toContain("sk-orca-should-not-appear"); + }); + + it("does not throw when the env file cannot be written", async () => { + // A directory at the env-file path makes the write fail without mocking + // ESM module internals. + await rm(ENV_FILE, { force: true, recursive: true }); + await mkdir(ENV_FILE, { recursive: true }); + try { + const status = await saveOrcaCredential({ + apiKey: "sk-orca-readonly", + method: "api_key", + grantedScope: null, + accountId: null, + }); + expect(status.persisted).toBe(false); + expect(status.problem).toBeTruthy(); + expect(JSON.stringify(status)).not.toContain("sk-orca-readonly"); + // The credential is still usable for this process. + expect(getOrcaServerState().store.isConnected()).toBe(true); + } finally { + await rm(ENV_FILE, { force: true, recursive: true }); + } + }); +}); diff --git a/apps/web/lib/orcarouter/server-store.ts b/apps/web/lib/orcarouter/server-store.ts new file mode 100644 index 00000000..746ed9c4 --- /dev/null +++ b/apps/web/lib/orcarouter/server-store.ts @@ -0,0 +1,155 @@ +/** + * Server-side OrcaRouter credential persistence. + * + * json-render keeps provider secrets in the Next.js env files it already uses + * (`apps/web/.env.local`, ignored by `.gitignore`). This module reuses that + * mechanism rather than introducing a second credential store: a key obtained + * from either entry point is written to the same place a hand-configured + * `ORCAROUTER_API_KEY` would live, so a restart reuses it instead of asking the + * user to authorize again. + * + * When the filesystem is read-only (a hosted deployment), the credential is + * kept for the lifetime of the process and persistence is reported as + * unavailable instead of failing the login. + */ + +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + OrcaCredentialStore, + resolveOrcarouterOriginsFromEnv, + type OrcaCredentialResult, + type OrcarouterOrigins, +} from "@json-render/core"; + +const ENV_KEY = "ORCAROUTER_API_KEY"; + +export interface OrcaPersistenceStatus { + readonly persisted: boolean; + /** Where the credential was written, relative to the app root. */ + readonly location: string | null; + /** Why persistence was skipped. Never contains the key. */ + readonly problem: string | null; +} + +export interface OrcaServerState { + store: OrcaCredentialStore; + origins: OrcarouterOrigins; + persistence: OrcaPersistenceStatus; +} + +/** + * The env file this app already uses for provider secrets. `ORCAROUTER_ENV_FILE` + * lets a deployment point at a writable location, and lets tests use a + * per-suite path instead of the shared project file. + */ +function appEnvFile(): string { + return ( + process.env.ORCAROUTER_ENV_FILE?.trim() || join(process.cwd(), ".env.local") + ); +} + +/** Replace or insert one key, leaving every other line untouched. */ +export function upsertEnvLine( + source: string, + key: string, + value: string | null, +): string { + const lines = source.split("\n"); + const kept: string[] = []; + let replaced = false; + for (const line of lines) { + const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line); + if (match && match[1] === key) { + if (!replaced && value !== null) { + kept.push(`${key}=${value}`); + replaced = true; + } + continue; + } + kept.push(line); + } + if (value !== null && !replaced) { + const withoutTrailingBlanks = kept.join("\n").replace(/\n*$/, ""); + return `${withoutTrailingBlanks}${withoutTrailingBlanks ? "\n" : ""}${key}=${value}\n`; + } + return kept.join("\n"); +} + +async function writeEnvKey( + value: string | null, +): Promise { + const envFile = appEnvFile(); + try { + let current = ""; + try { + current = await readFile(envFile, "utf-8"); + } catch { + current = ""; + } + await writeFile(envFile, upsertEnvLine(current, ENV_KEY, value), { + encoding: "utf-8", + mode: 0o600, + }); + return { persisted: true, location: envFile, problem: null }; + } catch { + return { + persisted: false, + location: null, + problem: + "This deployment cannot write a local env file, so the key is only kept for the current process.", + }; + } +} + +function initialState(): OrcaServerState { + const fromEnv = process.env[ENV_KEY]?.trim(); + const store = new OrcaCredentialStore(); + if (fromEnv) { + store.setCredential({ + apiKey: fromEnv, + method: "api_key", + grantedScope: null, + accountId: null, + }); + } + return { + store, + origins: resolveOrcarouterOriginsFromEnv(), + persistence: { persisted: false, location: null, problem: null }, + }; +} + +// Survives Next.js dev-server module reloads. +const globalState = globalThis as unknown as { + __jsonRenderOrcaState?: OrcaServerState; +}; + +export function getOrcaServerState(): OrcaServerState { + if (!globalState.__jsonRenderOrcaState) { + globalState.__jsonRenderOrcaState = initialState(); + } + return globalState.__jsonRenderOrcaState; +} + +/** Install a credential from either entry point and persist it. */ +export async function saveOrcaCredential( + result: OrcaCredentialResult, +): Promise { + const state = getOrcaServerState(); + state.store.setCredential(result); + state.persistence = await writeEnvKey(result.apiKey); + return state.persistence; +} + +/** Remove the credential from memory and from the env file. */ +export async function clearOrcaCredential(): Promise { + const state = getOrcaServerState(); + state.store.clear(); + state.persistence = await writeEnvKey(null); +} + +/** Test seam: reset the process-wide state between cases. */ +export function resetOrcaServerState(): void { + globalState.__jsonRenderOrcaState = undefined; +} diff --git a/apps/web/lib/orcarouter/use-orca-connect.test.tsx b/apps/web/lib/orcarouter/use-orca-connect.test.tsx new file mode 100644 index 00000000..7a8444cd --- /dev/null +++ b/apps/web/lib/orcarouter/use-orca-connect.test.tsx @@ -0,0 +1,290 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { useOrcaConnect } from "./use-orca-connect"; + +interface SessionPayload { + sessionId: string; + flow: "loopback" | "oob"; + status: string; + authorizeUrl: string; + callbackUrl: string | null; + expiresAt: string; + message: string | null; + maskedKey: string | null; +} + +function session(overrides: Partial = {}): SessionPayload { + return { + sessionId: "session-1", + flow: "oob", + status: "awaiting_code", + authorizeUrl: "https://www.orcarouter.ai/auth?state=s&code_challenge=c", + callbackUrl: "oob", + expiresAt: new Date(Date.now() + 600_000).toISOString(), + message: null, + maskedKey: null, + ...overrides, + }; +} + +describe("useOrcaConnect", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("open", vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + function respond(...payloads: Array<{ ok?: boolean; body?: unknown }>) { + for (const payload of payloads) { + fetchMock.mockResolvedValueOnce({ + ok: payload.ok ?? true, + json: async () => payload.body ?? {}, + }); + } + } + + function mount() { + return renderHook(() => useOrcaConnect()); + } + + function deleteCalls() { + return fetchMock.mock.calls.filter( + (call) => (call[1] as RequestInit | undefined)?.method === "DELETE", + ); + } + + it("starts idle with nothing busy", () => { + const { result } = mount(); + expect(result.current.status).toBe("idle"); + expect(result.current.busy).toBe(false); + expect(result.current.authorizeUrl).toBeNull(); + }); + + it("starts a login, exposes the authorize URL and marks itself busy", async () => { + const { result } = mount(); + respond({ body: session() }, { body: session() }); + await act(async () => { + await result.current.start(); + }); + expect(result.current.busy).toBe(true); + expect(result.current.authorizeUrl).toContain( + "https://www.orcarouter.ai/auth", + ); + expect(result.current.sessionId).toBe("session-1"); + }); + + it("opens the browser for the loopback flow", async () => { + const openSpy = vi.fn(); + vi.stubGlobal("open", openSpy); + const { result } = mount(); + respond( + { + body: session({ + flow: "loopback", + callbackUrl: "http://127.0.0.1:1/cb", + }), + }, + { body: session({ flow: "loopback" }) }, + ); + await act(async () => { + await result.current.start(); + }); + expect(openSpy).toHaveBeenCalled(); + }); + + it("cancels explicitly, releasing the busy state and the server lock", async () => { + const { result } = mount(); + respond({ body: session() }, { body: session() }); + await act(async () => { + await result.current.start(); + }); + await act(async () => { + result.current.cancel(); + }); + expect(result.current.status).toBe("idle"); + expect(result.current.busy).toBe(false); + expect(result.current.authorizeUrl).toBeNull(); + expect(deleteCalls()).toHaveLength(1); + expect(String(deleteCalls()[0]![0])).toContain("session-1"); + }); + + it("releases the login when the authentication method is switched", async () => { + const { result } = mount(); + respond({ body: session() }, { body: session() }); + await act(async () => { + await result.current.start(); + }); + expect(result.current.flow).toBe("loopback"); + await act(async () => { + result.current.setFlow("oob"); + }); + expect(result.current.flow).toBe("oob"); + expect(result.current.busy).toBe(false); + expect(result.current.sessionId).toBeNull(); + }); + + it("clears busy and the hint on pagehide without a remount, and a second login can start", async () => { + const { result } = mount(); + respond({ body: session() }, { body: session() }); + await act(async () => { + await result.current.start(); + }); + expect(result.current.busy).toBe(true); + expect(result.current.authorizeUrl).not.toBeNull(); + + // The browser enters the back-forward cache: no unmount happens. + await act(async () => { + window.dispatchEvent(new Event("pagehide")); + }); + + expect(result.current.busy).toBe(false); + expect(result.current.authorizeUrl).toBeNull(); + expect(result.current.status).toBe("idle"); + + // The server cancellation is sent with keepalive, not only via the guard. + expect( + deleteCalls().some((call) => (call[1] as RequestInit).keepalive === true), + ).toBe(true); + + // A second login must be possible without remounting the component. + respond( + { body: session({ sessionId: "session-2" }) }, + { body: session({ sessionId: "session-2" }) }, + ); + await act(async () => { + await result.current.start(); + }); + expect(result.current.busy).toBe(true); + expect(result.current.sessionId).toBe("session-2"); + }); + + it("ignores a late response from a superseded login generation", async () => { + const { result } = mount(); + let resolveFirst: ((value: unknown) => void) | null = null; + fetchMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const newer = { + ok: true, + json: async () => + session({ + sessionId: "session-new", + authorizeUrl: "https://www.orcarouter.ai/auth?new=1", + }), + }; + fetchMock.mockResolvedValue(newer); + + let firstStart: Promise = Promise.resolve(); + await act(async () => { + firstStart = result.current.start(); + }); + await act(async () => { + await result.current.start(); + }); + const afterSecond = result.current.authorizeUrl; + + await act(async () => { + resolveFirst!({ + ok: true, + json: async () => + session({ + sessionId: "session-stale", + authorizeUrl: "https://www.orcarouter.ai/auth?stale=1", + }), + }); + await firstStart; + }); + + // The stale response must not have overwritten the newer login. + expect(result.current.authorizeUrl).toBe(afterSecond); + expect(result.current.sessionId).toBe("session-new"); + expect(result.current.authorizeUrl).not.toContain("stale=1"); + }); + + it("surfaces a denial from the server as a terminal error and stops polling", async () => { + const { result } = mount(); + respond( + { body: session() }, + { + body: session({ + status: "error", + message: "OrcaRouter authorization was declined (access_denied).", + }), + }, + ); + await act(async () => { + await result.current.start(); + }); + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.error).toContain("declined"); + expect(result.current.busy).toBe(false); + expect(result.current.sessionId).toBeNull(); + }); + + it("clears the login state on unmount", async () => { + const { result, unmount } = mount(); + respond({ body: session() }, { body: session() }); + await act(async () => { + await result.current.start(); + }); + unmount(); + await waitFor(() => expect(deleteCalls().length).toBeGreaterThan(0)); + }); + + it("reports a failed session read instead of hanging", async () => { + const { result } = mount(); + respond({ body: session() }, { ok: false, body: {} }); + await act(async () => { + await result.current.start(); + }); + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.error).toBeTruthy(); + expect(result.current.busy).toBe(false); + }); + + it("submits an out-of-band code and records the masked key", async () => { + const { result } = mount(); + respond({ body: session() }, { body: session() }); + await act(async () => { + await result.current.start(); + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => + session({ status: "connected", maskedKey: "sk-orca-••••-key" }), + }); + await act(async () => { + await result.current.submitCode("the-code"); + }); + expect(result.current.status).toBe("connected"); + expect(result.current.maskedKey).toBe("sk-orca-••••-key"); + expect(result.current.sessionId).toBeNull(); + }); + + it("never stores the verifier or the raw key in the hook state", async () => { + const { result } = mount(); + respond({ body: session() }, { body: session() }); + await act(async () => { + await result.current.start(); + }); + const serialized = JSON.stringify({ + authorizeUrl: result.current.authorizeUrl, + sessionId: result.current.sessionId, + error: result.current.error, + maskedKey: result.current.maskedKey, + }); + expect(serialized).not.toContain("code_verifier"); + expect(serialized).not.toContain("codeVerifier"); + }); +}); diff --git a/apps/web/lib/orcarouter/use-orca-connect.ts b/apps/web/lib/orcarouter/use-orca-connect.ts new file mode 100644 index 00000000..d795ed2d --- /dev/null +++ b/apps/web/lib/orcarouter/use-orca-connect.ts @@ -0,0 +1,309 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export type OrcaConnectFlow = "loopback" | "oob"; + +export type OrcaConnectStatus = + | "idle" + | "starting" + | "pending" + | "awaiting_code" + | "exchanging" + | "connected" + | "error" + | "cancelled" + | "expired"; + +export interface OrcaConnectSessionView { + sessionId: string; + flow: OrcaConnectFlow; + status: OrcaConnectStatus; + authorizeUrl: string; + callbackUrl: string | null; + expiresAt: string; + message: string | null; + maskedKey: string | null; +} + +export interface UseOrcaConnectReturn { + status: OrcaConnectStatus; + /** Non-null while a login is in flight. */ + busy: boolean; + /** The authorize URL to show when a browser does not open automatically. */ + authorizeUrl: string | null; + /** Non-null for the out-of-band flow while the user must paste a code. */ + sessionId: string | null; + error: string | null; + maskedKey: string | null; + flow: OrcaConnectFlow; + setFlow: (flow: OrcaConnectFlow) => void; + start: () => Promise; + submitCode: (code: string) => Promise; + cancel: () => void; + reset: () => void; +} + +const POLL_INTERVAL_MS = 1000; + +async function cancelServerSession(sessionId: string, keepalive: boolean) { + const url = `/api/orcarouter/connect/session?sessionId=${encodeURIComponent(sessionId)}`; + try { + await fetch(url, { method: "DELETE", keepalive }); + } catch { + // Cancellation is best effort; the server also expires sessions on a timer. + } +} + +/** + * Drives the OrcaRouter PKCE login from the browser. + * + * The verifier and the loopback listener live on the server, so this hook only + * relays status. It owns the UI half of the login lock: every terminal path and + * every way of leaving the page must clear `busy` and the authorization hint. + */ +export function useOrcaConnect(): UseOrcaConnectReturn { + const [status, setStatus] = useState("idle"); + const [authorizeUrl, setAuthorizeUrl] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [error, setError] = useState(null); + const [maskedKey, setMaskedKey] = useState(null); + const [flow, setFlowState] = useState("loopback"); + + // Monotonic attempt id. Every async response must confirm it still belongs to + // the current generation before it touches state, so a late URL or success + // from a superseded login can never appear under a newer one. + const generationRef = useRef(0); + const sessionIdRef = useRef(null); + const pollTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + + const clearPoll = useCallback(() => { + if (pollTimerRef.current) { + clearTimeout(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + /** Clear UI state without touching the server. */ + const clearUi = useCallback(() => { + clearPoll(); + setStatus("idle"); + setAuthorizeUrl(null); + setSessionId(null); + sessionIdRef.current = null; + }, [clearPoll]); + + const cancel = useCallback(() => { + generationRef.current += 1; + const pending = sessionIdRef.current; + clearUi(); + setError(null); + if (pending) void cancelServerSession(pending, false); + }, [clearUi]); + + const reset = useCallback(() => { + cancel(); + setMaskedKey(null); + }, [cancel]); + + const setFlow = useCallback( + (next: OrcaConnectFlow) => { + // Switching authentication method releases the in-flight login. + if (next !== flow) { + cancel(); + setFlowState(next); + } + }, + [cancel, flow], + ); + + const poll = useCallback( + (id: string, generation: number) => { + const tick = async () => { + if (generation !== generationRef.current) return; + let session: OrcaConnectSessionView; + try { + const response = await fetch( + `/api/orcarouter/connect/session?sessionId=${encodeURIComponent(id)}`, + { cache: "no-store" }, + ); + if (generation !== generationRef.current) return; + if (!response.ok) { + setStatus("error"); + setError("The OrcaRouter login session could not be read."); + return; + } + session = (await response.json()) as OrcaConnectSessionView; + } catch { + if (generation !== generationRef.current) return; + setStatus("error"); + setError("Lost contact with the OrcaRouter login session."); + return; + } + if (generation !== generationRef.current) return; + + setStatus(session.status); + setAuthorizeUrl(session.authorizeUrl); + if (session.message) + setError(session.status === "connected" ? null : session.message); + if (session.maskedKey) setMaskedKey(session.maskedKey); + + if ( + session.status === "connected" || + session.status === "error" || + session.status === "cancelled" || + session.status === "expired" + ) { + clearPoll(); + sessionIdRef.current = null; + setSessionId(null); + return; + } + pollTimerRef.current = setTimeout(tick, POLL_INTERVAL_MS); + }; + void tick(); + }, + [clearPoll], + ); + + const start = useCallback(async () => { + const generation = generationRef.current + 1; + generationRef.current = generation; + clearPoll(); + setError(null); + setStatus("starting"); + setAuthorizeUrl(null); + + try { + const response = await fetch("/api/orcarouter/connect/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ flow }), + }); + if (generation !== generationRef.current) return; + if (!response.ok) { + setStatus("error"); + setError("The OrcaRouter login could not be started."); + return; + } + const session = (await response.json()) as OrcaConnectSessionView; + if (generation !== generationRef.current) { + // A newer attempt owns the UI now; release the one we just created. + void cancelServerSession(session.sessionId, false); + return; + } + sessionIdRef.current = session.sessionId; + setSessionId(session.sessionId); + setAuthorizeUrl(session.authorizeUrl); + setStatus(session.status); + if (session.flow === "loopback") { + // The server returns a loopback URL the local browser can open. + if (session.callbackUrl) + window.open(session.authorizeUrl, "_blank", "noopener"); + } + poll(session.sessionId, generation); + } catch { + if (generation !== generationRef.current) return; + setStatus("error"); + setError("The OrcaRouter login could not be started."); + } + }, [clearPoll, flow, poll]); + + const submitCode = useCallback( + async (code: string) => { + const id = sessionIdRef.current; + if (!id) return; + const generation = generationRef.current; + setStatus("exchanging"); + try { + const response = await fetch("/api/orcarouter/connect/session", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: id, code }), + }); + if (generation !== generationRef.current) return; + if (!response.ok) { + setStatus("error"); + setError("The OrcaRouter authorization code could not be submitted."); + return; + } + const session = (await response.json()) as OrcaConnectSessionView; + if (generation !== generationRef.current) return; + setStatus(session.status); + if (session.status === "connected") { + clearPoll(); + sessionIdRef.current = null; + setSessionId(null); + if (session.maskedKey) setMaskedKey(session.maskedKey); + setError(null); + return; + } + if (session.message) setError(session.message); + } catch { + if (generation !== generationRef.current) return; + setStatus("error"); + setError("The OrcaRouter authorization code could not be submitted."); + } + }, + [clearPoll], + ); + + // A page entering the back-forward cache is never unmounted, so a guarded + // `finally` would leave it permanently busy. Invalidate the generation and + // clear the busy flag and hint synchronously, then ask the server to cancel. + useEffect(() => { + const onPageHide = () => { + generationRef.current += 1; + clearPoll(); + setStatus("idle"); + setAuthorizeUrl(null); + setSessionId(null); + setError(null); + const pending = sessionIdRef.current; + sessionIdRef.current = null; + if (pending) { + const url = `/api/orcarouter/connect/session?sessionId=${encodeURIComponent(pending)}`; + try { + void fetch(url, { method: "DELETE", keepalive: true }); + } catch { + // Best effort. + } + } + }; + window.addEventListener("pagehide", onPageHide); + return () => window.removeEventListener("pagehide", onPageHide); + }, [clearPoll]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + generationRef.current += 1; + if (pollTimerRef.current) clearTimeout(pollTimerRef.current); + const pending = sessionIdRef.current; + sessionIdRef.current = null; + // Cancel the server work without writing component state on unmount. + if (pending) void cancelServerSession(pending, false); + }; + }, []); + + return { + status, + busy: + status === "starting" || + status === "pending" || + status === "awaiting_code" || + status === "exchanging", + authorizeUrl, + sessionId, + error, + maskedKey, + flow, + setFlow, + start, + submitCode, + cancel, + reset, + }; +} diff --git a/apps/web/lib/orcarouter/use-orca-models.test.tsx b/apps/web/lib/orcarouter/use-orca-models.test.tsx new file mode 100644 index 00000000..d62415f1 --- /dev/null +++ b/apps/web/lib/orcarouter/use-orca-models.test.tsx @@ -0,0 +1,260 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { useOrcaModels } from "./use-orca-models"; + +/** A catalog response as the server route shapes it. */ +function response(overrides: Record = {}) { + return { + models: [ + { + id: "vendor/text-only", + name: "Text Only", + contextLength: 1000, + maxCompletionTokens: null, + inputModalities: ["text"], + reasoningEfforts: null, + }, + ], + source: "live", + degraded: false, + degradedReason: null, + fetchedAt: "2026-09-20T00:00:00.000Z", + capability: "chat", + requiredInputModalities: [], + maskedKey: "sk-orca-••••-key", + selectionStillValid: null, + ...overrides, + }; +} + +describe("useOrcaModels", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + function mount(options: Parameters[0]) { + return renderHook(() => useOrcaModels(options)); + } + + function lastUrl(): URL { + return new URL( + String(fetchMock.mock.calls[fetchMock.mock.calls.length - 1]![0]), + "http://localhost", + ); + } + + it("requests the chat capability from the API when enabled", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => response() }); + const { result } = mount({ enabled: true, capability: "chat" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(lastUrl().pathname).toBe("/api/orcarouter/models"); + expect(lastUrl().searchParams.get("capability")).toBe("chat"); + expect(result.current.models.map((m) => m.id)).toEqual([ + "vendor/text-only", + ]); + expect(result.current.source).toBe("live"); + }); + + it("adds the attachment modalities to the request and re-fetches on change", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => + response({ + models: [ + { + id: "vendor/vision", + name: "Vision", + contextLength: 1000, + maxCompletionTokens: null, + inputModalities: ["text", "image"], + reasoningEfforts: null, + }, + ], + requiredInputModalities: ["image"], + }), + }); + const { result, rerender } = mount({ + enabled: true, + capability: "chat", + requiredInputModalities: [], + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(lastUrl().searchParams.get("input")).toBeNull(); + + rerender(); + const { result: withImage } = mount({ + enabled: true, + capability: "chat", + requiredInputModalities: ["image"], + }); + await waitFor(() => expect(withImage.current.loading).toBe(false)); + expect(lastUrl().searchParams.get("input")).toBe("image"); + // Only the model that declares image input remains in the options. + expect(withImage.current.models.map((m) => m.id)).toEqual([ + "vendor/vision", + ]); + }); + + it("sends the current selection so the server can invalidate it", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => response({ selectionStillValid: false }), + }); + const { result } = mount({ + enabled: true, + capability: "chat", + selected: "vendor/text-only", + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(lastUrl().searchParams.get("selected")).toBe("vendor/text-only"); + expect(result.current.selectionInvalidated).toBe(true); + }); + + it("reports a degraded seed without degrading into free text", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => + response({ + models: [ + { + id: "openai/gpt-5.5", + name: "GPT-5.5", + contextLength: 400000, + maxCompletionTokens: null, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh"], + }, + ], + source: "seed", + degraded: true, + degradedReason: "Could not reach the OrcaRouter model catalog.", + fetchedAt: null, + }), + }); + const { result } = mount({ enabled: true, capability: "chat" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.degraded).toBe(true); + expect(result.current.source).toBe("seed"); + expect(result.current.degradedReason).toBeTruthy(); + expect(result.current.models.map((m) => m.id)).toEqual(["openai/gpt-5.5"]); + // The verified reasoning ladder survives the fallback. + expect(result.current.models[0]!.reasoningEfforts).toEqual([ + "low", + "medium", + "high", + "xhigh", + ]); + }); + + it("surfaces a failure as an error with no options, never as free text", async () => { + fetchMock.mockResolvedValue({ ok: false, json: async () => ({}) }); + const { result } = mount({ enabled: true, capability: "chat" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeTruthy(); + expect(result.current.models).toEqual([]); + }); + + it("surfaces a transport failure the same way", async () => { + fetchMock.mockRejectedValue(new Error("offline")); + const { result } = mount({ enabled: true, capability: "chat" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBeTruthy(); + expect(result.current.models).toEqual([]); + }); + + it("fetches nothing when the provider is not OrcaRouter", async () => { + const { result } = mount({ enabled: false, capability: "chat" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.models).toEqual([]); + }); + + it("refresh re-reads the catalog", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => response() }); + const { result } = mount({ enabled: true, capability: "chat" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + const before = fetchMock.mock.calls.length; + await act(async () => { + result.current.refresh(); + }); + await waitFor(() => + expect(fetchMock.mock.calls.length).toBeGreaterThan(before), + ); + }); + + it("ignores a stale response for the same hook when the capability changes", async () => { + let resolveSlow: ((value: unknown) => void) | null = null; + fetchMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveSlow = resolve; + }), + ); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => + response({ + capability: "embedding", + models: [ + { + id: "vendor/embed", + name: null, + contextLength: null, + maxCompletionTokens: null, + inputModalities: [], + reasoningEfforts: null, + }, + ], + }), + }); + + // One hook instance, whose capability changes while a request is in flight. + const { result, rerender } = renderHook( + (capability: "chat" | "embedding") => + useOrcaModels({ enabled: true, capability }), + { initialProps: "chat" as "chat" | "embedding" }, + ); + rerender("embedding"); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.models.map((m) => m.id)).toEqual(["vendor/embed"]); + + // The superseded chat response resolves late and must not overwrite it. + await act(async () => { + resolveSlow!({ + ok: true, + json: async () => + response({ + models: [ + { + id: "vendor/stale-chat", + name: null, + contextLength: null, + maxCompletionTokens: null, + inputModalities: ["text"], + reasoningEfforts: null, + }, + ], + }), + }); + }); + expect(result.current.models.map((m) => m.id)).toEqual(["vendor/embed"]); + }); + + it("never places the raw key in the browser: only the masked form arrives", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => response() }); + const { result } = mount({ enabled: true, capability: "chat" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.maskedKey).toBe("sk-orca-••••-key"); + // The request carries no Authorization header or key of its own. + const init = fetchMock.mock.calls[0]![1] as RequestInit | undefined; + expect(JSON.stringify(init ?? {})).not.toContain("sk-orca"); + }); +}); diff --git a/apps/web/lib/orcarouter/use-orca-models.ts b/apps/web/lib/orcarouter/use-orca-models.ts new file mode 100644 index 00000000..411de1bd --- /dev/null +++ b/apps/web/lib/orcarouter/use-orca-models.ts @@ -0,0 +1,156 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface OrcaModelOption { + id: string; + name: string | null; + contextLength: number | null; + maxCompletionTokens: number | null; + inputModalities: string[]; + reasoningEfforts: string[] | null; +} + +export interface OrcaModelsResponse { + models: OrcaModelOption[]; + source: "live" | "seed"; + degraded: boolean; + degradedReason: string | null; + fetchedAt: string | null; + capability: string; + requiredInputModalities: string[]; + maskedKey: string | null; + selectionStillValid: boolean | null; +} + +export interface UseOrcaModelsOptions { + readonly enabled: boolean; + readonly capability: "chat" | "embedding" | "image" | "video" | "rerank"; + /** + * Non-text modalities this entry point actually uploads. Changing this + * recomputes the selector's options. + */ + readonly requiredInputModalities?: readonly string[]; + /** Currently selected model id, revalidated on every recomputation. */ + readonly selected?: string | null; +} + +export interface UseOrcaModelsReturn { + models: OrcaModelOption[]; + loading: boolean; + /** The catalog could not be refreshed and a verified fallback is in use. */ + degraded: boolean; + degradedReason: string | null; + source: "live" | "seed" | null; + error: string | null; + /** True when the previous selection is no longer compatible. */ + selectionInvalidated: boolean; + maskedKey: string | null; + refresh: () => void; +} + +const EMPTY: OrcaModelOption[] = []; + +function modalityKey(modalities: readonly string[] | undefined): string { + return [...(modalities ?? [])].sort().join(","); +} + +/** + * Fetch the model list for one capability from the server. The API key never + * reaches the browser; only minimal model metadata does. + * + * A live result is authoritative. When discovery fails, the server returns its + * verified seed and marks the response degraded — the selector never degrades + * into a free-text field. + */ +export function useOrcaModels( + options: UseOrcaModelsOptions, +): UseOrcaModelsReturn { + const { enabled, capability, selected } = options; + const inputKey = modalityKey(options.requiredInputModalities); + + const [models, setModels] = useState(EMPTY); + const [loading, setLoading] = useState(false); + const [degraded, setDegraded] = useState(false); + const [degradedReason, setDegradedReason] = useState(null); + const [source, setSource] = useState<"live" | "seed" | null>(null); + const [error, setError] = useState(null); + const [selectionInvalidated, setSelectionInvalidated] = useState(false); + const [maskedKey, setMaskedKey] = useState(null); + const [reloadToken, setReloadToken] = useState(0); + + const generationRef = useRef(0); + const selectedRef = useRef(selected); + selectedRef.current = selected; + + useEffect(() => { + if (!enabled) { + generationRef.current += 1; + setModels(EMPTY); + setLoading(false); + setError(null); + setDegraded(false); + setDegradedReason(null); + setSource(null); + setSelectionInvalidated(false); + return; + } + + const generation = generationRef.current + 1; + generationRef.current = generation; + setLoading(true); + setError(null); + + const params = new URLSearchParams({ capability }); + if (inputKey) params.set("input", inputKey); + if (selectedRef.current) params.set("selected", selectedRef.current); + + void (async () => { + try { + const response = await fetch( + `/api/orcarouter/models?${params.toString()}`, + { cache: "no-store" }, + ); + if (generation !== generationRef.current) return; + if (!response.ok) { + setModels(EMPTY); + setLoading(false); + setError("The OrcaRouter model list could not be loaded."); + setSelectionInvalidated(false); + return; + } + const payload = (await response.json()) as OrcaModelsResponse; + if (generation !== generationRef.current) return; + setModels(payload.models); + setSource(payload.source); + setDegraded(payload.degraded); + setDegradedReason(payload.degradedReason); + setMaskedKey(payload.maskedKey); + setSelectionInvalidated(payload.selectionStillValid === false); + setLoading(false); + } catch { + if (generation !== generationRef.current) return; + setModels(EMPTY); + setLoading(false); + setError("The OrcaRouter model list could not be loaded."); + setSelectionInvalidated(false); + } + })(); + }, [capability, enabled, inputKey, reloadToken]); + + const refresh = useCallback(() => { + setReloadToken((value) => value + 1); + }, []); + + return { + models, + loading, + degraded, + degradedReason, + source, + error, + selectionInvalidated, + maskedKey, + refresh, + }; +} diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index 69053cef..071a729e 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -33,6 +33,7 @@ export const PAGE_TITLES: Record = { "docs/custom-schema": "Custom Schema & Renderer", "docs/devtools": "Devtools", "docs/ai-sdk": "AI SDK Integration", + "docs/orcarouter": "OrcaRouter Integration", "docs/jev": "Jev (Experimental)", "docs/adaptive-cards": "Adaptive Cards Integration", "docs/openapi": "OpenAPI Integration", diff --git a/apps/web/lib/use-playground-stream.ts b/apps/web/lib/use-playground-stream.ts index 03a11bee..34fb094b 100644 --- a/apps/web/lib/use-playground-stream.ts +++ b/apps/web/lib/use-playground-stream.ts @@ -17,6 +17,9 @@ import { applySpecPatch } from "./spec-patch"; export type PlaygroundModel = "default" | "typesafe-ai/jev"; +/** Which provider serves the generation. */ +export type PlaygroundProvider = "gateway" | "orcarouter" | "orcarouter-oauth"; + export interface CompositionSummary { stopReason: "finish" | "limit" | "unavailable"; elapsedMs: number; @@ -40,6 +43,12 @@ export interface UsePlaygroundStreamOptions { model?: PlaygroundModel; format: StreamFormat; editModes?: EditMode[]; + /** Defaults to the Vercel AI Gateway provider. */ + provider?: PlaygroundProvider; + /** OrcaRouter model id, chosen from the live catalog. */ + orcaModel?: string | null; + /** Non-text modalities this request uploads. Drives the model filter. */ + attachmentModalities?: readonly string[]; onError?: (error: Error) => void; onComplete?: (spec: Spec) => void; } @@ -109,6 +118,9 @@ export function usePlaygroundStream({ model = "default", format, editModes, + provider = "gateway", + orcaModel = null, + attachmentModalities, onError, onComplete, }: UsePlaygroundStreamOptions): UsePlaygroundStreamReturn { @@ -133,6 +145,12 @@ export function usePlaygroundStream({ formatRef.current = format; const editModesRef = useRef(editModes); editModesRef.current = editModes; + const providerRef = useRef(provider); + providerRef.current = provider; + const orcaModelRef = useRef(orcaModel); + orcaModelRef.current = orcaModel; + const attachmentModalitiesRef = useRef(attachmentModalities); + attachmentModalitiesRef.current = attachmentModalities; const stop = useCallback(() => abortControllerRef.current?.abort(), []); const clear = useCallback(() => { @@ -179,6 +197,11 @@ export function usePlaygroundStream({ model: requestModel, format: requestFormat, editModes: editModesRef.current, + provider: providerRef.current, + orcaModel: orcaModelRef.current, + attachments: (attachmentModalitiesRef.current ?? []).map( + (modality) => ({ modality }), + ), }), signal: controller.signal, }); diff --git a/apps/web/package.json b/apps/web/package.json index 92e95941..5adbe04c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@ai-sdk/gateway": "^3.0.13", + "@ai-sdk/openai-compatible": "^2.0.75", "@ai-sdk/react": "3.0.79", "@json-render/codegen": "workspace:*", "@json-render/core": "workspace:*", @@ -40,6 +41,7 @@ "diff": "^8.0.3", "embla-carousel-react": "^8.6.0", "geist": "1.7.0", + "just-bash": "^2.14.5", "lucide-react": "^0.562.0", "next": "16.1.1", "next-themes": "^0.4.6", diff --git a/apps/web/public/orcarouter-mark.png b/apps/web/public/orcarouter-mark.png new file mode 100644 index 00000000..fcf66b69 Binary files /dev/null and b/apps/web/public/orcarouter-mark.png differ diff --git a/packages/core/README.md b/packages/core/README.md index 0f808627..02d9646a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -59,6 +59,17 @@ V1 supports standard flat Spec catalogs, literals, `$state`, `$bindState`, state See the [Jev guide](https://json-render.dev/docs/jev) for complete catalog/candidate examples, source-build installation, rendering, custom evaluators, limitations, and feedback. The [playground implementation](../../apps/web/lib/jev) uses these same APIs. +## OrcaRouter provider + +[OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible AI gateway that routes many providers behind one endpoint. `@json-render/core` exports the provider seam so an app can offer it without duplicating auth or catalog logic. The inference transport stays in the host app (`@ai-sdk/openai-compatible`), so this package keeps no gateway dependency. + +- **Origins** — `resolveOrcarouterOrigins` keeps authorization (`https://www.orcarouter.ai`, `/auth` and `/api/v1/auth/keys`) separate from inference and the model catalog (`https://api.orcarouter.ai/v1`). Per-role overrides win over a shared `ORCA_BASE_URL`; remote origins must be HTTPS, with plain HTTP allowed only for loopback. +- **Credentials** — `createOrcaApiKeySource` and `createOrcaPkceSource` are two adapters over one `OrcaCredentialSource` interface, and both resolve to the same `OrcaCredentialResult`. The PKCE adapter implements Flow A (loopback redirect) and Flow B (out-of-band code) with S256, a fresh verifier and state per attempt, and a constant-time state comparison. The device grant is not implemented (`ORCAROUTER_UNSUPPORTED_FLOWS`). +- **Store** — `OrcaCredentialStore` is generation-aware: `markNeedsReauth(accountId, generation, reason)` only affects the exact account and generation whose request was rejected, so a late 401 cannot invalidate a credential the user just reconnected. +- **Catalog** — `fetchOrcaCatalog` requests `/v1/models` with the user's key, and `selectOrcaModels` filters per capability and per required input modality. A model without an explicit `architecture.input_modalities` entry is never treated as multimodal. `orcarouterSeedCatalog` is a five-model outage fallback, labelled as degraded and never merged into a live result. + +See the [OrcaRouter guide](https://json-render.dev/docs/orcarouter) for setup and the [playground implementation](../../apps/web/lib/orcarouter) for a worked example. + ## Key Concepts - **Schema**: Defines the structure of specs and catalogs diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 51fa3755..5f557067 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -244,3 +244,6 @@ export type { } from "./experimental-compose"; export { experimental_createEvaluator } from "./experimental-evaluator"; export type { Experimental_EvaluatorOptions } from "./experimental-evaluator"; + +// OrcaRouter — a first-class, OpenAI-compatible provider. +export * from "./orcarouter"; diff --git a/packages/core/src/orcarouter/catalog.test.ts b/packages/core/src/orcarouter/catalog.test.ts new file mode 100644 index 00000000..b39798fe --- /dev/null +++ b/packages/core/src/orcarouter/catalog.test.ts @@ -0,0 +1,439 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { + DEFAULT_CATALOG_TIMEOUT_MS, + MAX_CATALOG_BYTES, + OrcaCatalogError, + fetchOrcaCatalog, + isOrcaModelCompatible, + modelSupportsCapability, + modelSupportsInputModality, + orcaCatalogUrl, + orcarouterSeedCatalog, + parseOrcaCatalogPayload, + parseOrcaModelRecord, + selectOrcaModels, + type OrcaCatalog, + type OrcaModel, +} from "./catalog"; +import { resolveOrcarouterOrigins } from "./origins"; + +const origins = resolveOrcarouterOrigins(); + +const textOnly = { + id: "deepseek/deepseek-v4-pro", + object: "model", + supported_endpoint_types: ["openai", "openai-response"], + context_length: 1048576, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, +}; +const imageInputChat = { + id: "deepseek/deepseek-v4-flash-vision-exp", + supported_endpoint_types: ["openai", "anthropic"], + context_length: 1048576, + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["text"], + }, +}; +const embedding = { + id: "vendor/embed-1", + supported_endpoint_types: ["embeddings"], +}; +const imageGeneration = { + id: "vendor/image-1", + supported_endpoint_types: ["image-generation"], +}; +const video = { + id: "vendor/video-1", + supported_endpoint_types: ["openai-video"], +}; +const rerank = { + id: "vendor/rerank-1", + supported_endpoint_types: ["jina-rerank"], +}; + +function catalogOf(records: unknown[]): OrcaCatalog { + return { + models: parseOrcaCatalogPayload({ data: records }), + source: "live", + degraded: false, + degradedReason: null, + fetchedAt: "2026-09-20T00:00:00.000Z", + }; +} + +function catalogOfModels(models: OrcaModel[]): OrcaCatalog { + return { ...catalogOf([]), models }; +} + +describe("parseOrcaModelRecord", () => { + it("preserves the vendor/model namespace verbatim", () => { + const model = parseOrcaModelRecord(textOnly); + expect(model?.id).toBe("deepseek/deepseek-v4-pro"); + expect(model?.contextLength).toBe(1048576); + expect(model?.endpointTypes).toEqual(["openai", "openai-response"]); + expect(model?.architecture?.inputModalities).toEqual(["text"]); + }); + + it("drops records without a usable id", () => { + expect(parseOrcaModelRecord({ id: " " })).toBeNull(); + expect(parseOrcaModelRecord({ id: 42 })).toBeNull(); + expect(parseOrcaModelRecord(null)).toBeNull(); + expect(parseOrcaModelRecord("vendor/model")).toBeNull(); + }); + + it("ignores unknown modalities and non-integer context lengths", () => { + const model = parseOrcaModelRecord({ + id: "vendor/x", + context_length: -1, + supported_endpoint_types: ["openai", 7], + architecture: { input_modalities: ["text", "smell"] }, + }); + expect(model?.contextLength).toBeNull(); + expect(model?.endpointTypes).toEqual(["openai"]); + expect(model?.architecture?.inputModalities).toEqual(["text"]); + }); +}); + +describe("parseOrcaCatalogPayload", () => { + it("accepts the OpenAI-shaped envelope and a bare array", () => { + expect(parseOrcaCatalogPayload({ data: [textOnly] })).toHaveLength(1); + expect(parseOrcaCatalogPayload([textOnly])).toHaveLength(1); + }); + + it("rejects an unexpected shape", () => { + expect(() => parseOrcaCatalogPayload({ models: [] })).toThrow( + OrcaCatalogError, + ); + expect(() => parseOrcaCatalogPayload(null)).toThrow(OrcaCatalogError); + }); + + it("de-duplicates repeated ids", () => { + expect( + parseOrcaCatalogPayload({ data: [textOnly, textOnly] }), + ).toHaveLength(1); + }); +}); + +describe("capability filtering", () => { + const catalog = catalogOf([ + textOnly, + imageInputChat, + embedding, + imageGeneration, + video, + rerank, + ]); + + it("treats text endpoint types as chat and excludes non-text families", () => { + const ids = selectOrcaModels(catalog, { capability: "chat" }).map( + (m) => m.id, + ); + expect(ids).toEqual([ + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash-vision-exp", + ]); + expect(ids).not.toContain("vendor/image-1"); + expect(ids).not.toContain("vendor/video-1"); + expect(ids).not.toContain("vendor/rerank-1"); + expect(ids).not.toContain("vendor/embed-1"); + }); + + it("matches embedding, image, video and rerank strictly on endpoint type", () => { + expect( + selectOrcaModels(catalog, { capability: "embedding" }).map((m) => m.id), + ).toEqual(["vendor/embed-1"]); + expect( + selectOrcaModels(catalog, { capability: "image" }).map((m) => m.id), + ).toEqual(["vendor/image-1"]); + expect( + selectOrcaModels(catalog, { capability: "video" }).map((m) => m.id), + ).toEqual(["vendor/video-1"]); + expect( + selectOrcaModels(catalog, { capability: "rerank" }).map((m) => m.id), + ).toEqual(["vendor/rerank-1"]); + }); + + it("accepts each of the four text endpoint types", () => { + for (const endpointType of [ + "openai", + "anthropic", + "gemini", + "openai-response", + ]) { + const model = parseOrcaModelRecord({ + id: `vendor/${endpointType}`, + supported_endpoint_types: [endpointType], + }) as OrcaModel; + expect(modelSupportsCapability(model, "chat")).toBe(true); + } + }); + + it("excludes a model that mixes a text type with a non-text family", () => { + const mixed = parseOrcaModelRecord({ + id: "vendor/mixed", + supported_endpoint_types: ["openai", "image-generation"], + }) as OrcaModel; + expect(modelSupportsCapability(mixed, "chat")).toBe(false); + }); + + it("offers only chat models that declare the required input modality", () => { + const ids = selectOrcaModels(catalog, { + capability: "chat", + requiredInputModalities: ["image"], + }).map((m) => m.id); + expect(ids).toEqual(["deepseek/deepseek-v4-flash-vision-exp"]); + }); + + it("fails closed for a chat model with no architecture block", () => { + const undeclared = parseOrcaModelRecord({ + id: "vendor/undeclared", + supported_endpoint_types: ["openai"], + }) as OrcaModel; + expect(modelSupportsCapability(undeclared, "chat")).toBe(true); + expect(modelSupportsInputModality(undeclared, "image")).toBe(false); + expect( + selectOrcaModels(catalogOfModels([undeclared]), { + capability: "chat", + requiredInputModalities: ["image"], + }), + ).toEqual([]); + }); + + it("fails closed for audio and video input, and ignores text requirements", () => { + const model = parseOrcaModelRecord(imageInputChat) as OrcaModel; + expect(modelSupportsInputModality(model, "audio")).toBe(false); + expect(modelSupportsInputModality(model, "video")).toBe(false); + expect(modelSupportsInputModality(model, "text")).toBe(true); + expect( + selectOrcaModels(catalogOfModels([model]), { + capability: "chat", + requiredInputModalities: ["text"], + }), + ).toHaveLength(1); + }); + + it("never infers image input from a model name", () => { + const named = parseOrcaModelRecord({ + id: "vendor/super-vision-ultra", + supported_endpoint_types: ["openai"], + architecture: { input_modalities: ["text"] }, + }) as OrcaModel; + expect(modelSupportsInputModality(named, "image")).toBe(false); + }); +}); + +describe("isOrcaModelCompatible", () => { + const catalog = catalogOf([textOnly, imageInputChat]); + + it("keeps a compatible selection and invalidates an incompatible one", () => { + expect( + isOrcaModelCompatible(catalog, "deepseek/deepseek-v4-pro", { + capability: "chat", + }), + ).toBe(true); + expect( + isOrcaModelCompatible(catalog, "deepseek/deepseek-v4-pro", { + capability: "chat", + requiredInputModalities: ["image"], + }), + ).toBe(false); + expect( + isOrcaModelCompatible(catalog, "vendor/gone", { capability: "chat" }), + ).toBe(false); + }); +}); + +describe("orcaCatalogUrl", () => { + it("builds capability-scoped URLs on the inference origin", () => { + expect(orcaCatalogUrl(origins)).toBe("https://api.orcarouter.ai/v1/models"); + expect(orcaCatalogUrl(origins, "chat")).toBe( + "https://api.orcarouter.ai/v1/models?capability=chat", + ); + expect(orcaCatalogUrl(origins, "embedding")).toBe( + "https://api.orcarouter.ai/v1/models?capability=embedding", + ); + expect(orcaCatalogUrl(origins, "image")).toBe( + "https://api.orcarouter.ai/v1/models?capability=image", + ); + // The protocol has no video or rerank capability query; those are filtered + // strictly on the returned endpoint type instead. + expect(orcaCatalogUrl(origins, "video")).toBe( + "https://api.orcarouter.ai/v1/models", + ); + }); +}); + +describe("fetchOrcaCatalog", () => { + it("sends the key as a Bearer token to the inference origin", async () => { + const fetch = vi.fn(async () => + Response.json({ data: [textOnly] }), + ); + const catalog = await fetchOrcaCatalog({ + origins, + apiKey: "sk-orca-test", + capability: "chat", + fetch, + }); + expect(catalog.source).toBe("live"); + expect(catalog.degraded).toBe(false); + expect(catalog.models).toHaveLength(1); + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe("https://api.orcarouter.ai/v1/models?capability=chat"); + expect(init?.headers).toMatchObject({ + Authorization: "Bearer sk-orca-test", + }); + }); + + it.each([ + [401, /rejected while listing models/], + [403, /rejected while listing models/], + [500, /status 500/], + ])( + "reports status %i without echoing the key or body", + async (status, pattern) => { + const error = await fetchOrcaCatalog({ + origins, + apiKey: "sk-orca-secret-value", + fetch: async () => new Response("upstream detail", { status }), + }).catch((e: unknown) => e as OrcaCatalogError); + expect(error).toBeInstanceOf(OrcaCatalogError); + expect(error.status).toBe(status); + expect(error.message).toMatch(pattern); + expect(error.message).not.toContain("sk-orca-secret-value"); + expect(error.message).not.toContain("upstream detail"); + }, + ); + + it("requires a key before making a request", async () => { + const fetch = vi.fn(); + await expect( + fetchOrcaCatalog({ origins, apiKey: " ", fetch }), + ).rejects.toThrow(/API key is required/); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("surfaces a transport failure and invalid JSON as catalog errors", async () => { + await expect( + fetchOrcaCatalog({ + origins, + apiKey: "sk-orca-test", + fetch: async () => { + throw new TypeError("fetch failed"); + }, + }), + ).rejects.toThrow(/Could not reach the OrcaRouter model catalog/); + await expect( + fetchOrcaCatalog({ + origins, + apiKey: "sk-orca-test", + fetch: async () => new Response("nope"), + }), + ).rejects.toThrow(/not valid JSON/); + await expect( + fetchOrcaCatalog({ + origins, + apiKey: "sk-orca-test", + fetch: async () => Response.json({ models: [] }), + }), + ).rejects.toThrow(/unexpected shape/); + }); + + it("bounds the response size and the item count", async () => { + await expect( + fetchOrcaCatalog({ + origins, + apiKey: "sk-orca-test", + fetch: async () => + new Response("x".repeat(MAX_CATALOG_BYTES + 1), { status: 200 }), + }), + ).rejects.toThrow(/larger than this client accepts/); + + const many = Array.from({ length: 900 }, (_, i) => ({ + id: `vendor/model-${i}`, + supported_endpoint_types: ["openai"], + })); + const catalog = await fetchOrcaCatalog({ + origins, + apiKey: "sk-orca-test", + fetch: async () => Response.json({ data: many }), + }); + expect(catalog.models.length).toBeLessThanOrEqual(500); + }); + + it("applies a bounded default timeout", () => { + expect(DEFAULT_CATALOG_TIMEOUT_MS).toBeLessThanOrEqual(30_000); + }); + + it("aborts on caller cancellation instead of hanging", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + fetchOrcaCatalog({ + origins, + apiKey: "sk-orca-test", + signal: controller.signal, + fetch: async (_url, init) => { + if (init?.signal?.aborted) + throw new DOMException("aborted", "AbortError"); + return Response.json({ data: [] }); + }, + }), + ).rejects.toThrow(OrcaCatalogError); + }); + + it("follows an explicit self-hosted API origin", async () => { + const selfHosted = resolveOrcarouterOrigins({ + apiBaseUrl: "https://relay.example", + }); + const fetch = vi.fn(async () => + Response.json({ data: [] }), + ); + await fetchOrcaCatalog({ + origins: selfHosted, + apiKey: "sk-orca-test", + fetch, + }); + expect(fetch.mock.calls[0]![0]).toBe("https://relay.example/v1/models"); + }); +}); + +describe("orcarouterSeedCatalog", () => { + it("is marked degraded and labelled as a seed, never as live", () => { + const seed = orcarouterSeedCatalog("outage"); + expect(seed.source).toBe("seed"); + expect(seed.degraded).toBe(true); + expect(seed.degradedReason).toBe("outage"); + expect(seed.fetchedAt).toBeNull(); + }); + + it("keeps the verified fallback ids and their metadata intact", () => { + const seed = orcarouterSeedCatalog("outage"); + const ids = seed.models.map((m) => m.id); + expect(ids).toEqual([ + "openai/gpt-5.5", + "anthropic/claude-opus-4.8", + "google/gemini-3.5-flash", + "deepseek/deepseek-v4-pro", + "orcarouter/auto", + ]); + const gpt55 = seed.models[0]!; + expect(gpt55.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh"]); + expect(gpt55.contextLength).toBeGreaterThan(0); + expect(gpt55.architecture?.inputModalities).toContain("image"); + }); + + it("still satisfies the chat selector and the multimodal selector", () => { + const seed = orcarouterSeedCatalog("outage"); + expect( + selectOrcaModels(seed, { capability: "chat" }).length, + ).toBeGreaterThan(0); + expect( + selectOrcaModels(seed, { + capability: "chat", + requiredInputModalities: ["image"], + }).length, + ).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/orcarouter/catalog.ts b/packages/core/src/orcarouter/catalog.ts new file mode 100644 index 00000000..ebd085bf --- /dev/null +++ b/packages/core/src/orcarouter/catalog.ts @@ -0,0 +1,432 @@ +/** + * OrcaRouter model catalog. + * + * `GET {api}/v1/models` is the only source of truth for which models a + * workspace can actually call. The catalog drives the model selector; a + * capability that the catalog metadata cannot prove is never offered. + */ + +import { ORCAROUTER_MODELS_PATH, type OrcarouterOrigins } from "./origins"; + +export type OrcaCapability = + | "chat" + | "embedding" + | "image" + | "video" + | "rerank"; + +export type OrcaInputModality = "text" | "image" | "audio" | "video"; + +/** Endpoint types that can carry a text chat completion. */ +const TEXT_ENDPOINT_TYPES = [ + "openai", + "anthropic", + "gemini", + "openai-response", +] as const; + +/** Endpoint types that belong to a non-text model family. */ +const NON_TEXT_ENDPOINT_TYPES = [ + "image-generation", + "openai-video", + "jina-rerank", +] as const; + +const CAPABILITY_ENDPOINT_TYPE: Record< + Exclude, + string +> = { + embedding: "embeddings", + image: "image-generation", + video: "openai-video", + rerank: "jina-rerank", +}; + +const CAPABILITY_QUERY: Record = { + chat: "chat", + embedding: "embedding", + image: "image", + video: null, + rerank: null, +}; + +export interface OrcaModelArchitecture { + readonly inputModalities: readonly OrcaInputModality[]; + readonly outputModalities: readonly OrcaInputModality[]; +} + +export interface OrcaModel { + /** The vendor/model identifier, preserved verbatim. */ + readonly id: string; + readonly name: string | null; + readonly description: string | null; + readonly contextLength: number | null; + readonly maxCompletionTokens: number | null; + readonly endpointTypes: readonly string[]; + readonly architecture: OrcaModelArchitecture | null; + /** Reasoning effort levels this model accepts, when verified. */ + readonly reasoningEfforts: readonly string[] | null; +} + +export interface OrcaCatalog { + readonly models: readonly OrcaModel[]; + /** `live` results are authoritative; `seed` results are an outage fallback. */ + readonly source: "live" | "seed"; + readonly degraded: boolean; + /** Why the live catalog could not be used, when `degraded` is true. */ + readonly degradedReason: string | null; + readonly fetchedAt: string | null; +} + +export class OrcaCatalogError extends Error { + readonly status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = "OrcaCatalogError"; + this.status = status; + } +} + +/** Bounds so a catalog response cannot consume unbounded memory. */ +export const MAX_CATALOG_BYTES = 1_048_576; +export const MAX_CATALOG_MODELS = 500; +export const DEFAULT_CATALOG_TIMEOUT_MS = 10_000; + +const MODALITIES = new Set(["text", "image", "audio", "video"]); + +function asModalities(value: unknown): OrcaInputModality[] { + if (!Array.isArray(value)) return []; + return value.filter( + (item): item is OrcaInputModality => + typeof item === "string" && MODALITIES.has(item), + ); +} + +function asPositiveInteger(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 + ? value + : null; +} + +/** + * Parse one catalog record. Records that do not carry a usable id or a + * recognised endpoint list are dropped rather than guessed at. + */ +export function parseOrcaModelRecord(record: unknown): OrcaModel | null { + if (typeof record !== "object" || record === null) return null; + const raw = record as Record; + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + if (!id) return null; + + const endpointTypes = Array.isArray(raw.supported_endpoint_types) + ? raw.supported_endpoint_types.filter( + (item): item is string => typeof item === "string", + ) + : []; + + let architecture: OrcaModelArchitecture | null = null; + if (typeof raw.architecture === "object" && raw.architecture !== null) { + const arch = raw.architecture as Record; + architecture = { + inputModalities: asModalities(arch.input_modalities), + outputModalities: asModalities(arch.output_modalities), + }; + } + + return { + id, + name: typeof raw.name === "string" && raw.name.trim() ? raw.name : null, + description: + typeof raw.description === "string" && raw.description.trim() + ? raw.description + : null, + contextLength: asPositiveInteger(raw.context_length), + maxCompletionTokens: asPositiveInteger(raw.max_completion_tokens), + endpointTypes, + architecture, + reasoningEfforts: null, + }; +} + +/** Parse a catalog payload. Accepts `{ data: [...] }` and a bare array. */ +export function parseOrcaCatalogPayload(payload: unknown): OrcaModel[] { + const items = Array.isArray(payload) + ? payload + : typeof payload === "object" && + payload !== null && + Array.isArray((payload as Record).data) + ? ((payload as Record).data as unknown[]) + : null; + if (!items) { + throw new OrcaCatalogError( + "The OrcaRouter model catalog response had an unexpected shape.", + ); + } + const models: OrcaModel[] = []; + const seen = new Set(); + for (const item of items.slice(0, MAX_CATALOG_MODELS)) { + const model = parseOrcaModelRecord(item); + if (model && !seen.has(model.id)) { + seen.add(model.id); + models.push(model); + } + } + return models; +} + +/** `GET {api}/v1/models[?capability=…]` for one capability. */ +export function orcaCatalogUrl( + origins: OrcarouterOrigins, + capability?: OrcaCapability, +): string { + const query = capability ? CAPABILITY_QUERY[capability] : null; + const url = `${origins.apiBaseUrl}${ORCAROUTER_MODELS_PATH}`; + return query ? `${url}?capability=${query}` : url; +} + +export interface FetchOrcaCatalogOptions { + readonly origins: OrcarouterOrigins; + /** Bearer credential. Never logged, never included in an error message. */ + readonly apiKey: string; + readonly capability?: OrcaCapability; + readonly fetch?: typeof globalThis.fetch; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +} + +/** + * Fetch the live catalog. Throws `OrcaCatalogError` on any failure so the + * caller decides how to degrade; error messages never include the key or the + * upstream body. + */ +export async function fetchOrcaCatalog( + options: FetchOrcaCatalogOptions, +): Promise { + const apiKey = options.apiKey?.trim(); + if (!apiKey) { + throw new OrcaCatalogError( + "An OrcaRouter API key is required to list models.", + ); + } + const fetchImpl = options.fetch ?? globalThis.fetch; + const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS; + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = options.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + + let response: Response; + try { + response = await fetchImpl( + orcaCatalogUrl(options.origins, options.capability), + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal, + cache: "no-store", + }, + ); + } catch { + throw new OrcaCatalogError( + "Could not reach the OrcaRouter model catalog. Check your network and try again.", + ); + } + + if (!response.ok) { + const message = + response.status === 401 || response.status === 403 + ? `The OrcaRouter API key was rejected while listing models (status ${response.status}).` + : `The OrcaRouter model catalog request failed with status ${response.status}.`; + throw new OrcaCatalogError(message, response.status); + } + + const body = await response.text().catch(() => null); + if (body === null) { + throw new OrcaCatalogError( + "The OrcaRouter model catalog response could not be read.", + ); + } + if (body.length > MAX_CATALOG_BYTES) { + throw new OrcaCatalogError( + "The OrcaRouter model catalog response was larger than this client accepts.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + throw new OrcaCatalogError( + "The OrcaRouter model catalog response was not valid JSON.", + ); + } + + return { + models: parseOrcaCatalogPayload(parsed), + source: "live", + degraded: false, + degradedReason: null, + fetchedAt: new Date().toISOString(), + }; +} + +interface SeedModel { + readonly id: string; + readonly name: string; + readonly contextLength: number; + readonly endpointTypes: readonly string[]; + readonly inputModalities: readonly OrcaInputModality[]; + readonly reasoningEfforts: readonly string[] | null; +} + +/** + * Verified cold-start seed. Used only when live discovery fails, so a fresh + * installation is not left with an empty selector during an outage. When live + * discovery succeeds its result is authoritative and this seed is not merged + * into it. + */ +export const ORCAROUTER_SEED_MODELS: readonly SeedModel[] = [ + { + id: "openai/gpt-5.5", + name: "GPT-5.5", + contextLength: 400_000, + endpointTypes: ["openai", "openai-response", "anthropic"], + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh"], + }, + { + id: "anthropic/claude-opus-4.8", + name: "Claude Opus 4.8", + contextLength: 200_000, + endpointTypes: ["anthropic", "openai"], + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high"], + }, + { + id: "google/gemini-3.5-flash", + name: "Gemini 3.5 Flash", + contextLength: 1_000_000, + endpointTypes: ["gemini", "openai"], + inputModalities: ["text", "image", "audio", "video"], + reasoningEfforts: ["low", "medium", "high"], + }, + { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + contextLength: 1_048_576, + endpointTypes: ["openai", "openai-response"], + inputModalities: ["text"], + reasoningEfforts: null, + }, + { + id: "orcarouter/auto", + name: "OrcaRouter Auto", + contextLength: 400_000, + endpointTypes: ["openai", "openai-response", "anthropic", "gemini"], + inputModalities: ["text"], + reasoningEfforts: null, + }, +]; + +export function orcarouterSeedCatalog(reason: string): OrcaCatalog { + return { + models: ORCAROUTER_SEED_MODELS.map((seed) => ({ + id: seed.id, + name: seed.name, + description: null, + contextLength: seed.contextLength, + maxCompletionTokens: null, + endpointTypes: [...seed.endpointTypes], + architecture: { + inputModalities: [...seed.inputModalities], + outputModalities: ["text"], + }, + reasoningEfforts: seed.reasoningEfforts + ? [...seed.reasoningEfforts] + : null, + })), + source: "seed", + degraded: true, + degradedReason: reason, + fetchedAt: null, + }; +} + +function hasTextEndpoint(model: OrcaModel): boolean { + return model.endpointTypes.some((type) => + (TEXT_ENDPOINT_TYPES as readonly string[]).includes(type), + ); +} + +function hasNonTextEndpoint(model: OrcaModel): boolean { + return model.endpointTypes.some((type) => + (NON_TEXT_ENDPOINT_TYPES as readonly string[]).includes(type), + ); +} + +/** Whether a model can serve the given capability, from catalog metadata only. */ +export function modelSupportsCapability( + model: OrcaModel, + capability: OrcaCapability, +): boolean { + if (capability === "chat") { + return hasTextEndpoint(model) && !hasNonTextEndpoint(model); + } + return model.endpointTypes.includes(CAPABILITY_ENDPOINT_TYPE[capability]); +} + +/** + * Whether a model declares the given non-text input modality. Fails closed: a + * model that does not declare an `architecture` block is never treated as + * multimodal. + */ +export function modelSupportsInputModality( + model: OrcaModel, + modality: OrcaInputModality, +): boolean { + if (modality === "text") return true; + return model.architecture?.inputModalities.includes(modality) ?? false; +} + +export interface SelectOrcaModelsOptions { + readonly capability: OrcaCapability; + /** Non-text modalities this entry point actually uploads. */ + readonly requiredInputModalities?: readonly OrcaInputModality[]; +} + +/** + * Filter the catalog down to the models an entry point may offer. Every + * requirement must be provable from metadata, so this never guesses from a + * model name. + */ +export function selectOrcaModels( + catalog: OrcaCatalog, + options: SelectOrcaModelsOptions, +): OrcaModel[] { + const modalities = (options.requiredInputModalities ?? []).filter( + (modality) => modality !== "text", + ); + return catalog.models.filter((model) => { + if (!modelSupportsCapability(model, options.capability)) return false; + return modalities.every((modality) => + modelSupportsInputModality(model, modality), + ); + }); +} + +/** + * Whether a previously selected model is still offered for the current + * capability requirements. A stale selection must be cleared, not kept. + */ +export function isOrcaModelCompatible( + catalog: OrcaCatalog, + modelId: string, + options: SelectOrcaModelsOptions, +): boolean { + return selectOrcaModels(catalog, options).some( + (model) => model.id === modelId, + ); +} diff --git a/packages/core/src/orcarouter/credential-store.ts b/packages/core/src/orcarouter/credential-store.ts new file mode 100644 index 00000000..7d9a2ad9 --- /dev/null +++ b/packages/core/src/orcarouter/credential-store.ts @@ -0,0 +1,178 @@ +/** + * Generation-safe credential store for OrcaRouter. + * + * The store holds whatever credential the active source produced. It is + * deliberately storage-agnostic: callers persist the serialized form wherever + * their deployment already keeps secrets. Nothing here writes a side file or + * introduces a new secret store. + */ + +import { + classifyOrcaInferenceFailure, + type OrcaCredentialMethod, + type OrcaCredentialResult, + type OrcaStoredCredential, +} from "./credential"; + +export interface OrcaCredentialStoreSnapshot { + readonly credential: OrcaStoredCredential | null; +} + +export class OrcaCredentialStore { + private current: OrcaStoredCredential | null; + private generation = 0; + + constructor(initial: OrcaStoredCredential | null = null) { + this.current = initial; + this.generation = initial?.generation ?? 0; + } + + /** + * Install a credential from either source. Bumps the generation so a late + * failure from a request made with the previous credential cannot mark this + * one as broken. + */ + setCredential(result: OrcaCredentialResult): OrcaStoredCredential { + this.generation += 1; + const stored: OrcaStoredCredential = { + apiKey: result.apiKey, + method: result.method, + grantedScope: result.grantedScope, + accountId: result.accountId, + generation: this.generation, + status: "active", + issuedAt: new Date().toISOString(), + reauthReason: null, + }; + this.current = stored; + return stored; + } + + getCredential(): OrcaStoredCredential | null { + return this.current; + } + + /** The credential to send, or null when the account needs reauthentication. */ + getUsableCredential(): OrcaStoredCredential | null { + return this.current?.status === "active" ? this.current : null; + } + + isConnected(): boolean { + return this.getUsableCredential() !== null; + } + + get needsReauth(): boolean { + return this.current?.status === "needs_reauth"; + } + + /** + * Mark the credential that made a rejected request as needing + * reauthentication. + * + * Only the exact account and credential generation that made the request is + * touched: a late `401` from a request issued before a re-login leaves the + * new credential active. The stored secret is kept so the user can retry + * without losing the account. + */ + markNeedsReauth( + accountId: string | null, + generation: number, + reason: string, + ): boolean { + const credential = this.current; + if (!credential) return false; + if (credential.generation !== generation) return false; + if (credential.accountId !== accountId) return false; + this.current = { + ...credential, + status: "needs_reauth", + reauthReason: reason, + }; + return true; + } + + /** + * Record an inference failure against the credential that made the request. + * Non-terminal statuses leave the credential untouched. + */ + recordInferenceStatus( + accountId: string | null, + generation: number, + status: number, + ): boolean { + const classification = classifyOrcaInferenceFailure(status); + if (!classification.terminal || !classification.reason) return false; + return this.markNeedsReauth(accountId, generation, classification.reason); + } + + /** Remove the credential. The caller decides when the user asked for this. */ + clear(): void { + this.current = null; + } + + snapshot(): OrcaCredentialStoreSnapshot { + return { credential: this.current }; + } +} + +export interface SerializedOrcaCredentialStore { + readonly version: 1; + readonly credential: OrcaStoredCredential | null; +} + +/** + * Serialize for persistence. Callers must write the result wherever the + * deployment already stores secrets; it is not encrypted here. + */ +export function serializeOrcaCredentialStore( + store: OrcaCredentialStore, +): SerializedOrcaCredentialStore { + return { version: 1, credential: store.snapshot().credential }; +} + +export function deserializeOrcaCredentialStore( + value: unknown, +): OrcaCredentialStore { + if (typeof value !== "object" || value === null) { + return new OrcaCredentialStore(null); + } + const record = value as Record; + const raw = record.credential; + if (typeof raw !== "object" || raw === null) { + return new OrcaCredentialStore(null); + } + const candidate = raw as Record; + const apiKey = candidate.apiKey; + const generation = candidate.generation; + if ( + typeof apiKey !== "string" || + !apiKey || + typeof generation !== "number" || + !Number.isSafeInteger(generation) || + generation < 0 + ) { + return new OrcaCredentialStore(null); + } + const method: OrcaCredentialMethod = + candidate.method === "orcarouter-oauth" ? "orcarouter-oauth" : "api_key"; + return new OrcaCredentialStore({ + apiKey, + method, + grantedScope: + typeof candidate.grantedScope === "string" + ? candidate.grantedScope + : null, + accountId: + typeof candidate.accountId === "string" ? candidate.accountId : null, + generation, + status: candidate.status === "needs_reauth" ? "needs_reauth" : "active", + issuedAt: + typeof candidate.issuedAt === "string" + ? candidate.issuedAt + : new Date(0).toISOString(), + reauthReason: + typeof candidate.reauthReason === "string" + ? candidate.reauthReason + : null, + }); +} diff --git a/packages/core/src/orcarouter/credential.test.ts b/packages/core/src/orcarouter/credential.test.ts new file mode 100644 index 00000000..ed12d7e8 --- /dev/null +++ b/packages/core/src/orcarouter/credential.test.ts @@ -0,0 +1,546 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { + ORCA_API_KEY_PREFIX, + classifyOrcaInferenceFailure, + createOrcaApiKeySource, + createOrcaPkceSource, + redactOrcaApiKey, + validateOrcaApiKey, + type OrcaCodeReceiver, + type OrcaCredentialResult, + type OrcaCredentialSource, +} from "./credential"; +import { OrcaAuthError } from "./pkce"; +import { resolveOrcarouterOrigins } from "./origins"; +import { + OrcaCredentialStore, + deserializeOrcaCredentialStore, + serializeOrcaCredentialStore, +} from "./credential-store"; +import { selectOrcaModels } from "./catalog"; +import { orcarouterSeedCatalog } from "./catalog"; + +const origins = resolveOrcarouterOrigins(); + +describe("redactOrcaApiKey", () => { + it("never returns the whole key", () => { + const masked = redactOrcaApiKey("sk-orca-abcdefghijklmnop"); + expect(masked).not.toContain("ijklmnop"); + expect(masked).toBe("sk-orca-••••mnop"); + }); + + it("handles empty and short values without throwing", () => { + expect(redactOrcaApiKey("")).toBe(""); + expect(redactOrcaApiKey(null)).toBe(""); + expect(redactOrcaApiKey(undefined)).toBe(""); + expect(redactOrcaApiKey("short")).toBe("••••"); + }); +}); + +describe("validateOrcaApiKey", () => { + it("accepts a well-formed key", () => { + expect(validateOrcaApiKey("sk-orca-abc123")).toEqual({ + ok: true, + problem: null, + }); + }); + + it.each([ + ["", /Enter an OrcaRouter API key/], + [" ", /Enter an OrcaRouter API key/], + ["sk-orca-abc 123", /cannot contain whitespace/], + ["sk-live-abc", /starts with/], + [ORCA_API_KEY_PREFIX, /incomplete/], + ])("rejects %j", (value, pattern) => { + const result = validateOrcaApiKey(value); + expect(result.ok).toBe(false); + expect(result.problem).toMatch(pattern); + expect(result.problem).not.toContain("abc123"); + }); + + it("reports format as format, not as proof of validity", () => { + expect(validateOrcaApiKey("sk-orca-not-a-real-key").ok).toBe(true); + }); +}); + +describe("createOrcaApiKeySource", () => { + it("produces the shared credential result shape", async () => { + const source = createOrcaApiKeySource(" sk-orca-test-key "); + expect(source.method).toBe("api_key"); + expect(source.label).toBe("OrcaRouter - API"); + await expect(source.acquire()).resolves.toEqual({ + apiKey: "sk-orca-test-key", + method: "api_key", + grantedScope: null, + accountId: null, + }); + }); + + it("refuses a malformed key without leaking it", async () => { + const source = createOrcaApiKeySource("nope"); + const error = await source.acquire().catch((e: unknown) => e as Error); + expect(error).toBeInstanceOf(OrcaAuthError); + expect(error.message).not.toContain("nope"); + }); +}); + +function receiverFor( + callback: { + code?: string | null; + error?: string | null; + state?: string | null; + }, + callbackUrl = "http://127.0.0.1:51733/cb", +): OrcaCodeReceiver { + return { + callbackUrl, + async waitForCallback() { + return { + code: callback.code ?? null, + error: callback.error ?? null, + state: callback.state ?? null, + }; + }, + cancel: vi.fn(), + }; +} + +const exchangeOk = async () => ({ + key: "sk-orca-from-pkce", + userId: "u-1", + grantedScope: "api", +}); + +describe("createOrcaPkceSource", () => { + it("is a second, independently usable adapter on the same seam", () => { + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "loopback", + }); + expect(source.method).toBe("orcarouter-oauth"); + expect(source.label).toBe("OrcaRouter - Auth"); + }); + + it("completes authorize -> callback -> exchange -> credential on loopback", async () => { + const attempt = { + codeVerifier: "v", + codeChallenge: "c", + state: "expected-state", + }; + const receiver = receiverFor({ + code: "the-code", + state: "expected-state", + }); + const exchange = vi.fn(exchangeOk); + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "loopback", + createCodeReceiver: async () => receiver, + createAttempt: async () => attempt, + exchange, + }); + + await expect(source.acquire()).resolves.toEqual({ + apiKey: "sk-orca-from-pkce", + method: "orcarouter-oauth", + grantedScope: "api", + accountId: "u-1", + }); + expect(exchange).toHaveBeenCalledWith( + expect.objectContaining({ code: "the-code", attempt }), + ); + }); + + it("builds the authorize URL on the auth origin and opens it", async () => { + const openBrowser = vi.fn(); + const attempt = { + codeVerifier: "v", + codeChallenge: "the-challenge", + state: "the-state", + }; + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "loopback", + createCodeReceiver: async () => + receiverFor({ code: "c", state: "the-state" }), + createAttempt: async () => attempt, + exchange: exchangeOk, + openBrowser, + }); + await source.acquire(); + const url = new URL(openBrowser.mock.calls[0]![0] as string); + expect(url.origin).toBe("https://www.orcarouter.ai"); + expect(url.pathname).toBe("/auth"); + expect(url.searchParams.get("code_challenge")).toBe("the-challenge"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(openBrowser.mock.calls[0]![0]).not.toContain("v"); + }); + + it("rejects a state mismatch before touching the code and never exchanges", async () => { + const exchange = vi.fn(exchangeOk); + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "loopback", + createCodeReceiver: async () => + receiverFor({ code: "attacker-code", state: "wrong-state" }), + createAttempt: async () => ({ + codeVerifier: "v", + codeChallenge: "c", + state: "expected-state", + }), + exchange, + }); + const error = await source + .acquire() + .catch((e: unknown) => e as OrcaAuthError); + expect(error.code).toBe("state_mismatch"); + expect(exchange).not.toHaveBeenCalled(); + }); + + it("treats a missing state as a mismatch", async () => { + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "loopback", + createCodeReceiver: async () => receiverFor({ code: "c" }), + createAttempt: async () => ({ + codeVerifier: "v", + codeChallenge: "c", + state: "expected-state", + }), + exchange: exchangeOk, + }); + await expect(source.acquire()).rejects.toMatchObject({ + code: "state_mismatch", + }); + }); + + it("reports a denial as a terminal error after the state check", async () => { + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "loopback", + createCodeReceiver: async () => + receiverFor({ error: "access_denied", state: "s" }), + createAttempt: async () => ({ + codeVerifier: "v", + codeChallenge: "c", + state: "s", + }), + exchange: exchangeOk, + }); + const error = await source + .acquire() + .catch((e: unknown) => e as OrcaAuthError); + expect(error.code).toBe("denied"); + expect(error.message).toContain("access_denied"); + }); + + it("releases the listener when the wait fails", async () => { + const cancel = vi.fn(); + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "loopback", + createCodeReceiver: async () => ({ + callbackUrl: "http://127.0.0.1:1/cb", + waitForCallback: async () => { + throw new Error("socket closed"); + }, + cancel, + }), + createAttempt: async () => ({ + codeVerifier: "v", + codeChallenge: "c", + state: "s", + }), + exchange: exchangeOk, + }); + await expect(source.acquire()).rejects.toMatchObject({ code: "cancelled" }); + expect(cancel).toHaveBeenCalled(); + }); + + it("runs the out-of-band flow with callback_url=oob", async () => { + const exchange = vi.fn(exchangeOk); + let seenUrl = ""; + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "oob", + createAttempt: async () => ({ + codeVerifier: "v", + codeChallenge: "c", + state: "s", + }), + requestCode: async (url) => { + seenUrl = url; + return " pasted-code "; + }, + exchange, + }); + await expect(source.acquire()).resolves.toMatchObject({ + method: "orcarouter-oauth", + }); + const parsed = new URL(seenUrl); + expect(parsed.searchParams.get("callback_url")).toBe("oob"); + expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); + expect(exchange).toHaveBeenCalledWith( + expect.objectContaining({ code: "pasted-code" }), + ); + }); + + it("treats an empty pasted code as a cancellation, not an exchange", async () => { + const exchange = vi.fn(exchangeOk); + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "oob", + createAttempt: async () => ({ + codeVerifier: "v", + codeChallenge: "c", + state: "s", + }), + requestCode: async () => " ", + exchange, + }); + await expect(source.acquire()).rejects.toMatchObject({ code: "cancelled" }); + expect(exchange).not.toHaveBeenCalled(); + }); + + it("uses a fresh verifier and state on every attempt", async () => { + const seen: string[] = []; + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "oob", + requestCode: async () => "c", + exchange: async (options) => { + seen.push(options.attempt.codeVerifier); + return { key: "sk-orca-x", userId: null, grantedScope: "api" }; + }, + }); + await source.acquire(); + await source.acquire(); + expect(new Set(seen).size).toBe(2); + }); + + it("never places the verifier in the authorize URL or an error message", async () => { + const urls: string[] = []; + const source = createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "oob", + requestCode: async (url) => { + urls.push(url); + throw new OrcaAuthError("denied", "declined"); + }, + exchange: exchangeOk, + }); + const error = await source.acquire().catch((e: unknown) => e as Error); + const attemptVerifier = new URL(urls[0]!).searchParams.get( + "code_challenge", + )!; + expect(urls[0]).not.toContain("code_verifier"); + expect(error.message).not.toContain(attemptVerifier); + }); +}); + +describe("credential seam equivalence", () => { + const sources: OrcaCredentialSource[] = [ + createOrcaApiKeySource("sk-orca-pasted-key"), + createOrcaPkceSource({ + origins, + appName: "json-render", + flow: "oob", + requestCode: async () => "code", + exchange: async () => ({ + key: "sk-orca-pkce-key", + userId: "u-1", + grantedScope: "api", + }), + }), + ]; + + it("yields one credential shape from both adapters", async () => { + const results = await Promise.all(sources.map((s) => s.acquire())); + for (const result of results) { + expect(Object.keys(result).sort()).toEqual([ + "accountId", + "apiKey", + "grantedScope", + "method", + ]); + expect(typeof result.apiKey).toBe("string"); + expect(result.apiKey.startsWith(ORCA_API_KEY_PREFIX)).toBe(true); + } + expect(results.map((r) => r.method)).toEqual([ + "api_key", + "orcarouter-oauth", + ]); + }); + + it("downstream consumers never learn which adapter produced the credential", async () => { + const catalog = orcarouterSeedCatalog("test"); + for (const source of sources) { + const credential: OrcaCredentialResult = await source.acquire(); + const store = new OrcaCredentialStore(); + store.setCredential(credential); + const usable = store.getUsableCredential(); + expect(usable).not.toBeNull(); + + // Model discovery consumes only the key, never the method. + const options = { capability: "chat" as const }; + const forKeyA = selectOrcaModels(catalog, options).map((m) => m.id); + const forKeyB = selectOrcaModels(catalog, options).map((m) => m.id); + expect(forKeyA).toEqual(forKeyB); + expect( + usable!.method === "api_key" || usable!.method === "orcarouter-oauth", + ).toBe(true); + } + }); +}); + +describe("classifyOrcaInferenceFailure", () => { + it("treats 401 and 403 as terminal reauthentication", () => { + for (const status of [401, 403]) { + const result = classifyOrcaInferenceFailure(status); + expect(result.terminal).toBe(true); + expect(result.reason).toBeTruthy(); + } + }); + + it("leaves transient statuses non-terminal", () => { + for (const status of [408, 429, 500, 502, 503]) { + expect(classifyOrcaInferenceFailure(status)).toEqual({ + terminal: false, + reason: null, + }); + } + }); + + it("describes a revoked key as needing a new connection, not a refresh", () => { + const reason = classifyOrcaInferenceFailure(401).reason!; + expect(reason).toMatch(/Reconnect/); + expect(reason.toLowerCase()).not.toContain("refresh"); + }); +}); + +describe("OrcaCredentialStore", () => { + const result = ( + method: "api_key" | "orcarouter-oauth", + accountId: string | null, + ): OrcaCredentialResult => ({ + apiKey: `sk-orca-${accountId ?? "anon"}-${method}`, + method, + grantedScope: "api", + accountId, + }); + + it("installs credentials from either source and bumps the generation", () => { + const store = new OrcaCredentialStore(); + expect(store.isConnected()).toBe(false); + const first = store.setCredential(result("api_key", "u-1")); + const second = store.setCredential(result("orcarouter-oauth", "u-1")); + expect(second.generation).toBe(first.generation + 1); + expect(store.isConnected()).toBe(true); + expect(store.needsReauth).toBe(false); + }); + + it("marks only the exact account generation that was rejected", () => { + const store = new OrcaCredentialStore(); + const first = store.setCredential(result("api_key", "u-1")); + // A newer login replaces the credential while the old request is in flight. + store.setCredential(result("orcarouter-oauth", "u-1")); + expect(store.markNeedsReauth("u-1", first.generation, "revoked")).toBe( + false, + ); + expect(store.needsReauth).toBe(false); + expect(store.isConnected()).toBe(true); + }); + + it("does not mark a different account for reauthentication", () => { + const store = new OrcaCredentialStore(); + const credential = store.setCredential(result("api_key", "u-1")); + expect(store.markNeedsReauth("u-2", credential.generation, "revoked")).toBe( + false, + ); + expect(store.needsReauth).toBe(false); + }); + + it("enters needsReauth on 401 and keeps the stored secret", () => { + const store = new OrcaCredentialStore(); + const credential = store.setCredential(result("orcarouter-oauth", "u-1")); + expect(store.recordInferenceStatus("u-1", credential.generation, 401)).toBe( + true, + ); + expect(store.needsReauth).toBe(true); + expect(store.isConnected()).toBe(false); + expect(store.getUsableCredential()).toBeNull(); + // The secret is retained so a transient failure is not irreversible. + expect(store.getCredential()?.apiKey).toBe(credential.apiKey); + expect(store.getCredential()?.reauthReason).toBeTruthy(); + }); + + it("ignores a non-terminal status", () => { + const store = new OrcaCredentialStore(); + const credential = store.setCredential(result("api_key", "u-1")); + expect(store.recordInferenceStatus("u-1", credential.generation, 429)).toBe( + false, + ); + expect(store.isConnected()).toBe(true); + }); + + it("never attempts a refresh: a new login is a new credential, not a rotation", () => { + const store = new OrcaCredentialStore(); + const credential = store.setCredential(result("orcarouter-oauth", "u-1")); + store.recordInferenceStatus("u-1", credential.generation, 401); + const fresh = store.setCredential(result("orcarouter-oauth", "u-1")); + expect(fresh.generation).toBeGreaterThan(credential.generation); + expect(store.needsReauth).toBe(false); + expect(store.isConnected()).toBe(true); + }); + + it("clears on request", () => { + const store = new OrcaCredentialStore(); + store.setCredential(result("api_key", "u-1")); + store.clear(); + expect(store.getCredential()).toBeNull(); + expect(store.isConnected()).toBe(false); + }); + + it("round-trips through serialization without dropping the generation", () => { + const store = new OrcaCredentialStore(); + const credential = store.setCredential(result("orcarouter-oauth", "u-1")); + const restored = deserializeOrcaCredentialStore( + serializeOrcaCredentialStore(store), + ); + expect(restored.getCredential()).toEqual(credential); + expect(restored.isConnected()).toBe(true); + // A restored credential still refuses a stale generation. + expect( + restored.markNeedsReauth("u-1", credential.generation - 1, "revoked"), + ).toBe(false); + expect( + restored.markNeedsReauth("u-1", credential.generation, "revoked"), + ).toBe(true); + }); + + it.each([ + ["null", null], + ["a non-object", 42], + ["a missing credential", {}], + ["a credential with no key", { credential: { generation: 1 } }], + [ + "a credential with a bad generation", + { credential: { apiKey: "k", generation: -1 } }, + ], + ])("restores an empty store from %s", (_label, value) => { + const store = deserializeOrcaCredentialStore(value); + expect(store.getCredential()).toBeNull(); + }); +}); diff --git a/packages/core/src/orcarouter/credential.ts b/packages/core/src/orcarouter/credential.ts new file mode 100644 index 00000000..720a1ed8 --- /dev/null +++ b/packages/core/src/orcarouter/credential.ts @@ -0,0 +1,310 @@ +/** + * OrcaRouter credentials. + * + * Both entry points — a pasted API key and an OAuth 2.0 + PKCE login — produce + * the same thing: a normal, durable OrcaRouter API key. Everything downstream + * (inference, model discovery) consumes `OrcaCredentialResult` and never learns + * where the key came from. + */ + +import { + OrcaAuthError, + buildOrcaAuthorizeUrl, + constantTimeEqual, + createOrcaPkceAttempt, + exchangeOrcaAuthCode, + type OrcaAuthorizationScope, +} from "./pkce"; +import type { OrcarouterOrigins } from "./origins"; + +export type OrcaCredentialMethod = "api_key" | "orcarouter-oauth"; + +export const ORCA_API_KEY_PREFIX = "sk-orca-"; + +/** A credential handed to the transport. Never serialized, never logged. */ +export interface OrcaCredentialResult { + /** The bearer token. A durable key, not a refresh token. */ + readonly apiKey: string; + readonly method: OrcaCredentialMethod; + /** The scope the server reported as granted, when it reported one. */ + readonly grantedScope: string | null; + /** Stable account identifier for the issuing user, when known. */ + readonly accountId: string | null; +} + +export interface OrcaStoredCredential extends OrcaCredentialResult { + /** + * Monotonic counter, incremented on every successful replacement. Used to + * make a `401` from an in-flight request unable to mark a newer credential + * as broken. + */ + readonly generation: number; + readonly status: "active" | "needs_reauth"; + readonly issuedAt: string; + /** Present only when `status` is `needs_reauth`. */ + readonly reauthReason: string | null; +} + +/** Mask a key for display. The plaintext never reaches a UI or a log. */ +export function redactOrcaApiKey(apiKey: string | null | undefined): string { + if (typeof apiKey !== "string" || !apiKey) return ""; + const trimmed = apiKey.trim(); + if (trimmed.length <= 8) return "••••"; + return `${trimmed.slice(0, 8)}••••${trimmed.slice(-4)}`; +} + +export interface OrcaApiKeyValidation { + readonly ok: boolean; + /** User-facing problem description. Never contains the key. */ + readonly problem: string | null; +} + +/** + * Lightweight format check only. An `sk-orca-` prefix is not proof that a key + * is valid; there is no free validation request, so the first real call + * establishes validity. + */ +export function validateOrcaApiKey(apiKey: string): OrcaApiKeyValidation { + const trimmed = apiKey?.trim() ?? ""; + if (!trimmed) { + return { ok: false, problem: "Enter an OrcaRouter API key." }; + } + if (/\s/.test(trimmed)) { + return { + ok: false, + problem: "An OrcaRouter API key cannot contain whitespace.", + }; + } + if (!trimmed.startsWith(ORCA_API_KEY_PREFIX)) { + return { + ok: false, + problem: `An OrcaRouter API key starts with "${ORCA_API_KEY_PREFIX}".`, + }; + } + if (trimmed.length <= ORCA_API_KEY_PREFIX.length) { + return { ok: false, problem: "That OrcaRouter API key is incomplete." }; + } + return { ok: true, problem: null }; +} + +/** + * The credential seam. Adding an authentication method means adding one + * adapter; nothing downstream changes. + */ +export interface OrcaCredentialSource { + readonly method: OrcaCredentialMethod; + readonly label: string; + acquire(signal?: AbortSignal): Promise; +} + +export function createOrcaApiKeySource(apiKey: string): OrcaCredentialSource { + return { + method: "api_key", + label: "OrcaRouter - API", + async acquire() { + const validation = validateOrcaApiKey(apiKey); + if (!validation.ok) { + throw new OrcaAuthError( + "invalid_response", + validation.problem ?? "That OrcaRouter API key is not usable.", + ); + } + return { + apiKey: apiKey.trim(), + method: "api_key", + grantedScope: null, + accountId: null, + }; + }, + }; +} + +/** What a loopback listener or a prompt hands back to the PKCE adapter. */ +export interface OrcaCallbackResult { + readonly code: string | null; + readonly error: string | null; + readonly state: string | null; +} + +export interface OrcaCodeReceiver { + /** The `callback_url` to send to the consent screen. */ + readonly callbackUrl: string; + waitForCallback(): Promise; + /** Release the listener and any pending wait. Idempotent. */ + cancel(): void; +} + +export interface OrcaPkceSourceDependencies { + readonly origins: OrcarouterOrigins; + /** Shown on the consent screen as a claim by the requesting app. */ + readonly appName: string; + /** + * `loopback` opens a browser and receives the code on `127.0.0.1`; + * `oob` displays the authorize URL and reads the code back from the user. + */ + readonly flow: "loopback" | "oob"; + /** Required for `loopback`. */ + readonly createCodeReceiver?: () => Promise; + /** Required for `oob`. Receives the authorize URL, resolves with the code. */ + readonly requestCode?: ( + authorizeUrl: string, + signal?: AbortSignal, + ) => Promise; + readonly openBrowser?: (url: string) => void; + readonly fetch?: typeof globalThis.fetch; + readonly scope?: OrcaAuthorizationScope; + readonly acceptedScopes?: readonly string[]; + readonly loginHint?: string; + readonly workspaceHint?: string; + /** Test seam; production callers use the default. */ + readonly createAttempt?: typeof createOrcaPkceAttempt; + readonly buildAuthorizeUrl?: typeof buildOrcaAuthorizeUrl; + readonly exchange?: typeof exchangeOrcaAuthCode; +} + +/** + * The OAuth 2.0 + PKCE adapter. It never holds a client secret and never needs + * a pre-registered redirect URI. + */ +export function createOrcaPkceSource( + dependencies: OrcaPkceSourceDependencies, +): OrcaCredentialSource { + return { + method: "orcarouter-oauth", + label: "OrcaRouter - Auth", + async acquire(signal) { + const createAttempt = dependencies.createAttempt ?? createOrcaPkceAttempt; + const buildUrl = dependencies.buildAuthorizeUrl ?? buildOrcaAuthorizeUrl; + const exchange = dependencies.exchange ?? exchangeOrcaAuthCode; + + // A fresh verifier and state for every attempt. + const attempt = await createAttempt(); + + let receiver: OrcaCodeReceiver | null = null; + let code: string; + if (dependencies.flow === "loopback") { + if (!dependencies.createCodeReceiver) { + throw new OrcaAuthError( + "invalid_response", + "The loopback flow needs a code receiver.", + ); + } + receiver = await dependencies.createCodeReceiver(); + const authorizeUrl = buildUrl({ + origins: dependencies.origins, + callbackUrl: receiver.callbackUrl, + attempt, + appName: dependencies.appName, + scope: dependencies.scope, + loginHint: dependencies.loginHint, + workspaceHint: dependencies.workspaceHint, + }); + dependencies.openBrowser?.(authorizeUrl); + let callback: OrcaCallbackResult; + try { + callback = await receiver.waitForCallback(); + } catch (error) { + receiver.cancel(); + if (error instanceof OrcaAuthError) throw error; + throw new OrcaAuthError( + "cancelled", + "OrcaRouter authorization was cancelled.", + ); + } + // The state check is the only thing standing between this listener and + // a code somebody else's page dropped on it. Compare before anything + // else, and before the code is touched. + if ( + callback.state === null || + !constantTimeEqual(callback.state, attempt.state) + ) { + throw new OrcaAuthError( + "state_mismatch", + "The OrcaRouter authorization response did not match this request. Nothing was stored.", + ); + } + if (callback.error) { + throw new OrcaAuthError( + "denied", + `OrcaRouter authorization was declined (${callback.error}).`, + ); + } + if (!callback.code) { + throw new OrcaAuthError( + "invalid_response", + "The OrcaRouter authorization response did not include a code.", + ); + } + code = callback.code; + } else { + if (!dependencies.requestCode) { + throw new OrcaAuthError( + "invalid_response", + "The out-of-band flow needs a way to read the code back.", + ); + } + const authorizeUrl = buildUrl({ + origins: dependencies.origins, + callbackUrl: "oob", + attempt, + appName: dependencies.appName, + scope: dependencies.scope, + loginHint: dependencies.loginHint, + workspaceHint: dependencies.workspaceHint, + }); + dependencies.openBrowser?.(authorizeUrl); + const pasted = await dependencies.requestCode(authorizeUrl, signal); + const trimmed = pasted?.trim() ?? ""; + if (!trimmed) { + throw new OrcaAuthError( + "cancelled", + "No OrcaRouter authorization code was entered.", + ); + } + code = trimmed; + } + + const result = await exchange({ + origins: dependencies.origins, + code, + attempt, + fetch: dependencies.fetch, + signal, + acceptedScopes: dependencies.acceptedScopes, + }); + + return { + apiKey: result.key, + method: "orcarouter-oauth", + grantedScope: result.grantedScope, + accountId: result.userId, + }; + }, + }; +} + +/** + * Classify an inference failure. A `401` is terminal: the credential was + * revoked or is invalid, and the answer is to re-run the connect flow, never to + * attempt a refresh — OrcaRouter issues durable keys with no refresh grant. + */ +export function classifyOrcaInferenceFailure(status: number): { + readonly terminal: boolean; + readonly reason: string | null; +} { + if (status === 401) { + return { + terminal: true, + reason: + "OrcaRouter rejected this key. It may have been revoked. Reconnect with OrcaRouter to issue a new key.", + }; + } + if (status === 403) { + return { + terminal: true, + reason: + "OrcaRouter denied this key access to the requested resource. Check the workspace role that granted it.", + }; + } + return { terminal: false, reason: null }; +} diff --git a/packages/core/src/orcarouter/index.ts b/packages/core/src/orcarouter/index.ts new file mode 100644 index 00000000..c1f54d97 --- /dev/null +++ b/packages/core/src/orcarouter/index.ts @@ -0,0 +1,106 @@ +/** + * OrcaRouter as a first-class provider for json-render. + * + * OrcaRouter is an OpenAI-compatible AI gateway that routes many providers + * behind one endpoint. This module is the reusable seam: origin resolution, + * the two credential sources (a pasted API key and an OAuth 2.0 + PKCE login), + * the generation-safe credential store, and live model discovery. + * + * The inference transport itself is supplied by the host through + * `@ai-sdk/openai-compatible`, so this package stays dependency-light. + */ + +export { + DEFAULT_ORCAROUTER_API_BASE_URL, + DEFAULT_ORCAROUTER_AUTH_BASE_URL, + ORCAROUTER_AUTHORIZE_PATH, + ORCAROUTER_CHAT_COMPLETIONS_PATH, + ORCAROUTER_DEVICE_CODE_PATH, + ORCAROUTER_DEVICE_TOKEN_PATH, + ORCAROUTER_EXCHANGE_PATH, + ORCAROUTER_KEY_DASHBOARD_URL, + ORCAROUTER_MODELS_PATH, + isLoopbackHostname, + orcarouterAuthorizeUrl, + orcarouterChatCompletionsUrl, + orcarouterExchangeUrl, + orcarouterModelsUrl, + readOrcarouterOriginOverrides, + resolveOrcarouterOrigins, + resolveOrcarouterOriginsFromEnv, + validateCallbackUrl, +} from "./origins"; +export type { OrcarouterOriginOverrides, OrcarouterOrigins } from "./origins"; + +export { + ORCAROUTER_UNSUPPORTED_FLOWS, + OrcaAuthError, + base64UrlEncode, + buildOrcaAuthorizeUrl, + constantTimeEqual, + createOrcaPkceAttempt, + exchangeOrcaAuthCode, +} from "./pkce"; +export type { + OrcaAuthErrorCode, + OrcaAuthorizationScope, + OrcaAuthorizeUrlOptions, + OrcaExchangeOptions, + OrcaExchangeResult, + OrcaPkceAttempt, +} from "./pkce"; + +export { + ORCAROUTER_SEED_MODELS, + OrcaCatalogError, + DEFAULT_CATALOG_TIMEOUT_MS, + MAX_CATALOG_BYTES, + MAX_CATALOG_MODELS, + fetchOrcaCatalog, + isOrcaModelCompatible, + modelSupportsCapability, + modelSupportsInputModality, + orcaCatalogUrl, + orcarouterSeedCatalog, + parseOrcaCatalogPayload, + parseOrcaModelRecord, + selectOrcaModels, +} from "./catalog"; +export type { + FetchOrcaCatalogOptions, + OrcaCapability, + OrcaCatalog, + OrcaInputModality, + OrcaModel, + OrcaModelArchitecture, + SelectOrcaModelsOptions, +} from "./catalog"; + +export { + ORCA_API_KEY_PREFIX, + classifyOrcaInferenceFailure, + createOrcaApiKeySource, + createOrcaPkceSource, + redactOrcaApiKey, + validateOrcaApiKey, +} from "./credential"; +export type { + OrcaApiKeyValidation, + OrcaCallbackResult, + OrcaCodeReceiver, + OrcaCredentialMethod, + OrcaCredentialResult, + OrcaCredentialSource, + OrcaPkceSourceDependencies, + OrcaStoredCredential, +} from "./credential"; + +export { + OrcaCredentialStore, + deserializeOrcaCredentialStore, + serializeOrcaCredentialStore, +} from "./credential-store"; +export type { + OrcaCredentialStoreSnapshot, + SerializedOrcaCredentialStore, +} from "./credential-store"; diff --git a/packages/core/src/orcarouter/origins.test.ts b/packages/core/src/orcarouter/origins.test.ts new file mode 100644 index 00000000..71a7cb7a --- /dev/null +++ b/packages/core/src/orcarouter/origins.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { + DEFAULT_ORCAROUTER_API_BASE_URL, + DEFAULT_ORCAROUTER_AUTH_BASE_URL, + isLoopbackHostname, + orcarouterAuthorizeUrl, + orcarouterChatCompletionsUrl, + orcarouterExchangeUrl, + orcarouterModelsUrl, + resolveOrcarouterOrigins, + validateCallbackUrl, +} from "./origins"; + +describe("resolveOrcarouterOrigins", () => { + it("defaults auth to www and inference to api, and never derives one from the other", () => { + const origins = resolveOrcarouterOrigins(); + expect(origins.authBaseUrl).toBe(DEFAULT_ORCAROUTER_AUTH_BASE_URL); + expect(origins.apiBaseUrl).toBe(DEFAULT_ORCAROUTER_API_BASE_URL); + expect(orcarouterAuthorizeUrl(origins)).toBe( + "https://www.orcarouter.ai/auth", + ); + expect(orcarouterExchangeUrl(origins)).toBe( + "https://www.orcarouter.ai/api/v1/auth/keys", + ); + expect(orcarouterModelsUrl(origins)).toBe( + "https://api.orcarouter.ai/v1/models", + ); + expect(orcarouterChatCompletionsUrl(origins)).toBe( + "https://api.orcarouter.ai/v1/chat/completions", + ); + }); + + it("never produces the inference origin carrying an auth path", () => { + const origins = resolveOrcarouterOrigins(); + expect(orcarouterExchangeUrl(origins)).not.toContain("api.orcarouter.ai"); + expect(orcarouterExchangeUrl(origins)).not.toBe( + "https://api.orcarouter.ai/v1/auth/keys", + ); + expect(orcarouterModelsUrl(origins)).not.toContain("www.orcarouter.ai"); + }); + + it("uses a shared self-hosted base for both roles", () => { + const origins = resolveOrcarouterOrigins({ + baseUrl: "https://orca.internal.example", + }); + expect(origins.authBaseUrl).toBe("https://orca.internal.example"); + expect(origins.apiBaseUrl).toBe("https://orca.internal.example"); + }); + + it("lets explicit per-role overrides win over the shared base", () => { + const origins = resolveOrcarouterOrigins({ + baseUrl: "https://shared.example", + authBaseUrl: "https://auth.example", + apiBaseUrl: "https://relay.example", + }); + expect(origins.authBaseUrl).toBe("https://auth.example"); + expect(origins.apiBaseUrl).toBe("https://relay.example"); + expect(orcarouterExchangeUrl(origins)).toBe( + "https://auth.example/api/v1/auth/keys", + ); + expect(orcarouterModelsUrl(origins)).toBe( + "https://relay.example/v1/models", + ); + }); + + it("rejects plain http for a non-loopback origin", () => { + expect(() => + resolveOrcarouterOrigins({ baseUrl: "http://orca.example" }), + ).toThrow(/https unless it points at loopback/); + expect(() => + resolveOrcarouterOrigins({ apiBaseUrl: "http://10.0.0.4:8080" }), + ).toThrow(/https unless it points at loopback/); + }); + + it("permits plain http on loopback for local development", () => { + const origins = resolveOrcarouterOrigins({ + baseUrl: "http://127.0.0.1:8080", + }); + expect(origins.authBaseUrl).toBe("http://127.0.0.1:8080"); + expect(origins.apiBaseUrl).toBe("http://127.0.0.1:8080"); + }); + + it.each([ + ["not-a-url", /not a valid absolute URL/], + ["ftp://orca.example", /must use http or https/], + ["https://user:pass@orca.example", /must not contain userinfo/], + ["https://orca.example?a=1", /must not contain a query or fragment/], + ["https://orca.example#frag", /must not contain a query or fragment/], + ])("rejects %s", (value, pattern) => { + expect(() => resolveOrcarouterOrigins({ baseUrl: value })).toThrow(pattern); + }); + + it("falls back to the defaults when an override is empty or whitespace", () => { + for (const value of ["", " "]) { + const origins = resolveOrcarouterOrigins({ baseUrl: value }); + expect(origins.authBaseUrl).toBe(DEFAULT_ORCAROUTER_AUTH_BASE_URL); + expect(origins.apiBaseUrl).toBe(DEFAULT_ORCAROUTER_API_BASE_URL); + } + }); + + it("strips a trailing slash so paths never double up", () => { + const origins = resolveOrcarouterOrigins({ + baseUrl: "https://orca.example/", + }); + expect(orcarouterModelsUrl(origins)).toBe("https://orca.example/v1/models"); + }); +}); + +describe("isLoopbackHostname", () => { + it.each(["localhost", "127.0.0.1", "::1", "[::1]", "LOCALHOST"])( + "accepts %s", + (host) => { + expect(isLoopbackHostname(host)).toBe(true); + }, + ); + + it.each(["orca.example", "127.0.0.1.evil.example", "0.0.0.0"])( + "rejects %s", + (host) => { + expect(isLoopbackHostname(host)).toBe(false); + }, + ); +}); + +describe("validateCallbackUrl", () => { + it("accepts the literal oob marker", () => { + expect(validateCallbackUrl("oob")).toBe("oob"); + }); + + it("accepts http on loopback and https on any host", () => { + expect(validateCallbackUrl("http://127.0.0.1:51733/cb")).toContain( + "127.0.0.1:51733", + ); + expect(validateCallbackUrl("http://localhost:1234/cb")).toContain( + "localhost:1234", + ); + expect(validateCallbackUrl("https://tool.example/cb")).toContain( + "https://tool.example", + ); + }); + + it.each([ + ["http://tool.example/cb", /only allowed for localhost/], + ["https://user:pass@tool.example/cb", /must not contain userinfo/], + ["https://tool.example/cb#frag", /must not contain a fragment/], + ["/cb", /not a valid absolute URL/], + ["ftp://tool.example/cb", /must use https/], + ])("rejects %s", (value, pattern) => { + expect(() => validateCallbackUrl(value)).toThrow(pattern); + }); +}); diff --git a/packages/core/src/orcarouter/origins.ts b/packages/core/src/orcarouter/origins.ts new file mode 100644 index 00000000..cdbc69dd --- /dev/null +++ b/packages/core/src/orcarouter/origins.ts @@ -0,0 +1,177 @@ +/** + * OrcaRouter origin resolution. + * + * Authentication and inference live on two different public origins. They must + * never be derived from one another by swapping a hostname or appending `/v1`: + * `https://api.orcarouter.ai/v1/auth/keys` is a 404. + */ + +export const DEFAULT_ORCAROUTER_AUTH_BASE_URL = "https://www.orcarouter.ai"; +export const DEFAULT_ORCAROUTER_API_BASE_URL = "https://api.orcarouter.ai"; + +/** Consent screen. Opened in a browser; not an API. */ +export const ORCAROUTER_AUTHORIZE_PATH = "/auth"; +/** Auth-code exchange. On the auth origin, not under `/v1`. */ +export const ORCAROUTER_EXCHANGE_PATH = "/api/v1/auth/keys"; +export const ORCAROUTER_DEVICE_CODE_PATH = "/api/v1/auth/device/code"; +export const ORCAROUTER_DEVICE_TOKEN_PATH = "/api/v1/auth/device/token"; +/** Model catalog, on the inference origin. */ +export const ORCAROUTER_MODELS_PATH = "/v1/models"; +export const ORCAROUTER_CHAT_COMPLETIONS_PATH = "/v1/chat/completions"; + +export const ORCAROUTER_KEY_DASHBOARD_URL = + "https://www.orcarouter.ai/console/authorized-apps"; + +export interface OrcarouterOrigins { + /** Origin used for `/auth` and the code exchange. */ + readonly authBaseUrl: string; + /** Origin used for `/v1/models` and inference. */ + readonly apiBaseUrl: string; +} + +export interface OrcarouterOriginOverrides { + /** One origin serving both roles, for self-hosted deployments. */ + readonly baseUrl?: string; + /** Explicit override; wins over `baseUrl`. */ + readonly authBaseUrl?: string; + /** Explicit override; wins over `baseUrl`. */ + readonly apiBaseUrl?: string; +} + +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +export function isLoopbackHostname(hostname: string): boolean { + return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase()); +} + +function normalizeBaseUrl(value: string, role: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`The OrcaRouter ${role} base URL must not be empty.`); + } + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error( + `The OrcaRouter ${role} base URL is not a valid absolute URL.`, + ); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(`The OrcaRouter ${role} base URL must use http or https.`); + } + // Plain HTTP is only acceptable when the target is the local machine. + if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) { + throw new Error( + `The OrcaRouter ${role} base URL must use https unless it points at loopback.`, + ); + } + if (url.username || url.password) { + throw new Error( + `The OrcaRouter ${role} base URL must not contain userinfo.`, + ); + } + if (url.search || url.hash) { + throw new Error( + `The OrcaRouter ${role} base URL must not contain a query or fragment.`, + ); + } + const pathname = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${pathname}`; +} + +/** + * Resolve the auth and inference origins. Explicit per-role overrides take + * precedence over the shared self-hosted base, which in turn falls back to the + * public defaults. + */ +export function resolveOrcarouterOrigins( + overrides: OrcarouterOriginOverrides = {}, +): OrcarouterOrigins { + const shared = overrides.baseUrl?.trim(); + const auth = + overrides.authBaseUrl?.trim() || shared || DEFAULT_ORCAROUTER_AUTH_BASE_URL; + const api = + overrides.apiBaseUrl?.trim() || shared || DEFAULT_ORCAROUTER_API_BASE_URL; + return { + authBaseUrl: normalizeBaseUrl(auth, "auth"), + apiBaseUrl: normalizeBaseUrl(api, "inference"), + }; +} + +/** Read overrides from the process environment, if present. */ +export function readOrcarouterOriginOverrides( + env: Record, +): OrcarouterOriginOverrides { + return { + baseUrl: env.ORCA_BASE_URL, + authBaseUrl: env.ORCA_AUTH_BASE_URL, + apiBaseUrl: env.ORCA_API_BASE_URL, + }; +} + +/** Read overrides from `process.env` without requiring Node types. */ +export function resolveOrcarouterOriginsFromEnv(): OrcarouterOrigins { + const env = ( + globalThis as { process?: { env?: Record } } + ).process?.env; + return resolveOrcarouterOrigins( + env ? readOrcarouterOriginOverrides(env) : {}, + ); +} + +function joinUrl(baseUrl: string, path: string): string { + return `${baseUrl}${path}`; +} + +export function orcarouterAuthorizeUrl(origins: OrcarouterOrigins): string { + return joinUrl(origins.authBaseUrl, ORCAROUTER_AUTHORIZE_PATH); +} + +export function orcarouterExchangeUrl(origins: OrcarouterOrigins): string { + return joinUrl(origins.authBaseUrl, ORCAROUTER_EXCHANGE_PATH); +} + +export function orcarouterModelsUrl(origins: OrcarouterOrigins): string { + return joinUrl(origins.apiBaseUrl, ORCAROUTER_MODELS_PATH); +} + +export function orcarouterChatCompletionsUrl( + origins: OrcarouterOrigins, +): string { + return joinUrl(origins.apiBaseUrl, ORCAROUTER_CHAT_COMPLETIONS_PATH); +} + +/** + * Validate a `callback_url` the way the consent endpoint does, before a browser + * is ever opened, so a doomed attempt fails in-process. + */ +export function validateCallbackUrl(callbackUrl: string): string { + if (callbackUrl === "oob") return callbackUrl; + let url: URL; + try { + url = new URL(callbackUrl); + } catch { + throw new Error("The OAuth callback URL is not a valid absolute URL."); + } + if (url.username || url.password) { + throw new Error("The OAuth callback URL must not contain userinfo."); + } + if (url.hash) { + throw new Error("The OAuth callback URL must not contain a fragment."); + } + if (url.protocol === "http:") { + if (!isLoopbackHostname(url.hostname)) { + throw new Error( + "An http OAuth callback URL is only allowed for localhost, 127.0.0.1 or [::1].", + ); + } + return url.toString(); + } + if (url.protocol !== "https:") { + throw new Error( + "The OAuth callback URL must use https, or http on loopback.", + ); + } + return url.toString(); +} diff --git a/packages/core/src/orcarouter/pkce.test.ts b/packages/core/src/orcarouter/pkce.test.ts new file mode 100644 index 00000000..2a7d6fcd --- /dev/null +++ b/packages/core/src/orcarouter/pkce.test.ts @@ -0,0 +1,348 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { + OrcaAuthError, + base64UrlEncode, + buildOrcaAuthorizeUrl, + constantTimeEqual, + createOrcaPkceAttempt, + exchangeOrcaAuthCode, +} from "./pkce"; +import { resolveOrcarouterOrigins } from "./origins"; + +const origins = resolveOrcarouterOrigins(); +const originsFor = (base: string) => + resolveOrcarouterOrigins({ authBaseUrl: base, apiBaseUrl: base }); + +describe("base64UrlEncode", () => { + it("produces unpadded base64url", () => { + const encoded = base64UrlEncode(new Uint8Array([251, 255, 191, 0, 1])); + expect(encoded).not.toMatch(/[+/=]/); + expect(encoded).toBe("-_-_AAE"); + }); + + it("handles every input length without padding", () => { + for (let length = 0; length <= 8; length += 1) { + const encoded = base64UrlEncode(new Uint8Array(length).fill(255)); + expect(encoded).not.toContain("="); + } + }); +}); + +describe("createOrcaPkceAttempt", () => { + it("derives an S256 challenge from the verifier", async () => { + const attempt = await createOrcaPkceAttempt(); + const expected = base64UrlEncode( + new Uint8Array( + await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(attempt.codeVerifier), + ), + ), + ); + expect(attempt.codeChallenge).toBe(expected); + expect(attempt.codeChallenge).not.toBe(attempt.codeVerifier); + expect(attempt.codeChallenge).toHaveLength(43); + expect(attempt.codeChallenge).not.toContain("="); + expect(attempt.codeVerifier.length).toBeGreaterThanOrEqual(43); + expect(attempt.state.length).toBeGreaterThanOrEqual(20); + }); + + it("uses a fresh verifier and state for every attempt", async () => { + const attempts = await Promise.all( + Array.from({ length: 16 }, () => createOrcaPkceAttempt()), + ); + expect(new Set(attempts.map((a) => a.codeVerifier)).size).toBe(16); + expect(new Set(attempts.map((a) => a.state)).size).toBe(16); + expect(new Set(attempts.map((a) => a.codeChallenge)).size).toBe(16); + }); + + it("does not derive the verifier from anything guessable", async () => { + const attempt = await createOrcaPkceAttempt(); + expect(attempt.codeVerifier).not.toContain( + String(new Date().getFullYear()), + ); + // 32 random bytes encoded is exactly 43 base64url characters. + expect(attempt.codeVerifier).toHaveLength(43); + }); +}); + +describe("constantTimeEqual", () => { + it("compares equal and unequal values", () => { + expect(constantTimeEqual("abc", "abc")).toBe(true); + expect(constantTimeEqual("abc", "abd")).toBe(false); + expect(constantTimeEqual("abc", "abcd")).toBe(false); + expect(constantTimeEqual("", "")).toBe(true); + }); +}); + +describe("buildOrcaAuthorizeUrl", () => { + const attempt = { + codeVerifier: "verifier-value", + codeChallenge: "challenge-value", + state: "state-value", + }; + + it("targets /auth on the auth origin with S256 and no plain fallback", () => { + const url = new URL( + buildOrcaAuthorizeUrl({ + origins, + callbackUrl: "http://127.0.0.1:51733/cb", + attempt, + appName: "json-render", + }), + ); + expect(url.origin).toBe("https://www.orcarouter.ai"); + expect(url.pathname).toBe("/auth"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toBe("challenge-value"); + expect(url.searchParams.get("state")).toBe("state-value"); + expect(url.searchParams.get("scope")).toBe("api"); + expect(url.searchParams.get("app_name")).toBe("json-render"); + }); + + it("never puts the verifier in the URL", () => { + const url = buildOrcaAuthorizeUrl({ + origins, + callbackUrl: "oob", + attempt, + appName: "json-render", + }); + expect(url).not.toContain(attempt.codeVerifier); + expect(url).not.toContain("code_verifier"); + expect(new URL(url).searchParams.get("callback_url")).toBe("oob"); + }); + + it("sends S256 even on the loopback flow, because the user may pick a shown code", () => { + const url = new URL( + buildOrcaAuthorizeUrl({ + origins, + callbackUrl: "https://tool.example/cb", + attempt, + appName: "json-render", + }), + ); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("passes optional hints through and omits absent ones", () => { + const url = new URL( + buildOrcaAuthorizeUrl({ + origins, + callbackUrl: "oob", + attempt, + appName: "json-render", + loginHint: "user@example.test", + workspaceHint: "ws-1", + prompt: "consent", + scope: "api", + }), + ); + expect(url.searchParams.get("login_hint")).toBe("user@example.test"); + expect(url.searchParams.get("workspace_hint")).toBe("ws-1"); + expect(url.searchParams.get("prompt")).toBe("consent"); + + const bare = new URL( + buildOrcaAuthorizeUrl({ + origins, + callbackUrl: "oob", + attempt, + appName: "json-render", + }), + ); + expect(bare.searchParams.has("login_hint")).toBe(false); + expect(bare.searchParams.has("prompt")).toBe(false); + }); + + it("rejects a callback URL the consent endpoint would reject", () => { + expect(() => + buildOrcaAuthorizeUrl({ + origins, + callbackUrl: "http://tool.example/cb", + attempt, + appName: "json-render", + }), + ).toThrow(/only allowed for localhost/); + }); +}); + +describe("exchangeOrcaAuthCode", () => { + const attempt = { + codeVerifier: "the-verifier", + codeChallenge: "the-challenge", + state: "the-state", + }; + + it("posts the code, verifier and method to the auth origin's exchange path", async () => { + const fetch = vi.fn(async () => + Response.json({ key: "sk-orca-test-key", user_id: "42", scope: "api" }), + ); + const result = await exchangeOrcaAuthCode({ + origins, + code: "the-code", + attempt, + fetch, + }); + + expect(result).toEqual({ + key: "sk-orca-test-key", + userId: "42", + grantedScope: "api", + }); + + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe("https://www.orcarouter.ai/api/v1/auth/keys"); + expect(url).not.toContain("api.orcarouter.ai"); + expect(url).not.toMatch(/^https:\/\/[^/]+\/v1\/auth/); + expect(init?.method).toBe("POST"); + expect(JSON.parse(init!.body as string)).toEqual({ + code: "the-code", + code_verifier: "the-verifier", + code_challenge_method: "S256", + }); + }); + + it("reports a 403 as a terminal exchange failure without echoing the body", async () => { + const fetch = vi.fn(async () => + Response.json( + { error: "invalid_grant", error_description: "sensitive detail" }, + { status: 403 }, + ), + ); + const error = await exchangeOrcaAuthCode({ + origins, + code: "used-code", + attempt, + fetch, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(OrcaAuthError); + expect((error as OrcaAuthError).code).toBe("exchange_failed"); + expect((error as OrcaAuthError).status).toBe(403); + expect((error as Error).message).toContain("invalid_grant"); + expect((error as Error).message).not.toContain("sensitive detail"); + expect((error as Error).message).not.toContain("used-code"); + expect((error as Error).message).not.toContain("the-verifier"); + }); + + it("reports 400, 429 and other statuses distinctly", async () => { + const at = (status: number) => + exchangeOrcaAuthCode({ + origins, + code: "c", + attempt, + fetch: async () => new Response("", { status }), + }).catch((e: unknown) => e as OrcaAuthError); + + await expect(at(400)).resolves.toMatchObject({ + code: "exchange_failed", + status: 400, + }); + await expect(at(429)).resolves.toMatchObject({ + code: "exchange_failed", + status: 429, + }); + const other = await at(500); + expect(other.message).toContain("status 500"); + }); + + it("treats a granted scope narrower than the client needs as unusable", async () => { + const error = await exchangeOrcaAuthCode({ + origins, + code: "c", + attempt, + fetch: async () => + Response.json({ key: "sk-orca-x", scope: "read-only" }), + }).catch((e: unknown) => e as OrcaAuthError); + expect(error.code).toBe("insufficient_scope"); + expect(error.message).toContain("read-only"); + }); + + it("accepts a response with no scope field, and an empty accepted set is honoured", async () => { + const result = await exchangeOrcaAuthCode({ + origins, + code: "c", + attempt, + fetch: async () => Response.json({ key: "sk-orca-x" }), + }); + expect(result.grantedScope).toBeNull(); + expect(result.userId).toBeNull(); + + const strict = await exchangeOrcaAuthCode({ + origins, + code: "c", + attempt, + acceptedScopes: [], + fetch: async () => Response.json({ key: "sk-orca-x", scope: "api" }), + }).catch((e: unknown) => e as OrcaAuthError); + expect(strict.code).toBe("insufficient_scope"); + }); + + it.each([ + ["a missing key", { user_id: "1", scope: "api" }], + ["an empty key", { key: " " }], + ["a non-string key", { key: 123 }], + ])("rejects a malformed success response: %s", async (_label, body) => { + const error = await exchangeOrcaAuthCode({ + origins, + code: "c", + attempt, + fetch: async () => Response.json(body), + }).catch((e: unknown) => e as OrcaAuthError); + expect(error.code).toBe("invalid_response"); + }); + + it("surfaces a transport failure as a network error, not a hang", async () => { + const error = await exchangeOrcaAuthCode({ + origins, + code: "c", + attempt, + fetch: async () => { + throw new TypeError("fetch failed"); + }, + }).catch((e: unknown) => e as OrcaAuthError); + expect(error.code).toBe("network"); + }); + + it("reports cancellation when the caller aborts", async () => { + const controller = new AbortController(); + const error = await exchangeOrcaAuthCode({ + origins, + code: "c", + attempt, + signal: controller.signal, + fetch: async () => { + controller.abort(); + throw new DOMException("aborted", "AbortError"); + }, + }).catch((e: unknown) => e as OrcaAuthError); + expect(error.code).toBe("cancelled"); + }); + + it("rejects an empty code before making a request", async () => { + const fetch = vi.fn(); + const error = await exchangeOrcaAuthCode({ + origins, + code: " ", + attempt, + fetch, + }).catch((e: unknown) => e as OrcaAuthError); + expect(error.code).toBe("invalid_response"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("follows an explicit self-hosted auth origin", async () => { + const selfHosted = originsFor("https://orca.internal.example"); + const fetch = vi.fn(async () => + Response.json({ key: "sk-orca-x", scope: "api" }), + ); + await exchangeOrcaAuthCode({ + origins: selfHosted, + code: "c", + attempt, + fetch, + }); + expect(fetch.mock.calls[0]![0]).toBe( + "https://orca.internal.example/api/v1/auth/keys", + ); + }); +}); diff --git a/packages/core/src/orcarouter/pkce.ts b/packages/core/src/orcarouter/pkce.ts new file mode 100644 index 00000000..22f7ab74 --- /dev/null +++ b/packages/core/src/orcarouter/pkce.ts @@ -0,0 +1,302 @@ +/** + * OAuth 2.0 authorization-code flow with PKCE for OrcaRouter. + * + * Flow A (loopback redirect) and Flow B (out-of-band code) share everything + * except how the code reaches the process. The exchange always presents the + * original verifier; the verifier never leaves the process and is never logged, + * printed, or placed in a URL. + * + * No client secret is involved and no redirect URI is pre-registered. + */ + +import { + ORCAROUTER_AUTHORIZE_PATH, + ORCAROUTER_EXCHANGE_PATH, + validateCallbackUrl, + type OrcarouterOrigins, +} from "./origins"; + +export type OrcaAuthErrorCode = + | "denied" + | "state_mismatch" + | "cancelled" + | "timeout" + | "exchange_failed" + | "invalid_response" + | "insufficient_scope" + | "network"; + +/** + * A terminal, actionable failure. Messages are built only from status codes and + * protocol-level error names; upstream response bodies are never echoed because + * they may contain credential material. + */ +export class OrcaAuthError extends Error { + readonly code: OrcaAuthErrorCode; + readonly status?: number; + + constructor(code: OrcaAuthErrorCode, message: string, status?: number) { + super(message); + this.name = "OrcaAuthError"; + this.code = code; + this.status = status; + } +} + +export interface OrcaPkceAttempt { + /** High-entropy secret. Stays in-process until the exchange. */ + readonly codeVerifier: string; + /** `base64url(sha256(verifier))`, no padding. Safe to put in a URL. */ + readonly codeChallenge: string; + /** Opaque CSRF token, echoed back by the consent screen. */ + readonly state: string; +} + +const BASE64URL_ALPHABET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +/** Unpadded base64url. Implemented locally so no Buffer is required. */ +export function base64UrlEncode(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = bytes[i]!; + const b1 = bytes[i + 1]; + const b2 = bytes[i + 2]; + out += BASE64URL_ALPHABET[b0 >> 2]; + out += BASE64URL_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)]; + if (b1 === undefined) break; + out += BASE64URL_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)]; + if (b2 === undefined) break; + out += BASE64URL_ALPHABET[b2 & 0x3f]; + } + return out; +} + +function randomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(bytes); + return bytes; +} + +async function sha256(value: string): Promise { + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ); + return new Uint8Array(digest); +} + +/** + * Build a fresh attempt. A new verifier and state are generated for every + * authorization attempt from a cryptographic RNG. + */ +export async function createOrcaPkceAttempt(): Promise { + const codeVerifier = base64UrlEncode(randomBytes(32)); + return { + codeVerifier, + codeChallenge: base64UrlEncode(await sha256(codeVerifier)), + state: base64UrlEncode(randomBytes(16)), + }; +} + +/** Length-independent comparison, used for the Flow A `state` check. */ +export function constantTimeEqual(a: string, b: string): boolean { + const left = new TextEncoder().encode(a); + const right = new TextEncoder().encode(b); + let diff = left.length ^ right.length; + const max = Math.max(left.length, right.length); + for (let i = 0; i < max; i += 1) { + diff |= (left[i] ?? 0) ^ (right[i] ?? 0); + } + return diff === 0; +} + +export type OrcaAuthorizationScope = "api" | "connector"; + +export interface OrcaAuthorizeUrlOptions { + readonly origins: OrcarouterOrigins; + /** An absolute URL, or the literal `oob` for the out-of-band flow. */ + readonly callbackUrl: string; + readonly attempt: OrcaPkceAttempt; + readonly appName: string; + /** Defaults to `api`, the scope inference requires. */ + readonly scope?: OrcaAuthorizationScope; + readonly loginHint?: string; + readonly workspaceHint?: string; + readonly prompt?: "consent"; +} + +export function buildOrcaAuthorizeUrl( + options: OrcaAuthorizeUrlOptions, +): string { + const url = new URL(ORCAROUTER_AUTHORIZE_PATH, options.origins.authBaseUrl); + url.searchParams.set( + "callback_url", + validateCallbackUrl(options.callbackUrl), + ); + url.searchParams.set("code_challenge", options.attempt.codeChallenge); + // Always S256: the consent screen lets the user choose "show me a code" even + // on a redirect flow, and a displayed code must not be redeemable by anyone + // who merely saw the authorize URL. + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", options.attempt.state); + url.searchParams.set("app_name", options.appName); + url.searchParams.set("scope", options.scope ?? "api"); + if (options.loginHint) url.searchParams.set("login_hint", options.loginHint); + if (options.workspaceHint) + url.searchParams.set("workspace_hint", options.workspaceHint); + if (options.prompt) url.searchParams.set("prompt", options.prompt); + return url.toString(); +} + +export interface OrcaExchangeResult { + /** A normal, durable OrcaRouter API key. Not a refresh token. */ + readonly key: string; + readonly userId: string | null; + /** The scope that was *granted*, or null when the server did not report one. */ + readonly grantedScope: string | null; +} + +const SAFE_ERROR_NAME = /^[a-z_]{1,40}$/; + +/** Extract only a protocol-level error name; never the rest of the body. */ +function safeErrorName(body: unknown): string | null { + if (typeof body !== "object" || body === null) return null; + const value = (body as Record).error; + return typeof value === "string" && SAFE_ERROR_NAME.test(value) + ? value + : null; +} + +export interface OrcaExchangeOptions { + readonly origins: OrcarouterOrigins; + readonly code: string; + readonly attempt: OrcaPkceAttempt; + readonly fetch?: typeof globalThis.fetch; + readonly signal?: AbortSignal; + /** Scopes this client can actually use. Defaults to `["api"]`. */ + readonly acceptedScopes?: readonly string[]; +} + +/** + * Redeem an auth code at the auth origin's `/api/v1/auth/keys`. Auth codes are + * single-use with a 10 minute TTL. + */ +export async function exchangeOrcaAuthCode( + options: OrcaExchangeOptions, +): Promise { + const fetchImpl = options.fetch ?? globalThis.fetch; + const code = options.code?.trim(); + if (!code) { + throw new OrcaAuthError( + "invalid_response", + "No authorization code was provided.", + ); + } + + let response: Response; + try { + response = await fetchImpl( + `${options.origins.authBaseUrl}${ORCAROUTER_EXCHANGE_PATH}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + code_verifier: options.attempt.codeVerifier, + code_challenge_method: "S256", + }), + signal: options.signal, + cache: "no-store", + }, + ); + } catch (error) { + if (error instanceof OrcaAuthError) throw error; + if (options.signal?.aborted) { + throw new OrcaAuthError( + "cancelled", + "OrcaRouter authorization was cancelled.", + ); + } + throw new OrcaAuthError( + "network", + "Could not reach the OrcaRouter authorization service. Check your network and try again.", + ); + } + + if (!response.ok) { + const body = await response.json().catch(() => null); + const name = safeErrorName(body); + const suffix = name ? ` (${name})` : ""; + if (response.status === 403) { + throw new OrcaAuthError( + "exchange_failed", + `OrcaRouter rejected this authorization code: it is unknown, expired, already used, or the verifier did not match${suffix}. Start a new connection.`, + 403, + ); + } + if (response.status === 400) { + throw new OrcaAuthError( + "exchange_failed", + `OrcaRouter rejected the code challenge method${suffix}. Start a new connection.`, + 400, + ); + } + if (response.status === 429) { + throw new OrcaAuthError( + "exchange_failed", + "Too many OrcaRouter authorizations were started recently. Wait for an existing key or try again later.", + 429, + ); + } + throw new OrcaAuthError( + "exchange_failed", + `OrcaRouter authorization failed with status ${response.status}${suffix}.`, + response.status, + ); + } + + const payload = (await response.json().catch(() => null)) as unknown; + if (typeof payload !== "object" || payload === null) { + throw new OrcaAuthError( + "invalid_response", + "OrcaRouter returned an authorization response that could not be read.", + ); + } + const record = payload as Record; + const key = record.key; + if (typeof key !== "string" || !key.trim()) { + throw new OrcaAuthError( + "invalid_response", + "OrcaRouter returned an authorization response without a usable key.", + ); + } + + const grantedScope = + typeof record.scope === "string" && record.scope.trim() + ? record.scope.trim() + : null; + const accepted = options.acceptedScopes ?? ["api"]; + // A granted scope narrower than what this client needs is not usable. Do not + // treat the requested scope as if it had been granted. + if (grantedScope !== null && !accepted.includes(grantedScope)) { + throw new OrcaAuthError( + "insufficient_scope", + `OrcaRouter granted the "${grantedScope}" scope, which cannot be used for inference. Ask a workspace owner to grant "${accepted.join('" or "')}".`, + ); + } + + return { + key, + userId: typeof record.user_id === "string" ? record.user_id : null, + grantedScope, + }; +} + +/** + * The device grant (Flow C) is a supported protocol but is not implemented by + * this integration: a loopback listener is available on the server that runs + * the connect flow, and the out-of-band flow covers deployments where it is + * not. Exported so callers can detect and explain the gap. + */ +export const ORCAROUTER_UNSUPPORTED_FLOWS = ["device_grant"] as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bd26542..cf8863dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@ai-sdk/gateway': specifier: ^3.0.13 version: 3.0.57(zod@4.3.6) + '@ai-sdk/openai-compatible': + specifier: ^2.0.75 + version: 2.0.75(zod@4.3.6) '@ai-sdk/react': specifier: 3.0.79 version: 3.0.79(react@19.2.3)(zod@4.3.6) @@ -136,7 +139,7 @@ importers: version: 6.0.103(zod@4.3.6) bash-tool: specifier: 1.3.14 - version: 1.3.14(@vercel/sandbox@2.2.0)(ai@6.0.103(zod@4.3.6)) + version: 1.3.14(@vercel/sandbox@2.2.0)(ai@6.0.103(zod@4.3.6))(just-bash@2.14.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -152,6 +155,9 @@ importers: geist: specifier: 1.7.0 version: 1.7.0(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + just-bash: + specifier: ^2.14.5 + version: 2.14.5 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) @@ -390,7 +396,7 @@ importers: version: 2.1.1 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8) + version: 0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(sql.js@1.14.2) lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) @@ -2865,6 +2871,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/openai-compatible@2.0.75': + resolution: {integrity: sha512-W9w3tCoYrevct2Ips2X4EFOow8Kzrg0nqVJi9jm0lHiMlTmTON4nzaCv3Zkzg6eFCJJVH60kd4+t7tHgtOxx9Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.14': resolution: {integrity: sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==} engines: {node: '>=18'} @@ -2889,12 +2901,22 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.51': + resolution: {integrity: sha512-ukLTs9x1Xm6lxSIwbJIxYQxbZHmVeIczNntARZrBcOa6pjdBhqeZnATNnJMHa7M9dZ8Ji5NJbpKpvz7o5XyBtg==} + engines: {node: '>=18.17'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@5.0.0-canary.48': resolution: {integrity: sha512-340rMqAnqHDZOKS9ayrRbNr0/Om+orcopAnqJ7ftfEskxDUCTcudmhAhctFMSQeMzDnKaBPgz+4iff/hn7PkqQ==} engines: {node: '>=22'} peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@3.0.16': + resolution: {integrity: sha512-9Av6kg0t/IN/dcYAAmEJ4B9OPhcEJqwSR+GfHEA8olRkindXpUHadc3p3cyvgFUQFTVm1G5thtsmgZ9yVb2w3A==} + engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} @@ -3579,6 +3601,9 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -3681,11 +3706,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.0': resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} @@ -4833,6 +4858,21 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==} + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==} + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -4956,6 +4996,9 @@ packages: '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/ext-apps@1.2.0': resolution: {integrity: sha512-ijUQJX/FmNq8PWgOLzph/BAfy84sUZxoIRuHzr+F37wYtWjhdl8pliBJybapYolppY+XJ8oqjFZmTOuMqxwbWQ==} peerDependencies: @@ -4989,6 +5032,10 @@ packages: '@cfworker/json-schema': optional: true + '@mongodb-js/zstd@7.0.0': + resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} + engines: {node: '>= 20.19.0'} + '@monogrid/gainmap-js@3.4.0': resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} peerDependencies: @@ -7417,6 +7464,13 @@ packages: vitest: optional: true + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/once@1.1.2': resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} @@ -8566,6 +8620,9 @@ packages: '@internationalized/date': ^3.8.1 svelte: ^5.33.0 + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -8590,10 +8647,6 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} - engines: {node: 18 || 20 || >=22} - brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -8735,6 +8788,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -8884,6 +8940,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -8987,6 +9047,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} @@ -9262,6 +9323,10 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dedent-js@1.0.1: resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} @@ -9811,6 +9876,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -9932,6 +9998,10 @@ packages: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -10167,6 +10237,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -10246,6 +10320,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.3: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} @@ -10357,6 +10434,9 @@ packages: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -10684,6 +10764,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + ink@6.8.0: resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} engines: {node: '>=20'} @@ -11339,6 +11423,10 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + just-bash@2.14.5: + resolution: {integrity: sha512-MCBGnRlDeZ/MM7mcw+ZuSGFMBsggajrmKz6e/hrOAN7syvVZkjiY+Vh2wyCwN/CdcnAX5SxbiQB51n5nrQuX+g==} + hasBin: true + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -12106,9 +12194,9 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} @@ -12140,6 +12228,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -12148,6 +12239,10 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + modern-tar@0.7.7: + resolution: {integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==} + engines: {node: '>=18.0.0'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -12197,6 +12292,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -12332,6 +12430,14 @@ packages: sass: optional: true + node-abi@3.96.0: + resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} + engines: {node: '>=10'} + + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -12345,9 +12451,18 @@ packages: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-liblzma@2.2.0: + resolution: {integrity: sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==} + engines: {node: '>=16.0.0'} + hasBin: true + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -12536,6 +12651,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.7.0: + resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -12774,6 +12892,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -12880,6 +13004,13 @@ packages: queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + + quickjs-emscripten@0.32.0: + resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==} + engines: {node: '>=16.0.0'} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -12908,6 +13039,9 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + re2js@1.3.3: + resolution: {integrity: sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==} + react-confetti-explosion@3.0.3: resolution: {integrity: sha512-ow5ns/1ttzXsIlbbfJmWJNiyQK8lTHBL6lRSUXGaK44K/3NIMngR57Ja96l+D6txTeFhfe0BfXGvORMxhtRDng==} peerDependencies: @@ -13097,6 +13231,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -13431,6 +13569,10 @@ packages: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} + seek-bzip@2.0.0: + resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==} + hasBin: true + selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -13578,6 +13720,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -13607,6 +13755,10 @@ packages: resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} engines: {node: '>=8.0.0'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + solid-js@1.9.11: resolution: {integrity: sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==} @@ -13659,6 +13811,12 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + sql.js@1.14.2: + resolution: {integrity: sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -13834,6 +13992,10 @@ packages: strnum@2.4.0: resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} @@ -13986,6 +14148,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -14112,6 +14281,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -14238,6 +14411,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel-rat@0.1.2: resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} @@ -14275,6 +14451,10 @@ packages: resolution: {integrity: sha512-bkO4AddmDishzJB2ze7aYYPaejMoJVfS0XnaR6RCdXFOY8JGJfQE+l9fKiV7uDPa5Ut44gmOWJL3894CIMeH9g==} hasBin: true + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -14361,6 +14541,10 @@ packages: ufo@1.6.2: resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -14375,6 +14559,10 @@ packages: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} + undici@6.28.1: + resolution: {integrity: sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==} + engines: {node: '>=18.17'} + undici@7.27.2: resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} engines: {node: '>=20.18.1'} @@ -15243,6 +15431,12 @@ snapshots: pkce-challenge: 5.0.1 zod: 4.3.6 + '@ai-sdk/openai-compatible@2.0.75(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@ai-sdk/provider-utils': 4.0.51(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.14(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -15271,6 +15465,14 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.51(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.16 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.1.0 + undici: 6.28.1 + zod: 4.3.6 + '@ai-sdk/provider-utils@5.0.0-canary.48(zod@3.25.76)': dependencies: '@ai-sdk/provider': 4.0.0-canary.18 @@ -15287,6 +15489,10 @@ snapshots: eventsource-parser: 3.1.0 zod: 4.3.6 + '@ai-sdk/provider@3.0.16': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 @@ -16237,6 +16443,8 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@borewit/text-codec@0.2.2': {} + '@braintree/sanitize-url@7.1.2': {} '@chevrotain/types@11.1.2': {} @@ -17628,6 +17836,24 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -17709,7 +17935,7 @@ snapshots: '@mdx-js/mdx': 3.1.1 source-map: 0.7.6 optionalDependencies: - webpack: 5.96.1(esbuild@0.25.0) + webpack: 5.96.1 transitivePeerDependencies: - supports-color @@ -17764,6 +17990,8 @@ snapshots: - bufferutil - utf-8-validate + '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/ext-apps@1.2.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)': dependencies: '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) @@ -17816,6 +18044,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@mongodb-js/zstd@7.0.0': + dependencies: + node-addon-api: 8.9.2 + prebuild-install: 7.1.3 + optional: true + '@monogrid/gainmap-js@3.4.0(three@0.182.0)': dependencies: promise-worker-transferable: 1.0.4 @@ -21393,12 +21627,21 @@ snapshots: vite: 7.3.1(@types/node@22.19.6)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) vitest: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.7.0)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.2))(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0) + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tootallnate/once@1.1.2': {} '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 - minimatch: 10.2.4 + minimatch: 10.2.5 path-browserify: 1.0.1 '@tweenjs/tween.js@23.1.3': {} @@ -22762,7 +23005,7 @@ snapshots: baseline-browser-mapping@2.9.14: {} - bash-tool@1.3.14(@vercel/sandbox@2.2.0)(ai@6.0.103(zod@4.3.6)): + bash-tool@1.3.14(@vercel/sandbox@2.2.0)(ai@6.0.103(zod@4.3.6))(just-bash@2.14.5): dependencies: ai: 6.0.103(zod@4.3.6) fast-glob: 3.3.3 @@ -22770,6 +23013,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@vercel/sandbox': 2.2.0 + just-bash: 2.14.5 better-opn@3.0.2: dependencies: @@ -22811,6 +23055,13 @@ snapshots: transitivePeerDependencies: - '@sveltejs/kit' + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -22848,10 +23099,6 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: - dependencies: - balanced-match: 4.0.4 - brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -22985,6 +23232,9 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: + optional: true + chownr@3.0.0: {} chrome-launcher@0.15.2: @@ -23116,6 +23366,8 @@ snapshots: commander@4.1.1: {} + commander@6.2.1: {} + commander@7.2.0: {} commander@8.3.0: {} @@ -23507,6 +23759,11 @@ snapshots: decode-uri-component@0.2.2: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + optional: true + dedent-js@1.0.1: {} dedent@0.7.0: {} @@ -23654,11 +23911,12 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8): + drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(postgres@3.4.8)(sql.js@1.14.2): optionalDependencies: '@opentelemetry/api': 1.9.0 '@upstash/redis': 1.37.0 postgres: 3.4.8 + sql.js: 1.14.2 dunder-proto@1.0.1: dependencies: @@ -24324,6 +24582,9 @@ snapshots: exit@0.1.2: {} + expand-template@2.0.3: + optional: true + expect-type@1.3.0: {} expect@27.5.1: @@ -24764,6 +25025,15 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -24867,6 +25137,9 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: + optional: true + fs-extra@11.3.3: dependencies: graceful-fs: 4.2.11 @@ -24984,6 +25257,9 @@ snapshots: getenv@2.0.0: {} + github-from-package@0.0.0: + optional: true + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -25005,13 +25281,13 @@ snapshots: glob@13.0.1: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.2 path-scurry: 2.0.1 glob@13.0.6: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -25393,6 +25669,8 @@ snapshots: ini@1.3.8: {} + ini@6.0.0: {} + ink@6.8.0(@types/react@19.2.14)(react-devtools-core@6.1.5)(react@19.2.4): dependencies: '@alcalzone/ansi-tokenize': 0.2.5 @@ -26369,6 +26647,29 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + just-bash@2.14.5: + dependencies: + diff: 8.0.4 + fast-xml-parser: 5.7.3 + file-type: 21.3.4 + ini: 6.0.0 + minimatch: 10.2.5 + modern-tar: 0.7.7 + papaparse: 5.7.0 + quickjs-emscripten: 0.32.0 + re2js: 1.3.3 + seek-bzip: 2.0.0 + smol-toml: 1.8.0 + sprintf-js: 1.1.3 + sql.js: 1.14.2 + turndown: 7.2.4 + yaml: 2.9.0 + optionalDependencies: + '@mongodb-js/zstd': 7.0.0 + node-liblzma: 2.2.0 + transitivePeerDependencies: + - supports-color + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -26998,7 +27299,7 @@ snapshots: metro-cache: 0.83.3 metro-core: 0.83.3 metro-runtime: 0.83.3 - yaml: 2.8.2 + yaml: 2.9.0 transitivePeerDependencies: - bufferutil - supports-color @@ -27431,9 +27732,8 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.4 + mimic-response@3.1.0: + optional: true minimatch@10.2.5: dependencies: @@ -27461,6 +27761,9 @@ snapshots: dependencies: minipass: 7.1.2 + mkdirp-classic@0.5.3: + optional: true + mkdirp@1.0.4: {} mlly@1.8.0: @@ -27470,6 +27773,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.2 + modern-tar@0.7.7: {} + mri@1.2.0: {} mrmime@2.0.1: {} @@ -27548,6 +27853,9 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: + optional: true + natural-compare-lite@1.4.0: {} natural-compare@1.4.0: {} @@ -27702,6 +28010,14 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abi@3.96.0: + dependencies: + semver: 7.7.4 + optional: true + + node-addon-api@8.9.2: + optional: true + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -27712,8 +28028,17 @@ snapshots: node-forge@1.3.3: {} + node-gyp-build@4.8.4: + optional: true + node-int64@0.4.0: {} + node-liblzma@2.2.0: + dependencies: + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + optional: true + node-releases@2.0.27: {} nopt@7.2.1: @@ -27920,6 +28245,8 @@ snapshots: pako@1.0.11: {} + papaparse@5.7.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -28133,6 +28460,22 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.96.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + optional: true + prelude-ls@1.2.1: {} prettier@3.7.4: {} @@ -28256,6 +28599,18 @@ snapshots: dependencies: inherits: 2.0.4 + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@radix-ui/primitive': 1.1.3 @@ -28402,6 +28757,8 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + re2js@1.3.3: {} + react-confetti-explosion@3.0.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -28751,6 +29108,13 @@ snapshots: react@19.2.4: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + optional: true + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -29222,6 +29586,10 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 + seek-bzip@2.0.0: + dependencies: + commander: 6.2.1 + selderee@0.11.0: dependencies: parseley: 0.12.1 @@ -29469,6 +29837,16 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: + optional: true + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -29501,6 +29879,8 @@ snapshots: slugify@1.6.6: {} + smol-toml@1.8.0: {} + solid-js@1.9.11: dependencies: csstype: 3.2.3 @@ -29551,6 +29931,10 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + + sql.js@1.14.2: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -29793,6 +30177,10 @@ snapshots: dependencies: anynum: 1.0.0 + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + structured-headers@0.4.1: {} style-loader@4.0.0(webpack@5.96.1(esbuild@0.25.0)): @@ -30005,6 +30393,23 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + optional: true + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + tar-stream@3.1.7: dependencies: b4a: 1.8.1 @@ -30042,6 +30447,16 @@ snapshots: optionalDependencies: esbuild: 0.25.0 + terser-webpack-plugin@5.3.16(webpack@5.96.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.46.0 + webpack: 5.96.1 + optional: true + terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -30140,6 +30555,12 @@ snapshots: toidentifier@1.0.1: {} + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + totalist@3.0.1: {} tough-cookie@4.1.4: @@ -30307,6 +30728,11 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + tunnel-rat@0.1.2(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4): dependencies: zustand: 4.5.7(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4) @@ -30342,6 +30768,10 @@ snapshots: turbo-windows-64: 2.7.4 turbo-windows-arm64: 2.7.4 + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -30428,6 +30858,8 @@ snapshots: ufo@1.6.2: {} + uint8array-extras@1.5.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -30441,6 +30873,8 @@ snapshots: undici@6.23.0: {} + undici@6.28.1: {} + undici@7.27.2: {} undici@8.3.0: {} @@ -30965,6 +31399,37 @@ snapshots: webpack-sources@3.3.3: {} + webpack@5.96.1: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + browserslist: 4.28.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.19.0 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.16(webpack@5.96.1) + watchpack: 2.5.1 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + optional: true + webpack@5.96.1(esbuild@0.25.0): dependencies: '@types/eslint-scope': 3.7.7 diff --git a/scripts/orca-ui-evidence.py b/scripts/orca-ui-evidence.py new file mode 100644 index 00000000..89ec3c5b --- /dev/null +++ b/scripts/orca-ui-evidence.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Capture the OrcaRouter UI evidence from the real json-render playground. + +Run from the root of a json-render checkout whose dependencies are installed and +whose workspace packages are built, or through the repository's own driver: + + ORCAROUTER_API_KEY=... node scripts/orca-verify.mjs ui-evidence + +The script starts the app's own Next.js dev server, drives the real playground +with Playwright/Chromium, and writes `orca-evidence/manifest.json` plus the +screenshots next to the repository root. The catalog phase uses the real +`ORCAROUTER_API_KEY`, so the dropdown reflects the live catalog; the auth phase +installs a dedicated fixture key, so the masked secret that appears in a +screenshot is test data and never a fragment of a real key. + +`orca-evidence/` is generated output and is not committed. +""" + +import hashlib +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request + +from playwright.sync_api import sync_playwright + +REPO = os.getcwd() +OUT = os.path.join(REPO, "orca-evidence") +CATALOG_SOURCE = "https://api.orcarouter.ai/v1/models?capability=chat" +FIXTURE_KEY = "sk-orca-evidencefixture0000000000fixture" +SERVER_LOG = os.path.join(tempfile.gettempdir(), "orca-evidence-server.log") + + +def sha256(path): + with open(path, "rb") as handle: + return hashlib.sha256(handle.read()).hexdigest() + + +def free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def http_json(url, body=None, timeout=30): + data = json.dumps(body).encode() if body is not None else None + headers = {"Content-Type": "application/json"} if data else {} + request = urllib.request.Request( + url, data=data, headers=headers, method="POST" if data else "GET" + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read()) + + +def wait_for_server(base, timeout=240): + deadline = time.time() + timeout + last = None + while time.time() < deadline: + try: + with urllib.request.urlopen(base + "/playground", timeout=15) as response: + if response.status == 200: + return + except (urllib.error.URLError, TimeoutError, ConnectionError) as exc: + last = exc + time.sleep(1) + raise RuntimeError(f"dev server did not become ready: {last}") + + +def set_credential(base, key): + """Install a credential through the app's own connect endpoint.""" + return http_json( + base + "/api/orcarouter/connect", {"method": "api_key", "apiKey": key} + ) + + +def visible(page, testid): + """The rendered control. + + The playground renders a desktop and a mobile toolbar, so each test id can + appear twice; only one is displayed at the 1440px viewport used here. + """ + return page.locator(f"[data-testid='{testid}']:visible").first + + +def click_testid(page, testid, timeout=60_000, settle=250): + """Click a control through its DOM `click` handler. + + The Next.js development overlay intercepts pointer events, so a synthetic + click is used; React's delegated handler treats it exactly like a real one. + """ + control = visible(page, testid) + control.wait_for(state="visible", timeout=timeout) + control.dispatch_event("click") + page.wait_for_timeout(settle) + + +def is_visible(page, testid): + return page.locator(f"[data-testid='{testid}']:visible").count() > 0 + + +def open_playground(page, base): + """Load the playground and wait for the app's own ready signal. + + `networkidle` never settles here: the development server keeps a + hot-reload socket open, so readiness is the rendered toolbar instead. + """ + page.goto(base + "/playground", wait_until="domcontentloaded", timeout=120_000) + page.wait_for_selector( + "[data-testid='orca-provider-api']:visible", state="visible", timeout=120_000 + ) + page.wait_for_timeout(1500) + + +def dismiss_dialog(page): + """Close the connect dialog if the app opened it, so it stays out of shots.""" + if is_visible(page, "orca-connect-panel"): + page.keyboard.press("Escape") + page.wait_for_selector( + "[data-testid='orca-connect-panel']", state="hidden", timeout=20_000 + ) + page.wait_for_timeout(400) + + +def select_provider(page): + """Select OrcaRouter, reopening the panel when the provider is already on.""" + click_testid(page, "orca-provider-api", settle=800) + click_testid(page, "orca-provider-api", settle=600) + + +def capture_auth_methods(page, base): + set_credential(base, FIXTURE_KEY) + open_playground(page, base) + select_provider(page) + page.wait_for_selector( + "[data-testid='orca-connect-panel']", state="visible", timeout=60_000 + ) + page.wait_for_timeout(1500) + + panel_text = visible(page, "orca-connect-panel").inner_text() + api_visible = is_visible(page, "orca-choice-api-key") + pkce_visible = is_visible(page, "orca-choice-oauth") + secret_masked = "\u2022\u2022\u2022\u2022" in panel_text + if not (api_visible and pkce_visible): + raise AssertionError("both authentication choices must be visible") + if not secret_masked: + raise AssertionError("the stored secret must be rendered masked") + if re.search(r"sk-orca-[A-Za-z0-9]{12,}", panel_text): + raise AssertionError("the panel must never render a usable key") + + visible(page, "orca-api-key-input").fill("sk-orca-evidencefixture0000") + page.wait_for_timeout(300) + api_save_enabled = visible(page, "orca-save-api-key").is_enabled() + click_testid(page, "orca-choice-oauth", settle=600) + pkce_start_enabled = visible(page, "orca-start-connect").is_enabled() + if not api_save_enabled: + raise AssertionError("Save key must be enabled once a value is entered") + if not pkce_start_enabled: + raise AssertionError("Connect with OrcaRouter must be enabled") + click_testid(page, "orca-choice-api-key", settle=600) + + path = os.path.join(OUT, "auth-methods.png") + page.screenshot(path=path) + return { + "kind": "auth-methods", + "path": "auth-methods.png", + "sha256": sha256(path), + "ui": { + "api_key_visible": api_visible, + "pkce_visible": pkce_visible, + "secret_masked": secret_masked, + "controls_enabled": bool(api_save_enabled and pkce_start_enabled), + }, + } + + +def open_dropdown(page, trigger): + """Open the listbox, whether or not it is already open (the trigger toggles).""" + if page.locator("[data-testid='orca-model-listbox']:visible").count() == 0: + trigger.dispatch_event("click") + page.wait_for_selector( + "[data-testid='orca-model-listbox']", state="visible", timeout=60_000 + ) + page.wait_for_timeout(1000) + + +def dropdown_state(page, trigger): + listbox = visible(page, "orca-model-listbox") + options = listbox.locator("[data-testid='orca-model-option']") + count = options.count() + ids = [options.nth(i).get_attribute("data-model-id") for i in range(count)] + box = listbox.bounding_box() + style = listbox.evaluate( + "el => { const s = getComputedStyle(el);" + " return { bg: s.backgroundColor, border: s.borderTopWidth," + " borderStyle: s.borderTopStyle }; }" + ) + trigger_box = trigger.bounding_box() + right_delta = abs( + (trigger_box["x"] + trigger_box["width"]) - (box["x"] + box["width"]) + ) + if re.search(r"sk-orca", listbox.inner_text()): + raise AssertionError("the model control must never render key material") + return { + "count": count, + "ids": ids, + "ui": { + "dropdown_open": True, + "item_count": count, + "opaque_background": style["bg"] not in ("rgba(0, 0, 0, 0)", "transparent"), + "visible_border": ( + style["border"] not in ("0px", "") and style["borderStyle"] != "none" + ), + "trigger_panel_right_delta": right_delta, + }, + } + + +def capture_model_dropdowns(page, base, api_key): + set_credential(base, api_key) + open_playground(page, base) + click_testid(page, "orca-provider-api", settle=1500) + dismiss_dialog(page) + + # The dropdown must be populated from the live catalog, never from the + # outage seed. Fail loudly if discovery silently degraded. + discovery = http_json(base + "/api/orcarouter/models?capability=chat") + if discovery.get("source") != "live" or discovery.get("degraded"): + raise AssertionError( + "model discovery did not return the live catalog: " + f"source={discovery.get('source')} degraded={discovery.get('degraded')} " + f"reason={discovery.get('degradedReason')}" + ) + live = discovery.get("models") or [] + if not live: + raise AssertionError("the live catalog is empty") + for model in live: + if "/" not in model["id"]: + raise AssertionError(f"vendor namespace lost for {model['id']}") + + trigger = visible(page, "orca-model-trigger") + open_dropdown(page, trigger) + text = dropdown_state(page, trigger) + if text["count"] != len(live): + raise AssertionError( + "the rendered dropdown does not match the live catalog: " + f"{text['count']} rendered vs {len(live)} live" + ) + # The upstream catalog ordering is not stable between requests, so the + # rendered set (not the sequence) is what must match the live response. + if sorted(text["ids"]) != sorted(model["id"] for model in live): + raise AssertionError( + "the dropdown contents differ from the live catalog: " + f"{text['ids']} vs {[model['id'] for model in live]}" + ) + + path = os.path.join(OUT, "text-model-dropdown.png") + page.screenshot(path=path) + text_artifact = { + "kind": "text-model-dropdown", + "path": "text-model-dropdown.png", + "sha256": sha256(path), + "ui": text["ui"], + } + + # Choosing a text-only model and then requesting image input must clear the + # selection and drop every model that does not declare image input. + # Start from the captured listbox (it is open) and pick its first option; + # selecting closes it, so nothing has to be reopened before the click below. + listbox = visible(page, "orca-model-listbox") + listbox.locator("[data-testid='orca-model-option']").first.dispatch_event("click") + page.wait_for_timeout(800) + chosen = trigger.inner_text().strip() + page.wait_for_selector( + "[data-testid='orca-model-listbox']", state="hidden", timeout=20_000 + ) + click_testid(page, "orca-attachment-image", settle=2500) + + open_dropdown(page, trigger) + image = dropdown_state(page, trigger) + if image["count"] <= 0: + raise AssertionError("image-capable chat models must stay selectable") + if image["count"] >= text["count"]: + raise AssertionError("the image filter must remove text-only models") + declared = {model["id"]: model.get("inputModalities") or [] for model in live} + for model_id in image["ids"]: + if "image" not in declared.get(model_id, []): + raise AssertionError( + f"{model_id} is offered for image input without declaring it" + ) + cleared = trigger.inner_text().strip() + if cleared not in ("select model", "loading models..."): + raise AssertionError( + f"an incompatible selection must be cleared, got {cleared!r}" + ) + + mm_path = os.path.join(OUT, "multimodal-model-dropdown.png") + page.screenshot(path=mm_path) + image_artifact = { + "kind": "multimodal-model-dropdown", + "path": "multimodal-model-dropdown.png", + "sha256": sha256(mm_path), + "ui": image["ui"], + } + return { + "count": text["count"], + "image_count": image["count"], + "artifacts": [text_artifact, image_artifact], + "text_ids": text["ids"], + "image_ids": image["ids"], + "chosen": chosen, + } + + +def main(): + api_key = (os.environ.get("ORCAROUTER_API_KEY") or "").strip() + if not api_key: + raise RuntimeError("ORCAROUTER_API_KEY is required for the live catalog phase") + + if os.path.isdir(OUT): + shutil.rmtree(OUT) + os.makedirs(OUT) + + port = free_port() + base = f"http://127.0.0.1:{port}" + # Credentials and browser state stay outside the repository, so a run never + # modifies a tracked file. + scratch = tempfile.mkdtemp(prefix="orca-evidence-") + env = { + **os.environ, + "ORCAROUTER_ENV_FILE": os.path.join(scratch, ".env.local"), + "NEXT_TELEMETRY_DISABLED": "1", + "CI": "1", + } + # The key is installed at runtime through the app's own connect endpoint, so + # the server process does not need it in its environment. + env.pop("ORCAROUTER_API_KEY", None) + + # A stale build lock from an interrupted run would stop the server starting. + shutil.rmtree( + os.path.join(REPO, "apps", "web", ".next", "dev", "lock"), ignore_errors=True + ) + + server = subprocess.Popen( + ["npx", "next", "dev", "--port", str(port), "--hostname", "127.0.0.1"], + cwd=os.path.join(REPO, "apps", "web"), + env=env, + stdout=open(SERVER_LOG, "wb"), + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + wait_for_server(base) + with sync_playwright() as pw: + browser = pw.chromium.launch( + executable_path="/usr/bin/chromium", + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + try: + page = browser.new_page(viewport={"width": 1440, "height": 900}) + catalog = capture_model_dropdowns(page, base, api_key) + auth = capture_auth_methods(page, base) + finally: + browser.close() + finally: + server.terminate() + try: + server.wait(timeout=30) + except subprocess.TimeoutExpired: + server.kill() + + manifest = { + "automation": { + "framework": "playwright", + "passed": True, + "catalog_source": CATALOG_SOURCE, + "catalog_model_count": catalog["count"], + "image_model_count": catalog["image_count"], + }, + "artifacts": [auth, *catalog["artifacts"]], + "text_model_ids": catalog["text_ids"], + "multimodal_model_ids": catalog["image_ids"], + "selection_cleared_on_incompatible_attachment": True, + "text_only_selection_before_attachment": catalog["chosen"], + } + with open(os.path.join(OUT, "manifest.json"), "w") as handle: + json.dump(manifest, handle, indent=2) + handle.write("\n") + print(json.dumps(manifest["automation"], indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/orca-verify.mjs b/scripts/orca-verify.mjs new file mode 100644 index 00000000..0d6114a9 --- /dev/null +++ b/scripts/orca-verify.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * One reproducible entry point for verifying the OrcaRouter integration. + * + * The repository is a pnpm workspace, so every mode first makes sure the pinned + * package manager is available, the workspace dependencies are installed, and + * the `@json-render/*` packages are built; without the build, the web app and + * the package tests cannot resolve `@json-render/core`. Each step is + * idempotent, so only the first mode in a verification run pays for it. + * + * node scripts/orca-verify.mjs install + * node scripts/orca-verify.mjs unit [paths...] + * node scripts/orca-verify.mjs check-types + * node scripts/orca-verify.mjs lint + * node scripts/orca-verify.mjs version + * node scripts/orca-verify.mjs ui-evidence + * + * `pnpm` is resolved from a workspace-local bootstrap, then from `PATH`, then + * through `npx`, so this works in a minimal environment that only has Node.js. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const PNPM_VERSION = "11.1.3"; +const PNPM_BOOTSTRAP = path.join(root, "node_modules", ".orca-pnpm"); +const BOOTSTRAPPED_PNPM = path.join( + PNPM_BOOTSTRAP, + "node_modules/pnpm/bin/pnpm.cjs", +); + +function run(argv) { + process.stdout.write(`\n$ ${argv.join(" ")}\n`); + const result = spawnSync(argv[0], argv.slice(1), { + cwd: root, + stdio: "inherit", + env: process.env, + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +/** The pnpm invocation for this environment. */ +function pnpm() { + if (existsSync(BOOTSTRAPPED_PNPM)) return [process.execPath, BOOTSTRAPPED_PNPM]; + return ["npx", "--yes", `pnpm@${PNPM_VERSION}`]; +} + +/** + * The pinned package manager, installed into `node_modules/` (ignored) so the + * workspace stays byte-identical. Corepack is not usable here: it resolves the + * version over the network on every call and is not on `PATH` in a minimal + * environment. + */ +function ensurePnpm() { + if (existsSync(BOOTSTRAPPED_PNPM)) return; + run([ + "npm", + "install", + "--prefix", + PNPM_BOOTSTRAP, + "--no-package-lock", + `pnpm@${PNPM_VERSION}`, + ]); +} + +function ensure() { + ensurePnpm(); + const command = pnpm(); + if (!existsSync(path.join(root, "node_modules", ".pnpm"))) { + run([...command, "install", "--frozen-lockfile"]); + } + if (!existsSync(path.join(root, "packages", "core", "dist"))) { + // turbo cannot find a package manager binary outside PATH, so build the + // workspace packages with pnpm itself. + run([...command, "-r", "--filter", "./packages/*", "run", "build"]); + } +} + +const [mode, ...rest] = process.argv.slice(2); +if (!mode) { + process.stderr.write("usage: node scripts/orca-verify.mjs [args...]\n"); + process.exit(2); +} + +if (mode === "install") { + ensure(); + process.exit(0); +} + +ensure(); + +switch (mode) { + case "unit": + run(["npx", "vitest", "run", ...rest]); + break; + case "node-test": + run(["node", "--test", ...rest]); + break; + case "check-types": + run([...pnpm(), "--filter", "web", "run", "check-types"]); + break; + case "lint": + run([...pnpm(), "--filter", "web", "run", "lint"]); + break; + case "version": + run(["node", "scripts/check-version-sync.js"]); + break; + case "ui-evidence": + run(["python3", "scripts/orca-ui-evidence.py"]); + break; + default: + process.stderr.write(`unknown mode: ${mode}\n`); + process.exit(2); +} diff --git a/scripts/repo-gates.test.mjs b/scripts/repo-gates.test.mjs new file mode 100644 index 00000000..8251b211 --- /dev/null +++ b/scripts/repo-gates.test.mjs @@ -0,0 +1,43 @@ +// @ts-check +/** + * Regression test for the repository's own mandatory gates. + * + * `.github/workflows/ci.yml` runs `pnpm lint`, `pnpm type-check` and the + * version-sync check on every pull request. Those gates are scripts rather than + * test files, so this suite runs them through the repository's driver + * (`scripts/orca-verify.mjs`) and fails with their output if any of them stops + * passing. + * + * node --test scripts/repo-gates.test.mjs + */ + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const driver = path.join("scripts", "orca-verify.mjs"); + +function gate(mode) { + return spawnSync(process.execPath, [driver, mode], { + cwd: root, + encoding: "utf-8", + }); +} + +for (const [mode, label] of [ + ["version", "version:check"], + ["lint", "lint"], + ["check-types", "type-check"], +]) { + test(`the repository's ${label} gate passes`, () => { + const result = gate(mode); + assert.equal( + result.status, + 0, + `${label} failed:\n${result.stdout}\n${result.stderr}`, + ); + }); +} diff --git a/turbo.json b/turbo.json index a963a592..08507b06 100644 --- a/turbo.json +++ b/turbo.json @@ -1,7 +1,7 @@ { "$schema": "https://turborepo.dev/schema.json", "ui": "tui", - "globalEnv": ["AI_GATEWAY_API_KEY", "JEV_AI_GATEWAY_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_MODEL", "OPENAI_API_KEY", "CODEX_MODEL", "PI_MODEL", "AI_GATEWAY_MODEL", "ELEVENLABS_API_KEY", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"], + "globalEnv": ["AI_GATEWAY_API_KEY", "JEV_AI_GATEWAY_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_MODEL", "OPENAI_API_KEY", "CODEX_MODEL", "PI_MODEL", "AI_GATEWAY_MODEL", "ELEVENLABS_API_KEY", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY", "ORCAROUTER_API_KEY", "ORCAROUTER_ENV_FILE", "ORCA_BASE_URL", "ORCA_AUTH_BASE_URL", "ORCA_API_BASE_URL"], "tasks": { "build": { "dependsOn": ["^build"], diff --git a/vitest.config.mts b/vitest.config.mts index 0656ccda..0c5f2f2b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -29,6 +29,7 @@ export default defineConfig({ "react-dom": path.resolve(__dirname, "node_modules/react-dom"), vue: path.resolve(__dirname, "packages/vue/node_modules/vue"), "solid-js": path.resolve(__dirname, "node_modules/solid-js"), + "@": path.resolve(__dirname, "apps/web"), }, }, test: { @@ -38,6 +39,8 @@ export default defineConfig({ "packages/**/*.test.ts", "packages/**/*.test.tsx", "apps/web/lib/jev/**/*.test.ts", + "apps/web/lib/orcarouter/**/*.test.ts", + "apps/web/lib/orcarouter/**/*.test.tsx", "apps/web/lib/use-playground-stream.test.ts", ], server: {