diff --git a/.gitignore b/.gitignore index 3de77ac..7c7805f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,12 +46,4 @@ logs/ dist/ .direnv/ -# Flutter -flutter_client/.dart_tool/ -flutter_client/.flutter-plugins -flutter_client/.flutter-plugins-dependencies -flutter_client/build/ -flutter_client/.packages -flutter_client/pubspec.lock -flutter_client/flutter_02.png pair-link.txt diff --git a/.prettierignore b/.prettierignore index ffdc0ed..f40cb3b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ node_modules dist logs pnpm-lock.yaml +flutter_client diff --git a/client/src/components/GitStatus.tsx b/client/src/components/GitStatus.tsx index afcbc80..a4b13a3 100644 --- a/client/src/components/GitStatus.tsx +++ b/client/src/components/GitStatus.tsx @@ -19,6 +19,13 @@ interface GitStatusData { branches: string[]; } +interface PrData { + url: string; + number: number; + title: string; + state: string; +} + // Git status code to color/label function fileStatusColor(status: string): string { if (status.includes("M")) return "text-yellow-300"; @@ -64,6 +71,27 @@ export default function GitStatus({ const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [deleting, setDeleting] = useState(false); + const [pr, setPr] = useState(null); + + const fetchPr = useCallback(async () => { + if (!projectId) { + setPr(null); + return; + } + try { + const res = await apiFetch( + `/api/projects/${encodeURIComponent(projectId)}/pr`, + { serverId, serverUrl }, + ); + if (!res.ok) { + setPr(null); + return; + } + setPr(await res.json()); + } catch { + setPr(null); + } + }, [projectId, serverId, serverUrl]); const fetchStatus = useCallback(async () => { if (!projectId) { @@ -100,14 +128,18 @@ export default function GitStatus({ // Fetch on mount and when projectId changes useEffect(() => { fetchStatus(); - }, [fetchStatus]); + fetchPr(); + }, [fetchStatus, fetchPr]); // Refresh periodically (every 30s) useEffect(() => { if (!projectId) return; - const interval = setInterval(fetchStatus, 30000); + const interval = setInterval(() => { + fetchStatus(); + fetchPr(); + }, 30000); return () => clearInterval(interval); - }, [projectId, fetchStatus]); + }, [projectId, fetchStatus, fetchPr]); // Reset worktree create state when dropdown closes useEffect(() => { @@ -195,7 +227,7 @@ export default function GitStatus({ } return ( -
+
+ {pr && ( + + + + + #{pr.number} + + )} {/* Expanded details dropdown */} {expanded && ( @@ -320,6 +369,47 @@ export default function GitStatus({ )}
)} + + {/* PR link */} + {pr && ( +
+ + + + e.stopPropagation()} + className="inline-flex items-center gap-1 text-sm text-[var(--color-accent)] hover:text-[#d97a5a] underline decoration-[var(--color-accent-muted)] hover:decoration-[#d97a5a] underline-offset-2" + > + PR #{pr.number} + + + + + {pr.state !== "OPEN" && ( + + {pr.state.toLowerCase()} + + )} +
+ )}
{/* Changed files list */} @@ -352,6 +442,7 @@ export default function GitStatus({ onClick={(e) => { e.stopPropagation(); fetchStatus(); + fetchPr(); }} className="w-full flex items-center justify-center gap-2 px-3 py-1.5 text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)] rounded transition-colors" > diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 00948b8..de6e9bb 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -727,19 +727,36 @@ export default function Chat({ serverConfig, onNavigate }: Props) { } } else if (msg.type === "auth_error") { console.error("Auth failed:", msg.error); - cachedPinRef.current = null; - clearServerPin(serverConfig.id); - setIsReconnecting(false); - setReconnectAttempt(0); - reconnectAttemptRef.current = 0; if (msg.error === "device_expired") { + cachedPinRef.current = null; + clearServerPin(serverConfig.id); + setIsReconnecting(false); + setReconnectAttempt(0); + reconnectAttemptRef.current = 0; setError( "Device authorization has expired. Please re-pair this device.", ); // Redirect to server list after a short delay setTimeout(() => onNavigate("servers"), 3000); + } else if ( + msg.error?.includes("Too many attempts") || + msg.error?.includes("rate limit") + ) { + // Rate limited — don't clear PIN, just retry after a delay + console.log("[auth] Rate limited, will retry in 10s..."); + setError("Rate limited — retrying..."); + setTimeout(() => { + if (cachedPinRef.current) { + connectAndAuth(); + } + }, 10_000); } else { + cachedPinRef.current = null; + clearServerPin(serverConfig.id); + setIsReconnecting(false); + setReconnectAttempt(0); + reconnectAttemptRef.current = 0; setError( msg.error || "Authentication failed - please re-enter PIN", ); diff --git a/package.json b/package.json index 39a9b2b..44ab128 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", + "test": "tsx --test src/lib/*.test.ts", "knip": "knip", "ci:fix": "pnpm lint --fix && pnpm format", "logs:server": "tail -f logs/server.log", diff --git a/scripts/new-pair.sh b/scripts/new-pair.sh index 87bf00f..6ee71bc 100755 --- a/scripts/new-pair.sh +++ b/scripts/new-pair.sh @@ -1,15 +1,14 @@ #!/bin/bash # Generate a new pairing QR code / link -# Usage: ./scripts/new-pair.sh +# Must be run on the server machine (localhost auth exempt) +# Usage: ./scripts/new-pair.sh set -e -PIN="${1:?Usage: $0 }" SERVER="http://localhost:6767" OUTFILE="pair-link.txt" RESPONSE=$(curl -s -X POST "$SERVER/api/new-pair-token" \ - -H "Authorization: Bearer $PIN" \ -H "Content-Type: application/json") URL=$(echo "$RESPONSE" | jq -r '.pairingUrl // empty') diff --git a/server.ts b/server.ts index 7d5ea89..821aeba 100644 --- a/server.ts +++ b/server.ts @@ -96,11 +96,19 @@ function checkAuthRateLimit(ip: string): boolean { const now = Date.now(); const entry = authAttempts.get(ip); if (!entry || now >= entry.resetAt) { - authAttempts.set(ip, { count: 1, resetAt: now + AUTH_WINDOW_MS }); return true; } - entry.count++; - return entry.count <= AUTH_MAX_ATTEMPTS; + return entry.count < AUTH_MAX_ATTEMPTS; +} + +function recordAuthFailure(ip: string): void { + const now = Date.now(); + const entry = authAttempts.get(ip); + if (!entry || now >= entry.resetAt) { + authAttempts.set(ip, { count: 1, resetAt: now + AUTH_WINDOW_MS }); + } else { + entry.count++; + } } // Device token TTL: 6 months @@ -387,6 +395,7 @@ function checkApiAuth(req: IncomingMessage, res: ServerResponse): boolean { const auth = req.headers["authorization"]; if (!auth || !auth.startsWith("Bearer ")) { + recordAuthFailure(clientIp); json(res, { error: "Unauthorized" }, 401); return false; } @@ -410,6 +419,7 @@ function checkApiAuth(req: IncomingMessage, res: ServerResponse): boolean { } if (!matched) { + recordAuthFailure(clientIp); json(res, { error: "Unauthorized" }, 401); return false; } @@ -439,8 +449,17 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse) { return; } - // Auth gate: all /api/ routes require PIN auth, except /api/status (limited info without auth) - if (pathname?.startsWith("/api/") && pathname !== "/api/status") { + // Auth gate: all /api/ routes require PIN auth, except: + // - /api/status (limited info without auth) + // - /api/new-pair-token from localhost (if you're on the machine, you're authorized) + const isLocalhost = + req.socket.remoteAddress === "127.0.0.1" || + req.socket.remoteAddress === "::1" || + req.socket.remoteAddress === "::ffff:127.0.0.1"; + const authExempt = + pathname === "/api/status" || + (pathname === "/api/new-pair-token" && isLocalhost); + if (pathname?.startsWith("/api/") && !authExempt) { if (!checkApiAuth(req, res)) return; } @@ -775,6 +794,39 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse) { } } + // API: Get PR info for a project's current branch + if ( + pathname?.startsWith("/api/projects/") && + pathname.endsWith("/pr") && + method === "GET" + ) { + const projectId = decodeURIComponent( + pathname.split("/api/projects/")[1].replace("/pr", ""), + ); + if (!validateProjectId(projectId)) + return json(res, { error: "Invalid project ID" }, 400); + const project = getProject(projectId); + if (!project) { + return json(res, { error: "Project not found" }, 404); + } + + try { + const prJson = execSync("gh pr view --json url,number,title,state", { + cwd: project.path, + encoding: "utf-8", + timeout: 10000, + }).trim(); + const pr = JSON.parse(prJson); + console.log( + `[api] PR info for ${projectId}: #${pr.number} (${pr.state})`, + ); + return json(res, pr); + } catch { + console.log(`[api] No PR found for ${projectId}`); + return json(res, { error: "No PR found" }, 404); + } + } + // API: Worktree management if ( pathname?.startsWith("/api/projects/") && @@ -1422,6 +1474,7 @@ async function main() { } } else { console.log("Auth failed - invalid PIN"); + recordAuthFailure(clientIp); sendEncrypted({ type: "auth_error", error: "Invalid PIN" }); } } else if (msg.type === "list_projects") { diff --git a/src/lib/crypto.test.ts b/src/lib/crypto.test.ts new file mode 100644 index 0000000..31a2442 --- /dev/null +++ b/src/lib/crypto.test.ts @@ -0,0 +1,140 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + generateKeyPair, + deriveSharedSecret, + encrypt, + decrypt, +} from "./crypto.js"; + +describe("generateKeyPair", () => { + it("returns base64-encoded public and private keys", () => { + const kp = generateKeyPair(); + assert.ok(kp.publicKey.length > 0); + assert.ok(kp.privateKey.length > 0); + // Should be valid base64 + assert.doesNotThrow(() => Buffer.from(kp.publicKey, "base64")); + assert.doesNotThrow(() => Buffer.from(kp.privateKey, "base64")); + }); + + it("generates unique keypairs each time", () => { + const a = generateKeyPair(); + const b = generateKeyPair(); + assert.notEqual(a.publicKey, b.publicKey); + assert.notEqual(a.privateKey, b.privateKey); + }); +}); + +describe("deriveSharedSecret", () => { + it("derives the same secret from both sides of the exchange", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const secretA = deriveSharedSecret(alice.privateKey, bob.publicKey); + const secretB = deriveSharedSecret(bob.privateKey, alice.publicKey); + assert.equal(secretA, secretB); + }); + + it("derives a 32-byte (256-bit) key", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const secret = deriveSharedSecret(alice.privateKey, bob.publicKey); + const buf = Buffer.from(secret, "base64"); + assert.equal(buf.length, 32); + }); + + it("produces different secrets for different keypairs", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const charlie = generateKeyPair(); + const secretAB = deriveSharedSecret(alice.privateKey, bob.publicKey); + const secretAC = deriveSharedSecret(alice.privateKey, charlie.publicKey); + assert.notEqual(secretAB, secretAC); + }); +}); + +describe("encrypt / decrypt", () => { + const alice = generateKeyPair(); + const bob = generateKeyPair(); + const sharedSecret = deriveSharedSecret(alice.privateKey, bob.publicKey); + + it("round-trips plaintext through encrypt then decrypt", () => { + const plaintext = "hello, world"; + const encrypted = encrypt(plaintext, sharedSecret); + const decrypted = decrypt(encrypted, sharedSecret); + assert.equal(decrypted, plaintext); + }); + + it("handles empty string", () => { + const encrypted = encrypt("", sharedSecret); + const decrypted = decrypt(encrypted, sharedSecret); + assert.equal(decrypted, ""); + }); + + it("handles unicode and emoji", () => { + const plaintext = "Hello \u00e9\u00e8\u00ea \u4e16\u754c \ud83d\ude80"; + const encrypted = encrypt(plaintext, sharedSecret); + const decrypted = decrypt(encrypted, sharedSecret); + assert.equal(decrypted, plaintext); + }); + + it("handles large payloads", () => { + const plaintext = "x".repeat(100_000); + const encrypted = encrypt(plaintext, sharedSecret); + const decrypted = decrypt(encrypted, sharedSecret); + assert.equal(decrypted, plaintext); + }); + + it("produces different ciphertext for the same plaintext (random IV)", () => { + const plaintext = "same message"; + const a = encrypt(plaintext, sharedSecret); + const b = encrypt(plaintext, sharedSecret); + assert.notEqual(a.iv, b.iv); + assert.notEqual(a.ct, b.ct); + }); + + it("returns base64-encoded iv, ct, and tag", () => { + const encrypted = encrypt("test", sharedSecret); + assert.ok(typeof encrypted.iv === "string"); + assert.ok(typeof encrypted.ct === "string"); + assert.ok(typeof encrypted.tag === "string"); + // IV should be 12 bytes = 16 base64 chars + assert.equal(Buffer.from(encrypted.iv, "base64").length, 12); + // Tag should be 16 bytes + assert.equal(Buffer.from(encrypted.tag, "base64").length, 16); + }); + + it("fails to decrypt with wrong secret", () => { + const otherSecret = deriveSharedSecret( + generateKeyPair().privateKey, + generateKeyPair().publicKey, + ); + const encrypted = encrypt("secret message", sharedSecret); + assert.throws(() => decrypt(encrypted, otherSecret)); + }); + + it("fails to decrypt with tampered ciphertext", () => { + const encrypted = encrypt("secret message", sharedSecret); + // Flip bits in the ciphertext to corrupt it + const ctBuf = Buffer.from(encrypted.ct, "base64"); + ctBuf[0] ^= 0xff; + const tampered = { ...encrypted, ct: ctBuf.toString("base64") }; + assert.throws(() => decrypt(tampered, sharedSecret)); + }); + + it("fails to decrypt with tampered tag", () => { + const encrypted = encrypt("secret message", sharedSecret); + // Flip a byte in the tag + const tagBuf = Buffer.from(encrypted.tag, "base64"); + tagBuf[0] ^= 0xff; + const tampered = { ...encrypted, tag: tagBuf.toString("base64") }; + assert.throws(() => decrypt(tampered, sharedSecret)); + }); + + it("fails to decrypt with tampered IV", () => { + const encrypted = encrypt("secret message", sharedSecret); + const ivBuf = Buffer.from(encrypted.iv, "base64"); + ivBuf[0] ^= 0xff; + const tampered = { ...encrypted, iv: ivBuf.toString("base64") }; + assert.throws(() => decrypt(tampered, sharedSecret)); + }); +}); diff --git a/src/lib/store.test.ts b/src/lib/store.test.ts new file mode 100644 index 0000000..0fa2050 --- /dev/null +++ b/src/lib/store.test.ts @@ -0,0 +1,49 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { validateProjectId } from "./store.js"; + +describe("validateProjectId", () => { + it("accepts simple project names", () => { + assert.equal(validateProjectId("my-project"), true); + assert.equal(validateProjectId("claude-remote"), true); + assert.equal(validateProjectId("foo_bar"), true); + assert.equal(validateProjectId("project123"), true); + }); + + it("accepts names with dots (e.g. domain names)", () => { + assert.equal(validateProjectId("my.project"), true); + assert.equal(validateProjectId("v1.0.0"), true); + }); + + it("accepts worktree-style names with double dashes", () => { + assert.equal(validateProjectId("my-project--feature-branch"), true); + assert.equal(validateProjectId("claude-remote--fix-auth"), true); + }); + + it("rejects empty string", () => { + assert.equal(validateProjectId(""), false); + }); + + it("rejects path traversal with ..", () => { + assert.equal(validateProjectId(".."), false); + assert.equal(validateProjectId("../etc/passwd"), false); + assert.equal(validateProjectId("foo/../bar"), false); + assert.equal(validateProjectId("foo/../../etc"), false); + }); + + it("rejects forward slashes", () => { + assert.equal(validateProjectId("foo/bar"), false); + assert.equal(validateProjectId("/etc/passwd"), false); + assert.equal(validateProjectId("a/b/c"), false); + }); + + it("rejects backslashes", () => { + assert.equal(validateProjectId("foo\\bar"), false); + assert.equal(validateProjectId("..\\..\\etc"), false); + }); + + it("rejects null bytes", () => { + assert.equal(validateProjectId("foo\0bar"), false); + assert.equal(validateProjectId("\0"), false); + }); +});