diff --git a/README.md b/README.md index 1c95df7..6a435b6 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,64 @@ DATABASE_URL=postgres://user:pass@host/nixamp NIXAMP_JWT_SECRET=… nixamp serve Accounts live where the directory lives and nowhere else: a nixamp on a laptop has nobody to be an account of. +## Watch parties, and signing in with nixamp + +A watch party lives on the site that has the film. bittorrented.com has them: +a six-character code, a host, and everybody at the same second. nixamp has +rooms, chat, invitations, a directory, and five clients that can already open +one. A bridged party is both. + +The identity link is **OAuth 2.1**, with nixamp.com as the authorization +server. The site sends somebody here, they approve it once, and the site holds +a token that acts on their nixamp account. It is 2.1 and not 2.0, so: + +- authorization code only, with PKCE (S256) required of every client, public + or confidential. No implicit grant, no password grant. +- redirect URIs match the registered string exactly; only a loopback port may + vary, because a CLI cannot know its port before it listens. +- a code is spent once; presenting it twice withdraws everything it produced. +- refresh tokens rotate, and a retired one presented again withdraws the whole + family. + +The endpoints are where RFC 8414 says to look for them: + +``` +GET /.well-known/oauth-authorization-server +GET /api/v1/oauth/authorize the consent page +POST /api/v1/oauth/token authorization_code, refresh_token +POST /api/v1/oauth/revoke +GET /api/v1/oauth/userinfo +``` + +Scopes are `profile`, `email`, `parties` and `offline_access`. The Account +panel on nixamp.com lists what is connected and takes it away again. + +bittorrented.com is registered out of the box. Another client is added with +`NIXAMP_OAUTH_CLIENTS`, a JSON list: + +``` +NIXAMP_OAUTH_CLIENTS='[{"id":"example","name":"Example","redirectUris":["https://example.com/cb"]}]' +``` + +Once a party is bridged it is an ordinary live event with a room, so every +surface already knows what to do with it: + +``` +nixamp party list the ones you could join right now +nixamp party join ABC123 --open the room here, the film where it lives +nixamp party host ABC123 --url URL put one on the air as a nixamp room +nixamp party sync ABC123 --at 930 where playback is (hosts only) +``` + +and an agent reaches the same five actions over the Model Context Protocol: + +``` +nixamp mcp a stdio MCP server: list, get, host, sync, end +``` + +It acts as whoever the machine is signed in as, so `nixamp login` comes first. +The film never crosses over: what nixamp carries is the room. + ## BackToSchool.help BackToSchool.help is a branded, mobile-first client for NixAmp live events. It diff --git a/src/main.ts b/src/main.ts index 6c4828d..71a4dae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -80,6 +80,8 @@ const HELP = `nixamp — it really whips the terminal's ass. nixamp dns [set|rm] names under your handle, for your servers nixamp library [folder] where the media is; the daemon serves this and nothing outside it nixamp server list|add|remove the machines you run, kept against your account + nixamp party list|join|host watch parties, here and on the sites nixamp is connected to + nixamp mcp speak Model Context Protocol on stdin, for an agent nixamp opendir list|add|remove folders found on the web, published for everyone nixamp update [version] re-run the installer, keeping your choices nixamp uninstall [--yes] remove everything the installer created @@ -234,6 +236,35 @@ A share link printed in a terminal you have since closed is a server you have lost. This keeps the address against your account, so the answer is the same here, in the browser and in the desktop app. The share key is kept with it only if you pass one, since it is the secret that opens the machine. +`, + party: `nixamp party — watch parties, here and on the sites nixamp is connected to. + + nixamp party list the ones you could join right now + nixamp party join CODE the room, the links, and where the film is + nixamp party join CODE --open and open the picture in a browser + nixamp party host CODE --url URL put a party on the air as a nixamp room + nixamp party sync CODE --at 1234 say where playback is (hosts only) + nixamp party end CODE end it + +A watch party lives on the site that has the film — bittorrented.com, say — +and is bridged into nixamp as a room, so every nixamp client can join it: the +browser, this terminal, the desktop app, the television and an agent over MCP. +The film stays where it is; what nixamp carries is the room, the chat and the +second everybody is supposed to be at. + +The site connects to your nixamp account with OAuth 2.1, which you approve +once in a browser. The Account panel on nixamp.com lists what is connected and +takes it away again. +`, + mcp: `nixamp mcp — nixamp as a tool an agent can use. + + nixamp mcp speak Model Context Protocol on stdin and stdout + +It offers the watch party tools: list them, read one, put one on the air, +say where playback is, end it. It acts as whoever this machine is signed in +as, so \`nixamp login\` (or NIXAMP_TOKEN) comes first. + +Point an MCP client at it as a stdio server running \`nixamp mcp\`. `, attach: `nixamp attach — the player, in front of the running daemon. @@ -424,6 +455,16 @@ export async function main(): Promise { process.exitCode = await servers(rest); return; } + if (first === "party" || first === "parties" || first === "watch-party") { + const { party } = await import("./party.ts"); + process.exitCode = await party(rest); + return; + } + if (first === "mcp") { + const { mcp } = await import("./mcp.ts"); + process.exitCode = await mcp(); + return; + } if (first === "token" || first === "tokens") { const { tokens } = await import("./session.ts"); process.exitCode = await tokens(rest); diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..e4bdb5e --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,282 @@ +/** + * `nixamp mcp` -- nixamp as a tool an agent can use. + * + * The Model Context Protocol is JSON-RPC 2.0 over a pipe: one message per + * line on stdin, one per line on stdout. That is the whole transport, which + * is why this needs no dependency -- adding an SDK to a CLI that is packed + * into a tarball and run under node would cost more than it saves. + * + * What it offers is the watch party, because that is the part of nixamp an + * agent can usefully do something with: find the party, say where it is, put + * one on the air, move everybody to the same second. It signs in as whoever + * this machine is signed in as -- the session on disk, or NIXAMP_TOKEN -- + * because an agent holding its own credential is a credential nobody revokes. + * + * Anything written to stdout that is not a response corrupts the stream, so + * every diagnostic goes to stderr. That is the one rule of this file. + */ +import { createInterface } from "node:readline"; +import { clock, type PartyRow } from "./party.ts"; +import { readSession } from "./session.ts"; + +export const PROTOCOL_VERSION = "2025-06-18"; + +interface Request { + jsonrpc: "2.0"; + id?: string | number | null; + method: string; + params?: Record; +} + +interface ToolDefinition { + name: string; + description: string; + inputSchema: Record; +} + +const STRING = { type: "string" } as const; + +export const TOOLS: ToolDefinition[] = [ + { + name: "watch_parties_list", + description: + "List the watch parties on right now that this account could join. Each one is a room on nixamp bridged from the site hosting the film (bittorrented.com, for instance), with where playback has got to.", + inputSchema: { + type: "object", + properties: { + origin: { ...STRING, description: "Only parties bridged by this client, e.g. bittorrented." }, + limit: { type: "integer", description: "At most this many (default 30)." }, + }, + }, + }, + { + name: "watch_party_get", + description: + "One watch party, by the code the hosting site shows, or by its nixamp room id or slug. Answers where the film is now, the link to watch it, and the nixamp room link.", + inputSchema: { + type: "object", + properties: { code: { ...STRING, description: "The party code, room id or slug." } }, + required: ["code"], + }, + }, + { + name: "watch_party_host", + description: + "Put a watch party on the air as a nixamp room, so it is joinable from every nixamp client. Idempotent: calling it again for a party that is already bridged updates it rather than making a second room.", + inputSchema: { + type: "object", + properties: { + code: { ...STRING, description: "The party code on the hosting site." }, + title: { ...STRING, description: "What to call the room." }, + partyUrl: { ...STRING, description: "Where to watch it on the hosting site." }, + mediaTitle: { ...STRING, description: "What is playing." }, + visibility: { ...STRING, description: "public, unlisted (default) or private." }, + }, + required: ["code"], + }, + }, + { + name: "watch_party_sync", + description: + "Say where playback is, so everybody joining lands on the same second. Only the host of the party may do this.", + inputSchema: { + type: "object", + properties: { + code: STRING, + positionSeconds: { type: "number", description: "Seconds into the film." }, + playing: { type: "boolean", description: "False if it is paused (default true)." }, + }, + required: ["code", "positionSeconds"], + }, + }, + { + name: "watch_party_end", + description: "End a watch party. Only its host may.", + inputSchema: { type: "object", properties: { code: STRING }, required: ["code"] }, + }, +]; + +export interface McpOptions { + fetcher?: typeof fetch; + /** Injected by the tests; the session on disk otherwise. */ + session?: { site: string; token: string } | null; + say?: (line: string) => void; +} + +/** A tool answer, in the shape MCP wants: content blocks, and a flag for failure. */ +export interface ToolResult { + content: { type: "text"; text: string }[]; + isError?: boolean; +} + +function text(value: string): ToolResult { + return { content: [{ type: "text", text: value }] }; +} + +function failed(value: string): ToolResult { + return { content: [{ type: "text", text: value }], isError: true }; +} + +function describe(row: PartyRow): string { + return [ + `${row.party.partyCode} — ${row.event.title}${row.host ? " (this account is the host)" : ""}`, + `${row.party.playing ? "playing" : "paused"} at ${clock(row.party.positionNow)}${row.party.mediaTitle ? `, ${row.party.mediaTitle}` : ""}`, + `bridged from ${row.party.origin}; event ${row.event.id} is ${row.event.status}, ${row.event.visibility}`, + `watch: ${row.links.partyUrl || row.links.nixampUrl}`, + `nixamp room: ${row.links.nixampUrl}`, + ].join("\n"); +} + +/** + * Run one tool. Separate from the transport so it can be tested without a + * pipe, and so the same call is reachable from anywhere else that wants it. + */ +export async function callTool(name: string, args: Record, options: McpOptions = {}): Promise { + const session = options.session === undefined ? readSession() : options.session; + if (!session) { + return failed("This machine is not signed in to nixamp. Run `nixamp login`, or set NIXAMP_TOKEN."); + } + const send = options.fetcher ?? fetch; + const site = session.site.replace(/\/+$/, ""); + const where = `${site}/api/v1/watch-parties`; + const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" }; + const code = typeof args["code"] === "string" ? args["code"] : ""; + + const answerOf = async (response: Response): Promise => { + const body = (await response.json().catch(() => ({}))) as { error?: string }; + return body.error ?? `nixamp answered ${response.status}`; + }; + + try { + if (name === "watch_parties_list") { + const url = new URL(where); + if (typeof args["origin"] === "string" && args["origin"]) url.searchParams.set("origin", args["origin"]); + if (typeof args["limit"] === "number") url.searchParams.set("limit", String(args["limit"])); + const response = await send(url.toString(), { headers }); + if (!response.ok) return failed(await answerOf(response)); + const body = (await response.json()) as { parties?: PartyRow[] }; + const rows = body.parties ?? []; + return text(rows.length === 0 ? "No watch parties are on right now." : rows.map(describe).join("\n\n")); + } + + if (name === "watch_party_get") { + if (!code) return failed("Which party? Pass the code the hosting site shows."); + const response = await send(`${where}/${encodeURIComponent(code)}`, { headers }); + if (!response.ok) return failed(await answerOf(response)); + return text(describe((await response.json()) as PartyRow)); + } + + if (name === "watch_party_host") { + if (!code) return failed("Which party? Pass the code the hosting site shows."); + const response = await send(where, { + method: "POST", + headers, + body: JSON.stringify({ + partyCode: code, + ...(typeof args["title"] === "string" ? { title: args["title"] } : {}), + ...(typeof args["partyUrl"] === "string" ? { partyUrl: args["partyUrl"] } : {}), + ...(typeof args["mediaTitle"] === "string" ? { mediaTitle: args["mediaTitle"] } : {}), + ...(typeof args["visibility"] === "string" ? { visibility: args["visibility"] } : {}), + }), + }); + if (!response.ok) return failed(await answerOf(response)); + return text(describe((await response.json()) as PartyRow)); + } + + if (name === "watch_party_sync") { + if (!code) return failed("Which party?"); + const at = args["positionSeconds"]; + if (typeof at !== "number" || !Number.isFinite(at) || at < 0) { + return failed("positionSeconds must be a number of seconds into the film."); + } + const response = await send(`${where}/${encodeURIComponent(code)}/playback`, { + method: "POST", + headers, + body: JSON.stringify({ positionSeconds: at, playing: args["playing"] !== false }), + }); + if (!response.ok) return failed(await answerOf(response)); + return text(describe((await response.json()) as PartyRow)); + } + + if (name === "watch_party_end") { + if (!code) return failed("Which party?"); + const response = await send(`${where}/${encodeURIComponent(code)}/end`, { method: "POST", headers }); + if (!response.ok) return failed(await answerOf(response)); + return text(`Ended ${code}.`); + } + } catch (error) { + return failed(`Could not reach ${site}: ${(error as Error).message}`); + } + return failed(`No such tool: ${name}`); +} + +/** One JSON-RPC message in, one answer out -- or null for a notification. */ +export async function handleMessage(message: Request, options: McpOptions = {}): Promise | null> { + const id = message.id ?? null; + const reply = (result: unknown): Record => ({ jsonrpc: "2.0", id, result }); + + if (message.method === "initialize") { + return reply({ + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "nixamp", title: "nixamp watch parties", version: "1" }, + instructions: + "Watch parties on nixamp. A party lives on the site hosting the film and is bridged here as a room every nixamp client can join. Codes are the ones that site shows; positions are seconds into the film.", + }); + } + // Notifications carry no id and are answered with silence, which is what + // the protocol means by one: replying to notifications/initialized with a + // result whose id is null is the mistake that hangs a client. + if (message.id === undefined || message.id === null) { + if (message.method.startsWith("notifications/")) return null; + } + if (message.method === "tools/list") return reply({ tools: TOOLS }); + if (message.method === "ping") return reply({}); + if (message.method === "tools/call") { + const name = String(message.params?.["name"] ?? ""); + const args = (message.params?.["arguments"] ?? {}) as Record; + if (!TOOLS.some((tool) => tool.name === name)) { + return { jsonrpc: "2.0", id, error: { code: -32602, message: `no such tool: ${name}` } }; + } + return reply(await callTool(name, args, options)); + } + if (id === null) return null; + return { jsonrpc: "2.0", id, error: { code: -32601, message: `unknown method: ${message.method}` } }; +} + +/** The stdio server. Resolves when stdin closes, which is how a client stops it. */ +export async function mcp(options: McpOptions = {}): Promise { + const out = (value: unknown): void => { + process.stdout.write(`${JSON.stringify(value)}\n`); + }; + const lines = createInterface({ input: process.stdin }); + // Ordered on purpose: a client may send initialize and tools/list without + // waiting, and answering out of order is a client that never sees the + // tools. Each message is finished before the next is begun. + let chain: Promise = Promise.resolve(); + lines.on("line", (line) => { + const trimmed = line.trim(); + if (trimmed === "") return; + chain = chain.then(async () => { + let message: Request; + try { + message = JSON.parse(trimmed) as Request; + } catch { + out({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); + return; + } + try { + const answer = await handleMessage(message, options); + if (answer) out(answer); + } catch (error) { + out({ + jsonrpc: "2.0", + id: message.id ?? null, + error: { code: -32603, message: (error as Error).message }, + }); + } + }); + }); + await new Promise((done) => lines.on("close", () => void chain.then(done))); + return 0; +} diff --git a/src/oauth-api.ts b/src/oauth-api.ts new file mode 100644 index 0000000..2b63761 --- /dev/null +++ b/src/oauth-api.ts @@ -0,0 +1,481 @@ +/** + * The OAuth 2.1 endpoints, and the watch-party API they guard. + * + * Five URLs make nixamp an authorization server: + * + * /.well-known/oauth-authorization-server what and where everything is + * /api/v1/oauth/authorize the consent page, and the code + * /api/v1/oauth/token code or refresh -> tokens + * /api/v1/oauth/revoke hand a token back + * /api/v1/oauth/userinfo who the token belongs to + * + * and four more make a watch party somewhere else into a room here: + * + * POST /api/v1/watch-parties bridge one, idempotently + * GET /api/v1/watch-parties the ones you could join + * GET /api/v1/watch-parties/ one, with where playback is + * POST /api/v1/watch-parties//playback the host moving everybody + * POST /api/v1/watch-parties//end the host ending it + * + * The watch-party routes take either a session (a person on nixamp.com) or an + * OAuth access token with the `parties` scope (bittorrented.com acting for + * them). That is the whole point of the pairing: the same five endpoints + * answer the web app, the CLI, the desktop app, the MCP server and the site + * on the other side of the link, and none of them is a special case. + */ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { tokenFrom, type Account, type Accounts } from "./accounts.ts"; +import type { Handles } from "./handles.ts"; +import { LiveEventError } from "./live-events.ts"; +import { + AuthorizationServer, + OAuthError, + SCOPES, + SCOPE_NAMES, + type AuthorizeRequest, + type Scope, +} from "./oauth-server.ts"; +import { WatchPartyError, type PartyView, type WatchParties } from "./watch-party.ts"; + +export interface OAuthApiOptions { + server: AuthorizationServer; + accounts: Accounts; + parties?: WatchParties; + handles?: Handles; + /** True where the cookie may be marked Secure. */ + secureCookies?: boolean; +} + +const CORS = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", + "access-control-allow-headers": "content-type, authorization", +}; + +function json(response: ServerResponse, code: number, body: unknown, headers: Record = {}): void { + const value = JSON.stringify(body); + response.writeHead(code, { + ...CORS, + ...headers, + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(value), + // RFC 6749 §5.1: a token response is never cached, anywhere. + "cache-control": "no-store", + pragma: "no-cache", + }); + response.end(value); +} + +function html(response: ServerResponse, code: number, body: string, headers: Record = {}): void { + response.writeHead(code, { + ...headers, + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(body), + "cache-control": "no-store", + }); + response.end(body); +} + +async function readBody(request: IncomingMessage, limit = 64 * 1024): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = chunk as Buffer; + size += buffer.length; + if (size > limit) throw new OAuthError("invalid_request", "body too large", 413); + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function escape(value: string): string { + return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); +} + +const PAGE_STYLE = ` + :root { color-scheme: dark } + body { background:#000; color:#00e676; font:16px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; + margin:0; min-height:100vh; display:grid; place-items:center; padding:2rem } + main { width:min(32rem,100%) } + h1 { font-size:1.1rem; letter-spacing:.2em; text-transform:uppercase; color:#9ad } + ul { list-style:none; padding:0; border:1px solid #2a2a2a } + li { padding:.55rem .8rem; border-bottom:1px solid #1a1a1a; color:#cfcfcf } + li:last-child { border-bottom:0 } + li b { color:#00e676; font-weight:400 } + button { font:inherit; background:#111; color:#00e676; border:1px solid #2a2a2a; padding:.6rem 1rem; + cursor:pointer; margin-top:.5rem } + button.primary { border-color:#00e676 } + form { display:flex; gap:.5rem; flex-wrap:wrap } + p { color:#9a9a9a } code, a { color:#00e676 } +`; + +function page(title: string, body: string): string { + return ` + +${escape(title)} - nixamp +
${body}
`; +} + +/** + * The page that asks. + * + * Every parameter of the request is carried through as a hidden field rather + * than kept in a server-side map, so approving works in a second tab, after a + * sign-in redirect, and on a deployment with more than one process. Nothing + * in it is trusted on the way back: the whole request is checked again + * against the registered client before a code is issued. + */ +export function consentPage(request: AuthorizeRequest, account: Account, handle: string, raw: URLSearchParams): string { + const hidden = [...raw.entries()] + .map(([name, value]) => ``) + .join(""); + const who = handle ? `@${handle}` : account.email; + const site = request.client.homepage + ? `${escape(request.client.name)}` + : escape(request.client.name); + return page( + `Connect ${request.client.name}`, + `

Connect ${escape(request.client.name)}

+

${site} wants to act on your nixamp account as ${escape(who)}.

+
    ${request.scope.map((word) => `
  • ${escape(word)} — ${escape(SCOPES[word])}
  • `).join("")}
+
${hidden} + + +
+

You can disconnect it later from the Account panel on nixamp.com.

`, + ); +} + +function signInFirst(url: URL): string { + const back = `${url.pathname}${url.search}`; + return page( + "Sign in to nixamp", + `

Sign in first

+

Something wants to connect to your nixamp account, and nixamp does not + know who you are on this device yet.

+

Sign in at nixamp.com, then open this link again.

`, + ); +} + +/** A refusal that cannot be redirected is a page; there is nowhere safe to send it. */ +function refusalPage(error: string, description: string): string { + return page("Cannot connect", `

Cannot connect

${escape(description)}

${escape(error)}

`); +} + +// --- watch parties ------------------------------------------------------------- + +/** Who is asking, and whether the client they came through may ask this. */ +interface Caller { + account: Account; + /** "" when this is the person themselves rather than a client acting for them. */ + clientId: string; + scope: Scope[]; +} + +async function callerFor( + request: IncomingMessage, + options: OAuthApiOptions, + needs: Scope, +): Promise { + const token = tokenFrom(request.headers); + if (!token) return null; + const grant = await options.server.grantFor(token); + if (grant) { + return grant.scope.includes(needs) ? { account: grant.account, clientId: grant.clientId, scope: grant.scope } : null; + } + // Not an OAuth token: a session or a CLI token, which is the person, and a + // person needs no scope to act as themselves. + const account = await options.accounts.whoIs(token); + return account ? { account, clientId: "", scope: SCOPE_NAMES } : null; +} + +function partyBody(parties: WatchParties, view: PartyView, caller: Caller): Record { + return { + party: { ...view.party, positionNow: parties.positionNow(view.party) }, + event: view.event, + links: parties.links(view.party), + host: view.event.ownerId === caller.account.id, + }; +} + +// --- the handler --------------------------------------------------------------- + +export function oauthApiPath(path: string): boolean { + return ( + path === "/.well-known/oauth-authorization-server" || + path === "/.well-known/openid-configuration" || + path.startsWith("/api/v1/oauth/") || + path === "/api/v1/watch-parties" || + path.startsWith("/api/v1/watch-parties/") + ); +} + +export async function handleOAuthApi( + request: IncomingMessage, + response: ServerResponse, + url: URL, + options: OAuthApiOptions, +): Promise { + const path = url.pathname; + if (!oauthApiPath(path)) return false; + const server = options.server; + + try { + // --- what this server is, and where ---------------------------------- + if (path === "/.well-known/oauth-authorization-server" || path === "/.well-known/openid-configuration") { + json(response, 200, server.metadata(), { "cache-control": "public, max-age=3600" }); + return true; + } + + // --- the consent page, and the code it issues ------------------------- + if (path === "/api/v1/oauth/authorize") { + const method = request.method ?? "GET"; + if (method !== "GET" && method !== "HEAD" && method !== "POST") { + json(response, 405, { error: "invalid_request", error_description: "GET or POST" }); + return true; + } + const params = + method === "POST" ? new URLSearchParams(await readBody(request)) : new URLSearchParams(url.search); + const checked = server.check(params); + if ("error" in checked) { + // Redirect the refusal only where the redirect URI itself checked out. + if (checked.redirectUri) { + response.writeHead(302, { + location: AuthorizationServer.redirect(checked.redirectUri, { + error: checked.error, + error_description: checked.description, + state: checked.state ?? "", + }), + }); + response.end(); + return true; + } + html(response, 400, refusalPage(checked.error, checked.description)); + return true; + } + + const account = await options.accounts.whoIs(tokenFrom(request.headers)); + if (account === null) { + html(response, 401, signInFirst(url)); + return true; + } + + if (method === "GET" || method === "HEAD") { + const handle = (await options.handles?.of(account.id).catch(() => "")) ?? ""; + html(response, 200, consentPage(checked, account, handle, params)); + return true; + } + + if (params.get("decision") !== "allow") { + response.writeHead(302, { + location: AuthorizationServer.redirect(checked.redirectUri, { + error: "access_denied", + error_description: "the person said not now", + state: checked.state, + }), + }); + response.end(); + return true; + } + + const code = await server.issueCode(checked, account); + response.writeHead(302, { + location: AuthorizationServer.redirect(checked.redirectUri, { code, state: checked.state }), + }); + response.end(); + return true; + } + + // --- the token endpoint ---------------------------------------------- + if (path === "/api/v1/oauth/token") { + if (request.method !== "POST") { + json(response, 405, { error: "invalid_request", error_description: "POST only" }); + return true; + } + const form = new URLSearchParams(await readBody(request)); + const authorization = Array.isArray(request.headers["authorization"]) + ? request.headers["authorization"][0] + : request.headers["authorization"]; + const client = server.authenticateClient(form, authorization); + const grantType = form.get("grant_type") ?? ""; + if (grantType === "authorization_code") { + json(response, 200, await server.exchangeCode(client, form)); + return true; + } + if (grantType === "refresh_token") { + json(response, 200, await server.refresh(client, form)); + return true; + } + // Named rather than shrugged at: "password" and "implicit" are the two + // somebody will try, and both are gone from OAuth 2.1 on purpose. + throw new OAuthError( + "unsupported_grant_type", + `grant_type must be authorization_code or refresh_token; ${grantType || "none"} is not supported`, + ); + } + + // --- handing a token back --------------------------------------------- + if (path === "/api/v1/oauth/revoke") { + if (request.method !== "POST") { + json(response, 405, { error: "invalid_request", error_description: "POST only" }); + return true; + } + const form = new URLSearchParams(await readBody(request)); + const authorization = Array.isArray(request.headers["authorization"]) + ? request.headers["authorization"][0] + : request.headers["authorization"]; + const client = server.authenticateClient(form, authorization); + await server.revoke(client, form.get("token") ?? ""); + // RFC 7009: a token that was never valid is the same answer as one that + // just stopped being. Saying which would be a way to test tokens. + json(response, 200, { ok: true }); + return true; + } + + // --- who a token belongs to ------------------------------------------- + if (path === "/api/v1/oauth/userinfo") { + const info = await server.userinfo(tokenFrom(request.headers), async (id) => + (await options.handles?.of(id)) ?? "", + ); + if (info === null) { + response.writeHead(401, { + ...CORS, + "www-authenticate": 'Bearer error="invalid_token"', + "content-type": "application/json; charset=utf-8", + }); + response.end(JSON.stringify({ error: "invalid_token" })); + return true; + } + json(response, 200, info); + return true; + } + + // --- the connections an account has granted ---------------------------- + if (path === "/api/v1/oauth/connections" || path.startsWith("/api/v1/oauth/connections/")) { + // Deliberately the person only: a client must not be able to see or + // withdraw what another client holds on the same account. + const account = await options.accounts.whoIs(tokenFrom(request.headers)); + if (account === null) { + json(response, 401, { error: "sign in first" }); + return true; + } + if (path === "/api/v1/oauth/connections" && request.method === "GET") { + json(response, 200, { connections: await server.grants(account.id) }); + return true; + } + if (path.startsWith("/api/v1/oauth/connections/") && request.method === "DELETE") { + const clientId = decodeURIComponent(path.slice("/api/v1/oauth/connections/".length)); + const gone = await server.disconnect(account.id, clientId); + json(response, 200, { ok: true, withdrawn: gone }); + return true; + } + json(response, 405, { error: "GET or DELETE" }); + return true; + } + + // --- watch parties ------------------------------------------------------ + if (path === "/api/v1/watch-parties" || path.startsWith("/api/v1/watch-parties/")) { + const parties = options.parties; + if (!parties) { + json(response, 404, { error: "this nixamp does not keep watch parties" }); + return true; + } + const caller = await callerFor(request, options, "parties"); + if (caller === null) { + json(response, 401, { error: "a session, or a token granted the parties scope, is needed here" }); + return true; + } + // A client bridges parties under its own origin, so two sites cannot + // collide on a six-character code; a person acting directly is filed + // under nixamp itself. + const origin = caller.clientId || "nixamp"; + + if (path === "/api/v1/watch-parties") { + if (request.method === "GET") { + const wanted = url.searchParams.get("origin"); + const found = await parties.list({ + ...(wanted ? { origin: wanted } : {}), + limit: Number(url.searchParams.get("limit") ?? 30), + }); + json(response, 200, { parties: found.map((view) => partyBody(parties, view, caller)) }); + return true; + } + if (request.method === "POST") { + let input: Record; + try { + input = JSON.parse((await readBody(request)) || "{}") as Record; + } catch { + json(response, 400, { error: "bad JSON" }); + return true; + } + const view = await parties.bridge(caller.account.id, origin, { + partyCode: input["partyCode"] ?? input["code"], + title: input["title"], + partyUrl: input["partyUrl"], + mediaTitle: input["mediaTitle"], + visibility: input["visibility"], + chatEnabled: input["chatEnabled"], + handRaiseEnabled: input["handRaiseEnabled"], + }); + json(response, 201, partyBody(parties, view, caller)); + return true; + } + json(response, 405, { error: "GET or POST" }); + return true; + } + + const rest = path.slice("/api/v1/watch-parties/".length).split("/"); + const reference = decodeURIComponent(rest[0] ?? ""); + const action = rest[1]; + // A party is findable by its code on the origin that bridged it, or by + // the nixamp slug or room a client was handed, because a nixamp client + // arriving from a share link has only the latter. + const view = (await parties.byCode(origin, reference).catch(() => null)) ?? (await parties.byEvent(reference)); + if (!view) { + json(response, 404, { error: "watch party not found" }); + return true; + } + + if (!action && request.method === "GET") { + json(response, 200, partyBody(parties, view, caller)); + return true; + } + if (action === "playback" && request.method === "POST") { + let input: Record; + try { + input = JSON.parse((await readBody(request)) || "{}") as Record; + } catch { + json(response, 400, { error: "bad JSON" }); + return true; + } + const party = await parties.setPlayback(view.event.id, caller.account.id, { + positionSeconds: input["positionSeconds"], + playing: input["playing"], + mediaTitle: input["mediaTitle"], + }); + json(response, 200, partyBody(parties, { party, event: view.event }, caller)); + return true; + } + if (action === "end" && request.method === "POST") { + const event = await parties.end(view.event.id, caller.account.id); + json(response, 200, { ok: true, event }); + return true; + } + json(response, 405, { error: "method not allowed" }); + return true; + } + + return false; + } catch (error) { + if (error instanceof OAuthError) { + json(response, error.status, { error: error.error, error_description: error.description }); + return true; + } + if (error instanceof WatchPartyError || error instanceof LiveEventError) { + json(response, error.status, { error: error.message }); + return true; + } + json(response, 500, { error: "server_error" }); + return true; + } +} diff --git a/src/oauth-server.ts b/src/oauth-server.ts new file mode 100644 index 0000000..393a7cc --- /dev/null +++ b/src/oauth-server.ts @@ -0,0 +1,653 @@ +/** + * nixamp.com as somebody else's sign-in: an OAuth 2.1 authorization server. + * + * oauth.ts is the other direction -- nixamp signing in *with* GitHub. This + * file is bittorrented.com (and anything else registered) signing in with + * nixamp, so a watch party over there can be a room over here under the + * same account. + * + * It is OAuth 2.1 and not 2.0 on purpose, which means the things 2.0 made + * optional are not optional here: + * + * - the authorization code grant only, with PKCE (S256) on every request, + * public client or not. No implicit grant, no password grant. + * - redirect URIs match the registered string exactly. The one exception is + * the loopback port (RFC 8252 §7.3), because a CLI cannot know its port + * before it listens. + * - a code is used once. A second use is treated as theft: everything that + * code produced is withdrawn. + * - refresh tokens rotate. Each refresh answers a new one and retires the + * old; presenting a retired one again withdraws the whole family, since + * the only way that happens is two parties holding one secret. + * - bearer tokens are nixamp's own revocable `nxa_` tokens, so an access + * token walks through `Accounts.whoIs` like any other and every existing + * /api/v1 route already understands it. + * + * The server describes itself at /.well-known/oauth-authorization-server + * (RFC 8414), which is how a client finds the endpoints without a config + * file naming each one. + */ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { Account } from "./accounts.ts"; +import type { Queryable } from "./follows.ts"; +import { hashSecret, mintToken, splitToken, TOKEN_PREFIX, type Tokens } from "./tokens.ts"; + +// --- clients ----------------------------------------------------------------- + +export interface OAuthClient { + /** As it appears in `client_id`. */ + id: string; + /** As it appears on the consent page. */ + name: string; + /** Exact strings. A loopback one (http://127.0.0.1 or http://localhost) matches any port. */ + redirectUris: string[]; + /** sha256 of the secret, for a confidential client; absent for a public one (PKCE alone). */ + secretHash?: string; + /** Where the client lives, shown as a link on the consent page. */ + homepage?: string; +} + +/** What a client may ask for, and what each word means to the rest of nixamp. */ +export const SCOPES = { + profile: "who you are on nixamp (your handle)", + email: "the address on your account", + parties: "host and join watch parties as you", + offline_access: "stay connected without asking again", +} as const; + +export type Scope = keyof typeof SCOPES; + +export const SCOPE_NAMES = Object.keys(SCOPES) as Scope[]; + +/** How long the pieces live. A code is minutes; a session is not. */ +export const CODE_TTL_MS = 10 * 60_000; +export const ACCESS_TTL_MS = 60 * 60_000; +export const REFRESH_TTL_MS = 30 * 86_400_000; + +/** + * The client nixamp ships knowing about. bittorrented.com is the reason this + * file exists, and asking a deploy to paste its redirect URIs into an + * environment variable before the two sites can talk is a setup step that + * would be forgotten. Public (no secret) because PKCE is what protects the + * exchange, and a secret would only add something to leak. + */ +export const BITTORRENTED_CLIENT: OAuthClient = { + id: "bittorrented", + name: "bittorrented.com", + homepage: "https://bittorrented.com", + redirectUris: [ + "https://bittorrented.com/api/v1/nixamp/oauth/callback", + "http://localhost:3000/api/v1/nixamp/oauth/callback", + "http://127.0.0.1/api/v1/nixamp/oauth/callback", + ], +}; + +/** + * The registered clients: the built-in one, plus whatever NIXAMP_OAUTH_CLIENTS + * names. The variable is a JSON list of `{id, name, redirectUris, secret?, + * homepage?}`; an entry with the built-in id replaces it, so a staging + * bittorrented can point the callback somewhere else. + */ +export function clientsFrom(env: Record): OAuthClient[] { + const byId = new Map([[BITTORRENTED_CLIENT.id, BITTORRENTED_CLIENT]]); + const raw = env["NIXAMP_OAUTH_CLIENTS"]; + if (raw) { + let parsed: unknown = []; + try { + parsed = JSON.parse(raw); + } catch { + parsed = []; + } + for (const entry of Array.isArray(parsed) ? parsed : []) { + const record = (entry ?? {}) as Record; + const id = typeof record["id"] === "string" ? record["id"].trim() : ""; + const uris = Array.isArray(record["redirectUris"]) + ? record["redirectUris"].filter((one): one is string => typeof one === "string" && one !== "") + : []; + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(id) || uris.length === 0) continue; + const client: OAuthClient = { + id, + name: typeof record["name"] === "string" && record["name"] ? record["name"] : id, + redirectUris: uris, + ...(typeof record["secret"] === "string" && record["secret"] ? { secretHash: hashSecret(record["secret"]) } : {}), + ...(typeof record["homepage"] === "string" && record["homepage"] ? { homepage: record["homepage"] } : {}), + }; + byId.set(id, client); + } + } + if (env["NIXAMP_OAUTH_BITTORRENTED"] === "off") byId.delete(BITTORRENTED_CLIENT.id); + return [...byId.values()]; +} + +/** Loopback is the address; the port is whatever the CLI got (RFC 8252 §7.3). */ +function loopback(url: URL): boolean { + return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "localhost"); +} + +/** Does this redirect URI belong to the client, exactly, or exactly but for a loopback port? */ +export function redirectAllowed(client: OAuthClient, candidate: string): boolean { + if (client.redirectUris.includes(candidate)) return true; + let offered: URL; + try { + offered = new URL(candidate); + } catch { + return false; + } + if (!loopback(offered) || offered.hash !== "") return false; + return client.redirectUris.some((registered) => { + let known: URL; + try { + known = new URL(registered); + } catch { + return false; + } + return loopback(known) && known.hostname === offered.hostname && known.pathname === offered.pathname && known.search === offered.search; + }); +} + +// --- PKCE ---------------------------------------------------------------------- + +/** RFC 7636 §4.1: 43 to 128 of the unreserved characters. */ +const VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/; +/** base64url of 32 bytes, as S256 makes it. */ +const CHALLENGE = /^[A-Za-z0-9\-_]{43}$/; + +export function challengeFor(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +export function verifierMatches(verifier: unknown, challenge: string): boolean { + if (typeof verifier !== "string" || !VERIFIER.test(verifier)) return false; + const made = Buffer.from(challengeFor(verifier)); + const kept = Buffer.from(challenge); + return made.length === kept.length && timingSafeEqual(made, kept); +} + +// --- what a request is, once checked ------------------------------------------- + +export interface AuthorizeRequest { + client: OAuthClient; + redirectUri: string; + scope: Scope[]; + state: string; + codeChallenge: string; +} + +/** + * A request that cannot be answered by redirecting. Sending an error back to + * an unregistered redirect URI is an open redirector, which is why a bad + * client or a bad redirect is a page and not a bounce. + */ +export interface AuthorizeRefusal { + error: string; + description: string; + /** Set when the redirect URI checked out, so the client may be told. */ + redirectUri?: string; + state?: string; +} + +export function parseScope(value: unknown): Scope[] | null { + const words = typeof value === "string" && value.trim() !== "" ? value.trim().split(/\s+/) : ["profile"]; + const chosen: Scope[] = []; + for (const word of words) { + if (!SCOPE_NAMES.includes(word as Scope)) return null; + if (!chosen.includes(word as Scope)) chosen.push(word as Scope); + } + return chosen; +} + +export class OAuthError extends Error { + constructor( + readonly error: string, + readonly description: string, + readonly status = 400, + ) { + super(description); + } +} + +// --- storage ------------------------------------------------------------------- + +const CODES = "nixamp_oauth_codes"; +const REFRESH = "nixamp_oauth_refresh"; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS ${CODES} ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + redirect_uri TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT '', + code_challenge TEXT NOT NULL, + family TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ + ); + CREATE TABLE IF NOT EXISTS ${REFRESH} ( + id TEXT PRIMARY KEY, + secret_hash TEXT NOT NULL, + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT '', + family TEXT NOT NULL, + access_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + rotated_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS ${REFRESH}_family ON ${REFRESH} (family); + CREATE INDEX IF NOT EXISTS ${REFRESH}_user ON ${REFRESH} (user_id); +`; + +export const REFRESH_PREFIX = "nxr_"; + +function mintRefresh(): { id: string; secret: string; token: string } { + const id = randomBytes(8).toString("hex"); + const secret = randomBytes(32).toString("base64url"); + return { id, secret, token: `${REFRESH_PREFIX}${id}_${secret}` }; +} + +function splitRefresh(value: unknown): { id: string; secret: string } | null { + if (typeof value !== "string" || !value.startsWith(REFRESH_PREFIX)) return null; + const rest = value.slice(REFRESH_PREFIX.length); + const cut = rest.indexOf("_"); + if (cut <= 0) return null; + const id = rest.slice(0, cut); + const secret = rest.slice(cut + 1); + return /^[0-9a-f]+$/.test(id) && secret.length >= 16 ? { id, secret } : null; +} + +function sameHash(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && timingSafeEqual(a, b); +} + +function asTime(value: unknown): number | null { + if (value instanceof Date) return value.getTime(); + if (typeof value === "string") { + const at = Date.parse(value); + return Number.isNaN(at) ? null : at; + } + return typeof value === "number" ? value : null; +} + +/** The body of a successful token response, RFC 6749 §5.1 names and all. */ +export interface TokenResponse { + access_token: string; + token_type: "Bearer"; + expires_in: number; + refresh_token?: string; + scope: string; +} + +export interface AuthorizationServerOptions { + db: Queryable; + /** Where access tokens come from, so `whoIs` recognises them. */ + tokens: Tokens; + clients: OAuthClient[]; + /** The issuer, e.g. https://nixamp.com. */ + issuer: string; + now?: () => number; +} + +export class AuthorizationServer { + private ready: Promise | null = null; + private readonly now: () => number; + readonly issuer: string; + readonly clients: OAuthClient[]; + + constructor(private readonly options: AuthorizationServerOptions) { + this.now = options.now ?? Date.now; + this.issuer = options.issuer.replace(/\/+$/, ""); + this.clients = options.clients; + } + + private async ensure(): Promise { + this.ready ??= this.options.db.query(SCHEMA).then(() => undefined); + await this.ready; + } + + client(id: unknown): OAuthClient | null { + return typeof id === "string" ? (this.clients.find((one) => one.id === id) ?? null) : null; + } + + /** RFC 8414. Everything a client needs to find, in the place it looks. */ + metadata(): Record { + return { + issuer: this.issuer, + authorization_endpoint: `${this.issuer}/api/v1/oauth/authorize`, + token_endpoint: `${this.issuer}/api/v1/oauth/token`, + revocation_endpoint: `${this.issuer}/api/v1/oauth/revoke`, + userinfo_endpoint: `${this.issuer}/api/v1/oauth/userinfo`, + scopes_supported: SCOPE_NAMES, + response_types_supported: ["code"], + response_modes_supported: ["query"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"], + revocation_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"], + service_documentation: "https://github.com/profullstack/nixamp", + }; + } + + /** + * Check an authorization request before anybody is asked to approve it. + * The order matters: the client and redirect URI are checked first, and a + * problem with either is answered to the browser, never to the URI. + */ + check(params: URLSearchParams): AuthorizeRequest | AuthorizeRefusal { + const client = this.client(params.get("client_id")); + if (client === null) return { error: "invalid_client", description: "unknown client_id" }; + const redirectUri = params.get("redirect_uri") ?? ""; + if (!redirectUri || !redirectAllowed(client, redirectUri)) { + return { error: "invalid_request", description: "redirect_uri is not registered for this client" }; + } + const state = params.get("state") ?? ""; + const refuse = (error: string, description: string): AuthorizeRefusal => ({ error, description, redirectUri, state }); + if (params.get("response_type") !== "code") { + return refuse("unsupported_response_type", "only response_type=code is supported"); + } + const method = params.get("code_challenge_method") ?? ""; + const codeChallenge = params.get("code_challenge") ?? ""; + if (method !== "S256" || !CHALLENGE.test(codeChallenge)) { + return refuse("invalid_request", "code_challenge with code_challenge_method=S256 is required"); + } + const scope = parseScope(params.get("scope")); + if (scope === null) return refuse("invalid_scope", `scope may name ${SCOPE_NAMES.join(", ")}`); + return { client, redirectUri, scope, state, codeChallenge }; + } + + /** The URL the browser goes back to, with the answer in the query. */ + static redirect(uri: string, params: Record): string { + const url = new URL(uri); + for (const [name, value] of Object.entries(params)) { + if (value !== "") url.searchParams.set(name, value); + } + return url.toString(); + } + + /** Approved: mint a code the client can exchange, once, within minutes. */ + async issueCode(request: AuthorizeRequest, account: Account): Promise { + await this.ensure(); + const code = randomBytes(32).toString("base64url"); + const at = this.now(); + await this.options.db.query( + `INSERT INTO ${CODES} (code_hash, client_id, user_id, email, redirect_uri, scope, code_challenge, family, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + hashSecret(code), + request.client.id, + account.id, + account.email, + request.redirectUri, + request.scope.join(" "), + request.codeChallenge, + randomBytes(12).toString("hex"), + new Date(at).toISOString(), + new Date(at + CODE_TTL_MS).toISOString(), + ], + ); + return code; + } + + /** + * Who the token endpoint is talking to. A confidential client must present + * its secret, in the body or as basic auth; a public client must not + * present one it does not have. Either way the client named must exist. + */ + authenticateClient(form: URLSearchParams, authorization: string | undefined): OAuthClient { + let id = form.get("client_id") ?? ""; + let secret = form.get("client_secret") ?? ""; + const basic = /^Basic\s+(.+)$/i.exec(authorization ?? "")?.[1]; + if (basic) { + const decoded = Buffer.from(basic, "base64").toString("utf8"); + const cut = decoded.indexOf(":"); + if (cut > 0) { + id = decodeURIComponent(decoded.slice(0, cut)); + secret = decodeURIComponent(decoded.slice(cut + 1)); + } + } + const client = this.client(id); + if (client === null) throw new OAuthError("invalid_client", "unknown client_id", 401); + if (client.secretHash) { + if (!secret || !sameHash(client.secretHash, hashSecret(secret))) { + throw new OAuthError("invalid_client", "client authentication failed", 401); + } + } + return client; + } + + private async withdrawFamily(family: string): Promise { + const { rows } = await this.options.db.query( + `UPDATE ${REFRESH} SET revoked_at = NOW() WHERE family = $1 AND revoked_at IS NULL RETURNING access_id`, + [family], + ); + for (const row of rows) { + const accessId = String(row["access_id"] ?? ""); + if (accessId) await this.options.tokens.revokeById(accessId).catch(() => {}); + } + } + + private async issueTokens( + client: OAuthClient, + account: Account, + scope: string, + family: string, + ): Promise { + const words = scope ? scope.split(" ") : []; + const access = await this.options.tokens.issue({ + account, + kind: "oauth", + // The client and the scope, so `nixamp token list` says what this is + // and userinfo can tell an `email` grant from a `profile` one. + name: `${client.id} ${scope}`.trim(), + ttlMs: ACCESS_TTL_MS, + }); + const answer: TokenResponse = { + access_token: access.token, + token_type: "Bearer", + expires_in: Math.round(ACCESS_TTL_MS / 1000), + scope, + }; + if (words.includes("offline_access")) { + const refresh = mintRefresh(); + const at = this.now(); + await this.options.db.query( + `INSERT INTO ${REFRESH} (id, secret_hash, client_id, user_id, email, scope, family, access_id, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + refresh.id, + hashSecret(refresh.secret), + client.id, + account.id, + account.email, + scope, + family, + access.id, + new Date(at).toISOString(), + new Date(at + REFRESH_TTL_MS).toISOString(), + ], + ); + answer.refresh_token = refresh.token; + } + return answer; + } + + /** grant_type=authorization_code. */ + async exchangeCode(client: OAuthClient, form: URLSearchParams): Promise { + await this.ensure(); + const code = form.get("code") ?? ""; + if (!code) throw new OAuthError("invalid_request", "code is required"); + const { rows } = await this.options.db.query( + `SELECT client_id, user_id, email, redirect_uri, scope, code_challenge, family, expires_at, used_at + FROM ${CODES} WHERE code_hash = $1`, + [hashSecret(code)], + ); + const row = rows[0]; + if (!row || String(row["client_id"]) !== client.id) { + throw new OAuthError("invalid_grant", "that code is not valid"); + } + const family = String(row["family"] ?? ""); + if (row["used_at"]) { + // A code presented twice is a code somebody else also has. Everything + // the first use produced goes with it. + await this.withdrawFamily(family); + throw new OAuthError("invalid_grant", "that code was already used"); + } + const expiresAt = asTime(row["expires_at"]) ?? 0; + if (expiresAt <= this.now()) throw new OAuthError("invalid_grant", "that code has expired"); + if ((form.get("redirect_uri") ?? "") !== String(row["redirect_uri"])) { + throw new OAuthError("invalid_grant", "redirect_uri does not match the one the code was issued to"); + } + if (!verifierMatches(form.get("code_verifier"), String(row["code_challenge"] ?? ""))) { + throw new OAuthError("invalid_grant", "code_verifier does not match"); + } + await this.options.db.query(`UPDATE ${CODES} SET used_at = NOW() WHERE code_hash = $1`, [hashSecret(code)]); + const account: Account = { id: String(row["user_id"]), email: String(row["email"] ?? "") }; + return this.issueTokens(client, account, String(row["scope"] ?? ""), family); + } + + /** grant_type=refresh_token: a new pair, and the old one retired. */ + async refresh(client: OAuthClient, form: URLSearchParams): Promise { + await this.ensure(); + const parts = splitRefresh(form.get("refresh_token")); + if (parts === null) throw new OAuthError("invalid_grant", "that refresh token is not valid"); + const { rows } = await this.options.db.query( + `SELECT secret_hash, client_id, user_id, email, scope, family, access_id, expires_at, rotated_at, revoked_at + FROM ${REFRESH} WHERE id = $1`, + [parts.id], + ); + const row = rows[0]; + if (!row || !sameHash(String(row["secret_hash"] ?? ""), hashSecret(parts.secret)) || String(row["client_id"]) !== client.id) { + throw new OAuthError("invalid_grant", "that refresh token is not valid"); + } + const family = String(row["family"] ?? ""); + if (row["revoked_at"]) throw new OAuthError("invalid_grant", "that refresh token was revoked"); + if (row["rotated_at"]) { + // Already exchanged once. Whoever is presenting it now is not the + // holder of the current one, or is, and lost it -- both end the same. + await this.withdrawFamily(family); + throw new OAuthError("invalid_grant", "that refresh token was already used"); + } + if ((asTime(row["expires_at"]) ?? 0) <= this.now()) { + throw new OAuthError("invalid_grant", "that refresh token has expired"); + } + // A narrower scope may be asked for on refresh; a wider one may not. + const held = String(row["scope"] ?? "").split(" ").filter(Boolean); + const asked = form.get("scope"); + let scope = held; + if (asked) { + const wanted = parseScope(asked); + if (wanted === null || wanted.some((word) => !held.includes(word))) { + throw new OAuthError("invalid_scope", "a refresh may narrow the scope, not widen it"); + } + scope = wanted; + } + await this.options.db.query(`UPDATE ${REFRESH} SET rotated_at = NOW() WHERE id = $1`, [parts.id]); + const accessId = String(row["access_id"] ?? ""); + if (accessId) await this.options.tokens.revokeById(accessId).catch(() => {}); + const account: Account = { id: String(row["user_id"]), email: String(row["email"] ?? "") }; + return this.issueTokens(client, account, scope.join(" "), family); + } + + /** RFC 7009. Either kind of token; a token that is not ours is "already gone". */ + async revoke(client: OAuthClient, token: string): Promise { + await this.ensure(); + const refresh = splitRefresh(token); + if (refresh) { + const { rows } = await this.options.db.query(`SELECT client_id, family FROM ${REFRESH} WHERE id = $1`, [refresh.id]); + const row = rows[0]; + if (row && String(row["client_id"]) === client.id) await this.withdrawFamily(String(row["family"])); + return; + } + const access = splitToken(token); + if (access) { + const record = await this.options.tokens.inspect(token); + if (record && record.kind === "oauth" && record.name.split(" ")[0] === client.id) { + await this.options.tokens.revokeById(access.id); + } + } + } + + /** + * The grant behind a bearer token, or null for one that is not an OAuth + * access token at all. + * + * This is how a route decides whether the caller may do the thing: the + * account is who, and the scope is what they were allowed to ask for on + * that account's behalf. A session cookie or a CLI token answers null + * here, which is correct -- those are the person themselves, and a route + * that wants a scope should say so rather than assume. + */ + async grantFor(token: string): Promise<{ account: Account; clientId: string; scope: Scope[] } | null> { + if (!token.startsWith(TOKEN_PREFIX)) return null; + const record = await this.options.tokens.inspect(token); + if (!record || record.kind !== "oauth") return null; + const [clientId = "", ...words] = record.name.split(" "); + return { + account: record.account, + clientId, + scope: words.filter((word): word is Scope => SCOPE_NAMES.includes(word as Scope)), + }; + } + + /** + * What an access token says about its holder, RFC-shaped. The address is + * only in the answer for a grant that asked for it: a handle is public, + * an address is a credential. + */ + async userinfo(token: string, handleOf: (userId: string) => Promise): Promise | null> { + if (!token.startsWith(TOKEN_PREFIX)) return null; + const record = await this.options.tokens.inspect(token); + if (!record || record.kind !== "oauth") return null; + const [clientId = "", ...scope] = record.name.split(" "); + const handle = await handleOf(record.account.id).catch(() => ""); + return { + sub: record.account.id, + client_id: clientId, + scope: scope.join(" "), + ...(handle ? { handle, preferred_username: handle } : {}), + ...(scope.includes("email") ? { email: record.account.email, email_verified: true } : {}), + }; + } + + /** Every connection an account has granted, for a settings page. */ + async grants(userId: string): Promise<{ id: string; clientId: string; clientName: string; scope: string; createdAt: number }[]> { + await this.ensure(); + const { rows } = await this.options.db.query( + `SELECT id, client_id, scope, created_at FROM ${REFRESH} + WHERE user_id = $1 AND revoked_at IS NULL AND rotated_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, + [userId], + ); + return rows.map((row) => { + const clientId = String(row["client_id"] ?? ""); + return { + id: String(row["id"] ?? ""), + clientId, + clientName: this.client(clientId)?.name ?? clientId, + scope: String(row["scope"] ?? ""), + createdAt: asTime(row["created_at"]) ?? 0, + }; + }); + } + + /** Disconnect: the account withdraws everything one client holds. */ + async disconnect(userId: string, clientId: string): Promise { + await this.ensure(); + const { rows } = await this.options.db.query( + `SELECT DISTINCT family FROM ${REFRESH} WHERE user_id = $1 AND client_id = $2 AND revoked_at IS NULL`, + [userId, clientId], + ); + for (const row of rows) await this.withdrawFamily(String(row["family"])); + return rows.length; + } +} + +/** Only for tests: the token format the code endpoint mints, so a fake DB can match it. */ +export const _internal = { mintRefresh, splitRefresh, mintToken }; diff --git a/src/party.ts b/src/party.ts new file mode 100644 index 0000000..0544a32 --- /dev/null +++ b/src/party.ts @@ -0,0 +1,216 @@ +/** + * `nixamp party` -- a watch party, from a terminal. + * + * A watch party on bittorrented.com is a six-character code, a host and a + * film. Once it is bridged (watch-party.ts) it is also a nixamp room, which + * means this terminal can list them, join one, follow where the host is in + * the film, and put a new one on the air -- with no browser, over ssh, on a + * machine with no display. + * + * What this does NOT do is fetch the film. The media stays on the site that + * has it, because that is the site with the rights, the torrent and the + * bandwidth. What nixamp carries is the room: the audio channel, who is in + * it, the chat, and the second everybody is supposed to be at. `--open` + * hands the picture to a browser and keeps the room here, which is the + * arrangement that actually works on a laptop. + */ +import { openInBrowser, readSession } from "./session.ts"; + +export interface PartyRow { + party: { + eventId: string; + roomId: string; + slug: string; + origin: string; + partyCode: string; + partyUrl: string; + mediaTitle: string; + positionSeconds: number; + positionNow: number; + playing: boolean; + }; + event: { id: string; title: string; status: string; ownerId: string; visibility: string }; + links: { nixampUrl: string; roomUrl: string; partyUrl: string }; + host: boolean; +} + +/** mm:ss, or h:mm:ss once a film is long enough to need the hour. */ +export function clock(seconds: number): string { + const whole = Math.max(0, Math.floor(seconds)); + const s = String(whole % 60).padStart(2, "0"); + const m = Math.floor(whole / 60) % 60; + const h = Math.floor(whole / 3600); + return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`; +} + +export function partyLines(row: PartyRow): string[] { + const where = row.party.playing ? `▶ ${clock(row.party.positionNow)}` : `❚❚ ${clock(row.party.positionNow)}`; + return [ + `${row.party.partyCode} ${row.event.title}${row.host ? " (yours)" : ""}`, + ` ${where}${row.party.mediaTitle ? ` ${row.party.mediaTitle}` : ""} · ${row.party.origin}`, + ` watch: ${row.links.partyUrl || row.links.nixampUrl}`, + ` room: ${row.links.nixampUrl}`, + ]; +} + +const HELP = `nixamp party — watch parties, here and on the sites nixamp is connected to. + + nixamp party list the ones you could join right now + nixamp party join CODE the room, the links, and where the film is + nixamp party join CODE --open and open the picture in a browser + nixamp party host CODE --url URL put a party on the air as a nixamp room + nixamp party sync CODE --at 1234 say where playback is (hosts only) + nixamp party sync CODE --pause ...and that it is paused + nixamp party end CODE end it + +A party lives on the site that has the film; nixamp carries the room. The +code is the one that site shows you — bittorrented.com prints six characters. + + --site URL somewhere other than the nixamp you are signed in to + --json the raw answer, for a script +`; + +function flag(argv: string[], name: string): string | undefined { + const at = argv.indexOf(name); + return at === -1 ? undefined : argv[at + 1]; +} + +/** The whole `nixamp party` command. */ +export async function party(argv: string[], fetcher: typeof fetch = fetch): Promise { + const [command = "list", ...rest] = argv; + if (command === "help" || command === "--help" || command === "-h") { + console.log(HELP); + return 0; + } + + const session = readSession(); + if (session === null) { + console.error("nixamp: not signed in. Try `nixamp login`."); + return 1; + } + const site = (flag(rest, "--site") ?? session.site).replace(/\/+$/, ""); + const where = `${site}/api/v1/watch-parties`; + const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" }; + const asJson = rest.includes("--json"); + const code = rest.find((one) => !one.startsWith("-")) ?? ""; + + const fail = async (answer: Response): Promise => { + const body = (await answer.json().catch(() => ({}))) as { error?: string }; + console.error(`nixamp: ${body.error ?? `that did not work (${answer.status})`}`); + return 1; + }; + + try { + if (command === "list" || command === "ls") { + const answer = await fetcher(where, { headers }); + if (!answer.ok) return fail(answer); + const body = (await answer.json()) as { parties?: PartyRow[] }; + const rows = body.parties ?? []; + if (asJson) { + console.log(JSON.stringify(rows, null, 2)); + return 0; + } + if (rows.length === 0) { + console.log("No watch parties on right now. `nixamp party host CODE` starts one."); + return 0; + } + for (const row of rows) for (const line of partyLines(row)) console.log(line); + return 0; + } + + if (command === "join" || command === "open" || command === "show") { + if (!code) { + console.error("nixamp: which party? `nixamp party join ABC123`."); + return 64; + } + const answer = await fetcher(`${where}/${encodeURIComponent(code)}`, { headers }); + if (!answer.ok) return fail(answer); + const row = (await answer.json()) as PartyRow; + if (asJson) { + console.log(JSON.stringify(row, null, 2)); + return 0; + } + for (const line of partyLines(row)) console.log(line); + // The picture is the other site's; the room is ours. Opening one and + // printing the other is the arrangement that works on one screen. + if (rest.includes("--open")) { + const target = row.links.partyUrl || row.links.nixampUrl; + console.log(`\nOpening ${target}`); + openInBrowser(target); + } else { + console.log(`\n Listen here: nixamp attach --url ${site} --key `); + console.log(` Or open it: nixamp party join ${row.party.partyCode} --open`); + } + return 0; + } + + if (command === "host" || command === "bridge" || command === "start") { + if (!code) { + console.error("nixamp: which party? Give the code the site is showing, e.g. `nixamp party host ABC123`."); + return 64; + } + const answer = await fetcher(where, { + method: "POST", + headers, + body: JSON.stringify({ + partyCode: code, + ...(flag(rest, "--title") ? { title: flag(rest, "--title") } : {}), + ...(flag(rest, "--url") ? { partyUrl: flag(rest, "--url") } : {}), + ...(flag(rest, "--media") ? { mediaTitle: flag(rest, "--media") } : {}), + ...(rest.includes("--public") ? { visibility: "public" } : {}), + }), + }); + if (!answer.ok) return fail(answer); + const row = (await answer.json()) as PartyRow; + if (asJson) { + console.log(JSON.stringify(row, null, 2)); + return 0; + } + for (const line of partyLines(row)) console.log(line); + console.log(`\n Share the room: ${row.links.nixampUrl}`); + return 0; + } + + if (command === "sync" || command === "seek") { + if (!code) { + console.error("nixamp: which party? `nixamp party sync ABC123 --at 930`."); + return 64; + } + const at = Number(flag(rest, "--at") ?? flag(rest, "--seconds") ?? NaN); + if (!Number.isFinite(at) || at < 0) { + console.error("nixamp: where to? `--at 930` is fifteen and a half minutes in."); + return 64; + } + const answer = await fetcher(`${where}/${encodeURIComponent(code)}/playback`, { + method: "POST", + headers, + body: JSON.stringify({ + positionSeconds: at, + playing: !rest.includes("--pause") && !rest.includes("--paused"), + ...(flag(rest, "--media") ? { mediaTitle: flag(rest, "--media") } : {}), + }), + }); + if (!answer.ok) return fail(answer); + const row = (await answer.json()) as PartyRow; + console.log(`${row.party.partyCode} ${row.party.playing ? "playing" : "paused"} at ${clock(row.party.positionSeconds)}`); + return 0; + } + + if (command === "end" || command === "stop") { + if (!code) { + console.error("nixamp: which party? `nixamp party end ABC123`."); + return 64; + } + const answer = await fetcher(`${where}/${encodeURIComponent(code)}/end`, { method: "POST", headers }); + if (!answer.ok) return fail(answer); + console.log(`Ended ${code}.`); + return 0; + } + } catch (error) { + console.error(`nixamp: could not reach ${site}: ${(error as Error).message}`); + return 1; + } + + console.error(`nixamp party: unknown action ${command}. Try list, join, host, sync or end.`); + return 64; +} diff --git a/src/server.ts b/src/server.ts index 53041a6..8547494 100644 --- a/src/server.ts +++ b/src/server.ts @@ -45,6 +45,9 @@ import { signInFailedPage, SignIn, } from "./oauth.ts"; +import { AuthorizationServer, clientsFrom } from "./oauth-server.ts"; +import { handleOAuthApi, oauthApiPath } from "./oauth-api.ts"; +import { WatchParties } from "./watch-party.ts"; import { needsAdmin, needsMember, Owner } from "./owner.ts"; import { createHash as sha } from "node:crypto"; import { playJingle } from "./jingle.ts"; @@ -1258,6 +1261,10 @@ export function isSignInPath(path: string): boolean { // Public to read, so it must not be behind a share key either. path === "/api/v1/opendirs" || path.startsWith("/api/v1/opendirs/") || + // nixamp as an authorization server: a client arriving here has no share + // key and is not asking for one, and the metadata document is public by + // the RFC that defines where it lives. + oauthApiPath(path) || OAUTH_ROUTE.test(path) ); } @@ -1485,6 +1492,14 @@ export interface HandlerOptions { rooms?: Rooms; /** Tickets: a paid pass to one event's room. Absent means every show is free. */ tickets?: Tickets; + /** + * nixamp as an OAuth 2.1 authorization server, so another site can act on + * an account here. nixamp.com only: a nixamp on a laptop keeps no accounts + * and so has nobody to authorize. + */ + authServer?: AuthorizationServer; + /** Watch parties bridged from a client site into nixamp rooms. */ + parties?: WatchParties; /** * How an invite is sent: by email, by text, and the site the watch link is * built on. nixamp.com only; a personal nixamp has no mail to send from. @@ -1600,6 +1615,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) { return; } + // nixamp as an authorization server, and the watch parties that pairing + // exists for. Before the share-key check for the same reason sign-in is: + // a client site holds an account's token, never a server's key. + if (options.authServer && options.accounts && await handleOAuthApi(request, response, url, { + server: options.authServer, + accounts: options.accounts, + ...(options.parties ? { parties: options.parties } : {}), + ...(options.handles ? { handles: options.handles } : {}), + secureCookies: options.secureCookies ?? false, + })) return; + if (options.events && await handleLiveApi(request, response, url, { events: options.events, ...(options.accounts ? { accounts: options.accounts } : {}), @@ -4622,6 +4648,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise { : undefined; const follows = pool ? new Follows(pool) : undefined; const favorites = pool ? new Favorites(pool) : undefined; + const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, ""); const events = pool ? new LiveEvents(pool) : undefined; const layouts = pool ? new Layouts(pool) : undefined; const rooms = pool ? new Rooms(pool) : undefined; @@ -4632,6 +4659,57 @@ export async function serve(argv: string[], version = "0.1.0"): Promise { : null; const tickets = ticketConfig ? new Tickets(ticketConfig) : undefined; + // Accounts live where the directory lives, and only there: a nixamp on a + // laptop has nobody to be an account of. Made here rather than inline in + // the handler options because the authorization server issues its access + // tokens out of the same table -- an OAuth token IS a nixamp token with a + // client's name on it, which is why every existing route understands one. + const accounts = + options.directory && process.env["DATABASE_URL"] + ? new Accounts({ + connectionString: process.env["DATABASE_URL"], + secret: process.env["NIXAMP_JWT_SECRET"] ?? "", + }) + : undefined; + + // nixamp as an OAuth 2.1 authorization server, and the watch parties a + // client site bridges through it. Both need the same three things -- a + // database, accounts, and a site to be the issuer of -- so both appear or + // neither does. + const authServer = + pool && accounts?.tokens + ? new AuthorizationServer({ + db: pool, + tokens: accounts.tokens, + clients: clientsFrom(process.env), + issuer: nixampSite, + }) + : undefined; + const parties = + pool && events && authServer + ? new WatchParties({ + db: pool, + events, + site: nixampSite, + // A client may only point a watch link at its own site, which is + // read off the redirect URIs it registered rather than configured + // twice. Without it, "come and watch" could be sent anywhere. + hostsFor: (origin) => { + const client = authServer.client(origin); + if (!client) return []; + const hosts = new Set(); + for (const uri of client.redirectUris) { + try { + hosts.add(new URL(uri).hostname); + } catch { + // A malformed registration names no host, which allows none. + } + } + return [...hosts]; + }, + }) + : undefined; + // Names and certificates for signed-in servers, and the rate limit over // everything. All of it is nixamp.com's business: the DNS keys live only // here, the certificates are issued here, and a personal nixamp has neither @@ -4671,6 +4749,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise { { path: "/api/v1/invite", limit: 10 }, { path: "/api/v1/dns", limit: 30 }, { path: "/api/v1/certs", limit: 30 }, + // A token endpoint is where a stolen code or refresh token would be + // tried, so it is address-bucketed: a client presenting its own id + // must not get the credentialed budget to guess with. + { path: "/api/v1/oauth/token", limit: 30, credential: false }, + { path: "/api/v1/oauth/", limit: 60, credential: false }, + { path: "/api/v1/watch-parties", limit: 60 }, { path: "/api/health", open: true }, { path: "/api/directory", limit: 120 }, ], @@ -4974,6 +5058,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise { ...(layouts ? { layouts } : {}), ...(rooms ? { rooms } : {}), ...(tickets ? { tickets } : {}), + ...(authServer ? { authServer } : {}), + ...(parties ? { parties } : {}), // Invites go out the same way follow notifications do, and only from a // site that has somebody to send them for. ...(pool @@ -5008,12 +5094,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise { ...(partyLine ? { partyLine } : {}), // Accounts live where the directory lives, and only there: a nixamp on a // laptop has nobody to be an account of. - ...(options.directory && process.env["DATABASE_URL"] + ...(accounts ? { - accounts: new Accounts({ - connectionString: process.env["DATABASE_URL"], - secret: process.env["NIXAMP_JWT_SECRET"] ?? "", - }), + accounts, secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"), // A deployment reached over https is one behind somebody's proxy, so // the socket address is that proxy and the forwarded header is the diff --git a/src/tokens.ts b/src/tokens.ts index 5686ac4..057ac3f 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -28,8 +28,13 @@ export const TOKEN_PREFIX = "nxa_"; /** How long a sign-in lasts. Long, because signing in on a television is work. */ export const SESSION_DAYS = 90; -/** A session ends; a token a person made for a script does not, unless asked. */ -export type TokenKind = "session" | "cli"; +/** + * A session ends; a token a person made for a script does not, unless asked; + * and one another site holds on your behalf is an `oauth` token, which is the + * same thing with a client's name on it so it can be listed and withdrawn as + * "bittorrented.com" rather than as an anonymous string. + */ +export type TokenKind = "session" | "cli" | "oauth"; export interface TokenRecord { id: string; @@ -113,7 +118,7 @@ function toRecord(row: Record): TokenRecord { return { id: String(row["id"] ?? ""), name: String(row["name"] ?? ""), - kind: row["kind"] === "cli" ? "cli" : "session", + kind: row["kind"] === "cli" || row["kind"] === "oauth" ? (row["kind"] as TokenKind) : "session", createdAt: asTime(row["created_at"]) ?? 0, expiresAt: asTime(row["expires_at"]), lastUsedAt: asTime(row["last_used_at"]), @@ -233,6 +238,48 @@ export class Tokens { return rows.length > 0; } + /** + * One token, by its id, whatever kind it is. + * + * `revoke` is scoped to the owner because it answers a person deleting + * their own token. This one is for the server withdrawing a token it + * issued -- an access token whose refresh token was just rotated, or the + * whole family behind a code somebody replayed -- where there is no owner + * doing the asking. + */ + async revokeById(id: string): Promise { + await this.ensure(); + const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1 RETURNING id`, [id]); + return rows.length > 0; + } + + /** + * The record behind a token, without counting it as a use. + * + * `verify` is the hot path and stamps last_used_at; this answers the same + * question for a caller that needs the row itself -- which client holds + * this, and what it was granted -- and leaves the timestamp alone. + */ + async inspect(value: string): Promise<(TokenRecord & { account: Account }) | null> { + const parts = splitToken(value); + if (parts === null) return null; + await this.ensure(); + const { rows } = await this.db.query( + `SELECT id, user_id, email, kind, name, secret_hash, created_at, expires_at, last_used_at + FROM ${TABLE} WHERE id = $1`, + [parts.id], + ); + const row = rows[0]; + if (!row) return null; + if (!sameHash(String(row["secret_hash"] ?? ""), hashSecret(parts.secret))) return null; + const expiresAt = asTime(row["expires_at"]); + if (expiresAt !== null && expiresAt <= this.now()) return null; + return { + ...toRecord(row), + account: { id: String(row["user_id"] ?? ""), email: String(row["email"] ?? "") }, + }; + } + /** Signing out of one place should not sign you out of the build server. */ async revokeToken(value: string): Promise { const parts = splitToken(value); diff --git a/src/watch-party.ts b/src/watch-party.ts new file mode 100644 index 0000000..fc01d00 --- /dev/null +++ b/src/watch-party.ts @@ -0,0 +1,390 @@ +/** + * A watch party somewhere else, as a nixamp room. + * + * bittorrented.com has watch parties: a six-character code, a host, a list of + * people, one piece of media and a playback position everybody is supposed to + * be at. nixamp has live events, rooms, chat, hand raises, invitations and + * five clients that can already open one. This is the join between them, so a + * party started on bittorrented.com is a room every nixamp surface can see and + * play, and a party started from nixamp is one bittorrented.com can host. + * + * The shape is deliberately thin. A bridged party is a `live_events` row like + * any other -- so discovery, invitations, chat, the layout registry and the + * /live/:slug page all work with no special case -- plus one row here saying + * which external party it is, where to watch it and where playback had got to. + * Nothing about torrents, HLS or WebRTC crosses over: the media stays on the + * origin that has it, and what nixamp carries is the room. + * + * Playback position is kept because the point of a watch party is that + * everybody is at the same second. It is advisory and cheap to write: the + * host's client pushes it, and a client that joins late reads it once and + * seeks. Nothing here tries to be a clock. + */ +import { randomUUID } from "node:crypto"; +import type { Queryable } from "./follows.ts"; +import { LiveEventError, type LiveEvent, type LiveEvents } from "./live-events.ts"; + +/** Which site a bridged party came from. `bittorrented` is the first. */ +export type PartyOrigin = string; + +export interface WatchParty { + /** The nixamp event this party is. */ + eventId: string; + /** The nixamp channel a client listens to, which is the event's room. */ + roomId: string; + /** The slug that opens it at nixamp.com/live/. */ + slug: string; + /** Which client bridged it, by OAuth client id. */ + origin: PartyOrigin; + /** The party's own id over there, e.g. bittorrented's six characters. */ + partyCode: string; + /** Where to watch it on the origin. */ + partyUrl: string; + /** What is playing, as the origin describes it. */ + mediaTitle: string; + /** Seconds into the media, as the host last said. */ + positionSeconds: number; + playing: boolean; + /** When that position was true, so a late joiner can add the drift. */ + positionAt: string; + createdAt: string; + updatedAt: string; +} + +export class WatchPartyError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } +} + +const TABLE = "nixamp_watch_parties"; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS ${TABLE} ( + event_id TEXT PRIMARY KEY REFERENCES live_events(id) ON DELETE CASCADE, + origin TEXT NOT NULL, + party_code TEXT NOT NULL, + party_url TEXT NOT NULL DEFAULT '', + media_title TEXT NOT NULL DEFAULT '', + position_seconds DOUBLE PRECISION NOT NULL DEFAULT 0, + playing BOOLEAN NOT NULL DEFAULT FALSE, + position_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (origin, party_code) + ); + CREATE INDEX IF NOT EXISTS ${TABLE}_origin ON ${TABLE} (origin, updated_at DESC); +`; + +/** + * The code as it will be matched. + * + * bittorrented prints six of A-Z0-9 and people retype them, so the match is + * case-insensitive and rubbed of the spaces and dashes a person adds. The + * stored form is the upper-case one, which makes the unique index the thing + * that stops one party being bridged twice under two spellings. + */ +export function cleanPartyCode(value: unknown): string { + if (typeof value !== "string") throw new WatchPartyError("a party code is required", 422); + const cleaned = value.replace(/[\s_-]+/g, "").toUpperCase(); + if (!/^[A-Z0-9]{4,32}$/.test(cleaned)) throw new WatchPartyError("that does not look like a party code", 422); + return cleaned; +} + +/** A watch link has to be a real https address on the origin's own site. */ +export function cleanPartyUrl(value: unknown, allowedHosts: string[]): string { + if (value === undefined || value === null || value === "") return ""; + if (typeof value !== "string") throw new WatchPartyError("partyUrl must be a URL", 422); + let url: URL; + try { + url = new URL(value); + } catch { + throw new WatchPartyError("partyUrl must be a URL", 422); + } + const local = url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) { + throw new WatchPartyError("partyUrl must be https", 422); + } + if (allowedHosts.length > 0 && !allowedHosts.includes(url.hostname) && !local) { + throw new WatchPartyError("partyUrl is not on this client's site", 403); + } + return url.toString(); +} + +function seconds(value: unknown, name: string): number { + if (value === undefined || value === null || value === "") return 0; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 86_400 * 7) { + throw new WatchPartyError(`${name} must be a number of seconds`, 422); + } + return Math.round(value * 1000) / 1000; +} + +function iso(value: unknown): string { + if (value instanceof Date) return value.toISOString(); + if (typeof value === "string" && value !== "") { + const parsed = new Date(value); + if (Number.isFinite(parsed.getTime())) return parsed.toISOString(); + } + return new Date(0).toISOString(); +} + +function partyFrom(row: Record, event: { roomId: string; slug: string }): WatchParty { + return { + eventId: String(row["event_id"] ?? ""), + roomId: event.roomId, + slug: event.slug, + origin: String(row["origin"] ?? ""), + partyCode: String(row["party_code"] ?? ""), + partyUrl: String(row["party_url"] ?? ""), + mediaTitle: String(row["media_title"] ?? ""), + positionSeconds: Number(row["position_seconds"] ?? 0), + playing: Boolean(row["playing"]), + positionAt: iso(row["position_at"]), + createdAt: iso(row["created_at"]), + updatedAt: iso(row["updated_at"]), + }; +} + +export interface BridgeInput { + partyCode: unknown; + title?: unknown; + partyUrl?: unknown; + mediaTitle?: unknown; + visibility?: unknown; + chatEnabled?: unknown; + handRaiseEnabled?: unknown; +} + +export interface PartyView { + party: WatchParty; + event: LiveEvent; +} + +export interface WatchPartiesOptions { + db: Queryable; + events: LiveEvents; + /** nixamp.com, for the links handed back to the client. */ + site: string; + /** Hostnames a given client may point a watch link at. */ + hostsFor?: (origin: PartyOrigin) => string[]; + now?: () => number; +} + +export class WatchParties { + private ready: Promise | null = null; + private readonly now: () => number; + + constructor(private readonly options: WatchPartiesOptions) { + this.now = options.now ?? Date.now; + } + + private async ensure(): Promise { + this.ready ??= this.options.db.query(SCHEMA).then(() => undefined); + await this.ready; + } + + private get site(): string { + return this.options.site.replace(/\/+$/, ""); + } + + /** Everything a client needs to send somebody to this party, either way. */ + links(party: WatchParty): { nixampUrl: string; roomUrl: string; partyUrl: string } { + return { + nixampUrl: `${this.site}/live/${encodeURIComponent(party.slug)}`, + roomUrl: `${this.site}/api/channels/${encodeURIComponent(party.roomId)}`, + partyUrl: party.partyUrl, + }; + } + + /** + * Bridge a party, or find the bridge it already has. + * + * Idempotent on purpose: a client calls this every time somebody opens the + * party page, and the second call must answer the same room rather than + * making a second event nobody is in. What it does update is the mutable + * half -- the title, the media, the link -- because the host changing the + * film should not need a new room. + */ + async bridge(ownerId: string, origin: PartyOrigin, input: BridgeInput): Promise { + if (!ownerId) throw new WatchPartyError("sign in to host a watch party", 401); + await this.ensure(); + const code = cleanPartyCode(input.partyCode); + const partyUrl = cleanPartyUrl(input.partyUrl, this.options.hostsFor?.(origin) ?? []); + const mediaTitle = typeof input.mediaTitle === "string" ? input.mediaTitle.slice(0, 200).trim() : ""; + const title = + (typeof input.title === "string" && input.title.trim() !== "" ? input.title.trim() : "") || + mediaTitle || + `Watch party ${code}`; + + const existing = await this.byCode(origin, code); + if (existing) { + // Already bridged. Only the host may change what it says it is, and the + // event's own version check is what settles a race between two of them. + if (existing.event.ownerId !== ownerId) return existing; + const event = await this.options.events.update(existing.event.id, ownerId, { + version: existing.event.version, + title, + ...(mediaTitle ? { description: mediaTitle } : {}), + }); + const { rows } = await this.options.db.query( + `UPDATE ${TABLE} SET party_url = COALESCE(NULLIF($2, ''), party_url), + media_title = COALESCE(NULLIF($3, ''), media_title), + updated_at = now() + WHERE event_id = $1 RETURNING *`, + [existing.event.id, partyUrl, mediaTitle], + ); + const row = rows[0]; + return { party: row ? partyFrom(row, event) : existing.party, event }; + } + + // New. The event is created live rather than draft: a watch party exists + // because people are watching it now, and a party that has to be started + // twice -- once over there, once here -- is a party that is listed dead. + const event = await this.options.events.create(ownerId, { + title, + description: mediaTitle, + topic: "Watch party", + visibility: input.visibility ?? "unlisted", + chatEnabled: input.chatEnabled ?? true, + handRaiseEnabled: input.handRaiseEnabled ?? false, + }); + const live = await this.options.events.transition(event.id, ownerId, "live", event.version); + let rows: Record[]; + try { + ({ rows } = await this.options.db.query( + `INSERT INTO ${TABLE} (event_id, origin, party_code, party_url, media_title, position_at) + VALUES ($1, $2, $3, $4, $5, now()) RETURNING *`, + [live.id, origin, code, partyUrl, mediaTitle], + )); + } catch (error) { + if ((error as { code?: string }).code !== "23505") throw error; + // Two requests bridged the same party at once. The loser drops its + // event and answers the winner's, which is the same answer. + await this.options.events.remove(live.id, ownerId).catch(() => {}); + const raced = await this.byCode(origin, code); + if (raced) return raced; + throw new WatchPartyError("could not bridge that party", 409); + } + const row = rows[0]; + if (!row) throw new WatchPartyError("could not bridge that party", 500); + return { party: partyFrom(row, live), event: live }; + } + + async byCode(origin: PartyOrigin, partyCode: string): Promise { + await this.ensure(); + const code = cleanPartyCode(partyCode); + const { rows } = await this.options.db.query( + `SELECT * FROM ${TABLE} WHERE origin = $1 AND party_code = $2 LIMIT 1`, + [origin, code], + ); + const row = rows[0]; + if (!row) return null; + const event = await this.options.events.get(String(row["event_id"] ?? "")); + if (!event) return null; + return { party: partyFrom(row, event), event }; + } + + /** The party behind a nixamp room or slug, for a client that has only that. */ + async byEvent(reference: string): Promise { + await this.ensure(); + const event = (await this.options.events.get(reference)) ?? (await this.options.events.byRoom(reference)); + if (!event) return null; + const { rows } = await this.options.db.query(`SELECT * FROM ${TABLE} WHERE event_id = $1`, [event.id]); + const row = rows[0]; + return row ? { party: partyFrom(row, event), event } : null; + } + + /** + * Parties anyone may join: public and unlisted ones that are still live. + * + * Unlisted is included here and not in the general event list on purpose. + * A watch party code is already the thing you hand somebody, and this + * endpoint is reached only with a token the account granted, so what it + * lists is "the parties this person could join", not the public web. + */ + async list(options: { origin?: PartyOrigin; limit?: number } = {}): Promise { + await this.ensure(); + const limit = Math.min(100, Math.max(1, Math.floor(options.limit ?? 30))); + const values: unknown[] = []; + let where = ""; + if (options.origin) { + values.push(options.origin); + where = `WHERE p.origin = $${values.length}`; + } + values.push(limit); + const { rows } = await this.options.db.query( + `SELECT p.* FROM ${TABLE} p + JOIN live_events e ON e.id = p.event_id + ${where}${where ? " AND" : "WHERE"} e.status = 'live' AND e.visibility <> 'private' + ORDER BY p.updated_at DESC LIMIT $${values.length}`, + values, + ); + const found: PartyView[] = []; + for (const row of rows) { + const event = await this.options.events.get(String(row["event_id"] ?? "")); + if (event) found.push({ party: partyFrom(row, event), event }); + } + return found; + } + + /** + * Where the host says playback is. + * + * Only somebody who can manage the event may write it, because a listener + * who could would be able to drag everybody else around the film. Reading + * is open to whoever can see the event, which the API layer has already + * decided by the time this is called. + */ + async setPlayback( + eventId: string, + accountId: string, + input: { positionSeconds?: unknown; playing?: unknown; mediaTitle?: unknown }, + ): Promise { + await this.ensure(); + const event = await this.options.events.get(eventId); + if (!event) throw new WatchPartyError("watch party not found", 404); + if (!this.options.events.canManage(event, accountId)) { + throw new WatchPartyError("only the host can move everybody's playback", 403); + } + const position = seconds(input.positionSeconds, "positionSeconds"); + const playing = input.playing === undefined ? true : input.playing === true; + const mediaTitle = typeof input.mediaTitle === "string" ? input.mediaTitle.slice(0, 200).trim() : ""; + const { rows } = await this.options.db.query( + `UPDATE ${TABLE} SET position_seconds = $2, playing = $3, + media_title = COALESCE(NULLIF($4, ''), media_title), + position_at = $5, updated_at = now() + WHERE event_id = $1 RETURNING *`, + [eventId, position, playing, mediaTitle, new Date(this.now()).toISOString()], + ); + const row = rows[0]; + if (!row) throw new WatchPartyError("watch party not found", 404); + return partyFrom(row, event); + } + + /** + * Where playback is right now, which is not what was written down. + * + * A party that is playing has moved on since the host last said anything, + * so the answer is the stored second plus the time since. A paused one has + * not, and reporting drift on a paused film would make every client seek + * away from the frame everybody is looking at. + */ + positionNow(party: WatchParty): number { + if (!party.playing) return party.positionSeconds; + const since = (this.now() - Date.parse(party.positionAt)) / 1000; + return Math.max(0, party.positionSeconds + (Number.isFinite(since) ? since : 0)); + } + + /** The party goes when the event does; ending it is the event's transition. */ + async end(eventId: string, accountId: string): Promise { + const event = await this.options.events.get(eventId); + if (!event) throw new WatchPartyError("watch party not found", 404); + if (event.ownerId !== accountId) throw new WatchPartyError("only the host can end it", 403); + try { + return await this.options.events.transition(event.id, accountId, "ended", event.version); + } catch (error) { + if (error instanceof LiveEventError) throw new WatchPartyError(error.message, error.status); + throw error; + } + } +} diff --git a/test/oauth-server.test.ts b/test/oauth-server.test.ts new file mode 100644 index 0000000..da555a4 --- /dev/null +++ b/test/oauth-server.test.ts @@ -0,0 +1,695 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import type { AddressInfo } from "node:net"; +import { Accounts, type AdapterLike, type AuthLike } from "../src/accounts.ts"; +import type { Queryable } from "../src/follows.ts"; +import { LiveEvents } from "../src/live-events.ts"; +import { + AuthorizationServer, + BITTORRENTED_CLIENT, + challengeFor, + clientsFrom, + redirectAllowed, + SCOPE_NAMES, + verifierMatches, +} from "../src/oauth-server.ts"; +import { createServer, EmptyEngine } from "../src/server.ts"; +import { cleanPartyCode, cleanPartyUrl, WatchParties } from "../src/watch-party.ts"; + +/** + * Enough Postgres for the tables this feature adds, and no more. + * + * It recognises the statements the code actually sends, which is the point: + * what is under test is what the rows mean -- a code that may be spent once, + * a refresh token that retires when it is used, a party that bridges to one + * room however many times it is asked -- not what a database does with them. + */ +function fakeDb(): Queryable & { events: Map> } { + const codes = new Map>(); + const refresh = new Map>(); + const tokens = new Map>(); + const parties = new Map>(); + const events = new Map>(); + const now = (): string => new Date().toISOString(); + + const query = async (text: string, values: unknown[] = []) => { + const sql = text.trim().replace(/\s+/g, " "); + if (sql.startsWith("CREATE TABLE") || sql.startsWith("CREATE INDEX")) return { rows: [] }; + + // --- nixamp_tokens ---------------------------------------------------- + if (sql.startsWith("INSERT INTO nixamp_tokens")) { + const [id, user_id, email, kind, name, secret_hash, created_at, expires_at] = values; + tokens.set(String(id), { id, user_id, email, kind, name, secret_hash, created_at, expires_at, last_used_at: null }); + return { rows: [] }; + } + if (sql.startsWith("DELETE FROM nixamp_tokens WHERE id = $1")) { + const had = tokens.delete(String(values[0])); + return { rows: had ? [{ id: values[0] }] : [] }; + } + if (sql.startsWith("SELECT") && sql.includes("FROM nixamp_tokens WHERE id = $1")) { + const row = tokens.get(String(values[0])); + return { rows: row ? [row] : [] }; + } + if (sql.startsWith("UPDATE nixamp_tokens SET last_used_at")) return { rows: [] }; + + // --- nixamp_oauth_codes ----------------------------------------------- + if (sql.startsWith("INSERT INTO nixamp_oauth_codes")) { + const [code_hash, client_id, user_id, email, redirect_uri, scope, code_challenge, family, created_at, expires_at] = values; + codes.set(String(code_hash), { + code_hash, client_id, user_id, email, redirect_uri, scope, code_challenge, family, created_at, expires_at, used_at: null, + }); + return { rows: [] }; + } + if (sql.startsWith("SELECT") && sql.includes("FROM nixamp_oauth_codes WHERE code_hash = $1")) { + const row = codes.get(String(values[0])); + return { rows: row ? [row] : [] }; + } + if (sql.startsWith("UPDATE nixamp_oauth_codes SET used_at")) { + const row = codes.get(String(values[0])); + if (row) row["used_at"] = now(); + return { rows: [] }; + } + + // --- nixamp_oauth_refresh --------------------------------------------- + if (sql.startsWith("INSERT INTO nixamp_oauth_refresh")) { + const [id, secret_hash, client_id, user_id, email, scope, family, access_id, created_at, expires_at] = values; + refresh.set(String(id), { + id, secret_hash, client_id, user_id, email, scope, family, access_id, created_at, expires_at, + rotated_at: null, revoked_at: null, + }); + return { rows: [] }; + } + if (sql.startsWith("SELECT") && sql.includes("FROM nixamp_oauth_refresh WHERE id = $1")) { + const row = refresh.get(String(values[0])); + return { rows: row ? [row] : [] }; + } + if (sql.startsWith("UPDATE nixamp_oauth_refresh SET rotated_at")) { + const row = refresh.get(String(values[0])); + if (row) row["rotated_at"] = now(); + return { rows: [] }; + } + if (sql.startsWith("UPDATE nixamp_oauth_refresh SET revoked_at")) { + const hit: Record[] = []; + for (const row of refresh.values()) { + if (row["family"] === values[0] && row["revoked_at"] === null) { + row["revoked_at"] = now(); + hit.push({ access_id: row["access_id"] }); + } + } + return { rows: hit }; + } + if (sql.includes("SELECT DISTINCT family FROM nixamp_oauth_refresh")) { + const families = new Set(); + for (const row of refresh.values()) { + if (row["user_id"] === values[0] && row["client_id"] === values[1] && row["revoked_at"] === null) { + families.add(String(row["family"])); + } + } + return { rows: [...families].map((family) => ({ family })) }; + } + if (sql.includes("FROM nixamp_oauth_refresh WHERE user_id = $1")) { + return { + rows: [...refresh.values()].filter( + (row) => row["user_id"] === values[0] && row["revoked_at"] === null && row["rotated_at"] === null, + ), + }; + } + + // --- nixamp_watch_parties ---------------------------------------------- + if (sql.startsWith("SELECT") && sql.includes("FROM nixamp_watch_parties WHERE origin = $1")) { + const row = [...parties.values()].find((one) => one["origin"] === values[0] && one["party_code"] === values[1]); + return { rows: row ? [row] : [] }; + } + if (sql.startsWith("SELECT") && sql.includes("FROM nixamp_watch_parties WHERE event_id = $1")) { + const row = parties.get(String(values[0])); + return { rows: row ? [row] : [] }; + } + if (sql.startsWith("INSERT INTO nixamp_watch_parties")) { + const [event_id, origin, party_code, party_url, media_title] = values; + if ([...parties.values()].some((one) => one["origin"] === origin && one["party_code"] === party_code)) { + throw Object.assign(new Error("duplicate key"), { code: "23505" }); + } + const row = { + event_id, origin, party_code, party_url, media_title, + position_seconds: 0, playing: false, position_at: now(), created_at: now(), updated_at: now(), + }; + parties.set(String(event_id), row); + return { rows: [row] }; + } + if (sql.startsWith("UPDATE nixamp_watch_parties SET party_url")) { + const row = parties.get(String(values[0])); + if (!row) return { rows: [] }; + if (values[1]) row["party_url"] = values[1]; + if (values[2]) row["media_title"] = values[2]; + return { rows: [row] }; + } + if (sql.startsWith("UPDATE nixamp_watch_parties SET position_seconds")) { + const row = parties.get(String(values[0])); + if (!row) return { rows: [] }; + row["position_seconds"] = values[1]; + row["playing"] = values[2]; + if (values[3]) row["media_title"] = values[3]; + row["position_at"] = values[4]; + return { rows: [row] }; + } + if (sql.includes("FROM nixamp_watch_parties p")) { + const rows = [...parties.values()].filter((one) => { + const event = events.get(String(one["event_id"])); + return event?.["status"] === "live" && event["visibility"] !== "private"; + }); + return { rows }; + } + + // --- live_events -------------------------------------------------------- + if (sql.startsWith("INSERT INTO live_events")) { + // Column order follows the INSERT in src/live-events.ts, which grew + // kind, doors and the ticket fields with the concert work (#125). + const [id, slug, owner_id, title, description, topic, kind, doors_open_at, ticket_price_cents, ticket_currency, ticket_minutes, pay_to, starts_at, ends_at, timezone, minutes, status, visibility, room_id, chat, hand, recording, layout] = values; + const row = { + id, slug, owner_id, title, description, topic, kind, doors_open_at, + ticket_price_cents, ticket_currency, ticket_minutes, pay_to: pay_to || null, + starts_at, ends_at, timezone, + expected_duration_minutes: minutes, status, visibility, room_id, + chat_enabled: chat, hand_raise_enabled: hand, recording_enabled: recording, + recording_id: null, layout_id: layout || null, version: 1, + created_at: now(), updated_at: now(), + invitee_ids: [], speaker_ids: [], artist_ids: [], moderator_ids: [], + }; + events.set(String(id), row); + return { rows: [row] }; + } + if (sql.startsWith("UPDATE live_events SET")) { + const row = events.get(String(values[0])) ?? [...events.values()].find((one) => one["slug"] === values[0]); + if (!row || row["owner_id"] !== values[1] || row["version"] !== values[2]) return { rows: [] }; + row["title"] = values[3]; + row["description"] = values[4]; + row["status"] = values[10]; + row["visibility"] = values[11]; + row["version"] = Number(row["version"]) + 1; + row["updated_at"] = now(); + return { rows: [row] }; + } + if (sql.startsWith("DELETE FROM live_events")) { + const row = [...events.values()].find((one) => (one["id"] === values[0] || one["slug"] === values[0]) && one["owner_id"] === values[1]); + if (row) events.delete(String(row["id"])); + return { rows: row ? [{ id: row["id"] }] : [] }; + } + if (sql.includes("FROM live_events e")) { + if (sql.includes("e.room_id = $1")) { + const row = [...events.values()].find((one) => one["room_id"] === values[0]); + return { rows: row ? [row] : [] }; + } + if (sql.includes("e.id = $1 OR e.slug = $1")) { + const row = [...events.values()].find((one) => one["id"] === values[0] || one["slug"] === values[0]); + return { rows: row ? [row] : [] }; + } + return { rows: [...events.values()] }; + } + if (sql.includes("FROM live_event_invitations")) return { rows: [] }; + return { rows: [] }; + }; + + return { query, events }; +} + +/** Accounts backed by the fake, so the tokens it issues are real `nxa_` ones. */ +function fakeAccounts(db: Queryable): Accounts { + const users = new Map([["host@example.com", { id: "host-1", email: "host@example.com" }]]); + const adapter: AdapterLike = { + query: db.query.bind(db), + async getUserByEmail(email) { + return users.get(email) ?? null; + }, + async createUser(user) { + const made = { id: `user-${users.size + 1}`, email: user.email }; + users.set(user.email, made); + return made; + }, + }; + const system: AuthLike = { + async register() { + return {}; + }, + async login() { + return {}; + }, + async validateToken() { + return null; + }, + }; + return new Accounts({ connectionString: "", secret: "s", adapter, system }); +} + +interface Harness { + base: string; + db: ReturnType; + accounts: Accounts; + server: AuthorizationServer; + parties: WatchParties; + /** A session token for host-1, the way a browser would carry one. */ + session: string; +} + +async function withServer(run: (harness: Harness) => Promise): Promise { + const db = fakeDb(); + const accounts = fakeAccounts(db); + const authServer = new AuthorizationServer({ + db, + tokens: accounts.tokens!, + clients: clientsFrom({}), + issuer: "https://nixamp.test", + }); + const events = new LiveEvents(db); + const parties = new WatchParties({ + db, + events, + site: "https://nixamp.test", + hostsFor: () => ["bittorrented.com"], + }); + const http = createServer(new EmptyEngine(), { + web: null, + media: true, + version: "test", + load: async () => [], + accounts, + authServer, + parties, + events, + }); + await new Promise((done) => http.listen(0, "127.0.0.1", done)); + const { port } = http.address() as AddressInfo; + const session = await accounts.sessionFor({ id: "host-1", email: "host@example.com" }); + try { + await run({ base: `http://127.0.0.1:${port}`, db, accounts, server: authServer, parties, session }); + } finally { + await new Promise((done) => http.close(() => done())); + } +} + +/** The whole browser half of an authorization code flow, without a browser. */ +async function authorize( + harness: Harness, + options: { scope?: string; verifier?: string; redirectUri?: string; decision?: string } = {}, +): Promise<{ location: string; verifier: string }> { + const verifier = options.verifier ?? randomBytes(32).toString("base64url"); + const form = new URLSearchParams({ + response_type: "code", + client_id: BITTORRENTED_CLIENT.id, + redirect_uri: options.redirectUri ?? BITTORRENTED_CLIENT.redirectUris[0]!, + scope: options.scope ?? "profile parties offline_access", + state: "xyz", + code_challenge: challengeFor(verifier), + code_challenge_method: "S256", + decision: options.decision ?? "allow", + }); + const answer = await fetch(`${harness.base}/api/v1/oauth/authorize`, { + method: "POST", + redirect: "manual", + headers: { authorization: `Bearer ${harness.session}`, "content-type": "application/x-www-form-urlencoded" }, + body: form.toString(), + }); + return { location: answer.headers.get("location") ?? "", verifier }; +} + +async function exchange(harness: Harness, code: string, verifier: string): Promise> { + const answer = await fetch(`${harness.base}/api/v1/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: BITTORRENTED_CLIENT.id, + redirect_uri: BITTORRENTED_CLIENT.redirectUris[0]!, + code, + code_verifier: verifier, + }).toString(), + }); + return (await answer.json()) as Record; +} + +// --- the pieces, without a server --------------------------------------------- + +test("a redirect URI matches exactly, and only a loopback port may vary", () => { + const client = { id: "x", name: "X", redirectUris: ["https://bittorrented.com/api/v1/nixamp/oauth/callback", "http://127.0.0.1/cb"] }; + assert.ok(redirectAllowed(client, "https://bittorrented.com/api/v1/nixamp/oauth/callback")); + // Not a prefix match, not a subdomain, not a different path. + assert.equal(redirectAllowed(client, "https://bittorrented.com/api/v1/nixamp/oauth/callback/evil"), false); + assert.equal(redirectAllowed(client, "https://evil.bittorrented.com/api/v1/nixamp/oauth/callback"), false); + assert.equal(redirectAllowed(client, "https://bittorrented.com.evil.test/api/v1/nixamp/oauth/callback"), false); + // A CLI cannot know its port before it listens, so the port alone may vary. + assert.ok(redirectAllowed(client, "http://127.0.0.1:53219/cb")); + assert.equal(redirectAllowed(client, "http://127.0.0.1:53219/other"), false); +}); + +test("PKCE accepts only a well-formed verifier that hashes to the challenge", () => { + const verifier = randomBytes(32).toString("base64url"); + assert.ok(verifierMatches(verifier, challengeFor(verifier))); + assert.equal(verifierMatches(`${verifier}x`, challengeFor(verifier)), false); + // Too short to be a verifier at all, however it hashes. + assert.equal(verifierMatches("short", challengeFor("short")), false); + assert.equal(verifierMatches(undefined, challengeFor(verifier)), false); +}); + +test("bittorrented.com is registered out of the box, and the env may add more", () => { + assert.ok(clientsFrom({}).some((client) => client.id === "bittorrented")); + const extra = clientsFrom({ + NIXAMP_OAUTH_CLIENTS: JSON.stringify([{ id: "other", name: "Other", redirectUris: ["https://other.test/cb"] }]), + }); + assert.deepEqual(extra.map((client) => client.id).sort(), ["bittorrented", "other"]); + // An entry with no redirect URI is not a client; it is a mistake. + assert.equal(clientsFrom({ NIXAMP_OAUTH_CLIENTS: '[{"id":"bad"}]' }).length, 1); +}); + +test("a party code is rubbed of the spacing people type, and a watch link must be the client's own site", () => { + assert.equal(cleanPartyCode("abc 123"), "ABC123"); + assert.equal(cleanPartyCode("ABC-123"), "ABC123"); + assert.throws(() => cleanPartyCode("no!"), /party code/); + assert.equal(cleanPartyUrl("https://bittorrented.com/watch-party?code=ABC123", ["bittorrented.com"]), + "https://bittorrented.com/watch-party?code=ABC123"); + assert.throws(() => cleanPartyUrl("https://evil.test/watch", ["bittorrented.com"]), /not on this client/); + assert.throws(() => cleanPartyUrl("http://bittorrented.com/watch", ["bittorrented.com"]), /https/); +}); + +// --- the flow, over HTTP ------------------------------------------------------- + +test("the metadata document says what the server is and where", async () => { + await withServer(async (harness) => { + const answer = await fetch(`${harness.base}/.well-known/oauth-authorization-server`); + assert.equal(answer.status, 200); + const body = (await answer.json()) as Record; + assert.equal(body["issuer"], "https://nixamp.test"); + assert.equal(body["authorization_endpoint"], "https://nixamp.test/api/v1/oauth/authorize"); + assert.deepEqual(body["code_challenge_methods_supported"], ["S256"]); + assert.deepEqual(body["response_types_supported"], ["code"]); + // OAuth 2.1: no implicit, no password. + assert.deepEqual(body["grant_types_supported"], ["authorization_code", "refresh_token"]); + assert.deepEqual(body["scopes_supported"], SCOPE_NAMES); + }); +}); + +test("an authorization request without PKCE is refused, back to the client", async () => { + await withServer(async (harness) => { + const answer = await fetch( + `${harness.base}/api/v1/oauth/authorize?response_type=code&client_id=bittorrented` + + `&redirect_uri=${encodeURIComponent(BITTORRENTED_CLIENT.redirectUris[0]!)}&state=xyz`, + { redirect: "manual", headers: { authorization: `Bearer ${harness.session}` } }, + ); + assert.equal(answer.status, 302); + const location = new URL(answer.headers.get("location") ?? ""); + assert.equal(location.searchParams.get("error"), "invalid_request"); + assert.match(location.searchParams.get("error_description") ?? "", /code_challenge/); + assert.equal(location.searchParams.get("state"), "xyz"); + }); +}); + +test("a bad redirect URI is a page, never a redirect", async () => { + await withServer(async (harness) => { + const answer = await fetch( + `${harness.base}/api/v1/oauth/authorize?response_type=code&client_id=bittorrented` + + `&redirect_uri=${encodeURIComponent("https://evil.test/steal")}&state=xyz`, + { redirect: "manual", headers: { authorization: `Bearer ${harness.session}` } }, + ); + // Bouncing an error to an unregistered URI would make this an open redirector. + assert.equal(answer.status, 400); + assert.equal(answer.headers.get("location"), null); + assert.match(answer.headers.get("content-type") ?? "", /text\/html/); + }); +}); + +test("code, PKCE and refresh: the whole grant, and the access token is a nixamp token", async () => { + await withServer(async (harness) => { + const { location, verifier } = await authorize(harness); + const code = new URL(location).searchParams.get("code") ?? ""; + assert.ok(code); + assert.equal(new URL(location).searchParams.get("state"), "xyz"); + + const granted = await exchange(harness, code, verifier); + assert.equal(granted["token_type"], "Bearer"); + assert.ok(granted["access_token"]?.startsWith("nxa_")); + assert.ok(granted["refresh_token"]?.startsWith("nxr_")); + assert.equal(granted["scope"], "profile parties offline_access"); + + // The access token walks through whoIs like any other, which is what + // makes every existing /api/v1 route understand it. + const who = await harness.accounts.whoIs(granted["access_token"]!); + assert.equal(who?.id, "host-1"); + + const info = await fetch(`${harness.base}/api/v1/oauth/userinfo`, { + headers: { authorization: `Bearer ${granted["access_token"]}` }, + }); + const claims = (await info.json()) as Record; + assert.equal(claims["sub"], "host-1"); + assert.equal(claims["client_id"], "bittorrented"); + // No `email` scope was asked for, so no address is handed over. + assert.equal(claims["email"], undefined); + + // Refresh rotates: a new pair, and a different refresh token. + const refreshed = await fetch(`${harness.base}/api/v1/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: "bittorrented", + refresh_token: granted["refresh_token"]!, + }).toString(), + }); + const next = (await refreshed.json()) as Record; + assert.equal(refreshed.status, 200); + assert.notEqual(next["refresh_token"], granted["refresh_token"]); + assert.notEqual(next["access_token"], granted["access_token"]); + + // The retired one, used again, withdraws the whole family: that only + // happens when two parties hold one secret. + const replayed = await fetch(`${harness.base}/api/v1/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: "bittorrented", + refresh_token: granted["refresh_token"]!, + }).toString(), + }); + assert.equal(replayed.status, 400); + assert.equal(((await replayed.json()) as Record)["error"], "invalid_grant"); + assert.equal(await harness.accounts.whoIs(next["access_token"]!), null); + }); +}); + +test("a code is spent once, and spending it twice withdraws what it produced", async () => { + await withServer(async (harness) => { + const { location, verifier } = await authorize(harness); + const code = new URL(location).searchParams.get("code") ?? ""; + const first = await exchange(harness, code, verifier); + assert.ok(first["access_token"]); + const again = await exchange(harness, code, verifier); + assert.equal(again["error"], "invalid_grant"); + // The first exchange's tokens go too: a replayed code means somebody else + // has it, and there is no telling which of the two was the thief. + assert.equal(await harness.accounts.whoIs(first["access_token"]!), null); + }); +}); + +test("the wrong verifier gets nothing, however good the code is", async () => { + await withServer(async (harness) => { + const { location } = await authorize(harness); + const code = new URL(location).searchParams.get("code") ?? ""; + const stolen = await exchange(harness, code, randomBytes(32).toString("base64url")); + assert.equal(stolen["error"], "invalid_grant"); + assert.match(stolen["error_description"] ?? "", /code_verifier/); + }); +}); + +test("the grants OAuth 2.1 removed are refused by name", async () => { + await withServer(async (harness) => { + for (const grantType of ["password", "implicit", "client_credentials"]) { + const answer = await fetch(`${harness.base}/api/v1/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: grantType, + client_id: "bittorrented", + username: "host@example.com", + password: "hunter2", + }).toString(), + }); + assert.equal(answer.status, 400); + assert.equal(((await answer.json()) as Record)["error"], "unsupported_grant_type"); + } + }); +}); + +test("saying no sends the client an access_denied and no code", async () => { + await withServer(async (harness) => { + const { location } = await authorize(harness, { decision: "deny" }); + const url = new URL(location); + assert.equal(url.searchParams.get("error"), "access_denied"); + assert.equal(url.searchParams.get("code"), null); + }); +}); + +test("the consent page asks before anything is granted, and needs a signed-in person", async () => { + await withServer(async (harness) => { + const query = + `response_type=code&client_id=bittorrented&redirect_uri=${encodeURIComponent(BITTORRENTED_CLIENT.redirectUris[0]!)}` + + `&scope=profile+parties&state=xyz&code_challenge=${challengeFor("x".repeat(43))}&code_challenge_method=S256`; + const anonymous = await fetch(`${harness.base}/api/v1/oauth/authorize?${query}`, { redirect: "manual" }); + assert.equal(anonymous.status, 401); + + const asked = await fetch(`${harness.base}/api/v1/oauth/authorize?${query}`, { + redirect: "manual", + headers: { authorization: `Bearer ${harness.session}` }, + }); + assert.equal(asked.status, 200); + const page = await asked.text(); + assert.match(page, /bittorrented\.com/); + // Every scope is named on the page, not summarised as "access your account". + assert.match(page, /host and join watch parties/); + }); +}); + +// --- the watch party bridge ------------------------------------------------------ + +async function connected(harness: Harness, scope = "profile parties"): Promise { + const { location, verifier } = await authorize(harness, { scope }); + const code = new URL(location).searchParams.get("code") ?? ""; + return (await exchange(harness, code, verifier))["access_token"]!; +} + +test("a watch party bridges to a nixamp room, once, however many times it is asked", async () => { + await withServer(async (harness) => { + const token = await connected(harness); + const bridge = async () => + fetch(`${harness.base}/api/v1/watch-parties`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ + partyCode: "abc123", + title: "Dune, together", + partyUrl: "https://bittorrented.com/watch-party?code=ABC123", + mediaTitle: "Dune (2021)", + }), + }); + + const first = await bridge(); + assert.equal(first.status, 201); + const made = (await first.json()) as { + party: { partyCode: string; roomId: string; origin: string }; + event: { id: string; status: string; visibility: string }; + links: { nixampUrl: string; partyUrl: string }; + host: boolean; + }; + assert.equal(made.party.partyCode, "ABC123"); + assert.equal(made.party.origin, "bittorrented"); + // Live at once: a watch party exists because people are watching it now. + assert.equal(made.event.status, "live"); + assert.equal(made.event.visibility, "unlisted"); + assert.ok(made.host); + assert.match(made.links.nixampUrl, /^https:\/\/nixamp\.test\/live\//); + + // The client calls this every time somebody opens the party page, so the + // second call must answer the same room rather than make another. + const second = await bridge(); + const again = (await second.json()) as { party: { roomId: string }; event: { id: string } }; + assert.equal(again.event.id, made.event.id); + assert.equal(again.party.roomId, made.party.roomId); + }); +}); + +test("a watch link must be on the client's own site", async () => { + await withServer(async (harness) => { + const token = await connected(harness); + const answer = await fetch(`${harness.base}/api/v1/watch-parties`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ partyCode: "ABC124", partyUrl: "https://evil.test/come-here" }), + }); + assert.equal(answer.status, 403); + }); +}); + +test("a token without the parties scope cannot touch a party", async () => { + await withServer(async (harness) => { + const token = await connected(harness, "profile"); + const answer = await fetch(`${harness.base}/api/v1/watch-parties`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ partyCode: "ABC125" }), + }); + assert.equal(answer.status, 401); + }); +}); + +test("only the host moves everybody's playback, and a late joiner is told where it is now", async () => { + await withServer(async (harness) => { + const token = await connected(harness); + const made = await fetch(`${harness.base}/api/v1/watch-parties`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ partyCode: "ABC126", mediaTitle: "Dune (2021)" }), + }).then((answer) => answer.json()) as { party: { partyCode: string } }; + + const moved = await fetch(`${harness.base}/api/v1/watch-parties/${made.party.partyCode}/playback`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ positionSeconds: 930, playing: true }), + }); + assert.equal(moved.status, 200); + const state = (await moved.json()) as { party: { positionSeconds: number; playing: boolean; positionNow: number } }; + assert.equal(state.party.positionSeconds, 930); + assert.ok(state.party.playing); + // A playing film has moved on since the host last said anything, so the + // answer is never behind where it actually is. + assert.ok(state.party.positionNow >= 930); + + // A different account holds a token for the same client but is not the host. + const other = await harness.accounts.tokens!.issue({ + account: { id: "account-2", email: "other@example.com" }, + kind: "oauth", + name: "bittorrented profile parties", + }); + const refused = await fetch(`${harness.base}/api/v1/watch-parties/${made.party.partyCode}/playback`, { + method: "POST", + headers: { authorization: `Bearer ${other.token}`, "content-type": "application/json" }, + body: JSON.stringify({ positionSeconds: 0 }), + }); + assert.equal(refused.status, 403); + }); +}); + +test("a person's own session reaches the parties without any OAuth at all", async () => { + await withServer(async (harness) => { + const token = await connected(harness); + await fetch(`${harness.base}/api/v1/watch-parties`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: JSON.stringify({ partyCode: "ABC127", title: "Dune, together" }), + }); + // The same list, asked for with the session a browser on nixamp.com holds. + const listed = await fetch(`${harness.base}/api/v1/watch-parties`, { + headers: { authorization: `Bearer ${harness.session}` }, + }); + assert.equal(listed.status, 200); + const body = (await listed.json()) as { parties: { party: { partyCode: string } }[] }; + assert.ok(body.parties.some((row) => row.party.partyCode === "ABC127")); + }); +}); + +test("an account can see what it connected, and take it away", async () => { + await withServer(async (harness) => { + const token = await connected(harness, "profile parties offline_access"); + const listed = await fetch(`${harness.base}/api/v1/oauth/connections`, { + headers: { authorization: `Bearer ${harness.session}` }, + }); + const body = (await listed.json()) as { connections: { clientId: string; clientName: string }[] }; + assert.equal(body.connections[0]?.clientId, "bittorrented"); + assert.equal(body.connections[0]?.clientName, "bittorrented.com"); + + const off = await fetch(`${harness.base}/api/v1/oauth/connections/bittorrented`, { + method: "DELETE", + headers: { authorization: `Bearer ${harness.session}` }, + }); + assert.equal(off.status, 200); + // Disconnecting withdraws the access token the connection was holding. + assert.equal(await harness.accounts.whoIs(token), null); + }); +}); diff --git a/test/party-cli.test.ts b/test/party-cli.test.ts new file mode 100644 index 0000000..cf97f81 --- /dev/null +++ b/test/party-cli.test.ts @@ -0,0 +1,168 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { clock, partyLines, party, type PartyRow } from "../src/party.ts"; +import { callTool, handleMessage, PROTOCOL_VERSION, TOOLS } from "../src/mcp.ts"; + +const row: PartyRow = { + party: { + eventId: "event-1", + roomId: "event-abc123", + slug: "dune-together", + origin: "bittorrented", + partyCode: "ABC123", + partyUrl: "https://bittorrented.com/watch-party?code=ABC123", + mediaTitle: "Dune (2021)", + positionSeconds: 930, + positionNow: 934, + playing: true, + }, + event: { id: "event-1", title: "Dune, together", status: "live", ownerId: "host-1", visibility: "unlisted" }, + links: { + nixampUrl: "https://nixamp.test/live/dune-together", + roomUrl: "https://nixamp.test/api/channels/event-abc123", + partyUrl: "https://bittorrented.com/watch-party?code=ABC123", + }, + host: true, +}; + +const session = { site: "https://nixamp.test", token: "nxa_deadbeef_secret" }; + +/** A fetch that records what was asked and answers what the test says. */ +function recorder(answer: unknown, status = 200) { + const calls: { url: string; method: string; body: unknown }[] = []; + const fetcher = (async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(input), + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + return new Response(JSON.stringify(answer), { status, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + return { calls, fetcher }; +} + +test("a position reads as a clock, with the hour only once there is one", () => { + assert.equal(clock(0), "0:00"); + assert.equal(clock(930), "15:30"); + assert.equal(clock(3661), "1:01:01"); + // Never negative, whatever arithmetic produced it. + assert.equal(clock(-5), "0:00"); +}); + +test("a party prints both links: the film where it lives, the room here", () => { + const lines = partyLines(row); + assert.match(lines[0] ?? "", /ABC123.*Dune, together.*yours/); + assert.match(lines[1] ?? "", /▶ 15:34/); + assert.ok(lines.some((line) => line.includes("https://bittorrented.com/watch-party?code=ABC123"))); + assert.ok(lines.some((line) => line.includes("https://nixamp.test/live/dune-together"))); +}); + +test("`nixamp party` refuses before there is a session rather than asking anyway", async () => { + // No session file in this process's state directory, and no NIXAMP_TOKEN. + const had = process.env["NIXAMP_TOKEN"]; + delete process.env["NIXAMP_TOKEN"]; + const { calls, fetcher } = recorder({}); + try { + const code = await party(["list"], fetcher); + assert.equal(code, 1); + assert.equal(calls.length, 0); + } finally { + if (had !== undefined) process.env["NIXAMP_TOKEN"] = had; + } +}); + +test("`nixamp party host` sends the code and the link, and bridging is a POST", async () => { + process.env["NIXAMP_TOKEN"] = "nxa_test_token"; + process.env["NIXAMP_SITE"] = "https://nixamp.test"; + const { calls, fetcher } = recorder(row, 201); + try { + const code = await party( + ["host", "abc123", "--url", "https://bittorrented.com/watch-party?code=ABC123", "--media", "Dune (2021)"], + fetcher, + ); + assert.equal(code, 0); + assert.equal(calls[0]?.method, "POST"); + assert.equal(calls[0]?.url, "https://nixamp.test/api/v1/watch-parties"); + assert.deepEqual(calls[0]?.body, { + partyCode: "abc123", + partyUrl: "https://bittorrented.com/watch-party?code=ABC123", + mediaTitle: "Dune (2021)", + }); + } finally { + delete process.env["NIXAMP_TOKEN"]; + delete process.env["NIXAMP_SITE"]; + } +}); + +test("`nixamp party sync` will not guess where the film is", async () => { + process.env["NIXAMP_TOKEN"] = "nxa_test_token"; + process.env["NIXAMP_SITE"] = "https://nixamp.test"; + const { calls, fetcher } = recorder(row); + try { + assert.equal(await party(["sync", "ABC123"], fetcher), 64); + assert.equal(calls.length, 0); + assert.equal(await party(["sync", "ABC123", "--at", "930", "--pause"], fetcher), 0); + assert.equal(calls[0]?.url, "https://nixamp.test/api/v1/watch-parties/ABC123/playback"); + assert.deepEqual(calls[0]?.body, { positionSeconds: 930, playing: false }); + } finally { + delete process.env["NIXAMP_TOKEN"]; + delete process.env["NIXAMP_SITE"]; + } +}); + +// --- MCP ----------------------------------------------------------------------- + +test("the MCP server introduces itself and lists its tools", async () => { + const hello = await handleMessage({ jsonrpc: "2.0", id: 1, method: "initialize" }); + const result = hello?.["result"] as Record; + assert.equal(result["protocolVersion"], PROTOCOL_VERSION); + assert.equal((result["serverInfo"] as Record)["name"], "nixamp"); + + const listed = await handleMessage({ jsonrpc: "2.0", id: 2, method: "tools/list" }); + const tools = (listed?.["result"] as { tools: { name: string }[] }).tools; + assert.deepEqual( + tools.map((tool) => tool.name).sort(), + ["watch_parties_list", "watch_party_end", "watch_party_get", "watch_party_host", "watch_party_sync"], + ); + // Every tool says what it takes, or a client cannot call it. + for (const tool of TOOLS) assert.equal((tool.inputSchema as { type: string }).type, "object"); +}); + +test("a notification is answered with silence, and an unknown method with an error", async () => { + assert.equal(await handleMessage({ jsonrpc: "2.0", method: "notifications/initialized" }), null); + const unknown = await handleMessage({ jsonrpc: "2.0", id: 3, method: "resources/list" }); + assert.equal((unknown?.["error"] as { code: number }).code, -32601); + const missing = await handleMessage({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "nope" } }); + assert.equal((missing?.["error"] as { code: number }).code, -32602); +}); + +test("an MCP tool call reaches the same endpoint the CLI does, as this machine's account", async () => { + const { calls, fetcher } = recorder({ parties: [row] }); + const answer = await callTool("watch_parties_list", {}, { session, fetcher }); + assert.equal(answer.isError, undefined); + assert.equal(calls[0]?.url, "https://nixamp.test/api/v1/watch-parties"); + assert.match(answer.content[0]?.text ?? "", /ABC123 — Dune, together/); + assert.match(answer.content[0]?.text ?? "", /playing at 15:34/); +}); + +test("an MCP tool says plainly when nothing is signed in, rather than 401ing quietly", async () => { + const { calls, fetcher } = recorder({}); + const answer = await callTool("watch_party_get", { code: "ABC123" }, { session: null, fetcher }); + assert.equal(answer.isError, true); + assert.match(answer.content[0]?.text ?? "", /nixamp login/); + assert.equal(calls.length, 0); +}); + +test("an MCP sync will not send a position that is not one", async () => { + const { calls, fetcher } = recorder(row); + const answer = await callTool("watch_party_sync", { code: "ABC123", positionSeconds: "soon" }, { session, fetcher }); + assert.equal(answer.isError, true); + assert.equal(calls.length, 0); +}); + +test("an MCP tool carries the server's own refusal back rather than inventing one", async () => { + const { fetcher } = recorder({ error: "only the host can move everybody's playback" }, 403); + const answer = await callTool("watch_party_sync", { code: "ABC123", positionSeconds: 12 }, { session, fetcher }); + assert.equal(answer.isError, true); + assert.match(answer.content[0]?.text ?? "", /only the host/); +}); diff --git a/web/index.html b/web/index.html index 7529377..3b08026 100644 --- a/web/index.html +++ b/web/index.html @@ -220,6 +220,17 @@

Get paid

    + +

    Listening needs no account. Sign in to keep favourites, follow people, and publish.

    @@ -234,6 +245,11 @@

    Get paid

    + + +