Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/web/src/app/.well-known/oauth-authorization-server/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Original file line number Diff line number Diff line change
@@ -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 });
}
13 changes: 13 additions & 0 deletions apps/web/src/app/.well-known/oauth-protected-resource/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
18 changes: 13 additions & 5 deletions apps/web/src/app/api/mcp/route.ts
Original file line number Diff line number Diff line change
@@ -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];
Expand All @@ -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" } });
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/app/api/oauth/register/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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 });
}
34 changes: 34 additions & 0 deletions apps/web/src/app/api/oauth/token/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
if (type.includes("application/json")) p = ((await req.json().catch(() => null)) as Record<string, unknown>) ?? {};
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 });
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/v1/hosting/pushes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
107 changes: 107 additions & 0 deletions apps/web/src/app/oauth/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);

function page(title: string, body: string, status = 200) {
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)} · c0mpute</title><meta name="robots" content="noindex">
<style>
:root{--bg:#fff8ec;--fg:#1f2440;--muted:#5b6080;--card:#fff;--line:#e6dcc8;--ok:#2bb673;--okfg:#06210f}
@media (prefers-color-scheme:dark){:root{--bg:#0f1a33;--fg:#eef1ff;--muted:#a8b0d8;--card:#17244a;--line:#2a3a6a;--ok:#2bb673;--okfg:#06210f}}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,sans-serif;padding:16px}
main{max-width:440px;width:100%;background:var(--card);border:1px solid var(--line);border-radius:18px;padding:28px}
h1{font-size:1.35rem;margin:0 0 .5rem}p{margin:.5rem 0;color:var(--muted)}b{color:var(--fg)}ul{padding-left:1.2rem;margin:.75rem 0}li{margin:.25rem 0}
.row{display:flex;gap:10px;margin-top:1.25rem}button{flex:1;font:inherit;font-weight:700;padding:.75rem 1rem;border-radius:12px;border:1px solid var(--line);background:transparent;color:var(--fg);cursor:pointer}
button.ok{background:var(--ok);border-color:var(--ok);color:var(--okfg)}small{display:block;margin-top:1rem;color:var(--muted)}code{font-size:.9em}
</style></head><body><main>${body}</main></body></html>`;
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", `<h1>Cannot connect</h1><p>${esc(msg)}</p>`, 400);

function back(redirectUri: string, params: Record<string, string>) {
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]) => `<input type="hidden" name="${k}" value="${esc(v)}">`)
.join("");
return page(
"Connect",
`<h1>Connect <b>${esc(client.name)}</b> to c0mpute?</h1>
<p>Signed in as <b>${esc(user.name || user.did)}</b>.</p>
<p>${esc(client.name)} will be able to:</p>
<ul><li>publish, update and take down websites on the c0mpute network for you</li><li>ask models on the network, billed to your compute balance</li><li>make promo videos</li></ul>
<form method="post" action="/oauth/authorize">${hidden}
<div class="row"><button type="submit" name="decision" value="deny">Cancel</button><button class="ok" type="submit" name="decision" value="allow">Allow</button></div></form>
<small>You will be sent back to <code>${esc(host)}</code>. It gets an API key you can revoke any time in c0mpute chat (keys).</small>`,
);
}

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 });
}
2 changes: 1 addition & 1 deletion apps/web/src/lib/chat/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] };
};
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/lib/chat/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;

Expand All @@ -84,9 +89,9 @@ export async function handleRpc(msg: Rpc, ctx: McpCtx): Promise<object | null> {
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/<site_id>/). 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, {});
Expand Down
Loading
Loading