diff --git a/README.md b/README.md index 95b1a13..3ca9492 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,15 @@ npx stoops run codex --name MyCodex # their agent Two humans, two agents, one room. +### Over a private network (Tailscale, LAN) + +```bash +npx stoops --name MyName --expose # bind to 0.0.0.0 +npx stoops join http://:7890?token= # from another machine +``` + +`--expose` makes the server reachable beyond localhost. Without it, the server only accepts connections from `127.0.0.1`. The default port is 7890 — use `--port` to change it (e.g. `--port 7891`). You can run multiple rooms on different ports — each is independent with its own participants and tokens. Make sure the port is open in your firewall (`sudo ufw allow 7890`). + ### Watch mode ```bash @@ -99,11 +108,11 @@ Share links encode permissions. The host gets admin and member links at startup. ## All commands ```bash -npx stoops [--name ] [--room ] [--port ] [--share] # host + join -npx stoops serve [--room ] [--port ] [--share] # server only -npx stoops join [--name ] [--guest] # join a room -npx stoops run claude [--name ] [--admin] [-- ] # Claude Code -npx stoops run codex [--name ] [--admin] [-- ] # Codex +npx stoops [--name ] [--room ] [--port ] [--share] [--expose] # host + join +npx stoops serve [--room ] [--port ] [--share] [--expose] # server only +npx stoops join [--name ] [--guest] # join a room +npx stoops run claude [--name ] [--admin] [-- ] # Claude Code +npx stoops run codex [--name ] [--admin] [-- ] # Codex ``` Room state auto-saves. Use `--save file.json` / `--load file.json` for a specific file. @@ -133,12 +142,59 @@ Room state auto-saves. Use `--save file.json` / `--load file.json` for a specifi ## Prerequisites -- **Node.js** 18+ +- **Node.js** 20+ - **tmux** — `brew install tmux` (macOS) / `sudo apt install tmux` (Linux) - **Claude Code** — `npm install -g @anthropic-ai/claude-code` (for `run claude`) - **Codex** — `npm install -g @openai/codex` (for `run codex`) - **cloudflared** — `brew install cloudflared` (optional, for `--share`) +## Security + +Localhost-only by default (`--expose` opts in to network access). All API calls use `Authorization: Bearer` tokens. Share links expire after 1 hour, sessions after 24 hours. CORS restricted to localhost and tunnel URL (`--cors-origin` to add more). Rate limiting on joins and messages. Input size limits enforced. + +## Run from source + +If you're working off a branch or want to run without installing from npm: + +```bash +git clone && cd stoops-cli +npm install +npm run build +``` + +**Terminal 1 — host a room on your LAN:** + +```bash +npm start -- --name Jordan --room office --expose +``` + +You'll see share links printed in the TUI: + +``` +admin: stoops join http://172.16.10.96:7890/?token= +member: stoops join http://172.16.10.96:7890/?token= +guest: stoops join http://172.16.10.96:7890/?token= +``` + +Send the member link to your team. Admin link gives kick/mute powers. Guest link is read-only. + +**Terminal 2 — connect an agent:** + +```bash +npm start -- run claude --name MyClaude +npm start -- run codex --name MyCodex +``` + +Tell the agent the join URL and it connects automatically. + +**From another machine — join the room:** + +```bash +npm start -- join http://172.16.10.96:7890/?token= --name Alice +``` + +All args after `--` are passed through, so anything from the `npx stoops` examples works the same way with `npm start --`. + ## Contributing Issues and PRs welcome. See [GitHub Issues](https://github.com/stoops-io/stoops/issues). diff --git a/package-lock.json b/package-lock.json index de4ba29..f3d26d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,13 +116,6 @@ "node": ">=6.9.0" } }, - "node_modules/@cfworker/json-schema": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", - "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "license": "MIT", - "optional": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -937,54 +930,6 @@ "@langchain/core": "^1.1.29" } }, - "node_modules/@langchain/core": { - "version": "1.1.29", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.29.tgz", - "integrity": "sha512-BPoegTtIdZX4gl2kxcSXAlLrrJFl1cxeRsk9DM/wlIuvyPrFwjWqrEK5NwF5diDt5XSArhQxIFaifGAl4F7fgw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@cfworker/json-schema": "^4.0.2", - "ansi-styles": "^5.0.0", - "camelcase": "6", - "decamelize": "1.2.0", - "js-tiktoken": "^1.0.12", - "langsmith": ">=0.5.0 <1.0.0", - "mustache": "^4.2.0", - "p-queue": "^6.6.2", - "uuid": "^11.1.0", - "zod": "^3.25.76 || ^4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@langchain/langgraph": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.2.0.tgz", - "integrity": "sha512-wyqKIzXTAfXX3L1d8R7icM+HmRQBTbuNLWtUlpRJ/JP/ux1ei/sOSt6p8f90ARoOP2iJVlM70wOBYWaGErdBlA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@langchain/langgraph-checkpoint": "^1.0.0", - "@langchain/langgraph-sdk": "~1.6.5", - "@standard-schema/spec": "1.1.0", - "uuid": "^10.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@langchain/core": "^1.1.16", - "zod": "^3.25.32 || ^4.2.0", - "zod-to-json-schema": "^3.x" - }, - "peerDependenciesMeta": { - "zod-to-json-schema": { - "optional": true - } - } - }, "node_modules/@langchain/langgraph-checkpoint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz", @@ -1016,9 +961,9 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.6.5.tgz", - "integrity": "sha512-JjprmbhgCnoNJ9DUKcvrEU+C9FfKsNGyT3ooqWxAY5Cx2qofhXmDJOpTCqqbxfDHPKG0RjTs5HgVK3WW5M6Big==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.7.2.tgz", + "integrity": "sha512-8ad5OTwqc15J/DxLNJYLn3IC2mpfow09nxJdszxhwm3KgsolGZIUV6g7m67C2p4j3cbQZD5USHt3hKEL0ahqoA==", "license": "MIT", "optional": true, "dependencies": { @@ -1028,11 +973,17 @@ "uuid": "^13.0.0" }, "peerDependencies": { + "@angular/core": "^18.0.0 || ^19.0.0 || ^20.0.0", "@langchain/core": "^1.1.16", "react": "^18 || ^19", - "react-dom": "^18 || ^19" + "react-dom": "^18 || ^19", + "svelte": "^4.0.0 || ^5.0.0", + "vue": "^3.0.0" }, "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, "@langchain/core": { "optional": true }, @@ -1041,6 +992,12 @@ }, "react-dom": { "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true } } }, @@ -1095,20 +1052,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/@langchain/langgraph/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "optional": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@langchain/mcp-adapters": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@langchain/mcp-adapters/-/mcp-adapters-1.1.3.tgz", @@ -1583,6 +1526,7 @@ "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1593,6 +1537,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2378,6 +2323,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2490,6 +2436,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2771,6 +2718,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -3445,6 +3393,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3503,6 +3452,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3612,6 +3562,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4290,6 +4241,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -4339,6 +4291,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4398,6 +4351,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4694,6 +4648,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 63a9a37..66c5e68 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "watch": "tsup --watch", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "start": "node dist/cli/index.js" }, "author": "Izzat Alsharif ", "license": "MIT", diff --git a/src/agent/remote-room-data-source.ts b/src/agent/remote-room-data-source.ts index 8059467..274863c 100644 --- a/src/agent/remote-room-data-source.ts +++ b/src/agent/remote-room-data-source.ts @@ -70,7 +70,8 @@ export class RemoteRoomDataSource implements RoomDataSource { async getMessage(id: string): Promise { try { const res = await fetch( - `${this._serverUrl}/message/${encodeURIComponent(id)}?token=${this._sessionToken}`, + `${this._serverUrl}/message/${encodeURIComponent(id)}`, + { headers: { Authorization: `Bearer ${this._sessionToken}` } }, ); if (!res.ok) return null; const data = (await res.json()) as { message: Message }; @@ -85,11 +86,13 @@ export class RemoteRoomDataSource implements RoomDataSource { limit = 10, cursor: string | null = null, ): Promise> { - const params = new URLSearchParams({ token: this._sessionToken, query, count: String(limit) }); + const params = new URLSearchParams({ query, count: String(limit) }); if (cursor) params.set("cursor", cursor); try { - const res = await fetch(`${this._serverUrl}/search?${params}`); + const res = await fetch(`${this._serverUrl}/search?${params}`, { + headers: { Authorization: `Bearer ${this._sessionToken}` }, + }); if (!res.ok) return { items: [], has_more: false, next_cursor: null }; return (await res.json()) as PaginatedResult; } catch { @@ -101,11 +104,13 @@ export class RemoteRoomDataSource implements RoomDataSource { limit = 30, cursor: string | null = null, ): Promise> { - const params = new URLSearchParams({ token: this._sessionToken, count: String(limit) }); + const params = new URLSearchParams({ count: String(limit) }); if (cursor) params.set("cursor", cursor); try { - const res = await fetch(`${this._serverUrl}/messages?${params}`); + const res = await fetch(`${this._serverUrl}/messages?${params}`, { + headers: { Authorization: `Bearer ${this._sessionToken}` }, + }); if (!res.ok) return { items: [], has_more: false, next_cursor: null }; return (await res.json()) as PaginatedResult; } catch { @@ -118,12 +123,14 @@ export class RemoteRoomDataSource implements RoomDataSource { limit = 50, cursor: string | null = null, ): Promise> { - const params = new URLSearchParams({ token: this._sessionToken, count: String(limit) }); + const params = new URLSearchParams({ count: String(limit) }); if (category) params.set("category", category); if (cursor) params.set("cursor", cursor); try { - const res = await fetch(`${this._serverUrl}/events/history?${params}`); + const res = await fetch(`${this._serverUrl}/events/history?${params}`, { + headers: { Authorization: `Bearer ${this._sessionToken}` }, + }); if (!res.ok) return { items: [], has_more: false, next_cursor: null }; return (await res.json()) as PaginatedResult; } catch { @@ -136,13 +143,13 @@ export class RemoteRoomDataSource implements RoomDataSource { replyToId?: string, image?: { url: string; mimeType: string; sizeBytes: number } | null, ): Promise { - const body: Record = { token: this._sessionToken, content }; + const body: Record = { content }; if (replyToId) body.replyTo = replyToId; if (image) body.image = image; const res = await fetch(`${this._serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${this._sessionToken}` }, body: JSON.stringify(body), }); @@ -167,8 +174,8 @@ export class RemoteRoomDataSource implements RoomDataSource { async emitEvent(event: RoomEvent): Promise { const res = await fetch(`${this._serverUrl}/event`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: this._sessionToken, event }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${this._sessionToken}` }, + body: JSON.stringify({ event }), }); if (!res.ok) { diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 70f21b6..ebe9c37 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -14,14 +14,40 @@ import type { AuthorityLevel } from "../core/types.js"; interface SessionData { participantId: string; authority: AuthorityLevel; + createdAt: number; + expiresAt: number; } +interface ShareTokenData { + authority: AuthorityLevel; + createdAt: number; + expiresAt: number; +} + +export interface TokenManagerOptions { + /** Session token TTL in ms. Default: 24 hours. */ + sessionTtlMs?: number; + /** Share token TTL in ms. Default: 1 hour. */ + shareTtlMs?: number; +} + +const DEFAULT_SESSION_TTL = 24 * 60 * 60 * 1000; // 24h +const DEFAULT_SHARE_TTL = 60 * 60 * 1000; // 1h + export class TokenManager { - /** share token hash → authority level */ - private _shareTokens = new Map(); + /** share token → share data */ + private _shareTokens = new Map(); /** session token → participant data */ private _sessionTokens = new Map(); + private _sessionTtlMs: number; + private _shareTtlMs: number; + + constructor(options?: TokenManagerOptions) { + this._sessionTtlMs = options?.sessionTtlMs ?? DEFAULT_SESSION_TTL; + this._shareTtlMs = options?.shareTtlMs ?? DEFAULT_SHARE_TTL; + } + /** * Generate a share token at the given authority tier. * Callers can only generate tokens at their own tier or below. @@ -29,25 +55,48 @@ export class TokenManager { generateShareToken(callerAuthority: AuthorityLevel, targetAuthority: AuthorityLevel): string | null { if (!canGrant(callerAuthority, targetAuthority)) return null; const token = randomBytes(16).toString("hex"); - this._shareTokens.set(token, targetAuthority); + const now = Date.now(); + this._shareTokens.set(token, { + authority: targetAuthority, + createdAt: now, + expiresAt: now + this._shareTtlMs, + }); return token; } - /** Validate a share token and return its authority level. */ + /** Validate a share token and return its authority level. Deletes if expired. */ validateShareToken(token: string): AuthorityLevel | null { - return this._shareTokens.get(token) ?? null; + const data = this._shareTokens.get(token); + if (!data) return null; + if (Date.now() > data.expiresAt) { + this._shareTokens.delete(token); + return null; + } + return data.authority; } /** Create a session token for a participant. */ createSessionToken(participantId: string, authority: AuthorityLevel): string { const token = randomBytes(16).toString("hex"); - this._sessionTokens.set(token, { participantId, authority }); + const now = Date.now(); + this._sessionTokens.set(token, { + participantId, + authority, + createdAt: now, + expiresAt: now + this._sessionTtlMs, + }); return token; } - /** Validate a session token and return participant data. */ + /** Validate a session token and return participant data. Deletes if expired. */ validateSessionToken(token: string): SessionData | null { - return this._sessionTokens.get(token) ?? null; + const data = this._sessionTokens.get(token); + if (!data) return null; + if (Date.now() > data.expiresAt) { + this._sessionTokens.delete(token); + return null; + } + return data; } /** Revoke a session token (on disconnect). */ @@ -70,6 +119,38 @@ export class TokenManager { } return null; } + + /** Rotate a session token: create new with same participant data, delete old. */ + rotateSessionToken(oldToken: string): { newToken: string; data: SessionData } | null { + const data = this._sessionTokens.get(oldToken); + if (!data) return null; + if (Date.now() > data.expiresAt) { + this._sessionTokens.delete(oldToken); + return null; + } + this._sessionTokens.delete(oldToken); + const newToken = randomBytes(16).toString("hex"); + const now = Date.now(); + const newData: SessionData = { + participantId: data.participantId, + authority: data.authority, + createdAt: now, + expiresAt: now + this._sessionTtlMs, + }; + this._sessionTokens.set(newToken, newData); + return { newToken, data: newData }; + } + + /** Sweep expired tokens from both maps. */ + pruneExpired(): void { + const now = Date.now(); + for (const [token, data] of this._shareTokens) { + if (now > data.expiresAt) this._shareTokens.delete(token); + } + for (const [token, data] of this._sessionTokens) { + if (now > data.expiresAt) this._sessionTokens.delete(token); + } + } } /** Authority tier ordering: admin > member > guest. */ diff --git a/src/cli/index.ts b/src/cli/index.ts index 3648c7d..8afea9a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -116,6 +116,8 @@ async function main(): Promise { port, share: args.includes("--share"), headless: args.includes("--headless"), + expose: args.includes("--expose"), + corsOrigins: getAllFlags("cors-origin"), save: getFlag("save"), load: getFlag("load"), }); @@ -135,6 +137,8 @@ async function main(): Promise { port, share: args.includes("--share"), quiet: true, + expose: args.includes("--expose"), + corsOrigins: getAllFlags("cors-origin"), save: getFlag("save"), load: getFlag("load"), }); diff --git a/src/cli/join.ts b/src/cli/join.ts index d81c00e..b210b3c 100644 --- a/src/cli/join.ts +++ b/src/cli/join.ts @@ -79,8 +79,9 @@ export async function join(options: JoinOptions): Promise { roomName = String(data.roomName); authority = (data.authority as AuthorityLevel) ?? "member"; participants = (data.participants as Array<{ id: string; name: string; type: string; authority?: string }>) ?? []; - } catch { + } catch (err) { console.error(`Cannot reach stoops server at ${serverUrl}. Is it running?`); + console.error(` Error: ${err instanceof Error ? err.message : err}`); process.exit(1); } @@ -95,8 +96,8 @@ export async function join(options: JoinOptions): Promise { try { await fetch(`${serverUrl}/disconnect`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({}), }); } catch { // Server may be down @@ -123,8 +124,8 @@ export async function join(options: JoinOptions): Promise { try { await fetch(`${serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, content }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ content }), }); } catch { /* server may be down */ } }); @@ -194,7 +195,7 @@ export async function join(options: JoinOptions): Promise { // ── /who ────────────────────────────────────────────────────── case "who": { try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string; type: string; authority?: string }> }; const lines = data.participants.map((p) => { @@ -224,7 +225,7 @@ export async function join(options: JoinOptions): Promise { // Look up participant by name try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string }> }; const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase()); @@ -232,8 +233,8 @@ export async function join(options: JoinOptions): Promise { const kickRes = await fetch(`${serverUrl}/kick`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, participantId: target.id }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ participantId: target.id }), }); if (!kickRes.ok) { systemEvent(`Failed to kick: ${await kickRes.text()}`); return; } systemEvent(`Kicked ${targetName}.`); @@ -250,7 +251,7 @@ export async function join(options: JoinOptions): Promise { if (!targetName) { systemEvent("Usage: /mute "); return; } try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string }> }; const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase()); @@ -258,8 +259,8 @@ export async function join(options: JoinOptions): Promise { const authRes = await fetch(`${serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, participantId: target.id, authority: "guest" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ participantId: target.id, authority: "guest" }), }); if (!authRes.ok) { systemEvent(`Failed to mute: ${await authRes.text()}`); return; } systemEvent(`Muted ${targetName} (guest).`); @@ -276,7 +277,7 @@ export async function join(options: JoinOptions): Promise { if (!targetName) { systemEvent("Usage: /unmute "); return; } try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string }> }; const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase()); @@ -284,8 +285,8 @@ export async function join(options: JoinOptions): Promise { const authRes = await fetch(`${serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, participantId: target.id, authority: "member" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ participantId: target.id, authority: "member" }), }); if (!authRes.ok) { systemEvent(`Failed to unmute: ${await authRes.text()}`); return; } systemEvent(`Unmuted ${targetName} (member).`); @@ -303,7 +304,7 @@ export async function join(options: JoinOptions): Promise { if (!targetName || !mode) { systemEvent("Usage: /setmode "); return; } try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string }> }; const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase()); @@ -311,8 +312,8 @@ export async function join(options: JoinOptions): Promise { const modeRes = await fetch(`${serverUrl}/set-mode`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, participantId: target.id, mode }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ participantId: target.id, mode }), }); if (!modeRes.ok) { systemEvent(`Failed to set mode: ${await modeRes.text()}`); return; } systemEvent(`Set ${targetName} to ${mode}.`); @@ -332,13 +333,13 @@ export async function join(options: JoinOptions): Promise { } try { - const body: Record = { token: sessionToken }; - if (targetAuthority) body.authority = targetAuthority; + const shareBody: Record = {}; + if (targetAuthority) shareBody.authority = targetAuthority; const res = await fetch(`${serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify(shareBody), }); if (!res.ok) { systemEvent(`Failed: ${await res.text()}`); return; } const data = (await res.json()) as { links: Record }; @@ -382,8 +383,8 @@ export async function join(options: JoinOptions): Promise { try { await fetch(`${serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, content }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ content }), }); } catch { // Server may be down — silently fail diff --git a/src/cli/runtime-setup.ts b/src/cli/runtime-setup.ts index 7cbf529..c2a8fc6 100644 --- a/src/cli/runtime-setup.ts +++ b/src/cli/runtime-setup.ts @@ -103,8 +103,8 @@ export async function setupAgentRuntime(options: AgentRuntimeOptions): Promise { const roomName = options.room ?? randomRoomName(); const port = options.port ?? 7890; - const serverUrl = `http://127.0.0.1:${port}`; + const lanIp = options.expose ? getLanIp() : null; + const serverUrl = `http://${lanIp ?? "127.0.0.1"}:${port}`; const log = options.headless ? () => {} : logServer; let publicUrl = serverUrl; let tunnelProcess: ChildProcess | null = null; + // Validate save/load paths + if (options.save) options.save = validateSavePath(options.save); + if (options.load) options.load = validateSavePath(options.load); + // Create room with persistence (default: tmp folder) const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); // YYYY-MM-DDTHH-MM-SS const savePath = options.save ?? options.load ?? pathJoin(tmpdir(), `stoops-${roomName}-${timestamp}.json`); @@ -129,6 +157,13 @@ export async function serve(options: ServeOptions): Promise { // ── Auth helper ────────────────────────────────────────────────────────── + function extractSessionToken(req: IncomingMessage, url: URL, body?: Record): string | null { + const h = req.headers.authorization; + if (h?.startsWith("Bearer ")) return h.slice(7); + // Fallback: query param / body (deprecated) + return url.searchParams.get("token") ?? (body?.token ? String(body.token) : null); + } + function getSession(token: string | null) { if (!token) return null; const p = participants.get(token); @@ -138,6 +173,11 @@ export async function serve(options: ServeOptions): Promise { return null; } + function clampInt(val: string | null, def: number, min: number, max: number): number { + const n = parseInt(val ?? String(def), 10); + return isNaN(n) ? def : Math.max(min, Math.min(max, n)); + } + function jsonError(res: ServerResponse, status: number, error: string): void { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error })); @@ -148,17 +188,103 @@ export async function serve(options: ServeOptions): Promise { res.end(JSON.stringify({ ok: true, ...data })); } + // ── CORS helper ──────────────────────────────────────────────────────── + + const allowedOrigins = new Set([ + `http://127.0.0.1:${port}`, + `http://localhost:${port}`, + ...(options.corsOrigins ?? []), + ]); + + function addCorsOrigin(origin: string): void { + allowedOrigins.add(origin); + } + + function getCorsHeaders(req: IncomingMessage): Record { + const origin = req.headers.origin; + if (!origin) return {}; + if (allowedOrigins.has(origin)) { + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Accept", + "Access-Control-Max-Age": "86400", + Vary: "Origin", + }; + } + return {}; + } + + // ── Rate limiter ──────────────────────────────────────────────────────── + + class RateLimiter { + private _buckets = new Map(); + constructor(private _max: number, private _windowMs: number) {} + check(key: string): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + const bucket = this._buckets.get(key); + if (!bucket || now >= bucket.resetAt) { + this._buckets.set(key, { count: 1, resetAt: now + this._windowMs }); + return { allowed: true }; + } + if (bucket.count >= this._max) { + return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) }; + } + bucket.count++; + return { allowed: true }; + } + } + + const joinLimiter = new RateLimiter(10, 60_000); // 10/min per IP + const messageLimiter = new RateLimiter(30, 60_000); // 30/min per session + const shareLimiter = new RateLimiter(10, 60_000); // 10/min per session + const getLimiter = new RateLimiter(60, 60_000); // 60/min per session + const sseLimiter = new RateLimiter(5, 60_000); // 5/min per IP + + function getClientIp(req: IncomingMessage): string { + return req.socket.remoteAddress ?? "unknown"; + } + + function rateLimitError(res: ServerResponse, retryAfter: number): void { + res.writeHead(429, { + "Content-Type": "application/json", + "Retry-After": String(retryAfter), + }); + res.end(JSON.stringify({ error: "Too many requests" })); + } + // ── HTTP API ──────────────────────────────────────────────────────────── const httpServer = createServer(async (req, res) => { const url = new URL(req.url ?? "/", `http://localhost:${port}`); + // ── CORS preflight ─────────────────────────────────────────────────── + if (req.method === "OPTIONS") { + const corsHeaders = getCorsHeaders(req); + if (Object.keys(corsHeaders).length > 0) { + res.writeHead(204, corsHeaders); + } else { + res.writeHead(204); + } + res.end(); + return; + } + + // Apply CORS headers to all responses + const corsHeaders = getCorsHeaders(req); + for (const [k, v] of Object.entries(corsHeaders)) { + res.setHeader(k, v); + } + // ── SSE event stream ─────────────────────────────────────────────────── // ⚠️ MUST accept POST — DO NOT change to GET-only. // Cloudflare Quick Tunnels buffer GET streaming responses and only flush // when the connection closes. POST streams in real-time. (cloudflared#1449) // https://github.com/cloudflare/cloudflared/issues/1449 if (url.pathname === "/events" && (req.method === "GET" || req.method === "POST")) { + const sseCheck = sseLimiter.check(getClientIp(req)); + if (!sseCheck.allowed) return rateLimitError(res, sseCheck.retryAfter!); + const authHeader = req.headers.authorization; const sessionToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) @@ -175,7 +301,6 @@ export async function serve(options: ServeOptions): Promise { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", }); res.flushHeaders(); @@ -187,6 +312,12 @@ export async function serve(options: ServeOptions): Promise { sseConnections.set(session.id, res); + // Heartbeat every 30s to keep the connection alive through + // proxies, firewalls, and OS-level TCP idle timeouts. + const heartbeat = setInterval(() => { + res.write(":heartbeat\n\n"); + }, 30_000); + // Send recent history so the joiner has context const history = await room.listEvents(undefined, 50); for (const event of [...history.items].reverse()) { @@ -207,6 +338,7 @@ export async function serve(options: ServeOptions): Promise { // Cleanup on client disconnect req.on("close", () => { + clearInterval(heartbeat); sseConnections.delete(session.id); }); return; @@ -215,8 +347,12 @@ export async function serve(options: ServeOptions): Promise { // ── GET endpoints ───────────────────────────────────────────────────── if (req.method === "GET") { - const sessionToken = url.searchParams.get("token"); + const sessionToken = extractSessionToken(req, url); const session = getSession(sessionToken); + if (sessionToken) { + const getCheck = getLimiter.check(sessionToken); + if (!getCheck.allowed) return rateLimitError(res, getCheck.retryAfter!); + } // ── GET /participants ──────────────────────────────────────────────── if (url.pathname === "/participants") { @@ -246,7 +382,7 @@ export async function serve(options: ServeOptions): Promise { // ── GET /messages ──────────────────────────────────────────────────── if (url.pathname === "/messages") { if (!session) return jsonError(res, 401, "Invalid session token"); - const count = parseInt(url.searchParams.get("count") ?? "30", 10); + const count = clampInt(url.searchParams.get("count"), 30, 1, 100); const cursor = url.searchParams.get("cursor") ?? null; const result = await room.listMessages(count, cursor); res.writeHead(200, { "Content-Type": "application/json" }); @@ -258,7 +394,7 @@ export async function serve(options: ServeOptions): Promise { if (url.pathname === "/events/history") { if (!session) return jsonError(res, 401, "Invalid session token"); const category = url.searchParams.get("category") ?? null; - const count = parseInt(url.searchParams.get("count") ?? "50", 10); + const count = clampInt(url.searchParams.get("count"), 50, 1, 200); const cursor = url.searchParams.get("cursor") ?? null; const result = await room.listEvents(category as any, count, cursor); res.writeHead(200, { "Content-Type": "application/json" }); @@ -271,7 +407,7 @@ export async function serve(options: ServeOptions): Promise { if (!session) return jsonError(res, 401, "Invalid session token"); const query = url.searchParams.get("query") ?? ""; if (!query) return jsonError(res, 400, "Missing query parameter"); - const count = parseInt(url.searchParams.get("count") ?? "10", 10); + const count = clampInt(url.searchParams.get("count"), 10, 1, 50); const cursor = url.searchParams.get("cursor") ?? null; const result = await room.searchMessages(query, count, cursor); res.writeHead(200, { "Content-Type": "application/json" }); @@ -287,8 +423,11 @@ export async function serve(options: ServeOptions): Promise { // ── POST /join ────────────────────────────────────────────────────── if (url.pathname === "/join") { - // Accept share token OR legacy type-based join - const shareToken = String(body.token ?? ""); + const joinCheck = joinLimiter.check(getClientIp(req)); + if (!joinCheck.allowed) return rateLimitError(res, joinCheck.retryAfter!); + + // Accept share token (body.shareToken preferred, body.token as fallback) + const shareToken = String(body.shareToken ?? body.token ?? ""); const legacyType = String(body.type ?? ""); let authority: AuthorityLevel; @@ -367,16 +506,21 @@ export async function serve(options: ServeOptions): Promise { // ── All remaining POST endpoints require a session token ──────────── - const sessionToken = String(body.token ?? ""); + const sessionToken = extractSessionToken(req, url, body) ?? ""; const session = getSession(sessionToken); // ── POST /message ─────────────────────────────────────────────────── if (url.pathname === "/message") { if (!session) return jsonError(res, 401, "Invalid session token"); + if (sessionToken) { + const msgCheck = messageLimiter.check(sessionToken); + if (!msgCheck.allowed) return rateLimitError(res, msgCheck.retryAfter!); + } if (session.authority === "guest") return jsonError(res, 403, "Guests cannot send messages"); const content = String(body.content ?? ""); const replyTo = body.replyTo ? String(body.replyTo) : undefined; if (!content) return jsonError(res, 400, "Empty message"); + if (content.length > 50_000) return jsonError(res, 400, "Message too long (max 50000 chars)"); const p = participants.get(sessionToken); if (!p) return jsonError(res, 403, "Not a participant"); @@ -520,6 +664,10 @@ export async function serve(options: ServeOptions): Promise { // ── POST /share ───────────────────────────────────────────────────── if (url.pathname === "/share") { if (!session) return jsonError(res, 401, "Invalid session token"); + if (sessionToken) { + const shareCheck = shareLimiter.check(sessionToken); + if (!shareCheck.allowed) return rateLimitError(res, shareCheck.retryAfter!); + } if (session.authority === "guest") return jsonError(res, 403, "Guests cannot create share links"); const targetAuthority = (body.authority as AuthorityLevel) ?? undefined; @@ -545,10 +693,37 @@ export async function serve(options: ServeOptions): Promise { return; } + // ── POST /rotate-token ────────────────────────────────────────────── + if (url.pathname === "/rotate-token") { + if (!session || !sessionToken) return jsonError(res, 401, "Invalid session token"); + const result = tokens.rotateSessionToken(sessionToken); + if (!result) return jsonError(res, 401, "Token expired or invalid"); + + // Move participant/guest entry to new token + const p = participants.get(sessionToken); + if (p) { + participants.delete(sessionToken); + p.sessionToken = result.newToken; + participants.set(result.newToken, p); + idToSession.set(p.id, result.newToken); + } + const g = guests.get(sessionToken); + if (g) { + guests.delete(sessionToken); + g.sessionToken = result.newToken; + guests.set(result.newToken, g); + idToSession.set(g.id, result.newToken); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ sessionToken: result.newToken })); + return; + } + // ── POST /disconnect ──────────────────────────────────────────────── if (url.pathname === "/disconnect") { - // Accept either session token or legacy participantId/agentId - const token = String(body.token ?? ""); + // Accept Authorization header, body.token, or legacy participantId/agentId + const token = extractSessionToken(req, url, body) ?? ""; const legacyId = String(body.participantId ?? body.agentId ?? ""); let targetToken = token; @@ -598,15 +773,22 @@ export async function serve(options: ServeOptions): Promise { }); await new Promise((resolve) => { - httpServer.listen(port, "0.0.0.0", () => resolve()); + httpServer.listen(port, options.expose ? "0.0.0.0" : "127.0.0.1", () => resolve()); }); + // Prune expired tokens every 5 minutes + const pruneInterval = setInterval(() => tokens.pruneExpired(), 5 * 60 * 1000); + pruneInterval.unref(); + // Start tunnel if --share if (options.share) { tunnelProcess = await startTunnel(port); if (tunnelProcess) { const tunnelUrl = await waitForTunnelUrl(tunnelProcess); - if (tunnelUrl) publicUrl = tunnelUrl; + if (tunnelUrl) { + publicUrl = tunnelUrl; + addCorsOrigin(tunnelUrl); + } } } @@ -614,8 +796,14 @@ export async function serve(options: ServeOptions): Promise { const adminToken = tokens.generateShareToken("admin", "admin")!; const memberToken = tokens.generateShareToken("admin", "member")!; + function obfuscate(token: string): string { + if (token.length <= 8) return "****"; + return token.slice(0, 4) + "..." + token.slice(-4); + } + if (options.headless) { process.stdout.write(JSON.stringify({ serverUrl, publicUrl, roomName, adminToken, memberToken, savePath }) + "\n"); + process.stderr.write("Warning: raw tokens in stdout — do not share this output.\n"); } else if (!options.quiet) { let version = process.env.npm_package_version ?? ""; if (!version) { @@ -627,8 +815,8 @@ export async function serve(options: ServeOptions): Promise { version = "unknown"; } } - const adminUrl = buildShareUrl(publicUrl, adminToken); - const joinUrl = buildShareUrl(publicUrl, memberToken); + const adminUrlObfuscated = buildShareUrl(publicUrl, obfuscate(adminToken)); + const joinUrlObfuscated = buildShareUrl(publicUrl, obfuscate(memberToken)); console.log(` stoops v${version} @@ -637,10 +825,12 @@ export async function serve(options: ServeOptions): Promise { Server: ${serverUrl}${publicUrl !== serverUrl ? `\n Tunnel: ${publicUrl}` : ""} Saving: ${savePath} - Join: stoops join ${joinUrl} - Admin: stoops join ${adminUrl} - Claude: stoops run claude --name MyClaude → then tell agent to join: ${joinUrl} - Codex: stoops run codex --name MyCodex → then tell agent to join: ${joinUrl} + Join: stoops join ${joinUrlObfuscated} + Admin: stoops join ${adminUrlObfuscated} + Claude: stoops run claude --name MyClaude → then tell agent to join (use /share in TUI for full URL) + Codex: stoops run codex --name MyCodex → then tell agent to join (use /share in TUI for full URL) + + Use /share in the TUI or --headless to get full join URLs. `); } diff --git a/src/core/storage.ts b/src/core/storage.ts index b5fa9f9..9fc1d42 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -41,7 +41,8 @@ */ import { writeFile, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { resolve, sep } from "node:path"; +import { tmpdir } from "node:os"; import type { RoomEvent } from "./events.js"; import type { EventCategory, Message, PaginatedResult } from "./types.js"; @@ -254,6 +255,12 @@ export class FileBackedStorage extends InMemoryStorage { constructor(filePath: string) { super(); this._filePath = resolve(filePath); + // Defense-in-depth: ensure path is under cwd or tmp + const cwd = process.cwd(); + const tmp = tmpdir(); + if (!this._filePath.startsWith(cwd + sep) && !this._filePath.startsWith(tmp + sep)) { + throw new Error(`FileBackedStorage path must be under ${cwd} or ${tmp}`); + } } async addMessage(message: Message): Promise { diff --git a/src/langgraph/langchain.d.ts b/src/langgraph/langchain.d.ts new file mode 100644 index 0000000..b313eb7 --- /dev/null +++ b/src/langgraph/langchain.d.ts @@ -0,0 +1,29 @@ +// Ambient module declarations for optional @langchain/* dependencies. +// These are dynamically imported at runtime and validated in start(). +// The stubs let the DTS build succeed without the packages installed. + +declare module "@langchain/langgraph" { + export const StateGraph: any; + export const MemorySaver: any; + export const MessagesValue: any; + export const StateSchema: any; + export const START: any; + export const END: any; +} + +declare module "@langchain/langgraph/prebuilt" { + export const ToolNode: any; +} + +declare module "langchain/chat_models/universal" { + export function initChatModel(...args: any[]): any; +} + +declare module "@langchain/mcp-adapters" { + export const MultiServerMCPClient: any; +} + +declare module "@langchain/core/messages" { + export const HumanMessage: any; + export const SystemMessage: any; +} diff --git a/tests/integration.test.ts b/tests/integration.test.ts index a43fbc1..fe3b627 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -121,8 +121,8 @@ async function httpSend( ): Promise<{ ok: boolean; messageId?: string }> { const res = await fetch(`${serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, content }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ content }), }); return res.json() as Promise<{ ok: boolean; messageId?: string }>; } @@ -130,8 +130,8 @@ async function httpSend( async function httpDisconnect(serverUrl: string, sessionToken: string): Promise { await fetch(`${serverUrl}/disconnect`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({}), }).catch(() => {}); } @@ -139,7 +139,9 @@ async function httpParticipants( serverUrl: string, sessionToken: string, ): Promise> { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { + headers: { Authorization: `Bearer ${sessionToken}` }, + }); if (!res.ok) throw new Error(`Failed to get participants: ${await res.text()}`); const data = (await res.json()) as { participants: Array<{ id: string; name: string; type: string; authority?: string }> }; return data.participants; @@ -297,8 +299,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const admin = await httpJoin(server.serverUrl, server.adminToken, { name: "Admin" }); const shareRes = await fetch(`${server.serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: admin.sessionToken, authority: "guest" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, + body: JSON.stringify({ authority: "guest" }), }); const shareData = (await shareRes.json()) as { links: Record }; const guestUrl = shareData.links.guest; @@ -319,7 +321,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { // Search for the message const searchRes = await fetch( - `${server.serverUrl}/search?token=${alice.sessionToken}&query=unique123`, + `${server.serverUrl}/search?query=unique123`, + { headers: { Authorization: `Bearer ${alice.sessionToken}` } }, ); const searchData = (await searchRes.json()) as { items: Array<{ content: string }> }; expect(searchData.items.length).toBeGreaterThanOrEqual(1); @@ -352,8 +355,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const admin = await httpJoin(server.serverUrl, server.adminToken, { name: "Admin" }); const shareRes = await fetch(`${server.serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: admin.sessionToken, authority: "guest" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, + body: JSON.stringify({ authority: "guest" }), }); const shareData = (await shareRes.json()) as { links: Record }; const guestToken = new URL(shareData.links.guest).searchParams.get("token")!; @@ -363,8 +366,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const sendRes = await fetch(`${server.serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: obs.sessionToken, content: "should fail" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${obs.sessionToken}` }, + body: JSON.stringify({ content: "should fail" }), }); expect(sendRes.status).toBe(403); }, 15_000); @@ -380,8 +383,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const kickRes = await fetch(`${server.serverUrl}/kick`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: alice.sessionToken, participantId: bob.participantId }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ participantId: bob.participantId }), }); expect(kickRes.status).toBe(403); }, 15_000); @@ -397,8 +400,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const kickRes = await fetch(`${server.serverUrl}/kick`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: admin.sessionToken, participantId: target.participantId }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, + body: JSON.stringify({ participantId: target.participantId }), }); expect(kickRes.ok).toBe(true); @@ -418,17 +421,16 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { // Verify Alice can send before mute const send1 = await fetch(`${server.serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: alice.sessionToken, content: "before mute" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ content: "before mute" }), }); expect(send1.ok).toBe(true); // Mute Alice (demote to guest) const muteRes = await fetch(`${server.serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, body: JSON.stringify({ - token: admin.sessionToken, participantId: alice.participantId, authority: "guest", }), @@ -438,17 +440,16 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { // Alice should now be blocked from sending const send2 = await fetch(`${server.serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: alice.sessionToken, content: "should fail" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ content: "should fail" }), }); expect(send2.status).toBe(403); // Unmute Alice (restore to member) const unmuteRes = await fetch(`${server.serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, body: JSON.stringify({ - token: admin.sessionToken, participantId: alice.participantId, authority: "member", }), @@ -458,8 +459,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { // Alice should be able to send again const send3 = await fetch(`${server.serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: alice.sessionToken, content: "after unmute" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ content: "after unmute" }), }); expect(send3.ok).toBe(true); }, 15_000); @@ -491,8 +492,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const admin = await httpJoin(server.serverUrl, server.adminToken, { name: "Admin" }); const adminShareRes = await fetch(`${server.serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: admin.sessionToken }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, + body: JSON.stringify({}), }); const adminLinks = ((await adminShareRes.json()) as { links: Record }).links; expect(adminLinks.admin).toBeTruthy(); @@ -503,8 +504,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const part = await httpJoin(server.serverUrl, server.memberToken, { name: "Part" }); const partShareRes = await fetch(`${server.serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: part.sessionToken }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${part.sessionToken}` }, + body: JSON.stringify({}), }); const partLinks = ((await partShareRes.json()) as { links: Record }).links; expect(partLinks.admin).toBeUndefined(); @@ -543,9 +544,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const res = await fetch(`${server.serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, body: JSON.stringify({ - token: admin.sessionToken, participantId: admin.participantId, authority: "guest", }), @@ -564,9 +564,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const res = await fetch(`${server.serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, body: JSON.stringify({ - token: alice.sessionToken, participantId: bob.participantId, authority: "guest", }), diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..5954489 --- /dev/null +++ b/tests/security.test.ts @@ -0,0 +1,278 @@ +/** + * Security tests for stoops — exercises hardening features. + * + * Requires a built CLI at dist/cli/index.js. Run `npm run build` first. + */ + +import { describe, test, expect, afterEach } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { TokenManager } from "../src/cli/auth.js"; + +const CLI_PATH = resolve(__dirname, "../dist/cli/index.js"); +const NODE = process.execPath; + +const HAS_BUILD = existsSync(CLI_PATH); + +// ── Test helpers (duplicated from integration for isolation) ───────────── + +interface ServerHandle { + process: ChildProcess; + serverUrl: string; + publicUrl: string; + roomName: string; + adminToken: string; + memberToken: string; + cleanup: () => void; +} + +let nextPort = 19900 + Math.floor(Math.random() * 100); + +function getPort(): number { + return nextPort++; +} + +async function startServer(opts?: { port?: number; extraArgs?: string[] }): Promise { + const port = opts?.port ?? getPort(); + const args = ["serve", "--headless", "--port", String(port), ...(opts?.extraArgs ?? [])]; + + const child = spawn(NODE, [CLI_PATH, ...args], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Server startup timed out")), 10_000); + let buffer = ""; + + child.stdout!.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const newlineIdx = buffer.indexOf("\n"); + if (newlineIdx === -1) return; + const line = buffer.slice(0, newlineIdx); + + try { + const data = JSON.parse(line); + clearTimeout(timer); + resolve({ + process: child, + serverUrl: data.serverUrl, + publicUrl: data.publicUrl, + roomName: data.roomName, + adminToken: data.adminToken, + memberToken: data.memberToken, + cleanup: () => { child.kill("SIGTERM"); }, + }); + } catch { + clearTimeout(timer); + reject(new Error(`Failed to parse server output: ${line}`)); + } + }); + + child.on("error", (err) => { clearTimeout(timer); reject(err); }); + child.on("exit", (code) => { clearTimeout(timer); reject(new Error(`Server exited with code ${code}`)); }); + }); +} + +interface JoinResponse { + sessionToken: string; + participantId: string; + roomName: string; + roomId: string; + authority: string; +} + +async function httpJoin( + serverUrl: string, + token: string, + opts?: { name?: string; type?: string }, +): Promise { + const body: Record = { token }; + if (opts?.name) body.name = opts.name; + if (opts?.type) body.type = opts.type; + + const res = await fetch(`${serverUrl}/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`Join failed: ${await res.text()}`); + return res.json() as Promise; +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe.skipIf(!HAS_BUILD)("Security", () => { + const servers: ServerHandle[] = []; + + afterEach(async () => { + for (const s of servers.splice(0)) s.cleanup(); + await new Promise((r) => setTimeout(r, 100)); + }); + + // ── Token expiry ─────────────────────────────────────────────────────── + + test("TokenManager: expired session token is rejected", () => { + const tm = new TokenManager({ sessionTtlMs: 1, shareTtlMs: 1 }); + const token = tm.createSessionToken("p1", "member"); + // Token has 1ms TTL — wait a bit + const start = Date.now(); + while (Date.now() - start < 5) { /* spin */ } + expect(tm.validateSessionToken(token)).toBeNull(); + }); + + test("TokenManager: expired share token is rejected", () => { + const tm = new TokenManager({ sessionTtlMs: 60_000, shareTtlMs: 1 }); + const token = tm.generateShareToken("admin", "member")!; + expect(token).toBeTruthy(); + const start = Date.now(); + while (Date.now() - start < 5) { /* spin */ } + expect(tm.validateShareToken(token)).toBeNull(); + }); + + // ── Token rotation ──────────────────────────────────────────────────── + + test("TokenManager: rotated token works, old token rejected", () => { + const tm = new TokenManager(); + const oldToken = tm.createSessionToken("p1", "member"); + expect(tm.validateSessionToken(oldToken)).toBeTruthy(); + + const result = tm.rotateSessionToken(oldToken); + expect(result).toBeTruthy(); + expect(result!.newToken).not.toBe(oldToken); + + // Old token should be gone + expect(tm.validateSessionToken(oldToken)).toBeNull(); + // New token should work + expect(tm.validateSessionToken(result!.newToken)).toBeTruthy(); + expect(tm.validateSessionToken(result!.newToken)!.participantId).toBe("p1"); + }); + + // ── Token pruning ───────────────────────────────────────────────────── + + test("TokenManager: pruneExpired removes expired tokens", () => { + const tm = new TokenManager({ sessionTtlMs: 1, shareTtlMs: 1 }); + tm.createSessionToken("p1", "member"); + tm.generateShareToken("admin", "member"); + const start = Date.now(); + while (Date.now() - start < 5) { /* spin */ } + tm.pruneExpired(); + // After pruning, findSessionByParticipant should return null + expect(tm.findSessionByParticipant("p1")).toBeNull(); + }); + + // ── Integer bounds ──────────────────────────────────────────────────── + + test("count parameter is clamped to max", async () => { + const server = await startServer(); + servers.push(server); + + const alice = await httpJoin(server.serverUrl, server.memberToken, { name: "Alice" }); + + // count=999999 should be clamped, not error + const res = await fetch( + `${server.serverUrl}/messages?count=999999`, + { headers: { Authorization: `Bearer ${alice.sessionToken}` } }, + ); + expect(res.ok).toBe(true); + }, 15_000); + + // ── Message length ──────────────────────────────────────────────────── + + test("message >50k chars is rejected", async () => { + const server = await startServer(); + servers.push(server); + + const alice = await httpJoin(server.serverUrl, server.memberToken, { name: "Alice" }); + const longContent = "x".repeat(50_001); + + const res = await fetch(`${server.serverUrl}/message`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ content: longContent }), + }); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: string }; + expect(data.error).toContain("too long"); + }, 15_000); + + // ── Rate limiting ───────────────────────────────────────────────────── + + test("burst of join requests returns 429", async () => { + const server = await startServer(); + servers.push(server); + + // 10 joins should be fine (limit is 10/min), 11th should fail + const results: number[] = []; + for (let i = 0; i < 12; i++) { + const res = await fetch(`${server.serverUrl}/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: server.memberToken, name: `User${i}`, type: "human" }), + }); + results.push(res.status); + } + + expect(results.filter((s) => s === 200).length).toBe(10); + expect(results.filter((s) => s === 429).length).toBeGreaterThanOrEqual(1); + }, 15_000); + + // ── Backward compat: query param auth still works ───────────────────── + + test("legacy ?token= query param still works for GET", async () => { + const server = await startServer(); + servers.push(server); + + const alice = await httpJoin(server.serverUrl, server.memberToken, { name: "Alice" }); + + // Legacy style: token in query param + const res = await fetch(`${server.serverUrl}/participants?token=${alice.sessionToken}`); + expect(res.ok).toBe(true); + const data = (await res.json()) as { participants: unknown[] }; + expect(data.participants.length).toBeGreaterThanOrEqual(1); + }, 15_000); + + // ── Token rotation via HTTP endpoint ─────────────────────────────────── + + test("POST /rotate-token returns new token, old token rejected", async () => { + const server = await startServer(); + servers.push(server); + + const alice = await httpJoin(server.serverUrl, server.memberToken, { name: "Alice" }); + const oldToken = alice.sessionToken; + + // Rotate + const rotateRes = await fetch(`${server.serverUrl}/rotate-token`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${oldToken}` }, + body: JSON.stringify({}), + }); + expect(rotateRes.ok).toBe(true); + const rotateData = (await rotateRes.json()) as { sessionToken: string }; + expect(rotateData.sessionToken).toBeTruthy(); + expect(rotateData.sessionToken).not.toBe(oldToken); + + // New token works + const listRes = await fetch(`${server.serverUrl}/participants`, { + headers: { Authorization: `Bearer ${rotateData.sessionToken}` }, + }); + expect(listRes.ok).toBe(true); + + // Old token rejected + const oldRes = await fetch(`${server.serverUrl}/participants`, { + headers: { Authorization: `Bearer ${oldToken}` }, + }); + expect(oldRes.status).toBe(401); + }, 15_000); + + // ── Bind address ─────────────────────────────────────────────────────── + + test("server binds to 127.0.0.1 by default", async () => { + const server = await startServer(); + servers.push(server); + + // serverUrl should be 127.0.0.1 + expect(server.serverUrl).toContain("127.0.0.1"); + }, 15_000); +});