diff --git a/package-lock.json b/package-lock.json index 74451ff75..896eea7d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -835,6 +835,10 @@ "resolved": "packages/coding-agent", "link": true }, + "node_modules/@earendil-works/pi-connect": { + "resolved": "packages/connect", + "link": true + }, "node_modules/@earendil-works/pi-durable": { "resolved": "packages/durable", "link": true @@ -5964,6 +5968,22 @@ "extraneous": true, "license": "MIT" }, + "packages/connect": { + "name": "@earendil-works/pi-connect", + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "1.4.0" + }, + "devDependencies": { + "fake-indexeddb": "6.2.6", + "shx": "0.3.4", + "vitest": "4.1.12" + }, + "engines": { + "node": ">=22.19.0" + } + }, "packages/durable": { "name": "@earendil-works/pi-durable", "version": "1.4.0", diff --git a/package.json b/package.json index 04462a1d5..0d41776cd 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build", - "build:offline": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build:offline && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build", + "build": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../connect && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build", + "build:offline": "cd packages/telemetry && npm run build && cd ../chord && npm run build && cd ../tui && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build:offline && cd ../durable && npm run build && cd ../env && npm run build && cd ../connect && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build", "build:native:darwin": "npm --prefix packages/tui run build:native:darwin", "build:native:linux": "npm --prefix packages/tui run build:native:linux", "build:native:win32": "npm --prefix packages/tui run build:native:win32", diff --git a/packages/connect/README.md b/packages/connect/README.md new file mode 100644 index 000000000..e7b08f6bd --- /dev/null +++ b/packages/connect/README.md @@ -0,0 +1,130 @@ +# @earendil-works/pi-connect + +Sign in to model providers from the browser with [pi-ai](../ai), and know what works there and why. + +A pi-ai `Models` collection in a browser has three problems the CLI does not have: + +- Many providers' OAuth sign-ins redirect only to a local server (`http://127.0.0.1:…`), which a web page cannot run. +- Some providers' auth endpoints send no CORS headers, so a page can neither finish a sign-in nor refresh a token. +- There is no `~/.pi/agent/auth.json`: credentials and model catalogs need a home in the browser. + +pi-connect handles all three, and is transparent about what is not possible: + +```ts +import { openConnect } from "@earendil-works/pi-connect"; +import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic"; +import { radiusProvider } from "@earendil-works/pi-ai/providers/radius"; + +const connect = await openConnect({ providers: [radiusProvider(), anthropicProvider()] }); +void connect.refresh(); // newer auth documents and model catalogs from pi.dev + +for (const provider of connect.support()) { + // provider.available, provider.reason, provider.methods[].tier / .available / .reason +} + +signInButton.onclick = () => connect.signIn("radius"); // must run in a click handler +await connect.setApiKey("anthropic", key); + +// Hand the collection to pi-ai or pi-durable. +const harness = await Harness.open(storage, { models: connect.models }, context); +``` + +## What works in a browser + +Every sign-in method of every provider has a tier in the browser. Outside browsers every method works directly. + +| Tier | Meaning | +|---|---| +| `direct` | Works without pi infrastructure beyond the sign-in window. | +| `relay-login` | The sign-in goes through pi.dev's relay; refreshes go to the provider directly. | +| `relay-always` | The sign-in and every token refresh go through pi.dev's relay. | +| `manual` | The provider redirects to a local server; the user pastes the address of the page that fails to load. Off unless the app passes `manual: true`. | +| `unsupported` | Not possible, with the reason. | + +A provider is only usable when its model API accepts browser requests; otherwise every method is unavailable and the +provider's `reason` says why. The current matrix is at . + +The information comes from **auth documents**: metadata that pi.dev publishes at `/api/auth/providers` and this package +bundles (`BUILTIN_AUTH_DOCUMENTS`). `refresh()` adopts a newer revision and caches it in IndexedDB. Documents never name +an endpoint or a client ID; those stay in pi-ai's flow code, so a document cannot send a credential anywhere new. +Providers pi.dev does not document are offered with their API key, marked `unknown`. + +`scripts/probe-cors.ts` checks the bundled documents against what the providers' endpoints accept today and fails on +any mismatch. Run it before publishing new documents: + +```sh +node packages/connect/scripts/probe-cors.ts +``` + +## Signing in + +`signIn(provider, method?)` opens pi.dev's sign-in window (`https://pi.dev/connect`) and resolves once the credential +is stored: + +1. The window announces itself; the app answers with the provider, the method and a nonce. +2. The window takes the app's origin from that message (the browser sets it, so a page cannot claim another), shows it + ("http://example.com asks to sign in to your Anthropic account"), and waits for the user to continue. +3. It runs the provider's pi-ai flow: device codes, copy-a-code pages and, with `manual`, pasted addresses. +4. It sends the credential to that origin only and closes. pi.dev keeps no credential. + +Requirements for the app's page: + +- Call `signIn` from a click handler, or the browser blocks the window (`popup_blocked`). +- Do not send `Cross-Origin-Opener-Policy: same-origin`; it cuts the link between the windows. Use + `same-origin-allow-popups` if you need COOP. + +Failures reject with a `ConnectError` whose `code` is `unknown_provider`, `unavailable`, `popup_blocked`, +`popup_closed`, `cancelled` or `sign_in_failed`. + +## The relay + +pi.dev forwards requests to a fixed list of provider auth endpoints that send no CORS headers (`RELAY_ENDPOINTS`) and +stores nothing. Sign-in steps are accepted only from pi.dev's sign-in window, so every new sign-in passes the page +that shows which site receives the credential; token refreshes are accepted from any page. `openConnect` registers +pi-ai's OAuth flows with a fetch that routes exactly these endpoints through the relay (`relayFetch`); everything else, +including all model requests, goes to the provider directly. + +## Security model + +What protects what: + +- **The credential reaches only the site the user saw.** The window shows the origin the browser + reports for the opening page and posts the credential to that origin only. It is always a fresh + window (`_blank`), drops any window name, and opens links with `noopener`, so no other page can + get a handle to it. Its Continue button arms only after the window has been visible for a moment, + so a double-click aimed at the opening page cannot approve the sign-in. +- **The window cannot run injected code.** It is served with a Content Security Policy that allows + only same-origin scripts, no inline code, no framing and no form submissions, and enforces Trusted + Types. Addresses from providers are linked only if they are http(s). +- **The relay is not an open proxy.** It forwards only to exact allowlisted URLs and methods, at most + 16 KiB, re-serializes token bodies so the provider sees the grant type the relay checked, drops + cookies and redirects, rate limits per IP, and stores and logs no bodies. Its answers are sandboxed + documents. +- **Sign-in steps through the relay need the window.** The relay accepts code exchanges and device + polling only with pi.dev's origin. This keeps other sites from using pi's OAuth clients through + pi.dev without showing the user who asks. It is a policy boundary, not a credential boundary: anyone + with a server can call the providers directly. +- **Documents cannot redirect credentials.** They carry no endpoints; the endpoints are in pi-ai's code. + +What it does not protect: + +- Credentials in the app's IndexedDB are readable by any script on the app's origin. +- pi.dev is trusted: it serves the window's code. A compromised pi.dev can take every new sign-in. +- Tokens for relayed providers pass through pi.dev's memory at sign-in and, for `relay-always`, at + every refresh. +- Device-code sign-ins can be phished like anywhere else: an attacker's site can start one and show the + code. The window always names the asking site. + +## Storage + +Credentials and model catalogs live in IndexedDB (database `pi-connect` by default). A Web Lock per provider makes +token refreshes one critical section across tabs, so two tabs never spend the same refresh token. + +Credentials are readable by any script on the app's origin. Keep untrusted code, including model-generated code you +run in the page, away from them. + +## pi.dev + +pi.dev serves the auth documents, the model catalogs (`/api/models/...`), the relay (`/api/oauth/relay`) and the +sign-in window (`/connect`). It vendors this package's popup and data with `scripts/vendor-pi-connect.mjs`. Pass +`piUrl` to use another deployment, for example a local pi.dev during development. diff --git a/packages/connect/package.json b/packages/connect/package.json new file mode 100644 index 000000000..60f75ccbe --- /dev/null +++ b/packages/connect/package.json @@ -0,0 +1,61 @@ +{ + "name": "@earendil-works/pi-connect", + "version": "1.4.0", + "description": "Sign in to model providers from the browser with pi-ai: auth documents, a pi.dev sign-in popup and relay, and IndexedDB credential storage", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./page": { + "source": "./src/page.ts", + "types": "./dist/page.d.ts", + "import": "./dist/page.js", + "default": "./dist/page.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "clean": "shx rm -rf dist", + "build": "tsc -p tsconfig.build.json", + "test": "vitest --run", + "probe": "node scripts/probe-cors.ts", + "prepublishOnly": "npm run clean && npm run build" + }, + "keywords": [ + "ai", + "llm", + "oauth", + "browser", + "pi" + ], + "author": "Earendil Works", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/earendil-works/pi.git", + "directory": "packages/connect" + }, + "engines": { + "node": ">=22.19.0" + }, + "dependencies": { + "@earendil-works/pi-ai": "1.4.0" + }, + "devDependencies": { + "fake-indexeddb": "6.2.6", + "shx": "0.3.4", + "vitest": "4.1.12" + } +} diff --git a/packages/connect/scripts/probe-cors.ts b/packages/connect/scripts/probe-cors.ts new file mode 100644 index 000000000..c64b45d56 --- /dev/null +++ b/packages/connect/scripts/probe-cors.ts @@ -0,0 +1,235 @@ +#!/usr/bin/env node +/** + * Check the bundled auth documents against what providers' endpoints accept from browsers today. + * + * Providers change CORS without notice. This script sends each endpoint a CORS preflight from a + * foreign origin, with the headers pi-ai's requests carry, and compares the result with the + * documents and the relay list: + * + * - a provider's model API answers the preflight exactly when its document says `inference: direct`; + * - a method's sign-in needs the relay exactly when one of its sign-in endpoints refuses browsers, + * and its refresh exactly when its refresh endpoint refuses them; + * - every endpoint that refuses browsers and that a supported method needs is on the relay list. + * + * It exits with 1 on any mismatch. Run it before publishing new documents, and on a schedule: + * + * node packages/connect/scripts/probe-cors.ts + */ + +import { BUILTIN_AUTH_DOCUMENTS } from "../src/builtin-documents.ts"; +import { findRelayEndpoint } from "../src/relay.ts"; + +type Probe = { method: "GET" | "POST"; url: string; headers: string[] }; + +/** The endpoints a provider's requests reach, with the headers pi-ai sends there. */ +type ProviderProbes = { + inference: Probe; + /** Per OAuth method ID: the endpoints its sign-in calls, and the one its refresh calls. */ + methods?: Record; +}; + +const OPENAI_HEADERS = ["authorization", "content-type", "x-stainless-os", "x-stainless-lang"]; +const ANTHROPIC_HEADERS = [ + "x-api-key", + "authorization", + "content-type", + "anthropic-version", + "anthropic-beta", + "anthropic-dangerous-direct-browser-access", +]; +const FORM = ["content-type"]; + +const post = (url: string, headers: string[] = FORM): Probe => ({ method: "POST", url, headers }); + +const RADIUS_TOKEN = post("https://radius.pi.dev/v1/oauth/token"); +const ANTHROPIC_TOKEN = post("https://platform.claude.com/v1/oauth/token"); +const CODEX_TOKEN = post("https://auth.openai.com/oauth/token"); +const CHATGPT_TOKEN = post("https://auth.openai.com/api/accounts/oauth/token"); +const COPILOT_TOKEN: Probe = { + method: "GET", + url: "https://api.github.com/copilot_internal/v2/token", + headers: ["authorization", "editor-version", "editor-plugin-version", "copilot-integration-id"], +}; +const XAI_TOKEN = post("https://auth.x.ai/oauth2/token"); +const META_KEY = post("https://api.meta.ai/muse-code/key", ["authorization", "content-type", "x-api-version"]); +const KIMI_TOKEN = post("https://auth.kimi.com/api/oauth/token"); + +const PROBES: Record = { + radius: { + inference: post("https://radius.pi.dev/v1/messages", ["authorization", "content-type"]), + methods: { + "device-code": { signIn: [post("https://radius.pi.dev/v1/oauth/device"), RADIUS_TOKEN], refresh: RADIUS_TOKEN }, + }, + }, + anthropic: { + inference: post("https://api.anthropic.com/v1/messages", ANTHROPIC_HEADERS), + methods: { + copy_code: { signIn: [ANTHROPIC_TOKEN], refresh: ANTHROPIC_TOKEN }, + browser: { signIn: [ANTHROPIC_TOKEN], refresh: ANTHROPIC_TOKEN }, + }, + }, + openai: { + inference: post("https://api.openai.com/v1/responses", OPENAI_HEADERS), + methods: { browser: { signIn: [CHATGPT_TOKEN], refresh: CHATGPT_TOKEN } }, + }, + "openai-codex": { + inference: post("https://chatgpt.com/backend-api/codex/responses", [ + "authorization", + "content-type", + "chatgpt-account-id", + "openai-beta", + "session-id", + ]), + methods: { + device_code: { + signIn: [ + post("https://auth.openai.com/api/accounts/deviceauth/usercode", ["content-type"]), + post("https://auth.openai.com/api/accounts/deviceauth/token", ["content-type"]), + CODEX_TOKEN, + ], + refresh: CODEX_TOKEN, + }, + browser: { signIn: [CODEX_TOKEN], refresh: CODEX_TOKEN }, + }, + }, + "github-copilot": { + inference: post("https://api.individual.githubcopilot.com/chat/completions", [ + ...OPENAI_HEADERS, + "editor-version", + "editor-plugin-version", + "copilot-integration-id", + "openai-intent", + "x-initiator", + ]), + methods: { + device_code: { + signIn: [ + post("https://github.com/login/device/code"), + post("https://github.com/login/oauth/access_token"), + COPILOT_TOKEN, + ], + refresh: COPILOT_TOKEN, + }, + }, + }, + xai: { + inference: post("https://api.x.ai/v1/responses", OPENAI_HEADERS), + methods: { device_code: { signIn: [post("https://auth.x.ai/oauth2/device/code"), XAI_TOKEN], refresh: XAI_TOKEN } }, + }, + meta: { + inference: post("https://api.meta.ai/v1/responses", OPENAI_HEADERS), + methods: { + device_code: { + signIn: [ + post("https://auth.meta.com/oidc/device/authorization/"), + post("https://auth.meta.com/oidc/device/token/"), + META_KEY, + ], + refresh: META_KEY, + }, + }, + }, + "kimi-coding": { + inference: post("https://api.kimi.com/coding/v1/chat/completions", OPENAI_HEADERS), + methods: { + device_code: { signIn: [post("https://auth.kimi.com/api/oauth/device_authorization"), KIMI_TOKEN], refresh: KIMI_TOKEN }, + }, + }, + openrouter: { inference: post("https://openrouter.ai/api/v1/messages", ANTHROPIC_HEADERS) }, + google: { + inference: post("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent", [ + "content-type", + "x-goog-api-key", + "x-goog-api-client", + ]), + }, + mistral: { inference: post("https://api.mistral.ai/v1/chat/completions", ["authorization", "content-type"]) }, + groq: { inference: post("https://api.groq.com/openai/v1/chat/completions", OPENAI_HEADERS) }, + deepseek: { inference: post("https://api.deepseek.com/chat/completions", OPENAI_HEADERS) }, + cerebras: { inference: post("https://api.cerebras.ai/v1/chat/completions", OPENAI_HEADERS) }, + together: { inference: post("https://api.together.ai/v1/chat/completions", OPENAI_HEADERS) }, + huggingface: { inference: post("https://router.huggingface.co/v1/chat/completions", OPENAI_HEADERS) }, + fireworks: { inference: post("https://api.fireworks.ai/inference/v1/messages", ANTHROPIC_HEADERS) }, + moonshotai: { inference: post("https://api.moonshot.ai/v1/chat/completions", OPENAI_HEADERS) }, +}; + +const ORIGIN = "https://example.com"; + +const results = new Map>(); + +/** Whether a browser on a foreign origin may send the probe's request; each endpoint is asked once. */ +function acceptsBrowsers(probe: Probe): Promise { + const key = `${probe.method} ${probe.url} ${probe.headers.join(",")}`; + let result = results.get(key); + if (!result) { + result = preflight(probe); + results.set(key, result); + } + return result; +} + +async function preflight(probe: Probe): Promise { + try { + const response = await fetch(probe.url, { + method: "OPTIONS", + headers: { + origin: ORIGIN, + "access-control-request-method": probe.method, + "access-control-request-headers": probe.headers.join(","), + }, + signal: AbortSignal.timeout(15_000), + }); + const allowOrigin = response.headers.get("access-control-allow-origin"); + if (!response.ok || (allowOrigin !== "*" && allowOrigin !== ORIGIN)) return false; + const allowed = (response.headers.get("access-control-allow-headers") ?? "") + .split(",") + .map((header) => header.trim().toLowerCase()); + return allowed.includes("*") || probe.headers.every((header) => allowed.includes(header)); + } catch { + return false; + } +} + +const problems: string[] = []; + +for (const document of BUILTIN_AUTH_DOCUMENTS.providers) { + const probes = PROBES[document.provider]; + if (!probes) { + problems.push(`${document.provider}: no probes defined`); + continue; + } + const inference = await acceptsBrowsers(probes.inference); + console.log(`${document.provider}: inference ${inference ? "open" : "closed"}`); + if (inference !== (document.inference.browser === "direct")) { + problems.push(`${document.provider}: document says inference ${document.inference.browser}, endpoint is ${inference ? "open" : "closed"}`); + } + + for (const method of document.methods) { + if (method.type !== "oauth" || method.browser.signIn === "unsupported") continue; + const endpoints = probes.methods?.[method.id]; + if (!endpoints) { + problems.push(`${document.provider}/${method.id}: no probes defined`); + continue; + } + const closed: Probe[] = []; + for (const probe of endpoints.signIn) if (!(await acceptsBrowsers(probe))) closed.push(probe); + const refreshOpen = endpoints.refresh ? await acceptsBrowsers(endpoints.refresh) : true; + if (endpoints.refresh && !refreshOpen) closed.push(endpoints.refresh); + const needed = !refreshOpen ? "always" : closed.length > 0 ? "login" : "none"; + console.log(` ${method.id}: relay ${needed}`); + if (needed !== method.browser.relay) { + problems.push(`${document.provider}/${method.id}: document says relay ${method.browser.relay}, endpoints need ${needed}`); + } + for (const probe of closed) { + if (!findRelayEndpoint(new URL(probe.url), probe.method)) { + problems.push(`${document.provider}/${method.id}: ${probe.method} ${probe.url} refuses browsers and is not relayed`); + } + } + } +} + +if (problems.length > 0) { + console.error(`\n${problems.length} mismatch(es):\n${problems.map((problem) => `- ${problem}`).join("\n")}`); + process.exit(1); +} +console.log(`\nAll documents match (revision ${BUILTIN_AUTH_DOCUMENTS.revision}).`); diff --git a/packages/connect/src/builtin-documents.ts b/packages/connect/src/builtin-documents.ts new file mode 100644 index 000000000..4d94a15a7 --- /dev/null +++ b/packages/connect/src/builtin-documents.ts @@ -0,0 +1,246 @@ +// The auth documents this package ships with; pi.dev publishes the same set and may publish newer ones. + +import type { AuthDocument, AuthDocuments, AuthMethodDocument } from "./documents.ts"; + +const RELAY_ALWAYS = "so the sign-in and every token refresh go through pi.dev's relay."; + +const PASTE = "after signing in, paste the address of the page that fails to load"; + +/** + * Browser support of the providers pi knows, as last checked by `scripts/probe-cors.ts`. pi.dev serves + * this set from `/api/auth/providers`; apps use it until they fetch a newer revision. + */ +export const BUILTIN_AUTH_DOCUMENTS: AuthDocuments = { + version: 1, + revision: "2026-10-30.1", + providers: [ + { + provider: "radius", + name: "Radius", + inference: { browser: "direct" }, + methods: [ + apiKey("Radius API key"), + { + id: "device-code", + type: "oauth", + kind: "device_code", + label: "Sign in with a device code", + browser: { signIn: "direct", relay: "none" }, + }, + { + id: "browser", + type: "oauth", + kind: "pkce_loopback", + label: "Sign in with the browser", + browser: { + signIn: "unsupported", + relay: "none", + reason: + "Radius redirects this sign-in only to a local server and offers no paste fallback. Use the device code.", + }, + }, + ], + }, + { + provider: "anthropic", + name: "Anthropic", + inference: { browser: "direct" }, + methods: [ + apiKey("Anthropic API key"), + { + id: "copy_code", + type: "oauth", + kind: "pkce_copy_code", + label: "Sign in with Claude (copy a code)", + browser: { + signIn: "direct", + relay: "always", + reason: `Anthropic's token endpoint does not accept browser requests, ${RELAY_ALWAYS}`, + }, + }, + { + id: "browser", + type: "oauth", + kind: "pkce_loopback", + label: "Sign in with Claude (paste the address)", + browser: { + signIn: "manual", + relay: "always", + reason: `Anthropic redirects this sign-in only to localhost: ${PASTE}. Its token endpoint does not accept browser requests, ${RELAY_ALWAYS}`, + }, + }, + ], + }, + { + provider: "openai", + name: "OpenAI", + inference: { browser: "direct" }, + methods: [ + apiKey("OpenAI API key"), + { + id: "browser", + type: "oauth", + kind: "pkce_loopback", + label: "Sign in with ChatGPT", + browser: { + signIn: "manual", + relay: "none", + reason: `OpenAI redirects this sign-in only to 127.0.0.1: ${PASTE}.`, + }, + }, + ], + }, + { + provider: "openai-codex", + name: "OpenAI Codex (legacy)", + inference: { + browser: "unsupported", + reason: "chatgpt.com accepts browser requests only from pages on http://localhost.", + }, + methods: [ + { + id: "device_code", + type: "oauth", + kind: "device_code", + label: "Sign in with a device code", + browser: { signIn: "direct", relay: "none" }, + }, + { + id: "browser", + type: "oauth", + kind: "pkce_loopback", + label: "Sign in with the browser", + browser: { + signIn: "manual", + relay: "none", + reason: `OpenAI redirects this sign-in only to localhost: ${PASTE}.`, + }, + }, + ], + }, + { + provider: "github-copilot", + name: "GitHub Copilot", + inference: { browser: "direct" }, + methods: [ + apiKey("GitHub Copilot token"), + { + id: "device_code", + type: "oauth", + kind: "device_code", + label: "Sign in with GitHub", + browser: { + signIn: "direct", + relay: "always", + reason: `GitHub's sign-in endpoints do not accept browser requests and the Copilot token endpoint rejects the headers Copilot needs, ${RELAY_ALWAYS} GitHub Enterprise is not supported.`, + }, + }, + ], + }, + { + provider: "xai", + name: "xAI", + inference: { browser: "direct" }, + methods: [ + apiKey("xAI API key"), + { + id: "device_code", + type: "oauth", + kind: "device_code", + label: "Sign in with SuperGrok or X Premium", + browser: { + signIn: "direct", + relay: "always", + reason: `auth.x.ai does not accept browser requests, ${RELAY_ALWAYS}`, + }, + }, + ], + }, + { + provider: "meta", + name: "Meta", + inference: { browser: "direct" }, + methods: [ + apiKey("Meta Model API key"), + { + id: "device_code", + type: "oauth", + kind: "device_code", + label: "Sign in with Meta", + browser: { + signIn: "direct", + relay: "always", + reason: + "auth.meta.com and Meta's key endpoint do not accept browser requests, so the sign-in and the daily key renewal go through pi.dev's relay.", + }, + }, + ], + }, + { + provider: "kimi-coding", + name: "Kimi For Coding", + inference: { browser: "unsupported", reason: "api.kimi.com does not accept browser requests." }, + methods: [ + apiKey("Kimi API key"), + { + id: "device_code", + type: "oauth", + kind: "device_code", + label: "Sign in with Kimi Code", + browser: { signIn: "direct", relay: "none" }, + }, + ], + }, + { + provider: "openrouter", + name: "OpenRouter", + inference: { + browser: "unsupported", + reason: + "pi talks to OpenRouter's Anthropic-compatible API, and openrouter.ai refuses the anthropic-* headers those requests carry from browsers.", + }, + methods: [ + apiKey("OpenRouter API key"), + { + id: "browser", + type: "oauth", + kind: "pkce_loopback", + label: "Sign in with OpenRouter", + browser: { + signIn: "unsupported", + relay: "none", + reason: + "pi's OpenRouter sign-in waits for a local server and has no paste fallback yet. Use an API key.", + }, + }, + ], + }, + apiKeyProvider("google", "Google", "Gemini API key"), + apiKeyProvider("mistral", "Mistral", "Mistral API key"), + apiKeyProvider("groq", "Groq", "Groq API key"), + apiKeyProvider("deepseek", "DeepSeek", "DeepSeek API key"), + apiKeyProvider("cerebras", "Cerebras", "Cerebras API key"), + apiKeyProvider("together", "Together", "Together API key"), + apiKeyProvider("huggingface", "Hugging Face", "Hugging Face token"), + { + provider: "fireworks", + name: "Fireworks", + inference: { browser: "unsupported", reason: "api.fireworks.ai does not accept browser requests." }, + methods: [apiKey("Fireworks API key")], + }, + { + provider: "moonshotai", + name: "Moonshot AI", + inference: { browser: "unsupported", reason: "api.moonshot.ai does not accept browser requests." }, + methods: [apiKey("Moonshot AI API key")], + }, + ], +}; + +function apiKey(label: string): AuthMethodDocument { + return { id: "api_key", type: "api_key", kind: "api_key", label, browser: { signIn: "direct", relay: "none" } }; +} + +function apiKeyProvider(provider: string, name: string, keyLabel: string): AuthDocument { + return { provider, name, inference: { browser: "direct" }, methods: [apiKey(keyLabel)] }; +} diff --git a/packages/connect/src/connect.ts b/packages/connect/src/connect.ts new file mode 100644 index 000000000..62c5499be --- /dev/null +++ b/packages/connect/src/connect.ts @@ -0,0 +1,235 @@ +/// + +// A pi-ai Models collection for browsers: credentials in IndexedDB, sign-in through pi.dev. + +import { + type AuthOperationOptions, + type Credential, + createModels, + type ModelsRefreshResult, + type MutableModels, + type Provider, +} from "@earendil-works/pi-ai"; +import { registerOAuthFlows } from "@earendil-works/pi-ai/oauth-flows"; +import { withRemoteCatalog } from "@earendil-works/pi-ai/remote-catalog"; +import { BUILTIN_AUTH_DOCUMENTS } from "./builtin-documents.ts"; +import { IndexedDbCredentialStore } from "./credential-store.ts"; +import { ConnectDatabase } from "./database.ts"; +import { type AuthDocuments, parseAuthDocuments } from "./documents.ts"; +import { ConnectError } from "./errors.ts"; +import { IndexedDbModelsStore } from "./models-store.ts"; +import { type PopupHost, signInWithPopup } from "./popup.ts"; +import { relayFetch } from "./relay.ts"; +import { describeSupport, type ProviderSupport } from "./support.ts"; + +export interface ConnectOptions { + /** + * The providers to offer. Their API-key auth stays as configured; their OAuth flows run from this + * bundle, with the requests browsers cannot send going through pi.dev's relay. + */ + providers: readonly Provider[]; + /** Where auth documents, model catalogs, the relay and the sign-in popup live. Default: https://pi.dev */ + piUrl?: string; + /** Offer sign-ins that end with pasting the address of a page that fails to load. Default: false. */ + manual?: boolean; + /** IndexedDB database name. Default: "pi-connect". */ + database?: string; + /** Layer pi.dev's model catalogs over providers that have no catalog of their own. Default: true. */ + catalogs?: boolean; + /** For tests: the IndexedDB factory, the lock manager and the window that opens the popup. */ + indexedDB?: IDBFactory; + locks?: LockManager; + window?: PopupHost; +} + +export interface Connect { + /** The Models collection to hand to pi-ai or pi-durable. */ + readonly models: MutableModels; + /** The auth documents in use: the newest of the bundled, the cached and the fetched ones. */ + readonly documents: AuthDocuments; + /** What every configured provider supports in this browser, and why not. */ + support(): ProviderSupport[]; + /** Fetch newer auth documents and refresh every provider's models. Failures are returned, not thrown. */ + refresh(options?: AuthOperationOptions): Promise; + /** + * Sign in with an OAuth method (the first available one by default) in pi.dev's popup, store the + * credential and refresh the provider's models. Call it from a click handler. + */ + signIn(providerId: string, methodId?: string, options?: AuthOperationOptions): Promise; + /** Store an API key and refresh the provider's models. */ + setApiKey(providerId: string, key: string, options?: AuthOperationOptions): Promise; + /** Forget the provider's stored credential. */ + signOut(providerId: string, options?: AuthOperationOptions): Promise; + /** Close the database connection. */ + close(): void; +} + +const DEFAULT_PI_URL = "https://pi.dev"; +const DEFAULT_DATABASE = "pi-connect"; +const DOCUMENTS_KEY = "auth"; +const DOCUMENTS_PATH = "/api/auth/providers"; + +/** What the documents store keeps: the newest documents and the ETag they were fetched with. */ +interface CachedDocuments { + documents: AuthDocuments; + etag?: string; +} + +/** What `openConnect` prepared for the session. */ +interface SessionParts { + options: ConnectOptions; + piUrl: string; + database: ConnectDatabase; + credentials: IndexedDbCredentialStore; + models: MutableModels; + cached: CachedDocuments | undefined; +} + +/** + * Open pi-connect: register pi-ai's OAuth flows for this page, open the database and restore the cached + * auth documents and model catalogs without network access. OAuth flows are registered for the whole + * page, so all `Connect`s of a page share one `piUrl`. + */ +export async function openConnect(options: ConnectOptions): Promise { + const piUrl = checkedPiUrl(options.piUrl ?? DEFAULT_PI_URL); + const databaseName = options.database ?? DEFAULT_DATABASE; + registerOAuthFlows({ fetch: relayFetch(piUrl) }); + const database = await ConnectDatabase.open(databaseName, options.indexedDB); + // Only this module writes the documents store. + const cached = (await database.get("documents", DOCUMENTS_KEY)) as CachedDocuments | undefined; + const credentials = new IndexedDbCredentialStore(database, `pi-connect:${databaseName}`, options.locks); + const models = createModels({ credentials, modelsStore: new IndexedDbModelsStore(database) }); + for (const provider of options.providers) { + models.setProvider( + options.catalogs !== false && !provider.refreshModels + ? withRemoteCatalog(provider, { baseUrl: piUrl }) + : provider, + ); + } + await models.refresh({ allowNetwork: false }); + return new ConnectSession({ options, piUrl, database, credentials, models, cached }); +} + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** pi.dev's origin. Refresh tokens travel there through the relay, so plain http is refused except on this machine. */ +function checkedPiUrl(value: string): string { + const url = new URL(value); + if (url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname))) return url.origin; + throw new TypeError(`piUrl must be an https URL (http only on localhost): ${value}`); +} + +class ConnectSession implements Connect { + readonly models: MutableModels; + documents: AuthDocuments; + private readonly options: ConnectOptions; + private readonly piUrl: string; + private readonly database: ConnectDatabase; + private readonly credentials: IndexedDbCredentialStore; + private etag: string | undefined; + + constructor({ options, piUrl, database, credentials, models, cached }: SessionParts) { + this.options = options; + this.piUrl = piUrl; + this.database = database; + this.credentials = credentials; + this.models = models; + const useCached = cached !== undefined && cached.documents.revision >= BUILTIN_AUTH_DOCUMENTS.revision; + this.documents = useCached ? cached.documents : BUILTIN_AUTH_DOCUMENTS; + this.etag = useCached ? cached.etag : undefined; + } + + support(): ProviderSupport[] { + return this.models.getProviders().map((provider) => + describeSupport( + provider, + this.documents.providers.find((document) => document.provider === provider.id), + { manual: this.options.manual === true }, + ), + ); + } + + async refresh(options?: AuthOperationOptions): Promise { + let documentsError: Error | undefined; + try { + await this.refreshDocuments(options?.signal); + } catch (error) { + documentsError = error instanceof Error ? error : new Error(String(error)); + } + const result = await this.models.refresh({ signal: options?.signal }); + if (!documentsError) return result; + return { aborted: result.aborted, errors: new Map([...result.errors, ["auth-documents", documentsError]]) }; + } + + async signIn(providerId: string, methodId?: string, options?: AuthOperationOptions): Promise { + const support = this.requireSupport(providerId); + const oauth = support.methods.filter((method) => method.type === "oauth"); + const method = methodId + ? oauth.find((candidate) => candidate.id === methodId) + : (oauth.find((candidate) => candidate.available) ?? oauth[0]); + if (!method) { + throw new ConnectError( + "unavailable", + methodId + ? `${support.name} has no OAuth sign-in "${methodId}".` + : (support.reason ?? `${support.name} has no OAuth sign-in.`), + ); + } + if (!method.available) throw new ConnectError("unavailable", method.reason ?? "Not available in this browser."); + const credential = await signInWithPopup( + this.piUrl, + { provider: providerId, method: method.id, manual: this.options.manual === true }, + options?.signal, + this.options.window, + ); + return this.store(providerId, credential, options); + } + + setApiKey(providerId: string, key: string, options?: AuthOperationOptions): Promise { + this.requireSupport(providerId); + return this.store(providerId, { type: "api_key", key }, options); + } + + signOut(providerId: string, options?: AuthOperationOptions): Promise { + return this.models.logout(providerId, options); + } + + close(): void { + this.database.close(); + } + + private requireSupport(providerId: string): ProviderSupport { + const support = this.support().find((entry) => entry.provider === providerId); + if (!support) throw new ConnectError("unknown_provider", `No provider "${providerId}" is configured.`); + return support; + } + + /** Replace the provider's credential, then refresh its models with it. */ + private async store( + providerId: string, + credential: Credential, + options: AuthOperationOptions | undefined, + ): Promise { + await this.credentials.modify(providerId, async () => credential, options); + return this.models.refresh({ providers: [providerId], signal: options?.signal }); + } + + /** Fetch the documents; a newer revision replaces the current one and is cached. */ + private async refreshDocuments(signal: AbortSignal | undefined): Promise { + const response = await fetch(new URL(DOCUMENTS_PATH, this.piUrl), { + headers: this.etag + ? { accept: "application/json", "if-none-match": this.etag } + : { accept: "application/json" }, + signal, + }); + if (response.status === 304) return; + if (!response.ok) throw new Error(`Auth documents request failed: ${response.status}`); + const documents = parseAuthDocuments(await response.json()); + if (!documents) throw new Error("pi.dev sent invalid auth documents"); + if (documents.revision < this.documents.revision) return; + const etag = response.headers.get("etag") ?? undefined; + await this.database.put("documents", DOCUMENTS_KEY, { documents, etag } satisfies CachedDocuments); + this.documents = documents; + this.etag = etag; + } +} diff --git a/packages/connect/src/credential-store.ts b/packages/connect/src/credential-store.ts new file mode 100644 index 000000000..fe80dcf33 --- /dev/null +++ b/packages/connect/src/credential-store.ts @@ -0,0 +1,65 @@ +/// + +// pi-ai's CredentialStore in IndexedDB, serialized across tabs with Web Locks. + +import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai"; +import type { ConnectDatabase } from "./database.ts"; + +/** + * pi-ai refreshes an OAuth token inside `modify`, and some providers rotate the refresh token on + * every refresh. Two tabs refreshing at once would both spend the same refresh token, and the slower + * one would store a token the provider has already revoked. A Web Lock per provider makes the read, + * the refresh and the write one critical section across every tab of the origin. An IndexedDB + * transaction cannot span the refresh's network request, so the lock provides the exclusion and each + * read and write is its own transaction. As pi-ai requires, a signal cancels only the wait for the lock. + */ +export class IndexedDbCredentialStore implements CredentialStore { + private readonly database: ConnectDatabase; + private readonly lockPrefix: string; + private readonly locks: LockManager; + + /** `lockPrefix` names this database's locks, so two databases on one origin do not wait for each other. */ + constructor(database: ConnectDatabase, lockPrefix: string, locks: LockManager = navigator.locks) { + this.database = database; + this.lockPrefix = lockPrefix; + this.locks = locks; + } + + async read(providerId: string, options?: AuthOperationOptions): Promise { + options?.signal?.throwIfAborted(); + // Only this store writes the credentials store of its database. + return (await this.database.get("credentials", providerId)) as Credential | undefined; + } + + async list(options?: AuthOperationOptions): Promise { + const infos: CredentialInfo[] = []; + for (const providerId of await this.database.keys("credentials")) { + const credential = await this.read(providerId, options); + if (credential) infos.push({ providerId, type: credential.type }); + } + return infos; + } + + modify( + providerId: string, + fn: (current: Credential | undefined) => Promise, + options?: AuthOperationOptions, + ): Promise { + return this.locks.request(this.lockName(providerId), { signal: options?.signal }, async () => { + const current = await this.read(providerId); + const next = await fn(current); + if (next !== undefined) await this.database.put("credentials", providerId, next); + return next ?? current; + }); + } + + delete(providerId: string, options?: AuthOperationOptions): Promise { + return this.locks.request(this.lockName(providerId), { signal: options?.signal }, () => + this.database.delete("credentials", providerId), + ); + } + + private lockName(providerId: string): string { + return `${this.lockPrefix}:credential:${providerId}`; + } +} diff --git a/packages/connect/src/database.ts b/packages/connect/src/database.ts new file mode 100644 index 000000000..14653792b --- /dev/null +++ b/packages/connect/src/database.ts @@ -0,0 +1,72 @@ +/// + +// The IndexedDB database where pi-connect keeps credentials, model catalogs and auth documents. + +export type StoreName = "credentials" | "models" | "documents"; + +const STORES: readonly StoreName[] = ["credentials", "models", "documents"]; + +const DATABASE_VERSION = 1; + +/** One open pi-connect database: key-value stores of structured-cloned values. */ +export class ConnectDatabase { + private readonly db: IDBDatabase; + + private constructor(db: IDBDatabase) { + this.db = db; + } + + /** Open (and on first use create) the database `name`. */ + static open(name: string, factory: IDBFactory = indexedDB): Promise { + const request = factory.open(name, DATABASE_VERSION); + request.onupgradeneeded = () => { + for (const store of STORES) { + if (!request.result.objectStoreNames.contains(store)) request.result.createObjectStore(store); + } + }; + return settle(request).then((db) => new ConnectDatabase(db)); + } + + /** Read one value. */ + get(store: StoreName, key: string): Promise { + return settle(this.db.transaction(store, "readonly").objectStore(store).get(key)); + } + + /** List the keys of a store. */ + async keys(store: StoreName): Promise { + const keys = await settle(this.db.transaction(store, "readonly").objectStore(store).getAllKeys()); + return keys.map(String); + } + + /** Write one value; resolves once the write is committed. */ + put(store: StoreName, key: string, value: unknown): Promise { + return this.write(store, (objects) => objects.put(value, key)); + } + + /** Delete one value; resolves once the deletion is committed. */ + delete(store: StoreName, key: string): Promise { + return this.write(store, (objects) => objects.delete(key)); + } + + /** Close the connection. */ + close(): void { + this.db.close(); + } + + private write(store: StoreName, change: (objects: IDBObjectStore) => void): Promise { + const transaction = this.db.transaction(store, "readwrite"); + change(transaction.objectStore(store)); + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted")); + }); + } +} + +function settle(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} diff --git a/packages/connect/src/documents.ts b/packages/connect/src/documents.ts new file mode 100644 index 000000000..96a5c9c72 --- /dev/null +++ b/packages/connect/src/documents.ts @@ -0,0 +1,107 @@ +// Auth documents: what pi.dev publishes about each provider's sign-in methods in browsers. + +/** + * Documents carry metadata only. They say which methods work in a browser and why, never where a + * request goes: endpoints and client IDs stay in pi-ai's flow code, so a document cannot send a + * credential anywhere new. Outside browsers every method works directly, so documents describe + * browsers alone. + */ +export interface AuthDocuments { + version: 1; + /** Sortable revision, e.g. "2026-07-14.1"; newer documents replace older ones. */ + revision: string; + providers: AuthDocument[]; +} + +export interface AuthDocument { + /** pi-ai provider ID. */ + provider: string; + name: string; + /** Whether a browser can call the provider's model API. Without it no method helps. */ + inference: { browser: "direct" | "unsupported"; reason?: string }; + methods: AuthMethodDocument[]; +} + +/** How a method signs in. */ +export type AuthMethodKind = "api_key" | "device_code" | "pkce_copy_code" | "pkce_loopback" | "pkce_web"; + +export interface AuthMethodDocument { + /** + * For OAuth, the option ID the provider's pi-ai flow offers in its method selection ("copy_code", + * "device-code"); flows with a single method ignore it. + */ + id: string; + type: "api_key" | "oauth"; + kind: AuthMethodKind; + label: string; + browser: BrowserSupport; +} + +export interface BrowserSupport { + /** + * `direct`: completes in the browser. `manual`: the provider redirects to a local server, so the + * user pastes the address of the page that fails to load. `unsupported`: not possible. + */ + signIn: "direct" | "manual" | "unsupported"; + /** + * Which requests go through pi.dev's relay because the provider's endpoints send no CORS headers: + * none, the sign-in only, or the sign-in and every token refresh. + */ + relay: "none" | "login" | "always"; + /** Why the method is not plain `direct`; shown to users. */ + reason?: string; +} + +/** A method's support in one word, as the documentation names it. */ +export type BrowserTier = "direct" | "relay-login" | "relay-always" | "manual" | "unsupported"; + +/** Name the tier of a method's browser support. */ +export function browserTier(support: BrowserSupport): BrowserTier { + if (support.signIn !== "direct") return support.signIn; + if (support.relay === "none") return "direct"; + return support.relay === "login" ? "relay-login" : "relay-always"; +} + +/** Parse auth documents received from pi.dev, or undefined when they are not valid (wire input). */ +export function parseAuthDocuments(value: unknown): AuthDocuments | undefined { + if (!isRecord(value) || value.version !== 1 || typeof value.revision !== "string") return undefined; + if (!Array.isArray(value.providers) || !value.providers.every(isAuthDocument)) return undefined; + return { version: 1, revision: value.revision, providers: value.providers }; +} + +function isAuthDocument(value: unknown): value is AuthDocument { + return ( + isRecord(value) && + typeof value.provider === "string" && + typeof value.name === "string" && + isRecord(value.inference) && + (value.inference.browser === "direct" || value.inference.browser === "unsupported") && + isOptionalString(value.inference.reason) && + Array.isArray(value.methods) && + value.methods.every(isAuthMethodDocument) + ); +} + +const METHOD_KINDS: readonly unknown[] = ["api_key", "device_code", "pkce_copy_code", "pkce_loopback", "pkce_web"]; + +function isAuthMethodDocument(value: unknown): value is AuthMethodDocument { + return ( + isRecord(value) && + typeof value.id === "string" && + (value.type === "api_key" || value.type === "oauth") && + METHOD_KINDS.includes(value.kind) && + typeof value.label === "string" && + isRecord(value.browser) && + ["direct", "manual", "unsupported"].includes(value.browser.signIn as string) && + ["none", "login", "always"].includes(value.browser.relay as string) && + isOptionalString(value.browser.reason) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} diff --git a/packages/connect/src/errors.ts b/packages/connect/src/errors.ts new file mode 100644 index 000000000..afa2691f1 --- /dev/null +++ b/packages/connect/src/errors.ts @@ -0,0 +1,27 @@ +// The errors pi-connect rejects with, each with a code an app can act on. + +/** + * `unknown_provider`: the app did not configure the provider. `unavailable`: the method does not work + * in this browser (the message says why). `popup_blocked`: the browser refused to open the sign-in + * window, usually because `signIn` did not run in a click handler. `popup_closed`: the window closed + * before the sign-in finished. `cancelled`: the user cancelled. `sign_in_failed`: the provider's + * sign-in failed (the message says how). + */ +export type ConnectErrorCode = + | "unknown_provider" + | "unavailable" + | "popup_blocked" + | "popup_closed" + | "cancelled" + | "sign_in_failed"; + +/** A pi-connect failure. */ +export class ConnectError extends Error { + readonly code: ConnectErrorCode; + + constructor(code: ConnectErrorCode, message: string) { + super(message); + this.name = "ConnectError"; + this.code = code; + } +} diff --git a/packages/connect/src/index.ts b/packages/connect/src/index.ts new file mode 100644 index 000000000..49fe1591e --- /dev/null +++ b/packages/connect/src/index.ts @@ -0,0 +1,34 @@ +// pi-connect: sign in to model providers from the browser, with what works and why published by pi.dev. + +export { BUILTIN_AUTH_DOCUMENTS } from "./builtin-documents.ts"; +export { type Connect, type ConnectOptions, openConnect } from "./connect.ts"; +export { IndexedDbCredentialStore } from "./credential-store.ts"; +export { ConnectDatabase } from "./database.ts"; +export { + type AuthDocument, + type AuthDocuments, + type AuthMethodDocument, + type AuthMethodKind, + type BrowserSupport, + type BrowserTier, + browserTier, + parseAuthDocuments, +} from "./documents.ts"; +export { ConnectError, type ConnectErrorCode } from "./errors.ts"; +export { IndexedDbModelsStore } from "./models-store.ts"; +export { type PopupHost, type PopupSignIn, signInWithPopup } from "./popup.ts"; +export { + findRelayEndpoint, + RELAY_ENDPOINTS, + RELAY_PATH, + type RelayAccess, + type RelayEndpoint, + relayFetch, +} from "./relay.ts"; +export { + browserRefusal, + describeSupport, + type MethodSupport, + type ProviderSupport, + type SupportOptions, +} from "./support.ts"; diff --git a/packages/connect/src/messages.ts b/packages/connect/src/messages.ts new file mode 100644 index 000000000..b1c12253f --- /dev/null +++ b/packages/connect/src/messages.ts @@ -0,0 +1,79 @@ +// The messages between an app and pi.dev's sign-in popup, sent with window.postMessage. + +import type { OAuthCredential } from "@earendil-works/pi-ai"; + +/** + * 1. The app opens `/connect` with `window.open` and listens for messages from that window. + * 2. The popup sends `ready` to its opener with target origin "*"; it carries nothing. + * 3. The app answers `hello` with target origin pi.dev's: provider, method, a nonce, and whether it + * allows manual sign-ins. + * 4. The popup takes the app's origin from the `hello` event. The browser sets `event.origin`, so the + * page cannot claim another. The popup shows that origin, runs the sign-in and sends `credential`, + * `error` or `cancelled` to that origin only. + * + * The app accepts only messages from the window it opened, from pi.dev's origin, with its nonce. + */ +export const CONNECT_PROTOCOL = "pi-connect/1"; + +/** From the app to the popup. */ +export interface HelloMessage { + protocol: typeof CONNECT_PROTOCOL; + type: "hello"; + nonce: string; + provider: string; + method: string; + manual: boolean; +} + +/** From the popup to the app. */ +export type PopupMessage = + | { protocol: typeof CONNECT_PROTOCOL; type: "ready" } + | { protocol: typeof CONNECT_PROTOCOL; type: "credential"; nonce: string; credential: OAuthCredential } + | { protocol: typeof CONNECT_PROTOCOL; type: "error"; nonce: string; message: string } + | { protocol: typeof CONNECT_PROTOCOL; type: "cancelled"; nonce: string }; + +/** Parse a message the popup received (wire input from any page that opened it). */ +export function parseHelloMessage(data: unknown): HelloMessage | undefined { + if (!isProtocolMessage(data) || data.type !== "hello") return undefined; + const { nonce, provider, method, manual } = data; + if (typeof nonce !== "string" || typeof provider !== "string" || typeof method !== "string") return undefined; + if (typeof manual !== "boolean") return undefined; + return { protocol: CONNECT_PROTOCOL, type: "hello", nonce, provider, method, manual }; +} + +/** Parse a message the app received from the popup (wire input across origins). */ +export function parsePopupMessage(data: unknown): PopupMessage | undefined { + if (!isProtocolMessage(data)) return undefined; + if (data.type === "ready") return { protocol: CONNECT_PROTOCOL, type: "ready" }; + if (typeof data.nonce !== "string") return undefined; + const nonce = data.nonce; + switch (data.type) { + case "credential": + return isOAuthCredential(data.credential) + ? { protocol: CONNECT_PROTOCOL, type: "credential", nonce, credential: data.credential } + : undefined; + case "error": + return typeof data.message === "string" + ? { protocol: CONNECT_PROTOCOL, type: "error", nonce, message: data.message } + : undefined; + case "cancelled": + return { protocol: CONNECT_PROTOCOL, type: "cancelled", nonce }; + default: + return undefined; + } +} + +function isProtocolMessage(data: unknown): data is Record { + return typeof data === "object" && data !== null && (data as { protocol?: unknown }).protocol === CONNECT_PROTOCOL; +} + +function isOAuthCredential(value: unknown): value is OAuthCredential { + if (typeof value !== "object" || value === null) return false; + const credential = value as Record; + return ( + credential.type === "oauth" && + typeof credential.access === "string" && + typeof credential.refresh === "string" && + typeof credential.expires === "number" + ); +} diff --git a/packages/connect/src/models-store.ts b/packages/connect/src/models-store.ts new file mode 100644 index 000000000..21cdf72a6 --- /dev/null +++ b/packages/connect/src/models-store.ts @@ -0,0 +1,32 @@ +// pi-ai's ModelsStore in IndexedDB: the model catalogs providers refreshed, kept across page loads. + +import type { ModelsStore, ModelsStoreEntry, ModelsStoreOperationOptions } from "@earendil-works/pi-ai"; +import type { ConnectDatabase } from "./database.ts"; + +/** + * Catalogs are caches: two tabs writing one provider's catalog both write a valid catalog, so the + * last write wins and no lock is needed. pi-ai serializes a provider's writes within one page. + */ +export class IndexedDbModelsStore implements ModelsStore { + private readonly database: ConnectDatabase; + + constructor(database: ConnectDatabase) { + this.database = database; + } + + async read(providerId: string, options?: ModelsStoreOperationOptions): Promise { + options?.signal?.throwIfAborted(); + // Only this store writes the models store of its database. + return (await this.database.get("models", providerId)) as ModelsStoreEntry | undefined; + } + + async write(providerId: string, entry: ModelsStoreEntry, options?: ModelsStoreOperationOptions): Promise { + options?.signal?.throwIfAborted(); + await this.database.put("models", providerId, entry); + } + + async delete(providerId: string, options?: ModelsStoreOperationOptions): Promise { + options?.signal?.throwIfAborted(); + await this.database.delete("models", providerId); + } +} diff --git a/packages/connect/src/page.ts b/packages/connect/src/page.ts new file mode 100644 index 000000000..7f86af6d4 --- /dev/null +++ b/packages/connect/src/page.ts @@ -0,0 +1,328 @@ +/// + +// pi.dev's sign-in popup: shows which site asks, runs the provider's sign-in, hands the credential to that site. + +import type { + AuthEvent, + AuthPrompt, + LoginOptions, + OAuthAuth, + OAuthCredential, + ProviderAuthInteraction, +} from "@earendil-works/pi-ai"; +import { getBuiltinOAuthFlow, registerOAuthFlows } from "@earendil-works/pi-ai/oauth-flows"; +import { BUILTIN_AUTH_DOCUMENTS } from "./builtin-documents.ts"; +import type { AuthDocument, AuthMethodDocument } from "./documents.ts"; +import { CONNECT_PROTOCOL, type HelloMessage, type PopupMessage, parseHelloMessage } from "./messages.ts"; +import { relayFetch } from "./relay.ts"; +import { browserRefusal } from "./support.ts"; + +/** + * Run the popup in `root`. The page learns the asking site from the browser (`event.origin` of the + * app's hello), shows it before anything happens, and sends the credential to that origin only. It + * keeps no credential: the sign-in lives in this page's memory. Only the ChatGPT sign-in's device ID + * (one per asking site) stays in localStorage, because OpenAI identifies an installation by it. + * Requests that browsers cannot send go through the relay of the origin serving this page. + * + * Other pages of the same origin must not reach into this window while it holds a credential. They + * could only find it by its name, so the page drops any name it was opened with. + */ +export function runConnectPage(root: HTMLElement): void { + window.name = ""; + registerOAuthFlows({ fetch: relayFetch(location.origin) }); + const view = new PopupView(root); + const opener = window.opener as WindowProxy | null; + if (!opener) { + view.page("Nothing to sign in to", paragraph("Open this page from a site's sign-in button.")); + return; + } + void connect(opener, view); +} + +/** A sign-in the page can run. */ +interface SignInRequest { + document: AuthDocument; + method: AuthMethodDocument; + flow: OAuthAuth; +} + +async function connect(opener: WindowProxy, view: PopupView): Promise { + view.page("Connecting", paragraph("Waiting for the site that opened this window.")); + const { hello, origin } = await waitForHello(opener); + const reply = (message: PopupMessage) => opener.postMessage(message, origin); + const request = resolveRequest(hello, origin); + if (typeof request === "string") { + view.page("This sign-in is not available", paragraph(request)); + view.actions(button("Close", () => window.close())); + reply({ protocol: CONNECT_PROTOCOL, type: "error", nonce: hello.nonce, message: request }); + return; + } + + const controller = new AbortController(); + const cancel = () => { + controller.abort(); + reply({ protocol: CONNECT_PROTOCOL, type: "cancelled", nonce: hello.nonce }); + window.close(); + }; + if (!(await view.consent(origin, request.document, request.method))) return cancel(); + + view.page(`Sign in to ${request.document.name}`); + view.actions(button("Cancel", cancel)); + let credential: OAuthCredential; + try { + credential = await request.flow.login(interaction(view, request.method, controller.signal), loginOptions(origin)); + } catch (error) { + if (controller.signal.aborted) return; + const message = error instanceof Error ? error.message : String(error); + view.page("Sign-in failed", paragraph(message)); + view.actions(button("Close", () => window.close())); + reply({ protocol: CONNECT_PROTOCOL, type: "error", nonce: hello.nonce, message }); + return; + } + reply({ protocol: CONNECT_PROTOCOL, type: "credential", nonce: hello.nonce, credential }); + view.page("Signed in", paragraph(`${origin} has your credential. You can close this window.`)); + window.close(); +} + +/** Announce the page to its opener and wait for the opener's hello. */ +function waitForHello(opener: WindowProxy): Promise<{ hello: HelloMessage; origin: string }> { + return new Promise((resolve) => { + const onMessage = (event: MessageEvent) => { + if (event.source !== opener) return; + const hello = parseHelloMessage(event.data); + if (!hello) return; + window.removeEventListener("message", onMessage); + resolve({ hello, origin: event.origin }); + }; + window.addEventListener("message", onMessage); + const ready: PopupMessage = { protocol: CONNECT_PROTOCOL, type: "ready" }; + opener.postMessage(ready, "*"); + }); +} + +/** The sign-in a hello asks for, or why this page cannot run it. */ +function resolveRequest(hello: HelloMessage, origin: string): SignInRequest | string { + // Opaque origins (sandboxed frames, file pages) cannot receive a targeted message. + if (origin === "null") return "The page that opened this window has no origin to send a credential to."; + const document = BUILTIN_AUTH_DOCUMENTS.providers.find((candidate) => candidate.provider === hello.provider); + const method = document?.methods.find((candidate) => candidate.id === hello.method && candidate.type === "oauth"); + const flow = getBuiltinOAuthFlow(hello.provider); + if (!document || !method || !flow) return `pi.dev cannot sign in to "${hello.provider}" with "${hello.method}".`; + return browserRefusal(document, method, { manual: hello.manual }) ?? { document, method, flow }; +} + +function loginOptions(origin: string): LoginOptions { + return { getDeviceId: () => deviceId(origin), agentName: new URL(origin).host }; +} + +function deviceId(origin: string): string { + const key = `pi-connect:device:${origin}`; + const stored = localStorage.getItem(key); + if (stored) return stored; + const created = crypto.randomUUID(); + localStorage.setItem(key, created); + return created; +} + +/** The interaction a flow drives; the method selection is answered with the method the site asked for. */ +function interaction(view: PopupView, method: AuthMethodDocument, signal: AbortSignal): ProviderAuthInteraction { + return { + signal, + prompt: (prompt) => + prompt.type === "select" && prompt.options.some((option) => option.id === method.id) + ? Promise.resolve(method.id) + : view.ask(prompt, signal), + notify: (event) => view.show(event), + }; +} + +/** The popup's DOM: a title, a body, a status line and actions. pi.dev styles the `connect-*` classes. */ +class PopupView { + private readonly title: HTMLElement; + private readonly body: HTMLElement; + private readonly statusLine: HTMLElement; + private readonly actionBar: HTMLElement; + + constructor(root: HTMLElement) { + this.title = element("h1", "connect-title"); + this.body = element("div", "connect-body"); + this.statusLine = element("p", "connect-status"); + this.statusLine.setAttribute("aria-live", "polite"); + this.actionBar = element("div", "connect-actions"); + root.replaceChildren(this.title, this.body, this.statusLine, this.actionBar); + } + + /** Show a new step. */ + page(title: string, ...children: Node[]): void { + this.title.textContent = title; + this.body.replaceChildren(...children); + this.statusLine.textContent = ""; + this.actionBar.replaceChildren(); + } + + actions(...buttons: HTMLButtonElement[]): void { + this.actionBar.replaceChildren(...buttons); + } + + /** Ask whether `origin` may receive a credential for the method. */ + consent(origin: string, document: AuthDocument, method: AuthMethodDocument): Promise { + const site = element("strong"); + site.textContent = origin; + const ask = element("p"); + ask.append(site, ` asks to sign in to your ${document.name} account: ${method.label}.`); + const notes = [ + ask, + paragraph(`When you finish, pi.dev sends the credential to ${origin} only and keeps nothing.`), + ]; + if (method.browser.reason) notes.push(paragraph(method.browser.reason)); + this.page(`Sign in to ${document.name}?`, ...notes); + return new Promise((resolve) => { + const proceed = button("Continue", () => resolve(true)); + armWhenSeen(proceed); + this.actions( + proceed, + button("Cancel", () => resolve(false)), + ); + }); + } + + /** Render a flow event. */ + show(event: AuthEvent): void { + switch (event.type) { + case "progress": + this.statusLine.textContent = event.message; + return; + case "info": + this.statusLine.textContent = event.message; + for (const link of event.links ?? []) { + const line = paragraph(); + line.append(anchor(link.label ?? link.url, link.url)); + this.body.append(line); + } + return; + case "auth_url": + if (event.instructions) this.body.append(paragraph(event.instructions)); + this.body.append(anchor("Open the sign-in page", event.url)); + return; + case "device_code": { + const code = element("code", "connect-code"); + code.textContent = event.userCode; + const copy = button("Copy", () => void navigator.clipboard.writeText(event.userCode)); + const where = isWebAddress(event.verificationUri) ? new URL(event.verificationUri).host : "the provider"; + this.body.append( + paragraph(`Enter this code at ${where}:`), + code, + copy, + anchor("Open the sign-in page", event.verificationUri), + ); + return; + } + } + } + + /** Render a prompt and resolve with the answer; rejects when the sign-in is cancelled. */ + ask(prompt: AuthPrompt, signal: AbortSignal): Promise { + const form = element("form", "connect-prompt"); + const done = AbortSignal.any(prompt.signal ? [signal, prompt.signal] : [signal]); + const answer = new Promise((resolve, reject) => { + const settle = (value: string) => { + form.remove(); + resolve(value); + }; + done.addEventListener( + "abort", + () => { + form.remove(); + reject(new Error("Login cancelled")); + }, + { once: true }, + ); + if (prompt.type === "select") { + form.append( + paragraph(prompt.message), + ...prompt.options.map((option) => button(option.label, () => settle(option.id))), + ); + return; + } + const input = element("input", "connect-input"); + input.type = prompt.type === "secret" ? "password" : "text"; + input.placeholder = prompt.placeholder ?? ""; + input.autocomplete = "off"; + const label = element("label"); + label.append(prompt.message, input); + const submit = element("button"); + submit.type = "submit"; + submit.textContent = "Continue"; + form.append(label, submit); + form.addEventListener("submit", (event) => { + event.preventDefault(); + settle(input.value); + }); + }); + this.body.append(form); + return answer; + } +} + +function element(tag: K, className?: string): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + return node; +} + +function paragraph(text = ""): HTMLParagraphElement { + const node = element("p"); + node.textContent = text; + return node; +} + +function button(label: string, onClick: () => void): HTMLButtonElement { + const node = element("button", "connect-button"); + node.type = "button"; + node.textContent = label; + node.addEventListener("click", onClick); + return node; +} + +const ARM_DELAY_MS = 700; + +/** + * Enable a control only once the window has been visible for a moment. A popup opens under the + * pointer, so the second click of a double-click aimed at the opening page would otherwise land on + * Continue (double-click hijacking). + */ +function armWhenSeen(control: HTMLButtonElement): void { + control.disabled = true; + const onVisible = () => { + if (document.visibilityState !== "visible") return; + document.removeEventListener("visibilitychange", onVisible); + setTimeout(() => { + control.disabled = false; + }, ARM_DELAY_MS); + }; + document.addEventListener("visibilitychange", onVisible); + onVisible(); +} + +/** + * A link that opens in a new tab. Addresses come from providers' responses (wire input); anything but + * http(s), such as `javascript:`, would run in this page, so it is shown as text instead. + */ +function anchor(label: string, href: string): HTMLElement { + if (!isWebAddress(href)) return paragraph(`${label}: the provider sent an address that is not a web page.`); + const node = element("a", "connect-link"); + node.href = href; + node.target = "_blank"; + node.rel = "noopener noreferrer"; + node.textContent = label; + return node; +} + +function isWebAddress(href: string): boolean { + try { + const protocol = new URL(href).protocol; + return protocol === "https:" || protocol === "http:"; + } catch { + return false; + } +} diff --git a/packages/connect/src/popup.ts b/packages/connect/src/popup.ts new file mode 100644 index 000000000..60853b269 --- /dev/null +++ b/packages/connect/src/popup.ts @@ -0,0 +1,95 @@ +/// + +// The app side of pi.dev's sign-in popup: open it, introduce the app, receive the credential. + +import type { OAuthCredential } from "@earendil-works/pi-ai"; +import { ConnectError } from "./errors.ts"; +import { CONNECT_PROTOCOL, type HelloMessage, parsePopupMessage } from "./messages.ts"; + +/** What to sign in to. */ +export interface PopupSignIn { + provider: string; + method: string; + /** Whether the app allows sign-ins that end with pasting an address. */ + manual: boolean; +} + +/** The page's window, as far as the popup needs it. */ +export interface PopupHost { + open(url: string, target: string, features: string): WindowProxy | null; + addEventListener(type: "message", listener: (event: MessageEvent) => void): void; + removeEventListener(type: "message", listener: (event: MessageEvent) => void): void; +} + +const POPUP_FEATURES = "popup,width=480,height=720"; +const CLOSED_POLL_MS = 500; + +/** + * Sign in through pi.dev's popup and resolve with the credential. Call it from a click handler: + * browsers block windows opened otherwise. + */ +export function signInWithPopup( + piUrl: string, + request: PopupSignIn, + signal?: AbortSignal, + host: PopupHost = window, +): Promise { + const piOrigin = new URL(piUrl).origin; + const hello: HelloMessage = { protocol: CONNECT_PROTOCOL, type: "hello", nonce: crypto.randomUUID(), ...request }; + return new Promise((resolve, reject) => { + let popup: WindowProxy | null = null; + let introduced = false; + const finish = (result: { credential: OAuthCredential } | { error: unknown }) => { + clearInterval(closedPoll); + host.removeEventListener("message", onMessage); + signal?.removeEventListener("abort", onAbort); + if ("credential" in result) resolve(result.credential); + else reject(result.error); + }; + const onMessage = (event: MessageEvent) => { + if (event.source !== popup || event.origin !== piOrigin) return; + const message = parsePopupMessage(event.data); + if (!message) return; + if (message.type === "ready") { + introduced = true; + popup?.postMessage(hello, piOrigin); + return; + } + if (message.nonce !== hello.nonce) return; + if (message.type === "credential") finish({ credential: message.credential }); + else if (message.type === "error") finish({ error: new ConnectError("sign_in_failed", message.message) }); + else finish({ error: new ConnectError("cancelled", "Sign-in was cancelled.") }); + }; + // Only the app's own abort closes the window; after a failure it shows the user what went wrong. + const onAbort = () => { + popup?.close(); + finish({ error: signal?.reason }); + }; + const closedPoll = setInterval(() => { + if (!popup?.closed) return; + finish({ error: new ConnectError("popup_closed", closedMessage(introduced)) }); + }, CLOSED_POLL_MS); + + host.addEventListener("message", onMessage); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) return onAbort(); + // `_blank`, never a name: `window.open` with a name reuses an existing window of that name, whose + // opener (and so the origin the popup trusts) would be whoever created it. + popup = host.open(new URL("/connect", piOrigin).href, "_blank", POPUP_FEATURES); + if (!popup) { + finish({ + error: new ConnectError( + "popup_blocked", + "The browser blocked the sign-in window. Start the sign-in from a click.", + ), + }); + } + }); +} + +function closedMessage(introduced: boolean): string { + if (introduced) return "The sign-in window was closed before the sign-in finished."; + // Without `ready`, the popup never reached this page: a Cross-Origin-Opener-Policy of `same-origin` + // cuts the link between the two windows. + return "The sign-in window closed before it could reach this page. If this page sends Cross-Origin-Opener-Policy: same-origin, use same-origin-allow-popups."; +} diff --git a/packages/connect/src/relay.ts b/packages/connect/src/relay.ts new file mode 100644 index 000000000..94511493c --- /dev/null +++ b/packages/connect/src/relay.ts @@ -0,0 +1,114 @@ +// The relay: provider auth requests that browsers cannot send directly, forwarded by pi.dev. + +import type { OAuthFetch } from "@earendil-works/pi-ai/oauth-flows"; + +/** + * Some providers' auth endpoints send no CORS headers, so a browser cannot read their responses. + * pi.dev forwards requests to exactly these endpoints and stores nothing: no database, no logs of + * bodies. The list below is both the client's routing table and pi.dev's allowlist (pi.dev generates + * its copy from this file). + * + * Example: Anthropic's token refresh is `POST https://platform.claude.com/v1/oauth/token`. In a + * browser, `relayFetch("https://pi.dev")` sends it as + * `POST https://pi.dev/api/oauth/relay?url=https%3A%2F%2Fplatform.claude.com%2Fv1%2Foauth%2Ftoken` + * with the same headers and body, and returns Anthropic's answer. + */ + +/** + * Who may send a relayed request. `connect`: only pi.dev's sign-in popup, so every sign-in passes + * the page that shows the user which site receives the credential. `any`: any page, for token + * refreshes, which need a credential the page already holds. + */ +export type RelayAccess = "connect" | "any"; + +export interface RelayEndpoint { + provider: string; + method: "GET" | "POST"; + /** Exact URL, without a query. */ + url: string; + /** + * One rule for the endpoint, or a rule per OAuth `grant_type` of the request body for token + * endpoints that serve both sign-in and refresh. The relay refuses other grant types. + */ + access: RelayAccess | Readonly>; + /** Request headers the relay forwards besides `accept` and `content-type`. */ + headers?: readonly string[]; +} + +/** The path of the relay on pi.dev; the target goes in the `url` query parameter. */ +export const RELAY_PATH = "/api/oauth/relay"; + +const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +const GITHUB_HEADERS = ["user-agent"]; + +/** Every endpoint the relay forwards to. */ +export const RELAY_ENDPOINTS: readonly RelayEndpoint[] = [ + { + provider: "anthropic", + method: "POST", + url: "https://platform.claude.com/v1/oauth/token", + access: { authorization_code: "connect", refresh_token: "any" }, + }, + { + provider: "github-copilot", + method: "POST", + url: "https://github.com/login/device/code", + access: "connect", + headers: GITHUB_HEADERS, + }, + { + provider: "github-copilot", + method: "POST", + url: "https://github.com/login/oauth/access_token", + access: "connect", + headers: GITHUB_HEADERS, + }, + { + provider: "github-copilot", + method: "GET", + url: "https://api.github.com/copilot_internal/v2/token", + access: "any", + headers: [ + ...GITHUB_HEADERS, + "authorization", + "editor-version", + "editor-plugin-version", + "copilot-integration-id", + ], + }, + { provider: "xai", method: "POST", url: "https://auth.x.ai/oauth2/device/code", access: "connect" }, + { + provider: "xai", + method: "POST", + url: "https://auth.x.ai/oauth2/token", + access: { [DEVICE_CODE_GRANT]: "connect", refresh_token: "any" }, + }, + { provider: "meta", method: "POST", url: "https://auth.meta.com/oidc/device/authorization/", access: "connect" }, + { provider: "meta", method: "POST", url: "https://auth.meta.com/oidc/device/token/", access: "connect" }, + { + provider: "meta", + method: "POST", + url: "https://api.meta.ai/muse-code/key", + access: "any", + headers: ["authorization", "x-api-version"], + }, +]; + +/** A fetch that sends requests to relayed endpoints through the relay at `relayOrigin`, all others directly. */ +export function relayFetch(relayOrigin: string): OAuthFetch { + return (input, init) => { + const url = new URL(input); + if (!findRelayEndpoint(url, init?.method ?? "GET")) return fetch(input, init); + const relayed = new URL(RELAY_PATH, relayOrigin); + relayed.searchParams.set("url", url.href); + return fetch(relayed, init); + }; +} + +/** The relayed endpoint a request goes to, if any. */ +export function findRelayEndpoint(url: URL, method: string): RelayEndpoint | undefined { + if (url.search || url.hash) return undefined; + const target = `${url.origin}${url.pathname}`; + const upper = method.toUpperCase(); + return RELAY_ENDPOINTS.find((endpoint) => endpoint.url === target && endpoint.method === upper); +} diff --git a/packages/connect/src/support.ts b/packages/connect/src/support.ts new file mode 100644 index 000000000..a222c2bd4 --- /dev/null +++ b/packages/connect/src/support.ts @@ -0,0 +1,120 @@ +// What works in this browser: auth documents applied to the providers an app configured. + +import type { Provider } from "@earendil-works/pi-ai"; +import { type AuthDocument, type AuthMethodDocument, type BrowserTier, browserTier } from "./documents.ts"; + +export interface MethodSupport { + id: string; + type: "api_key" | "oauth"; + label: string; + /** `unknown` when pi.dev has no document for the provider. */ + tier: BrowserTier | "unknown"; + /** Whether the app can use the method in this browser. */ + available: boolean; + /** Why the method is unavailable, or what an available one involves (the relay, pasting). */ + reason?: string; +} + +export interface ProviderSupport { + provider: string; + name: string; + /** Whether a browser can call the provider's model API; `unknown` without a document. */ + inference: "direct" | "unsupported" | "unknown"; + /** Whether at least one method is available. */ + available: boolean; + /** Why the provider is unavailable, or that pi.dev has no information about it. */ + reason?: string; + methods: MethodSupport[]; +} + +export interface SupportOptions { + /** Offer methods that end with pasting the address of a page that fails to load. */ + manual: boolean; +} + +const NO_DOCUMENT = "pi.dev has no browser information for this provider; whether it works is untested."; + +/** Describe what a provider supports in a browser, from its auth document when pi.dev has one. */ +export function describeSupport( + provider: Provider, + document: AuthDocument | undefined, + options: SupportOptions, +): ProviderSupport { + if (!document) return describeUndocumented(provider); + const inference = document.inference; + const methods = document.methods.map((method) => describeMethod(provider, document, method, options)); + const available = methods.some((method) => method.available); + return { + provider: provider.id, + name: document.name, + inference: inference.browser, + available, + reason: + inference.browser === "unsupported" + ? inference.reason + : available + ? undefined + : "No sign-in method of this provider works in this browser.", + methods, + }; +} + +function describeMethod( + provider: Provider, + document: AuthDocument, + method: AuthMethodDocument, + options: SupportOptions, +): MethodSupport { + const unavailable = unavailableReason(provider, document, method, options); + return { + id: method.id, + type: method.type, + label: method.label, + tier: browserTier(method.browser), + available: unavailable === undefined, + reason: unavailable ?? method.browser.reason, + }; +} + +function unavailableReason( + provider: Provider, + document: AuthDocument, + method: AuthMethodDocument, + options: SupportOptions, +): string | undefined { + if (!(method.type === "api_key" ? provider.auth.apiKey : provider.auth.oauth)) { + return `This app's ${provider.name} provider has no ${method.type === "api_key" ? "API key" : "OAuth"} sign-in.`; + } + return browserRefusal(document, method, options); +} + +/** Why a documented method cannot be used in a browser with these options, or undefined when it can. */ +export function browserRefusal( + document: AuthDocument, + method: AuthMethodDocument, + options: SupportOptions, +): string | undefined { + if (document.inference.browser === "unsupported") { + return document.inference.reason ?? "The provider's model API does not accept browser requests."; + } + if (method.browser.signIn === "unsupported") return method.browser.reason ?? "Not possible in a browser."; + if (method.browser.signIn === "manual" && !options.manual) { + return `${method.browser.reason ?? "This sign-in ends with pasting an address."} The app has not enabled such sign-ins.`; + } + return undefined; +} + +function describeUndocumented(provider: Provider): ProviderSupport { + const apiKey = provider.auth.apiKey; + const methods: MethodSupport[] = apiKey + ? [{ id: "api_key", type: "api_key", label: apiKey.name, tier: "unknown", available: true, reason: NO_DOCUMENT }] + : []; + return { + provider: provider.id, + name: provider.name, + inference: "unknown", + available: methods.length > 0, + reason: NO_DOCUMENT, + methods, + }; +} diff --git a/packages/connect/test/builtin-documents.test.ts b/packages/connect/test/builtin-documents.test.ts new file mode 100644 index 000000000..a0a6804cf --- /dev/null +++ b/packages/connect/test/builtin-documents.test.ts @@ -0,0 +1,67 @@ +import type { AuthPrompt, OAuthAuth } from "@earendil-works/pi-ai"; +import { getBuiltinOAuthFlow } from "@earendil-works/pi-ai/oauth-flows"; +import { describe, expect, it } from "vitest"; +import { BUILTIN_AUTH_DOCUMENTS } from "../src/builtin-documents.ts"; +import { RELAY_ENDPOINTS } from "../src/relay.ts"; + +const oauthMethods = BUILTIN_AUTH_DOCUMENTS.providers.flatMap((document) => + document.methods.filter((method) => method.type === "oauth").map((method) => ({ document, method })), +); + +describe("BUILTIN_AUTH_DOCUMENTS", () => { + it("explains every method that is not plain direct", () => { + for (const document of BUILTIN_AUTH_DOCUMENTS.providers) { + if (document.inference.browser === "unsupported") expect(document.inference.reason).toBeTruthy(); + for (const method of document.methods) { + if (method.browser.signIn !== "direct" || method.browser.relay !== "none") { + expect(method.browser.reason, `${document.provider}/${method.id}`).toBeTruthy(); + } + } + } + }); + + it("relays exactly the providers whose methods need the relay", () => { + const relayed = new Set( + oauthMethods + .filter(({ method }) => method.browser.relay !== "none" && method.browser.signIn !== "unsupported") + .map(({ document }) => document.provider), + ); + expect(new Set(RELAY_ENDPOINTS.map((endpoint) => endpoint.provider))).toEqual(relayed); + }); + + it("names a pi-ai flow for every OAuth method", () => { + for (const { document } of oauthMethods) + expect(getBuiltinOAuthFlow(document.provider), document.provider).toBeDefined(); + }); + + it("uses the method IDs the flows offer in their selection", async () => { + // Flows with one method ignore the ID; flows with several start with a selection. + const multiMethod = oauthMethods.filter( + ({ document }) => oauthMethods.filter((entry) => entry.document === document).length > 1, + ); + expect(multiMethod.length).toBeGreaterThan(0); + for (const { document, method } of multiMethod) { + const offered = await selectionOffered(getBuiltinOAuthFlow(document.provider) as OAuthAuth); + expect(offered, `${document.provider}/${method.id}`).toContain(method.id); + } + }); +}); + +/** The option IDs of the flow's first prompt when it is a selection; the login stops at that prompt. */ +async function selectionOffered(flow: OAuthAuth): Promise { + let first: AuthPrompt | undefined; + await flow + .login( + { + signal: new AbortController().signal, + prompt: async (prompt) => { + first ??= prompt; + throw new Error("stop"); + }, + notify: () => {}, + }, + { getDeviceId: () => "00000000-0000-4000-8000-000000000000" }, + ) + .catch(() => undefined); + return first?.type === "select" ? first.options.map((option) => option.id) : undefined; +} diff --git a/packages/connect/test/connect.test.ts b/packages/connect/test/connect.test.ts new file mode 100644 index 000000000..758c4a841 --- /dev/null +++ b/packages/connect/test/connect.test.ts @@ -0,0 +1,204 @@ +/// + +import { createProvider, type OAuthCredential, type Provider } from "@earendil-works/pi-ai"; +import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic"; +import { radiusProvider } from "@earendil-works/pi-ai/providers/radius"; +import { IDBFactory } from "fake-indexeddb"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BUILTIN_AUTH_DOCUMENTS } from "../src/builtin-documents.ts"; +import { type ConnectOptions, openConnect } from "../src/connect.ts"; +import type { AuthDocuments } from "../src/documents.ts"; +import { CONNECT_PROTOCOL, type HelloMessage } from "../src/messages.ts"; +import type { PopupHost } from "../src/popup.ts"; +import { memoryLocks } from "./helpers.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const custom: Provider = createProvider({ + id: "custom", + name: "Custom", + auth: { apiKey: { name: "Custom key", resolve: async () => undefined } }, + models: [], + api: { + stream: () => { + throw new Error("not used"); + }, + streamSimple: () => { + throw new Error("not used"); + }, + }, +}); + +const radiusConfig = { + baseUrl: "https://radius.pi.dev/v1", + models: [ + { + id: "account-model", + name: "Account model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }, + ], +}; + +/** Answer requests by URL prefix; anything else fails the test. */ +function serve(routes: Record Response>) { + return vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + const route = Object.keys(routes).find((prefix) => url.startsWith(prefix)); + if (!route) throw new Error(`unexpected request: ${url}`); + return routes[route]!(); + }); +} + +const json = (body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { headers: { "content-type": "application/json", ...headers } }); + +/** A window whose popup answers the app's hello with `credential`, like pi.dev's page does. */ +function popupAnswering(credential: OAuthCredential): PopupHost & { hellos: HelloMessage[] } { + const listeners = new Set<(event: MessageEvent) => void>(); + const hellos: HelloMessage[] = []; + const send = (data: unknown) => { + for (const listener of [...listeners]) + listener({ data, origin: "https://pi.dev", source: popup } as MessageEvent); + }; + const popup = { + closed: false, + close() {}, + postMessage(hello: HelloMessage) { + hellos.push(hello); + queueMicrotask(() => send({ protocol: CONNECT_PROTOCOL, type: "credential", nonce: hello.nonce, credential })); + }, + }; + return { + hellos, + open: () => { + queueMicrotask(() => send({ protocol: CONNECT_PROTOCOL, type: "ready" })); + return popup as unknown as WindowProxy; + }, + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + }; +} + +function options(overrides: Partial = {}): ConnectOptions { + return { + providers: [anthropicProvider(), radiusProvider(), custom], + indexedDB: new IDBFactory(), + locks: memoryLocks(), + ...overrides, + }; +} + +describe("openConnect", () => { + it("reports what each configured provider supports with the bundled documents", async () => { + const connect = await openConnect(options()); + const support = new Map(connect.support().map((entry) => [entry.provider, entry])); + + expect(connect.documents).toBe(BUILTIN_AUTH_DOCUMENTS); + expect(support.get("anthropic")?.methods.map((method) => [method.id, method.tier, method.available])).toEqual([ + ["api_key", "direct", true], + ["copy_code", "relay-always", true], + ["browser", "manual", false], + ]); + expect(support.get("custom")).toMatchObject({ inference: "unknown", available: true }); + connect.close(); + }); + + it("stores an API key, uses it, and forgets it on sign-out", async () => { + serve({ "https://pi.dev/api/models/": () => new Response("", { status: 404 }) }); + const connect = await openConnect(options()); + await connect.setApiKey("anthropic", "sk-test"); + expect(await connect.models.getAuth("anthropic")).toMatchObject({ auth: { apiKey: "sk-test" } }); + await connect.signOut("anthropic"); + expect(await connect.models.checkAuth("anthropic")).toBeUndefined(); + connect.close(); + }); + + it("signs in through the popup, stores the credential and loads the account's models", async () => { + serve({ "https://radius.pi.dev/v1/config": () => json(radiusConfig) }); + const credential = { + type: "oauth" as const, + access: "token", + refresh: "refresh", + expires: Date.now() + 3_600_000, + }; + const window = popupAnswering(credential); + const connect = await openConnect(options({ window })); + + await connect.signIn("radius"); + expect(window.hellos).toEqual([ + expect.objectContaining({ provider: "radius", method: "device-code", manual: false }), + ]); + expect(await connect.models.getAuth("radius")).toMatchObject({ auth: { apiKey: "token" } }); + expect(connect.models.getModels("radius").map((model) => model.id)).toEqual(["account-model"]); + connect.close(); + }); + + it("refuses methods that do not work in this browser before opening a window", async () => { + const open = vi.fn(); + const connect = await openConnect(options({ window: { ...popupAnswering({} as OAuthCredential), open } })); + await expect(connect.signIn("anthropic", "browser")).rejects.toMatchObject({ code: "unavailable" }); + await expect(connect.signIn("nobody")).rejects.toMatchObject({ code: "unknown_provider" }); + expect(open).not.toHaveBeenCalled(); + connect.close(); + }); + + it("adopts newer documents from pi.dev and keeps them across page loads", async () => { + const newer: AuthDocuments = { ...BUILTIN_AUTH_DOCUMENTS, revision: "9999-01-01.1", providers: [] }; + serve({ + "https://pi.dev/api/auth/providers": () => json(newer, { etag: '"docs-1"' }), + "https://pi.dev/api/models/": () => new Response("", { status: 404 }), + "https://radius.pi.dev/v1/config": () => json(radiusConfig), + }); + const factory = new IDBFactory(); + const connect = await openConnect(options({ indexedDB: factory })); + const result = await connect.refresh(); + expect(result.errors.size).toBe(0); + expect(connect.documents.revision).toBe("9999-01-01.1"); + expect(connect.support().find((entry) => entry.provider === "anthropic")?.inference).toBe("unknown"); + connect.close(); + + const reloaded = await openConnect(options({ indexedDB: factory })); + expect(reloaded.documents.revision).toBe("9999-01-01.1"); + reloaded.close(); + }); + + it("keeps its documents when pi.dev serves an older revision", async () => { + serve({ + "https://pi.dev/api/auth/providers": () => json({ ...BUILTIN_AUTH_DOCUMENTS, revision: "2000-01-01.1" }), + "https://pi.dev/api/models/": () => new Response("", { status: 404 }), + "https://radius.pi.dev/v1/config": () => json(radiusConfig), + }); + const connect = await openConnect(options()); + await connect.refresh(); + expect(connect.documents).toBe(BUILTIN_AUTH_DOCUMENTS); + connect.close(); + }); + + it("refuses a pi.dev URL that would carry tokens in plain text", async () => { + await expect(openConnect(options({ piUrl: "http://pi.example" }))).rejects.toThrow("https"); + const local = await openConnect(options({ piUrl: "http://localhost:5173/" })); + local.close(); + }); + + it("refreshes tokens of relayed providers through pi.dev's relay", async () => { + const fetchSpy = serve({ + "https://pi.dev/api/oauth/relay?url=https%3A%2F%2Fplatform.claude.com%2Fv1%2Foauth%2Ftoken": () => + json({ access_token: "fresh", refresh_token: "rotated", expires_in: 3600 }), + }); + const window = popupAnswering({ type: "oauth", access: "stale", refresh: "old", expires: 0 }); + const connect = await openConnect(options({ providers: [anthropicProvider()], window, catalogs: false })); + await connect.signIn("anthropic", "copy_code"); + + expect(await connect.models.getAuth("anthropic")).toMatchObject({ auth: { apiKey: "fresh" } }); + const body = JSON.parse(String(fetchSpy.mock.calls.at(-1)?.[1]?.body)); + expect(body).toMatchObject({ grant_type: "refresh_token", refresh_token: "old" }); + connect.close(); + }); +}); diff --git a/packages/connect/test/credential-store.test.ts b/packages/connect/test/credential-store.test.ts new file mode 100644 index 000000000..858a80093 --- /dev/null +++ b/packages/connect/test/credential-store.test.ts @@ -0,0 +1,79 @@ +import type { Credential } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import { IndexedDbCredentialStore } from "../src/credential-store.ts"; +import { freshDatabase, memoryLocks } from "./helpers.ts"; + +const key = (value: string): Credential => ({ type: "api_key", key: value }); + +describe("IndexedDbCredentialStore", () => { + it("stores, lists and deletes credentials", async () => { + const { database } = await freshDatabase(); + const store = new IndexedDbCredentialStore(database, "test", memoryLocks()); + + expect(await store.read("anthropic")).toBeUndefined(); + expect(await store.modify("anthropic", async () => key("one"))).toEqual(key("one")); + expect(await store.read("anthropic")).toEqual(key("one")); + expect(await store.list()).toEqual([{ providerId: "anthropic", type: "api_key" }]); + + // Returning undefined leaves the credential as it is. + expect(await store.modify("anthropic", async () => undefined)).toEqual(key("one")); + + await store.delete("anthropic"); + expect(await store.read("anthropic")).toBeUndefined(); + expect(await store.list()).toEqual([]); + }); + + it("serializes modify across stores that share a lock manager, like tabs of one origin", async () => { + const { database } = await freshDatabase(); + const locks = memoryLocks(); + const tabA = new IndexedDbCredentialStore(database, "test", locks); + const tabB = new IndexedDbCredentialStore(database, "test", locks); + let releaseA!: () => void; + const aHolds = new Promise((resolve) => { + releaseA = resolve; + }); + + const seenByB: (Credential | undefined)[] = []; + const first = tabA.modify("xai", async () => { + await aHolds; + return key("refreshed-by-a"); + }); + const second = tabB.modify("xai", async (current) => { + seenByB.push(current); + return undefined; + }); + releaseA(); + await Promise.all([first, second]); + + // B ran after A had written, so it saw A's refresh instead of spending the same token again. + expect(seenByB).toEqual([key("refreshed-by-a")]); + }); + + it("cancels only the wait for the lock", async () => { + const { database } = await freshDatabase(); + const locks = memoryLocks(); + const store = new IndexedDbCredentialStore(database, "test", locks); + let release!: () => void; + let entered!: () => void; + const inside = new Promise((resolve) => { + entered = resolve; + }); + const holding = store.modify( + "meta", + () => + new Promise((resolve) => { + release = () => resolve(key("held")); + entered(); + }), + ); + await inside; + const controller = new AbortController(); + const waiting = store.modify("meta", async () => key("never"), { signal: controller.signal }); + controller.abort(new Error("gave up")); + await expect(waiting).rejects.toThrow("gave up"); + + release(); + expect(await holding).toEqual(key("held")); + expect(await store.read("meta")).toEqual(key("held")); + }); +}); diff --git a/packages/connect/test/database.test.ts b/packages/connect/test/database.test.ts new file mode 100644 index 000000000..243d800b4 --- /dev/null +++ b/packages/connect/test/database.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { ConnectDatabase } from "../src/database.ts"; +import { freshDatabase } from "./helpers.ts"; + +describe("ConnectDatabase", () => { + it("keeps values per store across connections", async () => { + const { database, factory, name } = await freshDatabase(); + await database.put("credentials", "radius", { type: "api_key", key: "k" }); + await database.put("documents", "radius", "not a credential"); + database.close(); + + const reopened = await ConnectDatabase.open(name, factory); + expect(await reopened.get("credentials", "radius")).toEqual({ type: "api_key", key: "k" }); + expect(await reopened.get("documents", "radius")).toBe("not a credential"); + expect(await reopened.keys("credentials")).toEqual(["radius"]); + await reopened.delete("credentials", "radius"); + expect(await reopened.keys("credentials")).toEqual([]); + }); +}); diff --git a/packages/connect/test/documents.test.ts b/packages/connect/test/documents.test.ts new file mode 100644 index 000000000..9fec72a54 --- /dev/null +++ b/packages/connect/test/documents.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { BUILTIN_AUTH_DOCUMENTS } from "../src/builtin-documents.ts"; +import { browserTier, parseAuthDocuments } from "../src/documents.ts"; + +describe("parseAuthDocuments", () => { + it("accepts documents as pi.dev sends them", () => { + const wire = JSON.parse(JSON.stringify(BUILTIN_AUTH_DOCUMENTS)); + expect(parseAuthDocuments(wire)).toEqual(BUILTIN_AUTH_DOCUMENTS); + }); + + it("rejects other versions and malformed methods", () => { + expect(parseAuthDocuments({ ...BUILTIN_AUTH_DOCUMENTS, version: 2 })).toBeUndefined(); + expect(parseAuthDocuments(null)).toBeUndefined(); + const badMethod = { + version: 1, + revision: "x", + providers: [ + { + provider: "p", + name: "P", + inference: { browser: "direct" }, + methods: [ + { + id: "m", + type: "oauth", + kind: "pkce_loopback", + label: "M", + browser: { signIn: "maybe", relay: "none" }, + }, + ], + }, + ], + }; + expect(parseAuthDocuments(badMethod)).toBeUndefined(); + }); +}); + +describe("browserTier", () => { + it("names each combination of sign-in and relay", () => { + expect(browserTier({ signIn: "direct", relay: "none" })).toBe("direct"); + expect(browserTier({ signIn: "direct", relay: "login" })).toBe("relay-login"); + expect(browserTier({ signIn: "direct", relay: "always" })).toBe("relay-always"); + expect(browserTier({ signIn: "manual", relay: "always" })).toBe("manual"); + expect(browserTier({ signIn: "unsupported", relay: "none" })).toBe("unsupported"); + }); +}); diff --git a/packages/connect/test/helpers.ts b/packages/connect/test/helpers.ts new file mode 100644 index 000000000..838e389a7 --- /dev/null +++ b/packages/connect/test/helpers.ts @@ -0,0 +1,59 @@ +/// + +// Test doubles for the browser APIs pi-connect uses: Web Locks and a fresh IndexedDB. + +import { IDBFactory } from "fake-indexeddb"; +import { ConnectDatabase } from "../src/database.ts"; + +/** A LockManager with one queue per lock name, like `navigator.locks` within an origin. */ +export function memoryLocks(): LockManager { + const tails = new Map>(); + const request = async ( + name: string, + options: LockOptions, + callback: (lock: Lock | null) => T | PromiseLike, + ): Promise => { + const previous = tails.get(name) ?? Promise.resolve(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + tails.set( + name, + previous.then(() => held), + ); + const signal = options.signal; + if (signal) { + const aborted = new Promise((_, reject) => { + if (signal.aborted) reject(signal.reason); + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + try { + await Promise.race([previous, aborted]); + } catch (error) { + // A cancelled waiter still takes its turn in the queue, but gives it back at once. + void previous.then(release); + throw error; + } + } else { + await previous; + } + try { + return await callback({ name, mode: "exclusive" }); + } finally { + release(); + } + }; + // LockManager's overloads cannot be implemented by one typed function; pi-connect calls the + // (name, options, callback) form only. + return { request, query: async () => ({ held: [], pending: [] }) } as unknown as LockManager; +} + +let databases = 0; + +/** Open a database in a fresh in-memory IndexedDB. */ +export async function freshDatabase(): Promise<{ database: ConnectDatabase; factory: IDBFactory; name: string }> { + const factory = new IDBFactory(); + const name = `test-${++databases}`; + return { database: await ConnectDatabase.open(name, factory), factory, name }; +} diff --git a/packages/connect/test/messages.test.ts b/packages/connect/test/messages.test.ts new file mode 100644 index 000000000..6c72a26ba --- /dev/null +++ b/packages/connect/test/messages.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { CONNECT_PROTOCOL, parseHelloMessage, parsePopupMessage } from "../src/messages.ts"; + +describe("parseHelloMessage", () => { + it("accepts a complete hello and nothing else", () => { + const hello = { + protocol: CONNECT_PROTOCOL, + type: "hello", + nonce: "n", + provider: "xai", + method: "device_code", + manual: false, + }; + expect(parseHelloMessage(hello)).toEqual(hello); + expect(parseHelloMessage({ ...hello, manual: "yes" })).toBeUndefined(); + expect(parseHelloMessage({ ...hello, protocol: "other" })).toBeUndefined(); + }); +}); + +describe("parsePopupMessage", () => { + it("accepts credentials with the fields pi-ai needs and keeps the provider's extra fields", () => { + const credential = { type: "oauth", access: "a", refresh: "r", expires: 1, accountId: "acct" }; + expect(parsePopupMessage({ protocol: CONNECT_PROTOCOL, type: "credential", nonce: "n", credential })).toEqual({ + protocol: CONNECT_PROTOCOL, + type: "credential", + nonce: "n", + credential, + }); + expect( + parsePopupMessage({ + protocol: CONNECT_PROTOCOL, + type: "credential", + nonce: "n", + credential: { type: "oauth" }, + }), + ).toBeUndefined(); + }); + + it("requires a nonce on everything but ready", () => { + expect(parsePopupMessage({ protocol: CONNECT_PROTOCOL, type: "ready" })?.type).toBe("ready"); + expect(parsePopupMessage({ protocol: CONNECT_PROTOCOL, type: "cancelled" })).toBeUndefined(); + expect(parsePopupMessage({ protocol: CONNECT_PROTOCOL, type: "error", nonce: "n", message: "m" })).toMatchObject({ + type: "error", + message: "m", + }); + }); +}); diff --git a/packages/connect/test/models-store.test.ts b/packages/connect/test/models-store.test.ts new file mode 100644 index 000000000..582ac39ee --- /dev/null +++ b/packages/connect/test/models-store.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { IndexedDbModelsStore } from "../src/models-store.ts"; +import { freshDatabase } from "./helpers.ts"; + +describe("IndexedDbModelsStore", () => { + it("keeps a provider's catalog until it is deleted", async () => { + const { database } = await freshDatabase(); + const store = new IndexedDbModelsStore(database); + const entry = { models: [], checkedAt: 1, etag: '"catalog-1"' }; + + expect(await store.read("google")).toBeUndefined(); + await store.write("google", entry); + expect(await store.read("google")).toEqual(entry); + await store.delete("google"); + expect(await store.read("google")).toBeUndefined(); + }); +}); diff --git a/packages/connect/test/popup.test.ts b/packages/connect/test/popup.test.ts new file mode 100644 index 000000000..9f19091d1 --- /dev/null +++ b/packages/connect/test/popup.test.ts @@ -0,0 +1,94 @@ +/// + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ConnectError } from "../src/errors.ts"; +import { CONNECT_PROTOCOL, type HelloMessage } from "../src/messages.ts"; +import { type PopupHost, signInWithPopup } from "../src/popup.ts"; + +const PI = "https://pi.dev"; +const credential = { type: "oauth" as const, access: "a", refresh: "r", expires: 1 }; + +/** A page window whose `open` returns a fake popup that records what the app posts to it. */ +function fakeHost(options: { blocked?: boolean } = {}) { + const listeners = new Set<(event: MessageEvent) => void>(); + const posted: { message: HelloMessage; targetOrigin: string }[] = []; + const popup = { + closed: false, + postMessage: (message: HelloMessage, targetOrigin: string) => posted.push({ message, targetOrigin }), + close() { + this.closed = true; + }, + }; + const opened: { url: string; target: string }[] = []; + const host: PopupHost = { + open: (url, target) => { + opened.push({ url, target }); + return options.blocked ? null : (popup as unknown as WindowProxy); + }, + addEventListener: (_type, listener) => listeners.add(listener), + removeEventListener: (_type, listener) => listeners.delete(listener), + }; + const send = (data: unknown, from: { origin?: string; source?: unknown } = {}) => { + const event = { data, origin: from.origin ?? PI, source: from.source ?? popup } as MessageEvent; + for (const listener of [...listeners]) listener(event); + }; + return { host, popup, posted, send, opened }; +} + +const request = { provider: "xai", method: "device_code", manual: false }; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("signInWithPopup", () => { + it("introduces the app once the popup is ready and resolves with its credential", async () => { + const { host, posted, send, opened } = fakeHost(); + const result = signInWithPopup(PI, request, undefined, host); + // A fresh window: a named one could be an existing window that someone else opened. + expect(opened).toEqual([{ url: "https://pi.dev/connect", target: "_blank" }]); + + send({ protocol: CONNECT_PROTOCOL, type: "ready" }); + expect(posted).toHaveLength(1); + expect(posted[0]).toMatchObject({ targetOrigin: PI, message: { type: "hello", ...request } }); + const nonce = posted[0]!.message.nonce; + + send({ protocol: CONNECT_PROTOCOL, type: "credential", nonce: "other", credential }); + send({ protocol: CONNECT_PROTOCOL, type: "credential", nonce, credential }, { origin: "https://evil.example" }); + send({ protocol: CONNECT_PROTOCOL, type: "credential", nonce, credential }, { source: {} }); + send({ protocol: CONNECT_PROTOCOL, type: "credential", nonce, credential }); + await expect(result).resolves.toEqual(credential); + }); + + it("rejects with the popup's error", async () => { + const { host, posted, send } = fakeHost(); + const result = signInWithPopup(PI, request, undefined, host); + send({ protocol: CONNECT_PROTOCOL, type: "ready" }); + send({ protocol: CONNECT_PROTOCOL, type: "error", nonce: posted[0]!.message.nonce, message: "denied" }); + await expect(result).rejects.toMatchObject({ code: "sign_in_failed", message: "denied" }); + }); + + it("reports a blocked window", async () => { + const { host } = fakeHost({ blocked: true }); + await expect(signInWithPopup(PI, request, undefined, host)).rejects.toMatchObject({ code: "popup_blocked" }); + }); + + it("explains a window that closed before it reached the page", async () => { + vi.useFakeTimers(); + const { host, popup } = fakeHost(); + const result = signInWithPopup(PI, request, undefined, host); + popup.closed = true; + vi.advanceTimersByTime(500); + await expect(result).rejects.toThrow(ConnectError); + await expect(result).rejects.toThrow("Cross-Origin-Opener-Policy"); + }); + + it("closes the popup when the app aborts", async () => { + const { host, popup } = fakeHost(); + const controller = new AbortController(); + const result = signInWithPopup(PI, request, controller.signal, host); + controller.abort(new Error("navigated away")); + await expect(result).rejects.toThrow("navigated away"); + expect(popup.closed).toBe(true); + }); +}); diff --git a/packages/connect/test/relay.test.ts b/packages/connect/test/relay.test.ts new file mode 100644 index 000000000..1d7a4bd09 --- /dev/null +++ b/packages/connect/test/relay.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { findRelayEndpoint, RELAY_ENDPOINTS, relayFetch } from "../src/relay.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("findRelayEndpoint", () => { + it("matches the exact URL and method only", () => { + const token = new URL("https://platform.claude.com/v1/oauth/token"); + expect(findRelayEndpoint(token, "post")?.provider).toBe("anthropic"); + expect(findRelayEndpoint(token, "GET")).toBeUndefined(); + expect(findRelayEndpoint(new URL("https://platform.claude.com/v1/oauth/token?x=1"), "POST")).toBeUndefined(); + expect(findRelayEndpoint(new URL("https://platform.claude.com/v1/oauth/tokens"), "POST")).toBeUndefined(); + }); + + it("lists every endpoint as an exact https URL", () => { + for (const endpoint of RELAY_ENDPOINTS) { + const url = new URL(endpoint.url); + expect(url.protocol).toBe("https:"); + expect(`${url.origin}${url.pathname}`).toBe(endpoint.url); + } + }); +}); + +describe("relayFetch", () => { + it("sends relayed endpoints through the relay with the same request", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}")); + const init = { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"grant_type":"refresh_token"}', + }; + await relayFetch("https://pi.dev")("https://platform.claude.com/v1/oauth/token", init); + + const [target, sent] = fetchSpy.mock.calls[0] ?? []; + expect(String(target)).toBe( + "https://pi.dev/api/oauth/relay?url=https%3A%2F%2Fplatform.claude.com%2Fv1%2Foauth%2Ftoken", + ); + expect(sent).toBe(init); + }); + + it("sends every other request directly", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}")); + await relayFetch("https://pi.dev")("https://radius.pi.dev/v1/oauth/token", { method: "POST" }); + expect(String(fetchSpy.mock.calls[0]?.[0])).toBe("https://radius.pi.dev/v1/oauth/token"); + }); +}); diff --git a/packages/connect/test/support.test.ts b/packages/connect/test/support.test.ts new file mode 100644 index 000000000..eeeb0e130 --- /dev/null +++ b/packages/connect/test/support.test.ts @@ -0,0 +1,114 @@ +import { createProvider, type Provider } from "@earendil-works/pi-ai"; +import { describe, expect, it } from "vitest"; +import type { AuthDocument } from "../src/documents.ts"; +import { browserRefusal, describeSupport } from "../src/support.ts"; + +const streams = { + stream: () => { + throw new Error("not used"); + }, + streamSimple: () => { + throw new Error("not used"); + }, +}; + +function provider(id: string, auth: { apiKey?: boolean; oauth?: boolean }): Provider { + return createProvider({ + id, + name: id, + auth: { + apiKey: auth.apiKey ? { name: `${id} key`, resolve: async () => undefined } : undefined, + oauth: auth.oauth + ? { + name: id, + login: async () => { + throw new Error("not used"); + }, + refresh: async (credential) => credential, + toAuth: async () => ({}), + } + : undefined, + }, + models: [], + api: streams, + }); +} + +const document: AuthDocument = { + provider: "example", + name: "Example", + inference: { browser: "direct" }, + methods: [ + { id: "api_key", type: "api_key", kind: "api_key", label: "Key", browser: { signIn: "direct", relay: "none" } }, + { + id: "copy_code", + type: "oauth", + kind: "pkce_copy_code", + label: "Copy", + browser: { signIn: "direct", relay: "always", reason: "Relayed." }, + }, + { + id: "browser", + type: "oauth", + kind: "pkce_loopback", + label: "Paste", + browser: { signIn: "manual", relay: "none", reason: "Paste the address." }, + }, + { + id: "loopback", + type: "oauth", + kind: "pkce_loopback", + label: "Loopback", + browser: { signIn: "unsupported", relay: "none", reason: "Local server only." }, + }, + ], +}; + +describe("describeSupport", () => { + it("offers manual sign-ins only when the app enables them", () => { + const example = provider("example", { apiKey: true, oauth: true }); + const off = describeSupport(example, document, { manual: false }); + expect(off.available).toBe(true); + expect(off.methods.map((method) => [method.id, method.tier, method.available])).toEqual([ + ["api_key", "direct", true], + ["copy_code", "relay-always", true], + ["browser", "manual", false], + ["loopback", "unsupported", false], + ]); + expect(off.methods[1]?.reason).toBe("Relayed."); + expect(off.methods[2]?.reason).toContain("has not enabled"); + expect(off.methods[3]?.reason).toBe("Local server only."); + + const on = describeSupport(example, document, { manual: true }); + expect(on.methods[2]).toMatchObject({ available: true, reason: "Paste the address." }); + }); + + it("refuses every method when browsers cannot call the model API", () => { + const closed: AuthDocument = { ...document, inference: { browser: "unsupported", reason: "No CORS." } }; + const support = describeSupport(provider("example", { apiKey: true, oauth: true }), closed, { manual: true }); + expect(support).toMatchObject({ available: false, inference: "unsupported", reason: "No CORS." }); + expect(support.methods.every((method) => !method.available && method.reason === "No CORS.")).toBe(true); + }); + + it("refuses methods the app's provider has no auth for", () => { + const support = describeSupport(provider("example", { apiKey: true }), document, { manual: true }); + expect(support.methods[1]).toMatchObject({ + available: false, + reason: "This app's example provider has no OAuth sign-in.", + }); + }); + + it("offers only the API key of a provider pi.dev does not document, marked untested", () => { + const support = describeSupport(provider("custom", { apiKey: true, oauth: true }), undefined, { manual: true }); + expect(support).toMatchObject({ inference: "unknown", available: true }); + expect(support.methods).toEqual([ + expect.objectContaining({ id: "api_key", tier: "unknown", available: true, label: "custom key" }), + ]); + }); +}); + +describe("browserRefusal", () => { + it("is undefined for usable methods", () => { + expect(browserRefusal(document, document.methods[1]!, { manual: false })).toBeUndefined(); + }); +}); diff --git a/packages/connect/tsconfig.build.json b/packages/connect/tsconfig.build.json new file mode 100644 index 000000000..8d15a8afa --- /dev/null +++ b/packages/connect/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "lib": ["ES2024", "DOM"], + "paths": { + "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], + "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"] + }, + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} diff --git a/packages/connect/vitest.config.ts b/packages/connect/vitest.config.ts new file mode 100644 index 000000000..7020ed96a --- /dev/null +++ b/packages/connect/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import baseConfig from "../../vitest.base.ts"; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + environment: "node", + reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"], + silent: "passed-only", + }, + }), +); diff --git a/scripts/check-browser-smoke.mjs b/scripts/check-browser-smoke.mjs index 0484d19d1..121fc45fb 100644 --- a/scripts/check-browser-smoke.mjs +++ b/scripts/check-browser-smoke.mjs @@ -6,6 +6,7 @@ import { build } from "esbuild"; const outputPath = join(tmpdir(), "pi-browser-smoke.js"); const durableOutputPath = join(tmpdir(), "pi-durable-browser-smoke.js"); const agentTreeshakeOutputPath = join(tmpdir(), "pi-agent-treeshake-smoke.js"); +const connectOutputDir = join(tmpdir(), "pi-connect-browser-smoke"); const errorLogPath = join(tmpdir(), "pi-browser-smoke-errors.log"); const generatedCatalogDataDir = join(process.cwd(), "packages/ai/src/providers/data"); @@ -85,6 +86,18 @@ try { if (nodeAdapter) throw new Error(`Durable browser bundle unexpectedly includes ${nodeAdapter}`); } + // pi-connect runs every OAuth flow in the browser, so the flows' Node-only parts must stay lazy. + await build({ + entryPoints: ["packages/connect/src/index.ts", "packages/connect/src/page.ts"], + bundle: true, + platform: "browser", + format: "esm", + logLevel: "silent", + outdir: connectOutputDir, + plugins: [generatedCatalogDataPlugin], + write: false, + }); + const agentTreeshakeBuild = await build({ entryPoints: ["scripts/agent-treeshake-smoke-entry.ts"], bundle: true, diff --git a/tsconfig.json b/tsconfig.json index eea4c716b..0eb72c881 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,6 +39,8 @@ "./packages/durable/src/*" ], "@earendil-works/pi-env": ["./packages/env/src/index.ts"], + "@earendil-works/pi-connect": ["./packages/connect/src/index.ts"], + "@earendil-works/pi-connect/page": ["./packages/connect/src/page.ts"], "@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"], "@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], "@earendil-works/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"],