feat(connect): add pi-connect for browser sign-in

openConnect() gives browser apps a pi-ai Models collection: credentials and model catalogs in IndexedDB (Web Locks serialize token refreshes across tabs), OAuth sign-in in pi.dev's popup, the token requests browsers cannot send routed through pi.dev's relay, and support(), which says for every provider and method whether it works in the browser and why not.

Auth documents (metadata only, bundled and refreshed from pi.dev) describe the browser tiers; scripts/probe-cors.ts checks them against the providers' endpoints. The relay allowlist in relay.ts is the source pi.dev vendors.
This commit is contained in:
Armin Ronacher 2026-10-30 23:13:26 +01:00
parent c3bfd689ef
commit 7f2d80920a
33 changed files with 2838 additions and 2 deletions

20
package-lock.json generated
View file

@ -835,6 +835,10 @@
"resolved": "packages/coding-agent", "resolved": "packages/coding-agent",
"link": true "link": true
}, },
"node_modules/@earendil-works/pi-connect": {
"resolved": "packages/connect",
"link": true
},
"node_modules/@earendil-works/pi-durable": { "node_modules/@earendil-works/pi-durable": {
"resolved": "packages/durable", "resolved": "packages/durable",
"link": true "link": true
@ -5964,6 +5968,22 @@
"extraneous": true, "extraneous": true,
"license": "MIT" "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": { "packages/durable": {
"name": "@earendil-works/pi-durable", "name": "@earendil-works/pi-durable",
"version": "1.4.0", "version": "1.4.0",

View file

@ -13,8 +13,8 @@
], ],
"scripts": { "scripts": {
"clean": "npm run clean --workspaces", "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": "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 ../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:darwin": "npm --prefix packages/tui run build:native:darwin",
"build:native:linux": "npm --prefix packages/tui run build:native:linux", "build:native:linux": "npm --prefix packages/tui run build:native:linux",
"build:native:win32": "npm --prefix packages/tui run build:native:win32", "build:native:win32": "npm --prefix packages/tui run build:native:win32",

130
packages/connect/README.md Normal file
View file

@ -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 <https://pi.dev/connect/providers>.
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.

View file

@ -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"
}
}

View file

@ -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<string, { signIn: Probe[]; refresh?: Probe }>;
};
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<string, ProviderProbes> = {
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<string, Promise<boolean>>();
/** Whether a browser on a foreign origin may send the probe's request; each endpoint is asked once. */
function acceptsBrowsers(probe: Probe): Promise<boolean> {
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<boolean> {
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}).`);

View file

@ -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)] };
}

View file

@ -0,0 +1,235 @@
/// <reference lib="dom" />
// 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<ModelsRefreshResult>;
/**
* 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<ModelsRefreshResult>;
/** Store an API key and refresh the provider's models. */
setApiKey(providerId: string, key: string, options?: AuthOperationOptions): Promise<ModelsRefreshResult>;
/** Forget the provider's stored credential. */
signOut(providerId: string, options?: AuthOperationOptions): Promise<void>;
/** 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<Connect> {
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<ModelsRefreshResult> {
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<ModelsRefreshResult> {
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<ModelsRefreshResult> {
this.requireSupport(providerId);
return this.store(providerId, { type: "api_key", key }, options);
}
signOut(providerId: string, options?: AuthOperationOptions): Promise<void> {
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<ModelsRefreshResult> {
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<void> {
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;
}
}

View file

@ -0,0 +1,65 @@
/// <reference lib="dom" />
// 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<Credential | undefined> {
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<readonly CredentialInfo[]> {
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<Credential | undefined>,
options?: AuthOperationOptions,
): Promise<Credential | undefined> {
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<void> {
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}`;
}
}

View file

@ -0,0 +1,72 @@
/// <reference lib="dom" />
// 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<ConnectDatabase> {
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<unknown> {
return settle(this.db.transaction(store, "readonly").objectStore(store).get(key));
}
/** List the keys of a store. */
async keys(store: StoreName): Promise<string[]> {
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<void> {
return this.write(store, (objects) => objects.put(value, key));
}
/** Delete one value; resolves once the deletion is committed. */
delete(store: StoreName, key: string): Promise<void> {
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<void> {
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<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

View file

@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isOptionalString(value: unknown): boolean {
return value === undefined || typeof value === "string";
}

View file

@ -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;
}
}

View file

@ -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";

View file

@ -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 `<pi.dev>/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<string, unknown> {
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<string, unknown>;
return (
credential.type === "oauth" &&
typeof credential.access === "string" &&
typeof credential.refresh === "string" &&
typeof credential.expires === "number"
);
}

View file

@ -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<ModelsStoreEntry | undefined> {
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<void> {
options?.signal?.throwIfAborted();
await this.database.put("models", providerId, entry);
}
async delete(providerId: string, options?: ModelsStoreOperationOptions): Promise<void> {
options?.signal?.throwIfAborted();
await this.database.delete("models", providerId);
}
}

View file

@ -0,0 +1,328 @@
/// <reference lib="dom" />
// 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<void> {
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<boolean> {
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<string> {
const form = element("form", "connect-prompt");
const done = AbortSignal.any(prompt.signal ? [signal, prompt.signal] : [signal]);
const answer = new Promise<string>((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<K extends keyof HTMLElementTagNameMap>(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;
}
}

View file

@ -0,0 +1,95 @@
/// <reference lib="dom" />
// 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<OAuthCredential> {
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.";
}

View file

@ -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<Record<string, RelayAccess>>;
/** 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);
}

View file

@ -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,
};
}

View file

@ -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<string[] | undefined> {
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;
}

View file

@ -0,0 +1,204 @@
/// <reference lib="dom" />
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<string, () => 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<string, string> = {}) =>
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> = {}): 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();
});
});

View file

@ -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<void>((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<void>((resolve) => {
entered = resolve;
});
const holding = store.modify(
"meta",
() =>
new Promise<Credential>((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"));
});
});

View file

@ -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([]);
});
});

View file

@ -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");
});
});

View file

@ -0,0 +1,59 @@
/// <reference lib="dom" />
// 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<string, Promise<void>>();
const request = async <T>(
name: string,
options: LockOptions,
callback: (lock: Lock | null) => T | PromiseLike<T>,
): Promise<T> => {
const previous = tails.get(name) ?? Promise.resolve();
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
tails.set(
name,
previous.then(() => held),
);
const signal = options.signal;
if (signal) {
const aborted = new Promise<never>((_, 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 };
}

View file

@ -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",
});
});
});

View file

@ -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();
});
});

View file

@ -0,0 +1,94 @@
/// <reference lib="dom" />
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);
});
});

View file

@ -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");
});
});

View file

@ -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();
});
});

View file

@ -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"]
}

View file

@ -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",
},
}),
);

View file

@ -6,6 +6,7 @@ import { build } from "esbuild";
const outputPath = join(tmpdir(), "pi-browser-smoke.js"); const outputPath = join(tmpdir(), "pi-browser-smoke.js");
const durableOutputPath = join(tmpdir(), "pi-durable-browser-smoke.js"); const durableOutputPath = join(tmpdir(), "pi-durable-browser-smoke.js");
const agentTreeshakeOutputPath = join(tmpdir(), "pi-agent-treeshake-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 errorLogPath = join(tmpdir(), "pi-browser-smoke-errors.log");
const generatedCatalogDataDir = join(process.cwd(), "packages/ai/src/providers/data"); 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}`); 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({ const agentTreeshakeBuild = await build({
entryPoints: ["scripts/agent-treeshake-smoke-entry.ts"], entryPoints: ["scripts/agent-treeshake-smoke-entry.ts"],
bundle: true, bundle: true,

View file

@ -39,6 +39,8 @@
"./packages/durable/src/*" "./packages/durable/src/*"
], ],
"@earendil-works/pi-env": ["./packages/env/src/index.ts"], "@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-agent-core": ["./packages/agent/src/index.ts"],
"@earendil-works/pi-coding-agent": ["./packages/coding-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"], "@earendil-works/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"],