diff --git a/apps/web/src/app/.well-known/oauth-authorization-server/route.ts b/apps/web/src/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 00000000..515f52cb --- /dev/null +++ b/apps/web/src/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,13 @@ +import { authServerMetadata, CORS } from "@/lib/chat/oauth"; +import { json, siteUrl } from "@/lib/chat/server"; + +export const dynamic = "force-dynamic"; + +/** RFC 8414: where MCP clients (ChatGPT, claude.ai, Claude Code) register, sign in and get a token. */ +export function GET() { + return json(authServerMetadata(siteUrl()), 200, { ...CORS, "cache-control": "public, max-age=3600" }); +} + +export function OPTIONS() { + return new Response(null, { status: 204, headers: CORS }); +} diff --git a/apps/web/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts b/apps/web/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts new file mode 100644 index 00000000..007c8fcd --- /dev/null +++ b/apps/web/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts @@ -0,0 +1,13 @@ +import { CORS, protectedResourceMetadata } from "@/lib/chat/oauth"; +import { json, siteUrl } from "@/lib/chat/server"; + +export const dynamic = "force-dynamic"; + +/** RFC 9728 path-suffixed form: clients that build the URL from the resource path ask here. */ +export function GET() { + return json(protectedResourceMetadata(siteUrl()), 200, { ...CORS, "cache-control": "public, max-age=3600" }); +} + +export function OPTIONS() { + return new Response(null, { status: 204, headers: CORS }); +} diff --git a/apps/web/src/app/.well-known/oauth-protected-resource/route.ts b/apps/web/src/app/.well-known/oauth-protected-resource/route.ts new file mode 100644 index 00000000..59ef9105 --- /dev/null +++ b/apps/web/src/app/.well-known/oauth-protected-resource/route.ts @@ -0,0 +1,13 @@ +import { CORS, protectedResourceMetadata } from "@/lib/chat/oauth"; +import { json, siteUrl } from "@/lib/chat/server"; + +export const dynamic = "force-dynamic"; + +/** RFC 9728: the MCP server at /api/mcp is protected by c0mpute.com's own OAuth server. */ +export function GET() { + return json(protectedResourceMetadata(siteUrl()), 200, { ...CORS, "cache-control": "public, max-age=3600" }); +} + +export function OPTIONS() { + return new Response(null, { status: 204, headers: CORS }); +} diff --git a/apps/web/src/app/api/mcp/route.ts b/apps/web/src/app/api/mcp/route.ts index a60661d1..f2be4c36 100644 --- a/apps/web/src/app/api/mcp/route.ts +++ b/apps/web/src/app/api/mcp/route.ts @@ -1,18 +1,22 @@ import { handleRpc } from "@/lib/chat/mcp"; -import { currentUser, infernetFor, json, pushStore, rateLimit, unauthorized } from "@/lib/chat/server"; +import { CORS } from "@/lib/chat/oauth"; +import { currentUser, infernetFor, json, mcpUnauthorized, pushStore, rateLimit } from "@/lib/chat/server"; export const dynamic = "force-dynamic"; export const maxDuration = 300; /** * Remote MCP server (Streamable HTTP, JSON responses). - * claude mcp add --transport http c0mpute https://c0mpute.com/api/mcp --header "Authorization: Bearer c0k_..." - * Bearer keys only: no cookies, so a web page cannot drive it cross-site. + * claude mcp add --transport http c0mpute https://c0mpute.com/api/mcp (OAuth sign-in) + * or --header "Authorization: Bearer c0k_..." with a key from c0mpute.com/chat. + * ChatGPT apps and claude.ai connectors sign in through OAuth 2.1 (lib/chat/oauth.ts), + * which hands them a c0k_ key. Bearer keys only: no cookies, so a web page + * cannot drive it cross-site. */ export async function POST(req: Request) { - if (!/^Bearer\s/i.test(req.headers.get("authorization") ?? "")) return unauthorized(); + if (!/^Bearer\s/i.test(req.headers.get("authorization") ?? "")) return mcpUnauthorized(); const user = await currentUser(req); - if (!user) return unauthorized(); + if (!user) return mcpUnauthorized(); const body = await req.json().catch(() => null); if (!body) return json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }, 400); const batch = Array.isArray(body) ? body : [body]; @@ -29,6 +33,10 @@ export async function POST(req: Request) { return json(Array.isArray(body) ? out : out[0]); } +export function OPTIONS() { + return new Response(null, { status: 204, headers: CORS }); +} + export async function GET() { // No server-initiated stream. return new Response(null, { status: 405, headers: { allow: "POST" } }); diff --git a/apps/web/src/app/api/oauth/register/route.ts b/apps/web/src/app/api/oauth/register/route.ts new file mode 100644 index 00000000..3002c11b --- /dev/null +++ b/apps/web/src/app/api/oauth/register/route.ts @@ -0,0 +1,35 @@ +import { CORS, OAuthError } from "@/lib/chat/oauth"; +import { clientIp, json, oauthStore, rateLimit } from "@/lib/chat/server"; + +export const dynamic = "force-dynamic"; + +/** RFC 7591 dynamic client registration (public clients, PKCE). */ +export async function POST(req: Request) { + if (!rateLimit(`oauth-register:${clientIp(req)}`, 60).ok) return json({ error: "slow_down" }, 429, CORS); + const body = (await req.json().catch(() => null)) as Record | null; + if (!body || typeof body !== "object") return json({ error: "invalid_client_metadata", error_description: "JSON body required" }, 400, CORS); + try { + const c = await oauthStore().registerClient(body); + return json( + { + client_id: c.client_id, + client_id_issued_at: Math.floor(Date.parse(c.created_at) / 1000), + client_name: c.name, + redirect_uris: c.redirect_uris, + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + 201, + CORS, + ); + } catch (e) { + if (e instanceof OAuthError) return json({ error: e.code, error_description: e.message }, e.status, CORS); + console.error("oauth register", e); + return json({ error: "server_error" }, 503, CORS); + } +} + +export function OPTIONS() { + return new Response(null, { status: 204, headers: CORS }); +} diff --git a/apps/web/src/app/api/oauth/token/route.ts b/apps/web/src/app/api/oauth/token/route.ts new file mode 100644 index 00000000..776731ea --- /dev/null +++ b/apps/web/src/app/api/oauth/token/route.ts @@ -0,0 +1,34 @@ +import { CORS, OAuthError } from "@/lib/chat/oauth"; +import { clientIp, json, oauthStore, rateLimit, store } from "@/lib/chat/server"; + +export const dynamic = "force-dynamic"; + +/** + * Token endpoint: authorization_code + PKCE in, a c0k_ API key out. The key + * does not expire; the person revokes it with their other keys in c0mpute chat. + */ +export async function POST(req: Request) { + if (!rateLimit(`oauth-token:${clientIp(req)}`, 120).ok) return json({ error: "slow_down" }, 429, CORS); + const type = req.headers.get("content-type") ?? ""; + let p: Record = {}; + if (type.includes("application/json")) p = ((await req.json().catch(() => null)) as Record) ?? {}; + else p = Object.fromEntries(new URLSearchParams(await req.text())); + if (p.grant_type !== "authorization_code") { + return json({ error: "unsupported_grant_type", error_description: "only authorization_code" }, 400, CORS); + } + try { + const oauth = oauthStore(); + const r = await oauth.redeemCode(p.code, { client_id: p.client_id, redirect_uri: p.redirect_uri, code_verifier: p.code_verifier }); + const client = await oauth.client(r.client_id); + const key = await store().createKey(r.user_id, `${client?.name ?? "MCP client"} (OAuth)`); + return json({ access_token: key, token_type: "Bearer", scope: r.scope }, 200, { ...CORS, pragma: "no-cache" }); + } catch (e) { + if (e instanceof OAuthError) return json({ error: e.code, error_description: e.message }, e.status, CORS); + console.error("oauth token", e); + return json({ error: "server_error" }, 503, CORS); + } +} + +export function OPTIONS() { + return new Response(null, { status: 204, headers: CORS }); +} diff --git a/apps/web/src/app/api/v1/hosting/pushes/[id]/files/[n]/route.ts b/apps/web/src/app/api/v1/hosting/pushes/[id]/files/[n]/route.ts index 5235e0af..48e42378 100644 --- a/apps/web/src/app/api/v1/hosting/pushes/[id]/files/[n]/route.ts +++ b/apps/web/src/app/api/v1/hosting/pushes/[id]/files/[n]/route.ts @@ -16,7 +16,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri const { id, n } = await params; let file; try { - const p = await pushStore().pushById(user.id, id); + const p = await pushStore().pushForCaller(user.id, id); if (!p) return bad("no such push on this account", 404); file = p.files[Number(n)]; } catch (e) { diff --git a/apps/web/src/app/api/v1/hosting/pushes/route.ts b/apps/web/src/app/api/v1/hosting/pushes/route.ts index eab70a42..0dc7cb25 100644 --- a/apps/web/src/app/api/v1/hosting/pushes/route.ts +++ b/apps/web/src/app/api/v1/hosting/pushes/route.ts @@ -37,7 +37,7 @@ export async function POST(req: Request) { const ps = pushStore(); const existing = await ps.push(user.id, "hosting", name); // An existing site stays on the node that signed it unless one is named. - const node = await pickNode(ps, user.id, body.node ?? (existing?.site_id ? existing.node_id : undefined)); + const node = await pickNode(ps, user.id, body.node, existing); if (node instanceof Response) return node; const push = await ps.upsertPush(user.id, { target: "hosting", name, node, files, disks: "auto" }); return json({ push: publicPush(push, node), poll: `/api/v1/hosting/pushes/${push.id}` }, 202); diff --git a/apps/web/src/app/oauth/authorize/route.ts b/apps/web/src/app/oauth/authorize/route.ts new file mode 100644 index 00000000..cf5cbe35 --- /dev/null +++ b/apps/web/src/app/oauth/authorize/route.ts @@ -0,0 +1,107 @@ +import { consentToken, redirectMatches, SCOPE, tokenEq } from "@/lib/chat/oauth"; +import { currentUser, oauthStore, sessionToken, siteUrl } from "@/lib/chat/server"; + +export const dynamic = "force-dynamic"; + +/** + * OAuth 2.1 authorization endpoint for the MCP server. GET checks the request, + * sends a signed-out person through "Sign in with CoinPay" and back, and shows + * a consent page; POST (the consent form) issues a code or access_denied. + */ + +type Q = { client_id: string; redirect_uri: string; code_challenge: string; state: string; scope: string }; + +const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); + +function page(title: string, body: string, status = 200) { + const html = ` +${esc(title)} · c0mpute +
${body}
`; + return new Response(html, { + status, + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-frame-options": "DENY", "referrer-policy": "no-referrer" }, + }); +} + +const errorPage = (msg: string) => page("Cannot connect", `

Cannot connect

${esc(msg)}

`, 400); + +function back(redirectUri: string, params: Record) { + const u = new URL(redirectUri); + for (const [k, v] of Object.entries(params)) if (v) u.searchParams.set(k, v); + u.searchParams.set("iss", siteUrl()); + return new Response(null, { status: 302, headers: { location: u.toString(), "cache-control": "no-store" } }); +} + +async function check(get: (k: string) => string | null) { + const oauth = oauthStore(); + const client = await oauth.client(get("client_id")); + if (!client) return { error: errorPage("Unknown app. Remove the connector and add it again.") } as const; + const redirect = get("redirect_uri") ?? (client.redirect_uris.length === 1 ? client.redirect_uris[0] : ""); + if (!redirect || !redirectMatches(client.redirect_uris, redirect)) return { error: errorPage("That return address is not registered for this app.") } as const; + const state = get("state") ?? ""; + if (get("response_type") !== "code") return { error: back(redirect, { error: "unsupported_response_type", state }) } as const; + const challenge = get("code_challenge") ?? ""; + if (get("code_challenge_method") !== "S256" || !/^[A-Za-z0-9_-]{43,128}$/.test(challenge)) { + return { error: back(redirect, { error: "invalid_request", error_description: "PKCE S256 required", state }) } as const; + } + const q: Q = { client_id: client.client_id, redirect_uri: redirect, code_challenge: challenge, state, scope: SCOPE }; + return { client, q } as const; +} + +export async function GET(req: Request) { + const url = new URL(req.url); + const r = await check((k) => url.searchParams.get(k)); + if ("error" in r) return r.error; + const user = await currentUser(req); + const session = sessionToken(req); + if (!user || user.token_kind !== "session" || !session) { + const next = `/oauth/authorize${url.search}`; + return new Response(null, { status: 302, headers: { location: `${siteUrl()}/api/auth/coinpay/login?next=${encodeURIComponent(next)}`, "cache-control": "no-store" } }); + } + const { client, q } = r; + const host = new URL(q.redirect_uri).host; + const csrf = consentToken(session, q); + const hidden = Object.entries({ ...q, csrf }) + .map(([k, v]) => ``) + .join(""); + return page( + "Connect", + `

Connect ${esc(client.name)} to c0mpute?

+

Signed in as ${esc(user.name || user.did)}.

+

${esc(client.name)} will be able to:

+
  • publish, update and take down websites on the c0mpute network for you
  • ask models on the network, billed to your compute balance
  • make promo videos
+
${hidden} +
+You will be sent back to ${esc(host)}. It gets an API key you can revoke any time in c0mpute chat (keys).`, + ); +} + +export async function POST(req: Request) { + const origin = req.headers.get("origin"); + if (origin && origin !== siteUrl() && origin !== new URL(req.url).origin) return errorPage("Cross-site request refused."); + const form = await req.formData().catch(() => null); + if (!form) return errorPage("Bad request."); + const get = (k: string) => { + const v = form.get(k); + return typeof v === "string" ? v : null; + }; + // check() wants the GET shape; the form carries the same fields. + const r = await check((k) => (k === "response_type" ? "code" : k === "code_challenge_method" ? "S256" : get(k))); + if ("error" in r) return r.error; + const { q } = r; + const user = await currentUser(req); + const session = sessionToken(req); + if (!user || user.token_kind !== "session" || !session) return errorPage("Your sign-in expired. Start the connection again from the app."); + if (!tokenEq(get("csrf") ?? "", consentToken(session, q))) return errorPage("This consent page expired. Start the connection again from the app."); + if (get("decision") !== "allow") return back(q.redirect_uri, { error: "access_denied", state: q.state }); + const code = await oauthStore().createCode({ client_id: q.client_id, user_id: user.id, redirect_uri: q.redirect_uri, challenge: q.code_challenge, scope: q.scope }); + return back(q.redirect_uri, { code, state: q.state }); +} diff --git a/apps/web/src/lib/chat/chat.test.ts b/apps/web/src/lib/chat/chat.test.ts index f3c5e57d..279033e5 100644 --- a/apps/web/src/lib/chat/chat.test.ts +++ b/apps/web/src/lib/chat/chat.test.ts @@ -122,7 +122,7 @@ describe("mcp", () => { const init = (await handleRpc({ jsonrpc: "2.0", id: 1, method: "initialize" }, { user, upstream })) as { result: { serverInfo: { name: string } }; }; - expect(init.result.serverInfo.name).toBe("c0mpute-chat"); + expect(init.result.serverInfo.name).toBe("c0mpute"); const list = (await handleRpc({ id: 2, method: "tools/list" }, { user, upstream })) as { result: { tools: { name: string }[] }; }; diff --git a/apps/web/src/lib/chat/mcp.ts b/apps/web/src/lib/chat/mcp.ts index bbeb2c9d..f614a3c0 100644 --- a/apps/web/src/lib/chat/mcp.ts +++ b/apps/web/src/lib/chat/mcp.ts @@ -35,11 +35,13 @@ export const TOOLS = [ temperature: { type: "number" }, }, }, + annotations: { title: "Ask a network model", readOnlyHint: true, openWorldHint: true }, }, { name: "list_models", description: "Models live on the c0mpute network right now.", inputSchema: { type: "object", properties: {} }, + annotations: { title: "List models", readOnlyHint: true, openWorldHint: false }, }, { name: "promo_video", @@ -55,16 +57,19 @@ export const TOOLS = [ }, required: ["url"], }, + annotations: { title: "Make a promo video", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, }, { name: "promo_status", description: "Progress of a promo_video job (brand, script, frames, motion, voice, music, render), and the MP4 and SRT links when it is done (fetch them with the same Bearer key).", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + annotations: { title: "Promo video status", readOnlyHint: true, openWorldHint: false }, }, { name: "whoami", description: "The signed-in c0mpute account (CoinPay DID).", inputSchema: { type: "object", properties: {} }, + annotations: { title: "Who am I", readOnlyHint: true, openWorldHint: false }, }, ] as const; @@ -84,9 +89,9 @@ export async function handleRpc(msg: Rpc, ctx: McpCtx): Promise { return ok(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, - serverInfo: { name: "c0mpute-chat", version: "1.0.0" }, + serverInfo: { name: "c0mpute", title: "c0mpute", version: "1.1.0" }, instructions: - "Chat with models on the c0mpute network. Call list_models, then chat. promo_video makes a promo video of a website; poll promo_status. hosting_push publishes files as a site through one of your linked nodes (hosting_nodes); poll hosting_push_status.", + "c0mpute is a peer-to-peer network of everyday computers. hosting_push publishes a website (inline HTML/CSS/JS files) on the network with at least three self-healing copies, free through the shared cloud node when the person has no node of their own; poll hosting_push_status until it is live and give them the url (https://gateway.c0mpute.com/s//). hosting_pushes lists their sites, hosting_push_rm takes one down. chat asks open models on the network (list_models first). promo_video makes a promo video of a website; poll promo_status.", }); case "ping": return ok(id, {}); diff --git a/apps/web/src/lib/chat/oauth.test.ts b/apps/web/src/lib/chat/oauth.test.ts new file mode 100644 index 00000000..f56c70fd --- /dev/null +++ b/apps/web/src/lib/chat/oauth.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { createHash } from "node:crypto"; +import type { Sql } from "./store"; +import { authServerMetadata, consentToken, createOAuthStore, pkceOk, protectedResourceMetadata, redirectMatches, validRedirectUri } from "./oauth"; + +function memSql(): Sql { + const db = new Database(":memory:"); + return { + all: async (q, p = []) => db.query(q).all(...(p as never[])) as Record[], + run: async (q, p = []) => { + db.query(q).run(...(p as never[])); + }, + }; +} + +const verifier = "a".repeat(20) + "B".repeat(20) + "-._~1234"; +const challenge = createHash("sha256").update(verifier).digest("base64url"); +const CLAUDE = "https://claude.ai/api/mcp/auth_callback"; +const CHATGPT = "https://chatgpt.com/connector_platform_oauth_redirect"; + +describe("redirect uris", () => { + test("https anywhere, http only on loopback, no fragments", () => { + expect(validRedirectUri(CLAUDE)).toBe(true); + expect(validRedirectUri(CHATGPT)).toBe(true); + expect(validRedirectUri("http://localhost:33418/callback")).toBe(true); + expect(validRedirectUri("http://127.0.0.1/cb")).toBe(true); + expect(validRedirectUri("http://evil.com/cb")).toBe(false); + expect(validRedirectUri("https://x.com/cb#frag")).toBe(false); + expect(validRedirectUri("javascript:alert(1)")).toBe(false); + }); + test("loopback matches on any port, others exactly", () => { + expect(redirectMatches(["http://localhost/callback"], "http://localhost:5555/callback")).toBe(true); + expect(redirectMatches(["http://localhost/callback"], "http://localhost:5555/other")).toBe(false); + expect(redirectMatches([CLAUDE], CLAUDE)).toBe(true); + expect(redirectMatches([CLAUDE], CLAUDE + "x")).toBe(false); + }); +}); + +describe("pkce", () => { + test("S256", () => { + expect(pkceOk(verifier, challenge)).toBe(true); + expect(pkceOk(verifier + "x", challenge)).toBe(false); + expect(pkceOk("short", challenge)).toBe(false); + }); +}); + +describe("oauth store", () => { + test("register, code, redeem once", async () => { + const o = createOAuthStore(memSql()); + const c = await o.registerClient({ client_name: "Claude", redirect_uris: [CLAUDE] }); + expect(c.client_id.startsWith("c0c_")).toBe(true); + expect((await o.client(c.client_id))?.redirect_uris).toEqual([CLAUDE]); + const code = await o.createCode({ client_id: c.client_id, user_id: "u1", redirect_uri: CLAUDE, challenge }); + const r = await o.redeemCode(code, { client_id: c.client_id, redirect_uri: CLAUDE, code_verifier: verifier }); + expect(r.user_id).toBe("u1"); + await expect(o.redeemCode(code, { client_id: c.client_id, redirect_uri: CLAUDE, code_verifier: verifier })).rejects.toThrow(/unknown or used/); + }); + + test("a wrong verifier burns the code", async () => { + const o = createOAuthStore(memSql()); + const c = await o.registerClient({ client_name: "ChatGPT", redirect_uris: [CHATGPT] }); + const code = await o.createCode({ client_id: c.client_id, user_id: "u1", redirect_uri: CHATGPT, challenge }); + await expect(o.redeemCode(code, { client_id: c.client_id, redirect_uri: CHATGPT, code_verifier: "x".repeat(50) })).rejects.toThrow(/PKCE/); + await expect(o.redeemCode(code, { client_id: c.client_id, redirect_uri: CHATGPT, code_verifier: verifier })).rejects.toThrow(/unknown or used/); + }); + + test("rejects bad metadata", async () => { + const o = createOAuthStore(memSql()); + await expect(o.registerClient({ redirect_uris: ["http://evil.com/cb"] })).rejects.toThrow(/redirect_uris/); + await expect(o.registerClient({ redirect_uris: [CLAUDE], token_endpoint_auth_method: "client_secret_basic" })).rejects.toThrow(/none/); + const named = await o.registerClient({ client_name: "", redirect_uris: [CLAUDE] }); + expect(named.name).not.toContain("<"); + }); + + test("consent token is bound to the session and request", () => { + const q = { client_id: "c0c_a", redirect_uri: CLAUDE, code_challenge: challenge, state: "s" }; + expect(consentToken("c0s_1", q)).not.toBe(consentToken("c0s_2", q)); + expect(consentToken("c0s_1", q)).not.toBe(consentToken("c0s_1", { ...q, state: "t" })); + }); + + test("metadata points at this site", () => { + expect(authServerMetadata("https://c0mpute.com").registration_endpoint).toBe("https://c0mpute.com/api/oauth/register"); + expect(protectedResourceMetadata("https://c0mpute.com").resource).toBe("https://c0mpute.com/api/mcp"); + }); +}); diff --git a/apps/web/src/lib/chat/oauth.ts b/apps/web/src/lib/chat/oauth.ts new file mode 100644 index 00000000..bc2495c9 --- /dev/null +++ b/apps/web/src/lib/chat/oauth.ts @@ -0,0 +1,228 @@ +// OAuth 2.1 for the remote MCP server (/api/mcp), so ChatGPT apps, claude.ai +// connectors and Claude Code can sign a person in without a pasted key. +// +// c0mpute.com is its own authorization server: dynamic client registration +// (RFC 7591, public clients only), the authorization code flow with PKCE S256, +// and a consent page behind the normal "Sign in with CoinPay" session. The +// access token it hands out is an ordinary c0k_ API key labelled +// " (OAuth)", so the MCP route needs no new auth path and the person +// can see and revoke it with their other keys. + +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { Sql } from "./store"; + +export const CLIENT_PREFIX = "c0c_"; +export const CODE_PREFIX = "c0a_"; +const CODE_MINUTES = 5; +const MAX_REDIRECTS = 10; +export const SCOPE = "mcp"; + +const SCHEMA = [ + `CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS oauth_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + challenge TEXT NOT NULL, + scope TEXT, + expires_at TEXT NOT NULL + )`, +]; + +export type OAuthClient = { client_id: string; name: string; redirect_uris: string[]; created_at: string }; + +const sha256hex = (s: string) => createHash("sha256").update(s).digest("hex"); +const now = () => new Date().toISOString(); + +export class OAuthError extends Error { + constructor( + public code: string, + message: string, + public status = 400, + ) { + super(message); + } +} + +/** + * Redirect URIs a client may register: https anywhere (the consent page names + * the host, so a person sees where they are being sent), or http on loopback + * for desktop and CLI clients (RFC 8252). No fragments, no credentials. + */ +export function validRedirectUri(raw: unknown): raw is string { + if (typeof raw !== "string" || raw.length > 2000) return false; + let u: URL; + try { + u = new URL(raw); + } catch { + return false; + } + if (u.hash || u.username || u.password) return false; + if (u.protocol === "https:") return true; + return u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname); +} + +/** Loopback redirects match on everything but the port (RFC 8252 7.3); others exactly. */ +export function redirectMatches(registered: string[], uri: string): boolean { + if (registered.includes(uri)) return true; + let u: URL; + try { + u = new URL(uri); + } catch { + return false; + } + if (u.protocol !== "http:") return false; + return registered.some((r) => { + try { + const x = new URL(r); + return x.protocol === "http:" && x.hostname === u.hostname && x.pathname === u.pathname && x.search === u.search; + } catch { + return false; + } + }); +} + +/** PKCE S256: BASE64URL(SHA256(verifier)) == challenge. */ +export function pkceOk(verifier: unknown, challenge: string): boolean { + if (typeof verifier !== "string" || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return false; + const a = Buffer.from(createHash("sha256").update(verifier).digest("base64url")); + const b = Buffer.from(challenge); + return a.length === b.length && timingSafeEqual(a, b); +} + +export function createOAuthStore(sql: Sql) { + let ready: Promise | null = null; + const init = () => + (ready ??= (async () => { + for (const q of SCHEMA) await sql.run(q); + })().catch((e) => { + ready = null; + throw e; + })); + + const store = { + init, + + /** RFC 7591 registration. Public clients only: no secret is issued or accepted. */ + async registerClient(body: Record): Promise { + await init(); + const uris = body.redirect_uris; + if (!Array.isArray(uris) || uris.length === 0 || uris.length > MAX_REDIRECTS || !uris.every(validRedirectUri)) { + throw new OAuthError("invalid_redirect_uri", "redirect_uris: 1-10 https URLs (or http on localhost)"); + } + const method = body.token_endpoint_auth_method; + if (method !== undefined && method !== "none") { + throw new OAuthError("invalid_client_metadata", "token_endpoint_auth_method must be none (public clients with PKCE)"); + } + const grants = body.grant_types; + if (grants !== undefined && (!Array.isArray(grants) || !grants.includes("authorization_code"))) { + throw new OAuthError("invalid_client_metadata", "grant_types must include authorization_code"); + } + const raw = typeof body.client_name === "string" ? body.client_name : ""; + const name = raw.replace(/[\u0000-\u001f\u007f<>]/g, "").trim().slice(0, 80) || new URL(uris[0] as string).host; + const client: OAuthClient = { + client_id: CLIENT_PREFIX + randomBytes(16).toString("base64url"), + name, + redirect_uris: uris as string[], + created_at: now(), + }; + await sql.run(`INSERT INTO oauth_clients (client_id, name, redirect_uris, created_at) VALUES (?, ?, ?, ?)`, [ + client.client_id, + client.name, + JSON.stringify(client.redirect_uris), + client.created_at, + ]); + return client; + }, + + async client(clientId: unknown): Promise { + if (typeof clientId !== "string" || !clientId.startsWith(CLIENT_PREFIX) || clientId.length > 64) return null; + await init(); + const r = (await sql.all(`SELECT * FROM oauth_clients WHERE client_id = ?`, [clientId]))[0]; + if (!r) return null; + return { client_id: String(r.client_id), name: String(r.name), redirect_uris: JSON.parse(String(r.redirect_uris)), created_at: String(r.created_at) }; + }, + + async createCode(c: { client_id: string; user_id: string; redirect_uri: string; challenge: string; scope?: string | null }) { + await init(); + await sql.run(`DELETE FROM oauth_codes WHERE expires_at < ?`, [now()]); + const code = CODE_PREFIX + randomBytes(24).toString("base64url"); + await sql.run( + `INSERT INTO oauth_codes (code_hash, client_id, user_id, redirect_uri, challenge, scope, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + [sha256hex(code), c.client_id, c.user_id, c.redirect_uri, c.challenge, c.scope ?? null, new Date(Date.now() + CODE_MINUTES * 60_000).toISOString()], + ); + return code; + }, + + /** + * Redeem a code once. It is deleted before any check, so a replayed or + * mismatched code can never be tried twice. + */ + async redeemCode(code: unknown, p: { client_id: unknown; redirect_uri: unknown; code_verifier: unknown }) { + if (typeof code !== "string" || !code.startsWith(CODE_PREFIX)) throw new OAuthError("invalid_grant", "unknown code"); + await init(); + const h = sha256hex(code); + const r = (await sql.all(`SELECT * FROM oauth_codes WHERE code_hash = ?`, [h]))[0]; + if (!r) throw new OAuthError("invalid_grant", "unknown or used code"); + await sql.run(`DELETE FROM oauth_codes WHERE code_hash = ?`, [h]); + if (String(r.expires_at) < now()) throw new OAuthError("invalid_grant", "code expired"); + if (p.client_id !== r.client_id) throw new OAuthError("invalid_grant", "code was issued to another client"); + if (p.redirect_uri !== r.redirect_uri) throw new OAuthError("invalid_grant", "redirect_uri does not match the authorization request"); + if (!pkceOk(p.code_verifier, String(r.challenge))) throw new OAuthError("invalid_grant", "PKCE verification failed"); + return { user_id: String(r.user_id), client_id: String(r.client_id), scope: (r.scope as string | null) ?? SCOPE }; + }, + }; + return store; +} + +export type OAuthStore = ReturnType; + +/** CSRF token for the consent form: bound to the browser session and this exact request. */ +export function consentToken(session: string, q: { client_id: string; redirect_uri: string; code_challenge: string; state: string }) { + return sha256hex(`consent|${session}|${q.client_id}|${q.redirect_uri}|${q.code_challenge}|${q.state}`); +} + +export function tokenEq(a: string, b: string) { + const x = Buffer.from(a); + const y = Buffer.from(b); + return x.length === y.length && timingSafeEqual(x, y); +} + +/** Metadata documents (RFC 8414, RFC 9728). */ +export function authServerMetadata(site: string) { + return { + issuer: site, + authorization_endpoint: `${site}/oauth/authorize`, + token_endpoint: `${site}/api/oauth/token`, + registration_endpoint: `${site}/api/oauth/register`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + scopes_supported: [SCOPE], + service_documentation: `${site}/docs`, + }; +} + +export function protectedResourceMetadata(site: string) { + return { + resource: `${site}/api/mcp`, + authorization_servers: [site], + bearer_methods_supported: ["header"], + scopes_supported: [SCOPE], + resource_name: "c0mpute", + resource_documentation: `${site}/docs`, + }; +} + +export const CORS = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, OPTIONS", + "access-control-allow-headers": "authorization, content-type, mcp-protocol-version", +}; diff --git a/apps/web/src/lib/chat/push-http.ts b/apps/web/src/lib/chat/push-http.ts index e00b1661..5e78fbcb 100644 --- a/apps/web/src/lib/chat/push-http.ts +++ b/apps/web/src/lib/chat/push-http.ts @@ -1,5 +1,5 @@ // Shared bits for the /api/v1/hosting/* routes. -import { chooseNode, PushError, type HostingNode, type PushStore } from "./push"; +import { PushError, type HostingNode, type Push, type PushStore } from "./push"; import { json } from "./server"; export function pushErr(e: unknown) { @@ -11,11 +11,11 @@ export function pushErr(e: unknown) { export const bad = (message: string, code = 400) => json({ error: { message, code } }, code); /** - * Which node a push goes to: the one named, else the only one, else the one - * seen most recently. Paid pushes (`--disks`) need a node that pays its hosts - * through CoinPay. + * Which node a push goes to: the one named, else the node that signed the + * site, else the account's own (most recently seen), else the c0mpute cloud + * node. Paid pushes (`--disks`) need a node that pays its hosts through CoinPay. */ -export async function pickNode(ps: PushStore, userId: string, name: unknown): Promise { - const r = chooseNode(await ps.nodes(userId), name); +export async function pickNode(ps: PushStore, userId: string, name: unknown, existing?: Push | null): Promise { + const r = await ps.pickNode(userId, name, existing); return "error" in r ? bad(r.error, r.code) : r; } diff --git a/apps/web/src/lib/chat/push-mcp.ts b/apps/web/src/lib/chat/push-mcp.ts index 83aa4d84..09e099c4 100644 --- a/apps/web/src/lib/chat/push-mcp.ts +++ b/apps/web/src/lib/chat/push-mcp.ts @@ -1,23 +1,24 @@ // MCP tools for `/push hosting`: the same per-account nodes and pushes as the // chat and /api/v1/hosting, for MCP clients signed in with a c0k_ key. -import { chooseNode, nodeJob, publicNode, publicPush, PushError, SITE_NAME, validatePushFiles, type PushStore } from "./push"; +import { CLOUD_MAX_PUSHES, nodeJob, publicNode, publicPush, PushError, SITE_NAME, validatePushFiles, type PushStore } from "./push"; export const HOSTING_TOOLS = [ { name: "hosting_nodes", description: - "The hosting nodes linked to this c0mpute account (link one by running `c0mpute chat connect hosting --name ` on it). A node publishes your pushes with its own key and pays hosts through CoinPay.", + "The hosting nodes that can publish for this c0mpute account: your own linked nodes (link one by running `c0mpute chat connect hosting --name ` on it; it signs with its own key and pays hosts through CoinPay) and the shared c0mpute cloud node, which needs no setup.", inputSchema: { type: "object", properties: {} }, + annotations: { title: "List hosting nodes", readOnlyHint: true, openWorldHint: false }, }, { name: "hosting_push", description: - "Publish files as a site on the c0mpute network through one of your linked nodes (`hosting publish --disks auto`, paid hosts via CoinPay). Pushing the same name again ships a new version. Files are inline text (`text`, up to 1 MiB in all) or c0mpute chat results (`src`: /api/v1/images/, /api/v1/music/[/cover], /api/v1/album//zip|cover, /api/v1/promo//video|captions). Returns at once; poll hosting_push_status.", + "Publish files as a website on the c0mpute network (content-addressed, at least three self-healing copies, served by every gateway). With no node of your own it goes through the shared c0mpute cloud node: free, inline text files only (HTML, CSS, JS, SVG, Markdown), names first come first served, up to 5 sites per account. Your own linked node publishes with `--disks auto` (paid hosts via CoinPay) and also takes c0mpute chat results as `src` (/api/v1/images/, /api/v1/music/[/cover], /api/v1/album//zip|cover, /api/v1/promo//video|captions). Pushing the same name again ships a new version. Files are inline text (`text`, up to 1 MiB in all). Returns at once; poll hosting_push_status until it is live, then give the user its url.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Site name: 1-63 of a-z, 0-9 and '-'." }, - node: { type: "string", description: "Which linked node publishes it (default: the one seen most recently, or the node that signed this site)." }, + node: { type: "string", description: "Which node publishes it: one of yours, or \"cloud\" (default: the node that signed this site, else your most recently seen node, else the cloud node)." }, files: { type: "array", items: { @@ -34,21 +35,25 @@ export const HOSTING_TOOLS = [ }, required: ["name", "files"], }, + annotations: { title: "Publish a site", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, { name: "hosting_push_status", description: "A push's status (queued, publishing, live, failed, deleting), the node's latest progress line, and the site url once live.", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + annotations: { title: "Site status", readOnlyHint: true, openWorldHint: false }, }, { name: "hosting_pushes", description: "Every site this account has pushed, newest first.", inputSchema: { type: "object", properties: {} }, + annotations: { title: "List my sites", readOnlyHint: true, openWorldHint: false }, }, { name: "hosting_push_rm", description: "Take a pushed site off the network (its node runs `hosting rm`). Poll hosting_push_status until it reports not found.", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + annotations: { title: "Delete a site", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, }, ] as const; @@ -60,10 +65,17 @@ const err = (t: string): Result => ({ text: t, isError: true }); export async function hostingTool(name: string, args: Record, userId: string, ps: PushStore): Promise { try { - if (name === "hosting_nodes") return out({ nodes: (await ps.nodes(userId)).map((n) => publicNode(n)) }); + if (name === "hosting_nodes") { + const cloud = await ps.cloudNode(); + return out({ + nodes: (await ps.nodes(userId)).filter((n) => !ps.isCloud(n.id)).map((n) => publicNode(n)), + cloud: cloud ? { ...publicNode(cloud), name: "cloud", note: `free, inline text files, up to ${CLOUD_MAX_PUSHES} sites per account` } : null, + }); + } if (name === "hosting_pushes") { const [pushes, nodes] = await Promise.all([ps.pushes(userId, "hosting"), ps.nodes(userId)]); - const byId = new Map(nodes.map((n) => [n.id, n])); + const cloud = await ps.cloudNode(); + const byId = new Map([...(cloud ? [cloud] : []), ...nodes].map((n) => [n.id, n])); return out({ pushes: pushes.map((p) => publicPush(p, byId.get(p.node_id))) }); } const site = String(args.name ?? "").toLowerCase(); @@ -71,7 +83,7 @@ export async function hostingTool(name: string, args: Record, u if (name === "hosting_push_status") { const p = await ps.push(userId, "hosting", site); if (!p) return err(`no push named ${site} on this account`); - return out({ push: publicPush(p, await ps.node(userId, p.node_id)) }); + return out({ push: publicPush(p, await ps.nodeForPush(userId, p)) }); } if (name === "hosting_push_rm") { const r = await ps.deletePush(userId, "hosting", site); @@ -82,7 +94,7 @@ export async function hostingTool(name: string, args: Record, u const files = validatePushFiles(args.files); if (typeof files === "string") return err(files); const existing = await ps.push(userId, "hosting", site); - const node = chooseNode(await ps.nodes(userId), args.node ?? (existing?.site_id ? existing.node_id : undefined)); + const node = await ps.pickNode(userId, args.node, existing); if ("error" in node) return err(node.error); const p = await ps.upsertPush(userId, { target: "hosting", name: site, node, files, disks: "auto" }); return out({ push: publicPush(p, node), job: { action: nodeJob(p).action, files: files.length }, next: "poll hosting_push_status every 5-10 s" }); diff --git a/apps/web/src/lib/chat/push.test.ts b/apps/web/src/lib/chat/push.test.ts index 8f7fd5d4..f7131755 100644 --- a/apps/web/src/lib/chat/push.test.ts +++ b/apps/web/src/lib/chat/push.test.ts @@ -234,3 +234,64 @@ describe("push store", () => { expect(again.payments).toBe("off"); }); }); + +describe("cloud node", () => { + async function setup() { + const sql = memSql(); + const house = await createPushStore(sql).registerNode("house", { name: "cloud-vienna", payments: "off" }); + return { sql, s: createPushStore(sql, { cloudNodeId: house.id }), house }; + } + + test("someone with no node of their own publishes free through it", async () => { + const { s, house } = await setup(); + const node = await s.pickNode("u1", undefined, null); + expect("error" in node).toBe(false); + expect((node as { id: string }).id).toBe(house.id); + const p = await s.upsertPush("u1", { target: "hosting", name: "blog", node: node as never, files: FILES, disks: "auto" }); + expect(p.disks).toBe("free"); + // The house node claims every account's work, and reports on it. + const jobs = await s.claimJobs("house", house.id); + expect(jobs.map((j) => j.id)).toEqual([p.id]); + const live = await s.report("house", house.id, p.id, { rev: 1, ok: true, site_id: "abcdef0123456789abcd", url: "https://gateway.c0mpute.com/s/abcdef0123456789abcd/", copies: 3 }); + expect(live !== "deleted" && live.status).toBe("live"); + expect((await s.push("u1", "hosting", "blog"))?.status).toBe("live"); + // The house can read the file it publishes; another account cannot. + expect((await s.pushForCaller("house", p.id))?.id).toBe(p.id); + expect(await s.pushForCaller("u2", p.id)).toBeNull(); + }); + + test("names are first come, first served; media and too many sites refused", async () => { + const { s, house } = await setup(); + const cloud = (await s.cloudNode())!; + await s.upsertPush("u1", { target: "hosting", name: "blog", node: cloud, files: FILES }); + await expect(s.upsertPush("u2", { target: "hosting", name: "blog", node: cloud, files: FILES })).rejects.toThrow(/taken/); + await expect( + s.upsertPush("u2", { target: "hosting", name: "art", node: cloud, files: [{ path: "a.png", type: "image/png", src: "/api/v1/images/x" }] as never }), + ).rejects.toThrow(/inline text/); + for (const n of ["a", "b", "c", "d"]) await s.upsertPush("u1", { target: "hosting", name: n, node: cloud, files: FILES }); + await expect(s.upsertPush("u1", { target: "hosting", name: "e", node: cloud, files: FILES })).rejects.toThrow(/at most 5/); + // Updating an existing cloud site is not a new site. + await s.upsertPush("u1", { target: "hosting", name: "blog", node: cloud, files: FILES }); + expect(house.id).toBe(cloud.id); + }); + + test("your own node wins when you have one; 'cloud' picks the cloud node", async () => { + const { s, house } = await setup(); + const mine = await s.registerNode("u1", { name: "laptop", payments: "coinpay" }); + expect(((await s.pickNode("u1", undefined, null)) as { id: string }).id).toBe(mine.id); + expect(((await s.pickNode("u1", "cloud", null)) as { id: string }).id).toBe(house.id); + }); + + test("the account running the cloud node gets it as the cloud node, not its own", async () => { + const { s, house } = await setup(); + const n = await s.pickNode("house", undefined, null); + expect("error" in n).toBe(false); + expect((n as { id: string }).id).toBe(house.id); + }); + + test("without a configured cloud node nothing changes", async () => { + const s = createPushStore(memSql()); + const r = await s.pickNode("u1", undefined, null); + expect("error" in r && r.error).toMatch(/no hosting node/); + }); +}); diff --git a/apps/web/src/lib/chat/push.ts b/apps/web/src/lib/chat/push.ts index 8650307f..0f0fdf8d 100644 --- a/apps/web/src/lib/chat/push.ts +++ b/apps/web/src/lib/chat/push.ts @@ -191,7 +191,7 @@ export function chooseNode(nodes: HostingNode[], name: unknown): HostingNode | { if (!nodes.length) { return { error: - "no hosting node linked to this account yet. Run on the machine that should publish: c0mpute chat login && c0mpute chat connect hosting --name ", + "no hosting node linked to this account yet (and no c0mpute cloud node is configured). Run on the machine that should publish: c0mpute chat login && c0mpute chat connect hosting --name ", code: 409, }; } @@ -211,6 +211,16 @@ export function chooseNode(nodes: HostingNode[], name: unknown): HostingNode | { return node; } +/** + * The c0mpute cloud node: one house node (C0MPUTE_CLOUD_NODE_ID) that publishes + * for people who have not linked a node of their own, e.g. someone using the + * ChatGPT or Claude connector. It publishes on the network's free copies + * (`--disks free`), so it never spends a wallet, and it signs every site with + * one key, so site names are first come, first served across accounts. + */ +export const CLOUD_NODE = "cloud"; +export const CLOUD_MAX_PUSHES = 5; + export class PushError extends Error { constructor( message: string, @@ -220,7 +230,8 @@ export class PushError extends Error { } } -export function createPushStore(sql: Sql) { +export function createPushStore(sql: Sql, opts: { cloudNodeId?: string } = {}) { + const cloudId = opts.cloudNodeId || ""; let ready: Promise | null = null; const init = () => (ready ??= (async () => { @@ -235,9 +246,57 @@ export function createPushStore(sql: Sql) { return (await sql.all(q, p))[0] ?? null; } + const byId = async (pid: string): Promise => { + const r = await one(`SELECT * FROM chat_pushes WHERE id = ?`, [pid]); + return r ? toPush(r) : null; + }; + const store = { init, + // ── the cloud node ─────────────────────────────────────────────────────── + async cloudNode(): Promise { + if (!cloudId) return null; + const r = await one(`SELECT * FROM chat_nodes WHERE id = ?`, [cloudId]); + return (r as unknown as HostingNode) ?? null; + }, + + isCloud: (nodeId: string | null | undefined) => !!cloudId && nodeId === cloudId, + + /** + * Which node a push goes to. An existing site stays on the node that + * signed it; `cloud` (or no node of your own) means the cloud node; + * otherwise chooseNode over this account's nodes. + */ + async pickNode(userId: string, name: unknown, existing?: Push | null): Promise { + const cloud = await store.cloudNode(); + const named = typeof name === "string" && name ? name.toLowerCase() : ""; + if (cloud && (named === CLOUD_NODE || named === cloud.id)) return cloud; + // The account that runs the cloud node does not get it as "its own" node. + const nodes = (await store.nodes(userId)).filter((n) => !store.isCloud(n.id)); + if (!named && existing?.site_id) { + if (cloud && existing.node_id === cloud.id) return cloud; + return chooseNode(nodes, existing.node_id); + } + if (!named && cloud && nodes.length === 0) return cloud; + return chooseNode(nodes, named || undefined); + }, + + /** The node a push belongs to, the cloud node included. */ + async nodeForPush(userId: string, p: Push): Promise { + if (store.isCloud(p.node_id)) return store.cloudNode(); + return store.node(userId, p.node_id); + }, + + /** A push the caller may read: their own, or one on a cloud node they run. */ + async pushForCaller(userId: string, pid: string): Promise { + const own = await store.pushById(userId, pid); + if (own) return own; + if (!cloudId || !(await store.node(userId, cloudId))) return null; + const p = await byId(pid); + return p && p.node_id === cloudId ? p : null; + }, + // ── nodes ──────────────────────────────────────────────────────────────── /** A node signs in with the account's key and (re)registers by name. */ async registerNode( @@ -330,13 +389,32 @@ export function createPushStore(sql: Sql) { if (!p.files.length) throw new PushError("nothing to push: this chat has no files yet"); const bytes = 0; // the node reports the real size once it has fetched the files const files = JSON.stringify(p.files); - const disks = p.disks ?? "auto"; + const cloud = store.isCloud(p.node.id); + // The cloud node pays no one: free network copies only. + const disks = cloud ? "free" : (p.disks ?? "auto"); const existing = await store.push(userId, p.target, p.name); + if (cloud) { + // Media refs are fetched with the publishing node's key, which cannot read your chat results. + if (p.files.some((f) => f.text === undefined)) + throw new PushError("the c0mpute cloud node publishes inline text files only; link your own node for media", 400); + const taken = await one(`SELECT 1 AS x FROM chat_pushes WHERE node_id = ? AND target = ? AND name = ? AND user_id <> ?`, [ + p.node.id, + p.target, + p.name, + userId, + ]); + if (taken) throw new PushError(`${p.name} is taken on the c0mpute cloud node; pick another name`, 409); + if (!existing || existing.node_id !== p.node.id) { + const mine = await one(`SELECT COUNT(*) AS n FROM chat_pushes WHERE node_id = ? AND user_id = ?`, [p.node.id, userId]); + if (Number(mine?.n ?? 0) >= CLOUD_MAX_PUSHES) + throw new PushError(`at most ${CLOUD_MAX_PUSHES} sites per account on the c0mpute cloud node; remove one or link your own node`, 409); + } + } if (existing) { if (existing.status === "publishing" || existing.status === "deleting") throw new PushError(`${p.name} is ${existing.status}; wait for it to finish`, 409); if (existing.site_id && existing.node_id !== p.node.id) { - const owner = await store.node(userId, existing.node_id); + const owner = await store.nodeForPush(userId, existing); throw new PushError( `${p.name} is signed by node ${owner?.name ?? existing.node_id}; only that node can update it (or /push hosting rm ${p.name} first)`, 409, @@ -392,11 +470,13 @@ export function createPushStore(sql: Sql) { const t = now(); await sql.run(`UPDATE chat_nodes SET last_seen_at = ? WHERE id = ?`, [t, n.id]); const stale = new Date(Date.now() - CLAIM_TIMEOUT_MS).toISOString(); + // The cloud node works for every account; any other node only for its owner. + const shared = store.isCloud(n.id); const rows = await sql.all( - `SELECT * FROM chat_pushes WHERE user_id = ? AND node_id = ? + `SELECT * FROM chat_pushes WHERE ${shared ? "" : "user_id = ? AND "}node_id = ? AND (status = 'queued' OR (status IN ('publishing','deleting') AND (claimed_at IS NULL OR claimed_at < ?))) ORDER BY updated_at LIMIT 5`, - [userId, n.id, stale], + shared ? [n.id, stale] : [userId, n.id, stale], ); const out: Push[] = []; for (const r of rows) { @@ -416,7 +496,7 @@ export function createPushStore(sql: Sql) { pid: string, r: { rev?: unknown; progress?: unknown; bytes?: unknown; note?: unknown; ok?: unknown; site_id?: unknown; version?: unknown; url?: unknown; copies?: unknown; error?: unknown; deleted?: unknown }, ): Promise { - const p = await store.pushById(userId, pid); + const p = await store.pushForCaller(userId, pid); if (!p || p.node_id !== nodeId) throw new PushError("no such push on this node", 404); if (Number(r.rev) !== p.rev) throw new PushError(`stale report: push is at rev ${p.rev}`, 409); const t = now(); @@ -430,7 +510,7 @@ export function createPushStore(sql: Sql) { p.id, ]); await sql.run(`UPDATE chat_nodes SET last_seen_at = ? WHERE id = ?`, [t, nodeId]); - return (await store.pushById(userId, p.id))!; + return (await byId(p.id))!; } if (p.status === "deleting") { if (r.ok === true || r.deleted === true) { @@ -443,7 +523,7 @@ export function createPushStore(sql: Sql) { t, p.id, ]); - return (await store.pushById(userId, p.id))!; + return (await byId(p.id))!; } if (p.status !== "publishing") throw new PushError(`push is ${p.status}, not publishing`, 409); if (r.ok === true) { @@ -471,7 +551,7 @@ export function createPushStore(sql: Sql) { p.id, ]); } - return (await store.pushById(userId, p.id))!; + return (await byId(p.id))!; }, }; return store; diff --git a/apps/web/src/lib/chat/server.ts b/apps/web/src/lib/chat/server.ts index 505988bd..83ff454f 100644 --- a/apps/web/src/lib/chat/server.ts +++ b/apps/web/src/lib/chat/server.ts @@ -7,6 +7,7 @@ export const env = (name: string): string => (process.env as Record (_store ??= createStore(cloudSql)); let _pushStore: PushStore | null = null; /** `/push hosting` + `/connect hosting`: linked nodes and pushes, per account. */ -export const pushStore = () => (_pushStore ??= createPushStore(cloudSql)); +export const pushStore = () => (_pushStore ??= createPushStore(cloudSql, { cloudNodeId: env("C0MPUTE_CLOUD_NODE_ID") })); + +let _oauthStore: OAuthStore | null = null; +/** OAuth clients and authorization codes for the MCP server (ChatGPT, Claude). */ +export const oauthStore = () => (_oauthStore ??= createOAuthStore(cloudSql)); // ── request auth ───────────────────────────────────────────────────────────── export const SESSION_COOKIE = "c0_session"; @@ -93,6 +98,22 @@ export function json(body: unknown, status = 200, headers: Record (OAuth)`. It +does not expire. Revoke it with your other keys in c0mpute chat. + +Tables: `oauth_clients`, `oauth_codes` (hashed, single use, 5 minutes), in the +same SQLite Cloud database as `chat_tokens`. Code: `apps/web/src/lib/chat/oauth.ts`. + +## Publishing without a node: the cloud node + +`hosting_push` needs a node to sign and publish the site. Someone using the +ChatGPT or Claude connector usually has none, so the site falls back to the +**c0mpute cloud node**, one house node named by `C0MPUTE_CLOUD_NODE_ID` (the +`chat_nodes.id` of a node linked with `c0mpute chat connect hosting`). + +- It publishes with `--disks free` (the network's own copies), so it never spends a wallet. +- Inline text files only (HTML, CSS, JS, SVG, Markdown, up to 1 MiB per push). Media refs are fetched with the publishing node's key, which cannot read another account's chat results. +- It signs every site with one key, so site names are first come, first served across accounts. +- At most 5 sites per account. A person with their own linked node keeps using it; `node: "cloud"` picks the cloud node on purpose. +- Unset `C0MPUTE_CLOUD_NODE_ID` and nothing changes: pushes need your own node again. + +Run the cloud node like any other push node, on a box with disk (not dev2): + +```sh +c0mpute chat login # as the house account +c0mpute chat connect hosting --name cloud +``` + +then put its node id in `C0MPUTE_CLOUD_NODE_ID` in the site's app.env and the +`c0mpute--prod` vault.