diff --git a/.gitignore b/.gitignore index c4ee6f1d..0e24d3ab 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ supabase/.temp/ # Claude Code: local-only, machine-specific (keep shared config committed) **/.claude/settings.local.json **/.claude/worktrees/ + +# qc package build output (scripts/build-qc.js) +packages/qryptchat/dist/ diff --git a/bin/qc.js b/bin/qc.js new file mode 100755 index 00000000..a34eb717 --- /dev/null +++ b/bin/qc.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node +// qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest. +import { readFileSync } from 'node:fs'; +import { main } from '../src/cli/commands.js'; + +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + +// main resolves when the command is done (the TUI quit, stdin closed under +// mcp); exit then, or the SSE socket and timers would hold the process open. +main(process.argv.slice(2), { version: pkg.version }).then( + () => process.exit(0), + (err) => { + process.stderr.write(`qc: ${err?.message ?? err}\n`); + process.exit(1); + }, +); diff --git a/bun.lock b/bun.lock index 5204c1d8..5119d1f6 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "zustand": "^5.0.0", }, "devDependencies": { + "@profullstack/hqtui": "^0.8.0", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.6.1", @@ -368,6 +369,8 @@ "@profullstack/emailer": ["@profullstack/emailer@1.0.1", "", {}, "sha512-/uhHJJGH+1xSSz3mJn6X+m6aruYjMD3JOaRp/d4R/YWlzpy07H9z0/JUleIyRyBPNmaANSIwjTZ7aVjaukOEpg=="], + "@profullstack/hqtui": ["@profullstack/hqtui@0.8.0", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-7dLq4Tb/GO7PyUaSRDf86K0AjeD55aBYW2Y7Mb2CM8YEAANno79ey0o9Dzr7QiGz/smJByah+gGp5UdAdDVGrA=="], + "@profullstack/referrals": ["@profullstack/referrals@0.1.0", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"] }, "sha512-u66SdBVpsv3kc0N+NWISPoYD5vjCERyv5wfD07iSkZwQeC2IA+ihX5jNA4e7Xr+Y4AUvxLycG+3b4VaROqzgRg=="], "@profullstack/stack": ["@profullstack/stack@0.1.3", "", { "dependencies": { "@profullstack/emailer": "1.0.1", "@profullstack/referrals": "0.1.0" }, "peerDependencies": { "@supabase/ssr": ">=0.5.0", "next": ">=13.0.0", "react": ">=18.0.0" }, "optionalPeers": ["@supabase/ssr", "next", "react"] }, "sha512-NOseYE5cWMwH75/luUE46kJLyAd1xD+DSgGgwhxvgNMoJYfZ9W9vKmqdcGA/4Lgahzf7hWfjNvmLtVIzAqwhJw=="], diff --git a/package.json b/package.json index c39fd7f0..c5b70a9b 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,18 @@ "version": "0.0.3", "type": "module", "bin": { - "qryptchat": "./bin/qryptchat-cli.js", + "qc": "./bin/qc.js", + "qryptchat": "./bin/qc.js", "qryptchat-agent": "./bin/qryptchat-agent.js" }, "packageManager": "bun@1.4.0", "scripts": { "lint:oxlint": "oxlint", "lint:oxlint:fix": "oxlint --fix", - "cli": "bun bin/qryptchat-cli.js", + "cli": "bun bin/qc.js", "agent": "bun bin/qryptchat-agent.js", + "qc": "bun bin/qc.js", + "build:qc": "bun scripts/build-qc.js", "dev": "bun --bun next dev", "build": "bun --bun next build", "start": "bun --bun next start", @@ -31,6 +34,7 @@ "dependencies": "command -v pnpm && pnpm dlx @socketsecurity/socket-patch apply --silent --ecosystems npm || true" }, "devDependencies": { + "@profullstack/hqtui": "^0.8.0", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.6.1", diff --git a/packages/qryptchat/README.md b/packages/qryptchat/README.md new file mode 100644 index 00000000..37b5aaaa --- /dev/null +++ b/packages/qryptchat/README.md @@ -0,0 +1,80 @@ +# qc + +[qrypt.chat](https://qrypt.chat) in your terminal: a full-screen, end-to-end encrypted chat client, a scriptable CLI and an MCP server, all in one command. + +```sh +npm install -g @profullstack/qryptchat # or: bun add -g @profullstack/qryptchat +qc +``` + +The first run signs you in through your browser, then opens your chats. + +## The client + +``` + πŸ”’ qrypt.chat @you ● live +β”Œ Chats β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œ alice ───────────────────────── 2 people Β· ML-KEM-1024 ┐ +β”‚β–Έ alice 2 β”‚β”‚ ───────────────────── Tue 6 Oct ───────────────────── β”‚ +β”‚ team chat β”‚β”‚ alice 14:02 β”‚ +β”‚ ops β”‚β”‚ shipped it πŸš€ β”‚ +β”‚ β”‚β”‚ you 14:03 β”‚ +β”‚ β”‚β”‚ πŸ”₯πŸ”₯ β”‚ +β”‚ β”‚β”‚ alice is typing… β”‚ +β”‚ β”‚β”‚ Message alice (Ctrl+E emoji, :rocket: works too) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + ENTER send CTRL+E emoji TAB chats PGUP scroll CTRL+C quit +``` + +| Key | Does | +|---|---| +| Enter | send (or open the highlighted chat) | +| Ctrl+E | the emoji picker: search, recents and every group, drawn with [OpenEmoji](https://github.com/profullstack/openemoji) | +| `:rocket:` | shortcodes turn into emoji when you send | +| Tab / Esc | move between the chat list and the composer | +| Ctrl+N / Ctrl+P | next / previous chat | +| PgUp / PgDn, wheel | scroll back through the conversation | +| Ctrl+R | reload | +| Ctrl+C | quit | + +The mouse works too: click a chat, scroll the transcript, click an emoji. Unread counts, typing indicators and new messages arrive live. + +## Scripts and agents + +```sh +qc chats # id and name of every chat +qc read alice -n 20 # the last 20 messages, decrypted +qc send alice "on my way" # encrypted to every participant +echo "deploy done" | qc send ops - +qc listen --json # new messages as NDJSON, until you stop it +qc whoami +``` + +`` is a chat id or any unique part of its name. Add `--json` for machine output. + +### MCP + +```sh +qc mcp +``` + +runs an MCP server on stdio with three tools: `list_chats`, `read_chat` and `send_message`. Encryption and decryption happen on this machine; the server only ever sees ciphertext. For Claude Code: + +```sh +claude mcp add qryptchat -- qc mcp +``` + +## Signing in + +`qc login` uses OAuth 2.1 (authorization code + PKCE), started from the terminal: + +1. qc opens `qrypt.chat/cli/authorize` with a one-time ML-KEM-1024 public key and prints a confirmation code. +2. You check the browser shows the same code, then approve. +3. The browser seals your keys to that one-time key, and qc receives them plus a session of its own. The server only relays ciphertext. + +Over SSH, `qc login --oob` shows a code in the browser for you to paste instead of redirecting back. + +The session and keys are kept in `~/.config/qc/session.json` (or `$QC_HOME`), mode 0600. Sessions refresh themselves; `qc logout` deletes them. Point at another server with `--url` or `QC_URL`. + +## Licence + +MIT. Emoji: OpenEmoji by Profullstack, Inc. (CC BY 4.0). diff --git a/packages/qryptchat/bin/qc.js b/packages/qryptchat/bin/qc.js new file mode 100644 index 00000000..74e50a19 --- /dev/null +++ b/packages/qryptchat/bin/qc.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node +// qc: qrypt.chat in the terminal. `qc` opens the chat client; `qc --help` lists the rest. +import { readFileSync } from 'node:fs'; +import { main } from '../dist/commands.js'; + +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + +main(process.argv.slice(2), { version: pkg.version }).then( + () => process.exit(0), + (err) => { + process.stderr.write(`qc: ${err?.message ?? err}\n`); + process.exit(1); + }, +); diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json new file mode 100644 index 00000000..deb9a096 --- /dev/null +++ b/packages/qryptchat/package.json @@ -0,0 +1,41 @@ +{ + "name": "@profullstack/qryptchat", + "version": "0.1.0", + "description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.", + "type": "module", + "bin": { + "qc": "./bin/qc.js", + "qryptchat": "./bin/qc.js" + }, + "files": [ + "bin", + "dist", + "README.md" + ], + "engines": { + "node": ">=22" + }, + "dependencies": { + "@noble/ciphers": "^2.0.0", + "@profullstack/hqtui": "^0.8.0", + "mlkem": "^2.5.0" + }, + "keywords": [ + "qrypt.chat", + "chat", + "e2ee", + "ml-kem", + "post-quantum", + "tui", + "cli", + "mcp" + ], + "homepage": "https://qrypt.chat", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/qryptchat-web.git", + "directory": "packages/qryptchat" + }, + "license": "MIT", + "author": "Profullstack, Inc." +} diff --git a/public/llms.txt b/public/llms.txt index be69944a..29475b2c 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -17,6 +17,13 @@ QryptChat uses NIST-approved post-quantum algorithms (ML-KEM-1024 / CRYSTALS-Kyb - [Terms of Service](https://qrypt.chat/terms) β€” Usage terms - [Warrant Canary](https://qrypt.chat/warrant-canary) β€” Transparency statement +## Terminal and agents + +- [qc](https://www.npmjs.com/package/@profullstack/qryptchat) is the terminal client: `npm install -g @profullstack/qryptchat`, then run `qc`. + - It is a full-screen chat app for people, and `qc chats|read|send|listen` for scripts. + - `qc mcp` is an MCP server with the tools `list_chats`, `read_chat` and `send_message`. + - Encryption happens on the machine running qc. + ## Company - **Name**: Profullstack, Inc. diff --git a/public/skill.md b/public/skill.md index 4a0cba97..7bd46ec9 100644 --- a/public/skill.md +++ b/public/skill.md @@ -14,6 +14,14 @@ Quantum-resistant, end-to-end encrypted messaging. Phone number + SMS verification code. No passwords. -## API +## Terminal, scripts and agents: qc -No public API. Self-hostable via the open-source repo at https://github.com/profullstack/qryptchat-web +`npm install -g @profullstack/qryptchat` installs `qc`: + +- `qc` opens a full-screen chat client (emoji picker on Ctrl+E). +- `qc chats`, `qc read `, `qc send ` and `qc listen --json` are for scripts. +- `qc mcp` runs an MCP server on stdio with the tools `list_chats`, `read_chat` and `send_message`. + +`qc login` signs in through the browser (OAuth 2.1, authorization code + PKCE). The browser seals the account's keys to a one-time ML-KEM-1024 key that qc generated, so messages are encrypted and decrypted on the machine running qc. The server only ever sees ciphertext. + +Self-hostable via the open-source repo at https://github.com/profullstack/qryptchat-web diff --git a/scripts/build-qc.js b/scripts/build-qc.js new file mode 100644 index 00000000..8b7a54fb --- /dev/null +++ b/scripts/build-qc.js @@ -0,0 +1,31 @@ +#!/usr/bin/env bun +/** + * Build the publishable qc package: `bun scripts/build-qc.js`. + * + * Bundles src/cli (and the web app's own crypto it imports) into + * packages/qryptchat/dist, leaving the runtime dependencies external so npm + * installs them. The TUI and MCP server stay separate chunks, loaded only by + * the commands that need them. + */ +import { readFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const root = resolve(import.meta.dir, '..'); +const out = join(root, 'packages', 'qryptchat', 'dist'); +const pkg = JSON.parse(readFileSync(join(root, 'packages', 'qryptchat', 'package.json'), 'utf8')); + +rmSync(out, { recursive: true, force: true }); +const result = await Bun.build({ + entrypoints: [join(root, 'src/cli/commands.js'), join(root, 'src/cli/tui.js'), join(root, 'src/cli/mcp.js')], + outdir: out, + target: 'node', + format: 'esm', + splitting: true, + external: Object.keys(pkg.dependencies), + naming: { entry: '[name].js', chunk: 'chunk-[hash].js' }, +}); +if (!result.success) { + for (const log of result.logs) console.error(log); + process.exit(1); +} +console.log(result.outputs.map((o) => `${o.path.replace(`${root}/`, '')} ${(o.size / 1024).toFixed(0)} KB`).join('\n')); diff --git a/src/app/api/cli/authorize/route.js b/src/app/api/cli/authorize/route.js new file mode 100644 index 00000000..82bd6ab0 --- /dev/null +++ b/src/app/api/cli/authorize/route.js @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { withAuth } from '@/lib/api/middleware/auth.js'; +import { CliAuthError, issueCode, serviceClient } from '@/lib/auth/cli-auth.js'; + +/** + * POST /api/cli/authorize: the signed-in web app approves a `qc login`. + * Body: { code_challenge, code_challenge_method: "S256", redirect_uri, client_name, key_blob } + * Returns { code }. See src/lib/auth/cli-auth.js for the whole flow. + */ +export const POST = withAuth(async ({ request, locals }) => { + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'invalid_request', error_description: 'Invalid JSON body' }, { status: 400 }); + } + if (body?.code_challenge_method !== 'S256') { + return NextResponse.json({ error: 'invalid_request', error_description: 'Only S256 is supported' }, { status: 400 }); + } + try { + const code = await issueCode(serviceClient(), { + authUserId: locals.user.id, + codeChallenge: body.code_challenge, + redirectUri: body.redirect_uri, + clientName: body.client_name, + keyBlob: body.key_blob + }); + return NextResponse.json({ code }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (error) { + if (error instanceof CliAuthError) { + return NextResponse.json({ error: error.code, error_description: error.message }, { status: error.status }); + } + console.error('[cli/authorize]', error); + return NextResponse.json({ error: 'server_error' }, { status: 500 }); + } +}); diff --git a/src/app/api/cli/token/route.js b/src/app/api/cli/token/route.js new file mode 100644 index 00000000..3a9a2a39 --- /dev/null +++ b/src/app/api/cli/token/route.js @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; +import { CliAuthError, redeemCode, refresh, serviceClient } from '@/lib/auth/cli-auth.js'; + +/** + * POST /api/cli/token: the qc CLI's token endpoint. + * grant_type=authorization_code { code, code_verifier, redirect_uri } + * grant_type=refresh_token { refresh_token } + * JSON or form-encoded. Unauthenticated by design: the code and the PKCE + * verifier are the credential. + */ +export async function POST(request) { + let body; + try { + const type = request.headers.get('content-type') || ''; + body = type.includes('application/x-www-form-urlencoded') + ? Object.fromEntries(new URLSearchParams(await request.text())) + : await request.json(); + } catch { + return NextResponse.json({ error: 'invalid_request', error_description: 'Unreadable body' }, { status: 400 }); + } + const headers = { 'Cache-Control': 'no-store', Pragma: 'no-cache' }; + try { + if (body?.grant_type === 'authorization_code') { + const result = await redeemCode(serviceClient(), { + code: body.code, + codeVerifier: body.code_verifier, + redirectUri: body.redirect_uri + }); + return NextResponse.json(result, { headers }); + } + if (body?.grant_type === 'refresh_token') { + return NextResponse.json(await refresh(body.refresh_token), { headers }); + } + return NextResponse.json({ error: 'unsupported_grant_type' }, { status: 400, headers }); + } catch (error) { + if (error instanceof CliAuthError) { + return NextResponse.json({ error: error.code, error_description: error.message }, { status: error.status, headers }); + } + console.error('[cli/token]', error); + return NextResponse.json({ error: 'server_error' }, { status: 500, headers }); + } +} diff --git a/src/app/api/events/route.js b/src/app/api/events/route.js index 9ae6ba3f..8c2da9f5 100644 --- a/src/app/api/events/route.js +++ b/src/app/api/events/route.js @@ -4,7 +4,7 @@ */ import { sseManager } from '@/lib/api/sse-manager.js'; -import { createSupabaseServerClient } from '@/lib/supabase.js'; +import { authenticateRequest } from '@/lib/api/middleware/auth.js'; /** * GET handler for SSE connections @@ -14,14 +14,13 @@ export async function GET(request, { params } = {}) { console.log('πŸ“‘ [SSE] New connection request'); try { - // Authenticate the user using getUser() for security - const supabase = await createSupabaseServerClient(); - const { data: { user }, error: authError } = await supabase.auth.getUser(); - - if (authError || !user) { - console.error('πŸ“‘ [SSE] Authentication failed:', authError?.message || 'No user'); + // Bearer token (the qc CLI) or the session cookie (the web app). + const auth = await authenticateRequest(request); + if (!auth.success) { + console.error('πŸ“‘ [SSE] Authentication failed:', auth.error); return new Response('Unauthorized', { status: 401 }); } + const { user, supabase } = auth; const authUserId = user.id; console.log(`πŸ“‘ [SSE] Auth user ${authUserId} authenticated`); diff --git a/src/app/cli/authorize/page.jsx b/src/app/cli/authorize/page.jsx new file mode 100644 index 00000000..453f97b3 --- /dev/null +++ b/src/app/cli/authorize/page.jsx @@ -0,0 +1,177 @@ +'use client'; + +import { Suspense, useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { useAuthStore } from '@/lib/stores/auth.js'; +import { postQuantumEncryption } from '@/lib/crypto/post-quantum-encryption.js'; +import { matchCode } from '@/lib/auth/cli-match.js'; + +/** + * Approve a `qc login`. The terminal opened this page with a PKCE challenge, + * where to send the code, and a one-time ML-KEM-1024 public key. Approving + * seals this browser's keypair to that key (the server only relays it) and + * hands the terminal a five-minute, single-use code. + */ +function Authorize() { + const params = useSearchParams(); + const user = useAuthStore((s) => s.user); + const loading = useAuthStore((s) => s.loading); + const [match, setMatch] = useState(''); + const [status, setStatus] = useState('idle'); // idle | working | done | denied | error + const [error, setError] = useState(''); + const [oobCode, setOobCode] = useState(''); + + const challenge = params.get('code_challenge') || ''; + const redirectUri = params.get('redirect_uri') || ''; + const state = params.get('state') || ''; + const kem = params.get('kem') || ''; + const client = (params.get('client_name') || 'qc').slice(0, 80); + const valid = + /^[A-Za-z0-9_-]{43}$/.test(challenge) && + params.get('code_challenge_method') === 'S256' && + /^[A-Za-z0-9+/=]{2000,2200}$/.test(kem) && + (redirectUri === 'oob' || /^http:\/\/(127\.0\.0\.1|\[::1\]):\d+\//.test(redirectUri)); + + useEffect(() => { + if (valid) matchCode(challenge, kem).then(setMatch); + }, [valid, challenge, kem]); + + async function approve() { + setStatus('working'); + setError(''); + try { + await postQuantumEncryption.initialize(); + const keys = await postQuantumEncryption.exportUserKeys(); + const keyBlob = await postQuantumEncryption.encryptForRecipient(JSON.stringify({ v: 1, ...keys }), kem); + const session = JSON.parse(localStorage.getItem('qrypt_session') || '{}'); + const res = await fetch('/api/cli/authorize', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(session.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}), + }, + body: JSON.stringify({ + code_challenge: challenge, + code_challenge_method: 'S256', + redirect_uri: redirectUri, + client_name: client, + key_blob: keyBlob, + }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || !body.code) throw new Error(body.error_description || body.error || `HTTP ${res.status}`); + setStatus('done'); + if (redirectUri === 'oob') { + setOobCode(body.code); + } else { + const to = new URL(redirectUri); + to.searchParams.set('code', body.code); + if (state) to.searchParams.set('state', state); + window.location.assign(to.toString()); + } + } catch (err) { + setStatus('error'); + setError(err?.message || 'Something went wrong'); + } + } + + function deny() { + setStatus('denied'); + if (redirectUri !== 'oob') { + const to = new URL(redirectUri); + to.searchParams.set('error', 'access_denied'); + if (state) to.searchParams.set('state', state); + window.location.assign(to.toString()); + } + } + + let content; + if (!valid) { + content = ( + <> +

That link is not a qc login

+

Run qc login in your terminal and open the link it prints.

+ + ); + } else if (loading) { + content =

Loading…

; + } else if (!user) { + content = ( + <> +

Sign in first

+

Sign in to qrypt.chat in this browser, then open the link from your terminal again.

+ Sign in + + ); + } else if (status === 'done' && oobCode) { + content = ( + <> +

Paste this into your terminal

+
{oobCode}
+

It works once, for five minutes.

+ + ); + } else if (status === 'done') { + content = ( + <> +

Signed in

+

You can close this tab and go back to your terminal.

+ + ); + } else if (status === 'denied') { + content = ( + <> +

Not approved

+

Nothing was shared. You can close this tab.

+ + ); + } else { + content = ( + <> +

Sign in to {client}?

+

+ A terminal wants to use qrypt.chat as @{user.username}. It will be able to read and send your + messages, so approving copies your encryption keys to it, sealed so only that terminal can open them. +

+

Check that your terminal shows this code:

+
{match || '…'}
+ {error &&

{error}

} +
+ + +
+

Did not run qc login yourself? Deny.

+ + ); + } + + return ( +
+
{content}
+ +
+ ); +} + +export default function CliAuthorizePage() { + return ( + + + + ); +} diff --git a/src/cli/api.js b/src/cli/api.js new file mode 100644 index 00000000..6845295d --- /dev/null +++ b/src/cli/api.js @@ -0,0 +1,222 @@ +/** + * The qc client: qrypt.chat's REST API with a Bearer token, end-to-end + * encryption on the way out and decryption on the way in, and the SSE event + * stream for live updates. Sessions refresh themselves (rotating refresh + * tokens, POST /api/cli/token) and every refresh is written back to disk. + */ +import { baseUrl, saveSession } from './config.js'; +import { keyring } from './crypto.js'; + +export class QcError extends Error { + constructor(message, status) { + super(message); + this.name = 'QcError'; + this.status = status; + } +} + +export class QcClient { + /** + * @param {any} session as saved by `qc login` + * @param {{ save?: (session: any) => void, fetch?: typeof fetch, env?: NodeJS.ProcessEnv }} [options] + */ + constructor(session, options = {}) { + this.session = session; + this.base = baseUrl(session, options.env); + this.save = options.save ?? ((s) => saveSession(s, options.env)); + this.fetch = options.fetch ?? globalThis.fetch; + this.ring = keyring(session.keys); + this.refreshing = null; + } + + get me() { + return this.session.user; + } + + async refresh() { + this.refreshing ??= (async () => { + const res = await this.fetch(`${this.base}/api/cli/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: this.session.refresh_token }), + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new QcError(body.error_description || 'Your session has ended. Run qc login.', 401); + this.session = { ...this.session, ...body }; + this.save(this.session); + })().finally(() => { + this.refreshing = null; + }); + return this.refreshing; + } + + async token() { + const expiresAt = Number(this.session.expires_at || 0) * 1000; + if (expiresAt && expiresAt - Date.now() < 60_000) await this.refresh(); + return this.session.access_token; + } + + async request(path, { method = 'GET', body, signal, retry = true } = {}) { + const res = await this.fetch(`${this.base}${path}`, { + method, + signal, + headers: { + Authorization: `Bearer ${await this.token()}`, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (res.status === 401 && retry) { + await this.refresh(); + return this.request(path, { method, body, signal, retry: false }); + } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new QcError(data.error_description || data.error || `HTTP ${res.status}`, res.status); + return data; + } + + /** Conversations, newest activity first, each with a display title. */ + async conversations() { + const { conversations = [] } = await this.request('/api/conversations/load', { method: 'POST', body: {} }); + return conversations.map((c) => ({ ...c, title: conversationTitle(c, this.me) })); + } + + /** Decrypted messages, oldest first. */ + async messages(conversationId, { limit = 100, before } = {}) { + const { messages = [], hasMore = false } = await this.request('/api/messages/load', { + method: 'POST', + body: { conversationId, limit, ...(before ? { before } : {}) }, + }); + const out = []; + for (const m of messages) { + let text; + if (m.message_type === 'file' || m.has_attachments) { + text = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : ''; + text = `πŸ“Ž ${text && text !== '[File attachment]' ? `${text} ` : ''}(attachment: open qrypt.chat to download)`; + } else { + text = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : ''; + } + out.push({ + id: m.id, + conversationId: m.conversation_id, + senderId: m.sender_id, + sender: m.sender?.display_name || m.sender?.username || 'unknown', + username: m.sender?.username || '', + mine: m.sender_id === this.me?.id, + text, + at: m.created_at, + }); + } + return { messages: out, hasMore }; + } + + /** + * Encrypt the text once per participant (their public key, ML-KEM-1024) and + * send every copy. The server stores ciphertext only. + */ + async send(conversationId, text) { + const { participants = [] } = await this.request(`/api/chat/conversations/${encodeURIComponent(conversationId)}/participants`); + const userIds = participants.map((p) => p.user_id); + if (!userIds.length) throw new QcError('This conversation has no participants'); + const { public_keys: keys = {} } = await this.request('/api/crypto/public-keys', { method: 'POST', body: { user_ids: userIds } }); + const encryptedContents = {}; + const missing = []; + for (const id of userIds) { + if (!keys[id]) { + missing.push(id); + continue; + } + encryptedContents[id] = await this.ring.encrypt(text, keys[id]); + } + if (!Object.keys(encryptedContents).length) throw new QcError('No participant has a public key yet'); + const { message } = await this.request('/api/messages/send', { + method: 'POST', + body: { conversationId, encryptedContents, messageType: 'text' }, + }); + return { message, skipped: missing.length }; + } + + typing(conversationId, on) { + return this.request(`/api/typing/${on ? 'start' : 'stop'}`, { method: 'POST', body: { conversationId } }).catch(() => {}); + } + + /** + * Follow the SSE stream until signal aborts, reconnecting with backoff. + * onEvent({ type, data }); onStatus('live' | 'connecting' | 'offline'). + */ + async events(onEvent, { signal, onStatus = () => {} } = {}) { + let delay = 1000; + while (!signal?.aborted) { + onStatus('connecting'); + try { + const res = await this.fetch(`${this.base}/api/events`, { + headers: { Authorization: `Bearer ${await this.token()}`, Accept: 'text/event-stream' }, + signal, + }); + if (res.status === 401) { + await this.refresh(); + continue; + } + if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`); + onStatus('live'); + delay = 1000; + await readSse(res.body, onEvent); + } catch (err) { + if (signal?.aborted) break; + if (err instanceof QcError && err.status === 401) throw err; + } + if (signal?.aborted) break; + onStatus('offline'); + await sleep(delay, signal); + delay = Math.min(delay * 2, 30_000); + } + } +} + +/** Parse `event: X\ndata: {...}\n\n` frames from a byte stream. */ +export async function readSse(body, onEvent) { + const decoder = new TextDecoder(); + let buffer = ''; + for await (const chunk of body) { + buffer += decoder.decode(chunk, { stream: true }); + let at; + while ((at = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, at); + buffer = buffer.slice(at + 2); + let type = 'message'; + const data = []; + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) type = line.slice(6).trim(); + else if (line.startsWith('data:')) data.push(line.slice(5).trimStart()); + } + if (!data.length) continue; + let parsed; + try { + parsed = JSON.parse(data.join('\n')); + } catch { + parsed = data.join('\n'); + } + onEvent({ type, data: parsed?.payload ?? parsed?.data ?? parsed }); + } + } +} + +function sleep(ms, signal) { + return new Promise((resolve) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(t); + resolve(); + }); + }); +} + +/** A conversation's name, or the other participants' names for a direct chat. */ +export function conversationTitle(c, me) { + if (c.name) return c.name; + const others = (c.participants || []) + .filter((p) => p.user_id !== me?.id) + .map((p) => p.user?.display_name || p.user?.username) + .filter(Boolean); + return others.length ? others.join(', ') : 'Just you'; +} diff --git a/src/cli/commands.js b/src/cli/commands.js new file mode 100644 index 00000000..dd671d6a --- /dev/null +++ b/src/cli/commands.js @@ -0,0 +1,168 @@ +/** + * qc's commands. Bare `qc` is the full-screen client; everything else is for + * scripts and agents and prints plain text (or JSON with --json). + */ +import { QcClient } from './api.js'; +import { clearSession, loadSession, sessionPath } from './config.js'; +import { login } from './login.js'; + +export const HELP = `qc: qrypt.chat in your terminal (end-to-end encrypted, ML-KEM-1024) + +Usage: + qc open the chat client (signs you in first if needed) + qc login [--oob] sign in through your browser; --oob to paste a code (SSH) + qc logout forget this terminal's session and keys + qc whoami who this terminal is signed in as + qc chats list your chats + qc read [-n 20] print the last messages of a chat + qc send send a message (text "-" reads stdin) + qc listen print new messages as they arrive (NDJSON with --json) + qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message) + + is a chat id or part of its name. Options: --json, --url (or QC_URL). +Keys and session live in ${'$'}QC_HOME or ~/.config/qc (mode 0600).`; + +export function parseArgs(argv) { + const args = { _: [], flags: {} }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--') { + args._.push(...argv.slice(i + 1)); + break; + } + if (a.startsWith('--')) { + const [k, v] = a.slice(2).split('=', 2); + if (v !== undefined) args.flags[k] = v; + else if (['url', 'n', 'limit'].includes(k) && argv[i + 1] !== undefined) args.flags[k] = argv[++i]; + else args.flags[k] = true; + } else if (a === '-n' && argv[i + 1] !== undefined) { + args.flags.n = argv[++i]; + } else if (a === '-h') { + args.flags.help = true; + } else if (a === '-v') { + args.flags.version = true; + } else { + args._.push(a); + } + } + return args; +} + +/** Match a chat by id, exact name, then a unique name fragment. */ +export function findChat(chats, query) { + const q = String(query).toLowerCase(); + const byId = chats.find((c) => c.id === query); + if (byId) return byId; + const exact = chats.filter((c) => c.title.toLowerCase() === q); + if (exact.length === 1) return exact[0]; + const some = chats.filter((c) => c.title.toLowerCase().includes(q)); + if (some.length === 1) return some[0]; + if (some.length > 1) throw new Error(`"${query}" matches ${some.length} chats: ${some.map((c) => c.title).join(', ')}`); + throw new Error(`No chat matches "${query}". Try qc chats.`); +} + +async function client(flags, { interactive = false } = {}) { + if (flags.url) process.env.QC_URL = flags.url; + let session = loadSession(); + if (!session) { + if (!interactive) throw new Error('Not signed in. Run qc login.'); + session = await login({ oob: !!flags.oob }); + } + return new QcClient(session); +} + +const stamp = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); + +async function readStdin() { + let text = ''; + for await (const chunk of process.stdin) text += chunk; + return text; +} + +export async function main(argv, { version = '0.0.0' } = {}) { + const { _: [cmd, ...rest], flags } = parseArgs(argv); + const out = (line) => process.stdout.write(`${line}\n`); + const json = (value) => out(JSON.stringify(value, null, flags.json === 'compact' ? 0 : 2)); + + if (flags.version) return out(`qc ${version}`); + if (flags.help || cmd === 'help') return out(HELP); + if (flags.url) process.env.QC_URL = flags.url; + + switch (cmd) { + case undefined: + case 'tui': { + if (!process.stdout.isTTY) throw new Error('qc needs a terminal. For scripts use qc chats / read / send / listen.'); + const c = await client(flags, { interactive: true }); + const { runTui } = await import('./tui.js'); + return runTui(c, { initialChat: rest[0] }); + } + case 'login': { + const session = await login({ oob: !!flags.oob }); + return out(`Signed in as @${session.user?.username ?? 'unknown'} on ${session.base}. Keys saved to ${sessionPath()}.`); + } + case 'logout': + clearSession(); + return out('Signed out. This terminal no longer holds your keys.'); + case 'whoami': { + const session = loadSession(); + if (!session) throw new Error('Not signed in. Run qc login.'); + return flags.json ? json({ user: session.user, base: session.base }) : out(`@${session.user?.username} (${session.user?.display_name ?? ''}) on ${session.base}`); + } + case 'chats': + case 'ls': { + const chats = await (await client(flags)).conversations(); + if (flags.json) return json(chats.map((c) => ({ id: c.id, title: c.title, type: c.type, updated_at: c.updated_at, participants: c.participants?.length ?? 0 }))); + for (const c of chats) out(`${c.id} ${c.title}`); + return; + } + case 'read': { + if (!rest[0]) throw new Error('Usage: qc read [-n 20]'); + const c = await client(flags); + const chat = findChat(await c.conversations(), rest[0]); + const { messages } = await c.messages(chat.id, { limit: 100 }); + const last = messages.slice(-Math.max(1, Number(flags.n || flags.limit || 20))); + if (flags.json) return json(last); + for (const m of last) out(`[${stamp(m.at)}] ${m.mine ? 'you' : m.sender}: ${m.text}`); + return; + } + case 'send': { + if (!rest[0] || rest.length < 2) throw new Error('Usage: qc send (text "-" reads stdin)'); + const text = rest[1] === '-' && rest.length === 2 ? (await readStdin()).trim() : rest.slice(1).join(' '); + if (!text) throw new Error('Nothing to send.'); + const c = await client(flags); + const chat = findChat(await c.conversations(), rest[0]); + const { message, skipped } = await c.send(chat.id, text); + if (flags.json) return json({ id: message?.id, conversation: chat.id, skipped }); + return out(`Sent to ${chat.title}${skipped ? ` (${skipped} participant(s) have no key yet)` : ''}.`); + } + case 'listen': { + const c = await client(flags); + const chats = await c.conversations(); + const titles = new Map(chats.map((x) => [x.id, x.title])); + // Loading a chat joins its live room on the server. + for (const x of chats) await c.messages(x.id, { limit: 1 }).catch(() => {}); + const seen = new Set(); + await c.events( + async ({ type, data }) => { + if (type !== 'NEW_MESSAGE' || !data?.message?.conversation_id) return; + const id = data.message.conversation_id; + const { messages } = await c.messages(id, { limit: 100 }); + for (const m of messages.slice(-5)) { + if (seen.has(m.id) || m.id !== data.message.id) continue; + seen.add(m.id); + if (flags.json) out(JSON.stringify({ chat: id, title: titles.get(id), ...m })); + else out(`[${stamp(m.at)}] ${titles.get(id) ?? id} Β· ${m.mine ? 'you' : m.sender}: ${m.text}`); + } + }, + { onStatus: (s) => process.stderr.write(`qc: ${s}\n`) }, + ); + return; + } + case 'mcp': { + const { serveMcp } = await import('./mcp.js'); + return serveMcp(await client(flags), { version }); + } + default: + throw new Error(`Unknown command "${cmd}".\n\n${HELP}`); + } +} diff --git a/src/cli/config.js b/src/cli/config.js new file mode 100644 index 00000000..893efe8e --- /dev/null +++ b/src/cli/config.js @@ -0,0 +1,50 @@ +/** + * Where qc keeps its session and keys: $QC_HOME, else $XDG_CONFIG_HOME/qc, + * else ~/.config/qc. The directory is 0700 and session.json is 0600, written + * atomically (temp file + rename) so a crash never leaves half a key on disk. + */ +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const DEFAULT_URL = 'https://qrypt.chat'; + +export function configDir(env = process.env) { + if (env.QC_HOME) return env.QC_HOME; + return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'qc'); +} + +export const sessionPath = (env = process.env) => join(configDir(env), 'session.json'); + +/** The server qc talks to: $QC_URL, else the one the session was made on, else qrypt.chat. */ +export function baseUrl(session, env = process.env) { + return (env.QC_URL || session?.base || DEFAULT_URL).replace(/\/+$/, ''); +} + +export function loadSession(env = process.env) { + try { + const session = JSON.parse(readFileSync(sessionPath(env), 'utf8')); + return session && session.access_token && session.keys ? session : null; + } catch { + return null; + } +} + +export function saveSession(session, env = process.env) { + const dir = configDir(env); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + try { + chmodSync(dir, 0o700); + } catch { + // not ours to change (e.g. a shared mount); the file mode still holds + } + const file = sessionPath(env); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 0o600 }); + renameSync(tmp, file); +} + +export function clearSession(env = process.env) { + const file = sessionPath(env); + if (existsSync(file)) rmSync(file); +} diff --git a/src/cli/crypto.js b/src/cli/crypto.js new file mode 100644 index 00000000..c66cdbca --- /dev/null +++ b/src/cli/crypto.js @@ -0,0 +1,58 @@ +/** + * The web app's own post-quantum crypto (ML-KEM-1024 + HKDF + ChaCha20-Poly1305), + * run in Node/Bun with keys handed in rather than loaded from IndexedDB. The + * service logs every step to the console, which a full-screen TUI cannot have, + * so every call runs with the console muted. + */ +import { MlKem1024 } from 'mlkem'; +import { PostQuantumEncryptionService } from '../lib/crypto/post-quantum-encryption.js'; +import { Base64 } from '../lib/crypto/index.js'; + +const METHODS = ['log', 'info', 'warn', 'error', 'debug']; + +/** Run fn with the console silenced (the crypto modules are chatty). */ +export async function quietly(fn) { + const saved = METHODS.map((m) => console[m]); + for (const m of METHODS) console[m] = () => {}; + try { + return await fn(); + } finally { + METHODS.forEach((m, i) => (console[m] = saved[i])); + } +} + +/** + * A crypto service holding the account's keys. + * keys: { keys1024: {publicKey, privateKey}, keys768?: {publicKey, privateKey} } + */ +export function keyring(keys) { + const service = new PostQuantumEncryptionService(); + service.userKeys = { publicKey: keys.keys1024.publicKey, privateKey: keys.keys1024.privateKey }; + if (keys.keys768) service.userKeys768 = { publicKey: keys.keys768.publicKey, privateKey: keys.keys768.privateKey }; + service.isInitialized = true; + return { + encrypt: (text, recipientPublicKey) => quietly(() => service.encryptForRecipient(text, recipientPublicKey)), + /** Plaintext, or a bracketed placeholder the web app also shows when a copy cannot be opened. */ + decrypt: (content) => quietly(() => service.decryptFromSender(content, '')), + }; +} + +/** A one-time ML-KEM-1024 keypair for `qc login`, base64 like the app's keys. */ +export async function ephemeralKeypair() { + const [publicKey, privateKey] = await new MlKem1024().generateKeyPair(); + return { publicKey: Base64.encode(publicKey), privateKey: Base64.encode(privateKey) }; +} + +/** Open the keypair the browser sealed to our ephemeral key during login. */ +export async function openKeyBlob(blob, ephemeral) { + const ring = keyring({ keys1024: ephemeral }); + const text = await ring.decrypt(blob); + let keys; + try { + keys = JSON.parse(text); + } catch { + throw new Error('Could not open the keys the browser sent. Run qc login again.'); + } + if (!keys?.keys1024?.publicKey || !keys?.keys1024?.privateKey) throw new Error('The browser sent no usable keys.'); + return { keys1024: keys.keys1024, keys768: keys.keys768 }; +} diff --git a/src/cli/login.js b/src/cli/login.js new file mode 100644 index 00000000..20ee6423 --- /dev/null +++ b/src/cli/login.js @@ -0,0 +1,151 @@ +/** + * `qc login`: OAuth 2.1 authorization code + PKCE, started here. + * + * qc listens on a loopback port, opens qrypt.chat/cli/authorize in the browser + * with an S256 challenge and a one-time ML-KEM-1024 public key, and waits. + * Approving in the browser seals the account's keys to that public key and + * redirects back with a code; qc trades code + verifier for its own session + * and opens the keys. With --oob (or over SSH with no display) there is no + * loopback: the page shows the code and you paste it here. + */ +import { createHash, randomBytes } from 'node:crypto'; +import { createServer } from 'node:http'; +import { hostname } from 'node:os'; +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline/promises'; +import { baseUrl, saveSession } from './config.js'; +import { ephemeralKeypair, openKeyBlob } from './crypto.js'; +import { matchCode } from '../lib/auth/cli-match.js'; + +const b64url = (buf) => Buffer.from(buf).toString('base64url'); +const TIMEOUT_MS = 5 * 60 * 1000; + +const PAGE = (title, body) => `${title} + +

${title}

${body}

`; + +/** Open a URL in the default browser; false when there is nothing to open it with. */ +export function openBrowser(url, { platform = process.platform, env = process.env } = {}) { + if (env.QC_NO_BROWSER || ((env.SSH_CONNECTION || env.SSH_TTY) && !env.DISPLAY && !env.WAYLAND_DISPLAY && platform === 'linux')) { + return false; + } + const [cmd, args] = + platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]; + try { + const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); + child.on('error', () => {}); + child.unref(); + return true; + } catch { + return false; + } +} + +function waitForCallback(server, state) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for the browser (5 minutes). Run qc login again.')), TIMEOUT_MS); + server.on('request', (req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + if (url.pathname !== '/callback') { + res.writeHead(404).end(); + return; + } + const done = (status, title, body) => { + res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' }).end(PAGE(title, body)); + }; + if (url.searchParams.get('state') !== state) { + done(400, 'That was not this login', 'The state did not match. Go back to your terminal.'); + return; // keep waiting for the real one + } + clearTimeout(timer); + const error = url.searchParams.get('error'); + if (error) { + done(200, 'Not approved', 'Nothing was shared. You can close this tab.'); + reject(new Error(error === 'access_denied' ? 'Login was denied in the browser.' : `Login failed: ${error}`)); + return; + } + done(200, 'Signed in', 'You can close this tab and go back to your terminal.'); + resolve(url.searchParams.get('code')); + }); + }); +} + +/** + * @param {{ base?: string, oob?: boolean, print?: (line: string) => void, env?: NodeJS.ProcessEnv }} [options] + */ +export async function login(options = {}) { + const env = options.env ?? process.env; + const print = options.print ?? ((line) => process.stderr.write(`${line}\n`)); + const base = (options.base ?? baseUrl(null, env)).replace(/\/+$/, ''); + + const verifier = b64url(randomBytes(32)); + const challenge = b64url(createHash('sha256').update(verifier).digest()); + const state = b64url(randomBytes(16)); + const ephemeral = await ephemeralKeypair(); + + let server; + let redirectUri = 'oob'; + if (!options.oob) { + server = createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + redirectUri = `http://127.0.0.1:${server.address().port}/callback`; + } + + const url = new URL('/cli/authorize', base); + for (const [k, v] of Object.entries({ + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + redirect_uri: redirectUri, + state, + client_name: `qc on ${hostname()}`, + kem: ephemeral.publicKey, + })) { + url.searchParams.set(k, v); + } + + const match = await matchCode(challenge, ephemeral.publicKey); + print(''); + print(` Confirmation code: ${match}`); + print(' Check that the browser shows the same code before you approve.'); + print(''); + const opened = !options.oob && openBrowser(url.toString(), { env }); + print(opened ? ' Opened your browser. If it did not, open:' : ' Open this link in a browser where you are signed in to qrypt.chat:'); + print(` ${url.toString()}`); + print(''); + + let code; + try { + if (server) { + code = await waitForCallback(server, state); + } else { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + code = (await rl.question(' Paste the code from the browser: ')).trim(); + rl.close(); + } + } finally { + server?.close(); + } + if (!code) throw new Error('No code came back from the browser.'); + + const res = await fetch(`${base}/api/cli/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: redirectUri }), + }); + const token = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(token.error_description || token.error || `Sign-in failed (HTTP ${res.status})`); + + const keys = await openKeyBlob(token.key_blob, ephemeral); + const session = { + base, + access_token: token.access_token, + refresh_token: token.refresh_token, + expires_at: token.expires_at, + user: token.user, + keys, + created_at: new Date().toISOString(), + }; + saveSession(session, env); + return session; +} diff --git a/src/cli/mcp.js b/src/cli/mcp.js new file mode 100644 index 00000000..7919b518 --- /dev/null +++ b/src/cli/mcp.js @@ -0,0 +1,111 @@ +/** + * `qc mcp`: qrypt.chat as an MCP server over stdio (JSON-RPC 2.0, one message + * per line). Encryption and decryption happen here, on the machine that holds + * the keys; the server only ever sees ciphertext. + */ +import { createInterface } from 'node:readline'; +import { findChat } from './commands.js'; + +const TOOLS = [ + { + name: 'list_chats', + description: 'List your qrypt.chat conversations (id, title, participant count), most recent first.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'read_chat', + description: 'Read the latest decrypted messages of a conversation.', + inputSchema: { + type: 'object', + properties: { + chat: { type: 'string', description: 'Conversation id or part of its name' }, + limit: { type: 'number', description: 'How many of the latest messages (default 20, max 100)' }, + }, + required: ['chat'], + additionalProperties: false, + }, + }, + { + name: 'send_message', + description: 'Send an end-to-end encrypted message (ML-KEM-1024) to a conversation.', + inputSchema: { + type: 'object', + properties: { + chat: { type: 'string', description: 'Conversation id or part of its name' }, + text: { type: 'string', description: 'The message' }, + }, + required: ['chat', 'text'], + additionalProperties: false, + }, + }, +]; + +export async function callTool(client, name, args = {}) { + if (name === 'list_chats') { + const chats = await client.conversations(); + return chats.map((c) => ({ id: c.id, title: c.title, participants: c.participants?.length ?? 0, updated_at: c.updated_at })); + } + if (name === 'read_chat') { + const chat = findChat(await client.conversations(), args.chat); + const { messages } = await client.messages(chat.id, { limit: 100 }); + const limit = Math.min(100, Math.max(1, Number(args.limit) || 20)); + return { chat: { id: chat.id, title: chat.title }, messages: messages.slice(-limit).map(({ sender, mine, text, at }) => ({ from: mine ? 'me' : sender, text, at })) }; + } + if (name === 'send_message') { + if (!args.text || typeof args.text !== 'string') throw new Error('text is required'); + const chat = findChat(await client.conversations(), args.chat); + const { message, skipped } = await client.send(chat.id, args.text); + return { sent: true, id: message?.id, chat: chat.title, skipped }; + } + throw new Error(`Unknown tool ${name}`); +} + +export async function handle(client, msg, { version }) { + const { id, method, params } = msg; + switch (method) { + case 'initialize': + return { + protocolVersion: params?.protocolVersion ?? '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'qc', title: 'qrypt.chat', version }, + }; + case 'ping': + return {}; + case 'tools/list': + return { tools: TOOLS }; + case 'tools/call': + try { + const result = await callTool(client, params?.name, params?.arguments); + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result }; + } catch (err) { + return { content: [{ type: 'text', text: err.message }], isError: true }; + } + default: + if (id === undefined) return undefined; // a notification + throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 }); + } +} + +export function serveMcp(client, { version = '0.0.0', input = process.stdin, output = process.stdout } = {}) { + // stdout is the protocol; nothing else may write there. + for (const m of ['log', 'info', 'debug']) console[m] = (...a) => process.stderr.write(`${a.join(' ')}\n`); + const write = (obj) => output.write(`${JSON.stringify(obj)}\n`); + const rl = createInterface({ input }); + rl.on('line', async (line) => { + if (!line.trim()) return; + let msg; + try { + msg = JSON.parse(line); + } catch { + write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); + return; + } + try { + const result = await handle(client, msg, { version }); + if (msg.id !== undefined && result !== undefined) write({ jsonrpc: '2.0', id: msg.id, result }); + } catch (err) { + if (msg.id !== undefined) write({ jsonrpc: '2.0', id: msg.id, error: { code: err.code ?? -32603, message: err.message } }); + } + }); + return new Promise((resolve) => rl.on('close', resolve)); +} diff --git a/src/cli/tui.js b/src/cli/tui.js new file mode 100644 index 00000000..dca68e11 --- /dev/null +++ b/src/cli/tui.js @@ -0,0 +1,453 @@ +/** + * `qc`: qrypt.chat in the terminal, on hqtui. + * + * β”Œ Chats β”€β”€β”€β”€β”€β”€β”β”Œ alice ─────────────── 2 people Β· ML-KEM-1024 ┐ + * β”‚β–Έ alice 2β”‚β”‚ ── Tue 6 Oct ── β”‚ + * β”‚ team chat β”‚β”‚ alice 14:02 β”‚ + * β”‚ β”‚β”‚ shipped it πŸš€ β”‚ + * β”‚ β”‚β”‚ you 14:03 β”‚ + * β”‚ β”‚β”‚ πŸ”₯ β”‚ + * β”‚ β”‚β”‚ alice is typing… β”‚ + * β”‚ β”‚β”‚ ▏Message β”‚ + * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + * ENTER send CTRL+E emoji TAB chats PGUP scroll ● live + * + * The view is a pure function of one state object (render() below), so tests + * draw the real layout with renderToText. The controller (runTui) owns the + * network: conversations, decrypted messages, sending, and the SSE stream. + */ +import { createApp, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from '@profullstack/hqtui'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { configDir } from './config.js'; + +const SIDEBAR = 30; + +export function initialState(me) { + return { + me, + conversations: [], + selected: 0, + activeId: null, + messages: {}, + unread: {}, + typing: {}, + focus: 'compose', + field: { value: '', cursor: 0 }, + picker: null, + recent: [], + scrollBack: 0, + status: 'connecting', + notice: '', + loading: true, + }; +} + +const pad2 = (n) => String(n).padStart(2, '0'); +const timeOf = (iso) => { + const d = new Date(iso); + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +}; +const dayOf = (iso) => new Date(iso).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' }); + +/** A stable colour per sender, so a busy group stays readable. */ +function nameColor(theme, key) { + const palette = [theme.accent, theme.info, theme.success, theme.warning, theme.secondary, theme.primary]; + let h = 0; + for (const ch of key) h = (h * 31 + ch.codePointAt(0)) >>> 0; + return palette[h % palette.length]; +} + +/** The transcript as display lines for a given width, oldest first. */ +export function transcriptLines(messages, width, theme) { + const lines = []; + let day = ''; + let last = null; + for (const m of messages) { + const d = dayOf(m.at); + if (d !== day) { + day = d; + const label = ` ${d} `; + const side = Math.max(2, Math.floor((width - stringWidth(label)) / 2)); + lines.push({ text: `${'─'.repeat(side)}${label}${'─'.repeat(Math.max(0, width - side - stringWidth(label)))}`, fg: theme.border }); + last = null; + } + const sameRun = last && last.senderId === m.senderId && new Date(m.at) - new Date(last.at) < 5 * 60 * 1000; + if (!sameRun) { + lines.push({ + spans: [ + { text: m.mine ? 'you' : m.sender, fg: m.mine ? theme.primary : nameColor(theme, m.senderId || m.sender), bold: true }, + { text: ` ${timeOf(m.at)}`, fg: theme.muted }, + ], + }); + } + for (const part of String(m.text ?? '').split('\n')) { + for (const line of wrap(part, Math.max(4, width - 2))) lines.push({ text: ` ${line}`, fg: theme.foreground }); + } + last = m; + } + return lines; +} + +function drawTranscript(surface, state) { + const theme = surface.theme; + const messages = state.messages[state.activeId] || []; + if (!state.activeId) { + surface.text(1, 1, state.loading ? 'Loading your chats…' : 'Pick a chat on the left.', { fg: theme.muted }); + return; + } + if (!messages.length) { + surface.text(1, 1, 'No messages yet. Say hello πŸ‘‹', { fg: theme.muted }); + return; + } + const lines = transcriptLines(messages, surface.width, theme); + const start = Math.max(0, lines.length - surface.height - state.scrollBack); + // Newest at the bottom, just above the composer, like every chat app. + const top = Math.max(0, surface.height - (lines.length - start)); + for (let y = top; y < surface.height; y++) { + const line = lines[start + y - top]; + if (!line) break; + if (line.spans) { + let x = 0; + for (const span of line.spans) x += surface.text(x, y, span.text, { fg: span.fg, attrs: span.bold ? 1 : 0 }); + } else { + surface.text(0, y, truncate(line.text, surface.width), { fg: line.fg }); + } + } + if (state.scrollBack > 0) { + const hint = ` ↓ ${state.scrollBack} more `; + surface.text(Math.max(0, surface.width - stringWidth(hint)), surface.height - 1, hint, { fg: theme.background, bg: theme.accent }); + } +} + +/** Draw the whole screen for a state. Pure: callbacks are passed in. */ +export function render({ ui, width, height, theme }, state, on = {}) { + const active = state.conversations.find((c) => c.id === state.activeId); + const typingNames = Object.values(state.typing[state.activeId] || {}).map((t) => t.name); + + ui.row({ size: 1 }, (bar) => { + bar.draw((s) => { + const t = s.theme; + s.fillRect(0, 0, s.width, 1, { bg: t.surface ?? t.background }); + let x = s.text(0, 0, ' πŸ”’ qrypt.chat ', { fg: t.background, bg: t.primary, attrs: 1 }); + x += s.text(x, 0, ` @${state.me?.username ?? '?'}`, { fg: t.foreground, bg: t.surface ?? t.background }); + if (state.notice) s.text(x + 2, 0, truncate(state.notice, Math.max(0, s.width - x - 16)), { fg: t.warning, bg: t.surface ?? t.background }); + const live = { live: ['●', 'live', t.success], connecting: ['β—Œ', 'connecting', t.warning], offline: ['β—‹', 'offline', t.danger] }[state.status] ?? ['β—‹', state.status, t.muted]; + const label = `${live[0]} ${live[1]} `; + s.text(s.width - stringWidth(label), 0, label, { fg: live[2], bg: t.surface ?? t.background }); + }); + }); + + ui.row({ size: 'fill' }, (row) => { + const listWidth = Math.min(SIDEBAR, Math.max(18, Math.floor(width * 0.3))); + row.panel({ title: 'Chats', size: listWidth, borderColor: state.focus === 'list' ? theme?.borderFocused : undefined }, (p) => { + // hqtui's list declares a badge but does not draw it, so the unread + // count is laid out here: name on the left, ●N flush right. + const inner = Math.max(4, listWidth - 5); // borders, padding, scrollbar + p.list({ + items: state.conversations.map((c) => { + const unread = state.unread[c.id] || 0; + const tag = unread ? ` ●${unread > 99 ? '99+' : unread}` : ''; + const name = truncate(c.title, inner - stringWidth(tag)); + return { + label: `${name}${' '.repeat(Math.max(0, inner - stringWidth(name) - stringWidth(tag)))}${tag}`, + color: unread ? theme?.accent : undefined, + }; + }), + selected: state.selected, + followSelection: true, + scrollbar: true, + // One click opens a chat: no select-then-double-click. + onSelectRow: (i) => on.open?.(i), + onActivateRow: (i) => on.open?.(i), + }); + }); + const people = active?.participants?.length ?? 0; + row.panel( + { + title: active?.title ?? 'qc', + subtitle: active ? `${people} ${people === 1 ? 'person' : 'people'} Β· ML-KEM-1024` : 'end-to-end encrypted', + borderColor: state.focus === 'compose' ? theme?.borderFocused : undefined, + }, + (p) => { + p.draw((s) => { + drawTranscript(s, state); + p.ctx.hit({ rect: s.hitRect(), onScroll: (delta) => on.scroll?.(-delta) }); + }, { size: 'fill' }); + p.label(typingNames.length ? `${typingNames.join(', ')} ${typingNames.length === 1 ? 'is' : 'are'} typing…` : ' ', { size: 1 }); + p.textInput({ + value: state.field.value, + cursor: state.field.cursor, + placeholder: active ? `Message ${active.title} (Ctrl+E emoji, :rocket: works too)` : 'Pick a chat first', + focused: state.focus === 'compose' && !state.picker, + size: 1, + }); + }, + ); + }); + + ui.statusBar({ + items: [ + { key: 'Enter', label: state.focus === 'list' ? 'open' : 'send' }, + { key: 'Ctrl+E', label: 'emoji' }, + { key: 'Tab', label: state.focus === 'list' ? 'compose' : 'chats' }, + { key: 'PgUp', label: 'scroll' }, + { key: 'Ctrl+C', label: 'quit' }, + ], + size: 1, + }); + + if (state.picker) { + const size = widgets.emojiPickerSize(10, 7); + ui.emojiPicker({ + state: state.picker, + recent: state.recent, + x: Math.min(Math.max(0, width - size.width - 1), Math.min(SIDEBAR, Math.floor(width * 0.3)) + 1), + y: Math.max(0, height - size.height - 3), + onChange: (s) => on.pickerChange?.(s), + onPick: (e) => on.pick?.(e), + onClose: () => on.pickerClose?.(), + }); + } +} + +const recentFile = () => join(configDir(), 'recent-emoji.json'); +function loadRecent() { + try { + const list = JSON.parse(readFileSync(recentFile(), 'utf8')); + return Array.isArray(list) ? list.filter((c) => typeof c === 'string').slice(0, 30) : []; + } catch { + return []; + } +} +function saveRecent(list) { + try { + writeFileSync(recentFile(), JSON.stringify(list), { mode: 0o600 }); + } catch { + // recents are a convenience + } +} + +/** Run the full-screen client until Ctrl+C. */ +export async function runTui(client, { initialChat } = {}) { + const state = initialState(client.me); + state.recent = loadRecent(); + const abort = new AbortController(); + const app = await createApp({ quitKeys: ['ctrl+c'], focusNavigation: false }); + const redraw = () => app.invalidate(); + const note = (msg) => { + state.notice = msg; + redraw(); + if (msg) setTimeout(() => { + if (state.notice === msg) { + state.notice = ''; + redraw(); + } + }, 6000); + }; + + // A stray console line would tear the screen; the crypto is already muted. + for (const m of ['log', 'info', 'warn', 'error', 'debug']) console[m] = () => {}; + + async function loadMessages(id) { + try { + const { messages } = await client.messages(id, { limit: 100 }); + state.messages[id] = messages; + redraw(); + } catch (err) { + note(`Could not load messages: ${err.message}`); + } + } + + async function loadConversations() { + try { + const before = state.activeId; + state.conversations = await client.conversations(); + state.loading = false; + if (!state.activeId && state.conversations.length) { + const want = initialChat ? state.conversations.findIndex((c) => c.id === initialChat || c.title.toLowerCase().includes(String(initialChat).toLowerCase())) : 0; + openAt(Math.max(0, want)); + } else { + state.selected = Math.max(0, state.conversations.findIndex((c) => c.id === before)); + } + redraw(); + // Loading a conversation's messages joins its live room on the server, + // so warm every room: that is what makes unread counts arrive. + for (const c of state.conversations.slice(0, 40)) { + if (c.id !== state.activeId && !state.messages[c.id]) await loadMessages(c.id); + } + } catch (err) { + state.loading = false; + note(err.status === 401 ? 'Session ended: run qc login' : `Could not load chats: ${err.message}`); + } + } + + function openAt(i) { + const c = state.conversations[i]; + if (!c) return; + state.selected = i; + state.activeId = c.id; + state.unread[c.id] = 0; + state.scrollBack = 0; + if (!state.messages[c.id]) loadMessages(c.id); + redraw(); + } + + let typingSent = 0; + function typed() { + if (!state.activeId) return; + const now = Date.now(); + if (now - typingSent > 3000) { + typingSent = now; + client.typing(state.activeId, true); + } + } + + async function send() { + const text = emojify(state.field.value.trim(), { mode: 'emoji' }); + if (!text || !state.activeId) return; + const id = state.activeId; + state.field = { value: '', cursor: 0 }; + state.scrollBack = 0; + const pending = { id: `pending-${Date.now()}`, senderId: client.me?.id, sender: 'you', mine: true, text, at: new Date().toISOString() }; + state.messages[id] = [...(state.messages[id] || []), pending]; + redraw(); + try { + const { skipped } = await client.send(id, text); + if (skipped) note(`${skipped} participant(s) have no key yet and will not see this`); + client.typing(id, false); + typingSent = 0; + await loadMessages(id); + } catch (err) { + state.messages[id] = (state.messages[id] || []).filter((m) => m !== pending); + state.field = { value: text, cursor: text.length }; + note(`Not sent: ${err.message}`); + } + } + + const handlers = { + select: (i) => { + state.selected = i; + redraw(); + }, + open: (i) => { + openAt(i); + state.focus = 'compose'; + }, + scroll: (delta) => { + state.scrollBack = Math.max(0, state.scrollBack + delta * 3); + redraw(); + }, + pickerChange: (s) => { + state.picker = s; + redraw(); + }, + pick: (e) => { + state.field = insertText(state.field, e.char); + state.recent = [e.char, ...state.recent.filter((c) => c !== e.char)].slice(0, 30); + saveRecent(state.recent); + state.picker = null; + typed(); + redraw(); + }, + pickerClose: () => { + state.picker = null; + redraw(); + }, + }; + + app.on('key', (event) => { + if (state.picker) return; // the picker's own key handler has it + if (event.key === 'ctrl+e') { + state.picker = widgets.createEmojiPicker(state.recent); + } else if (event.key === 'tab' || event.key === 'shift+tab' || (event.key === 'escape' && state.focus === 'compose')) { + state.focus = state.focus === 'list' ? 'compose' : 'list'; + } else if (event.key === 'pageup') { + handlers.scroll(4); + } else if (event.key === 'pagedown') { + handlers.scroll(-4); + } else if (event.key === 'ctrl+n' || event.key === 'alt+down') { + openAt(Math.min(state.conversations.length - 1, state.selected + 1)); + } else if (event.key === 'ctrl+p' || event.key === 'alt+up') { + openAt(Math.max(0, state.selected - 1)); + } else if (event.key === 'ctrl+r') { + loadConversations(); + if (state.activeId) loadMessages(state.activeId); + } else if (state.focus === 'list') { + if (event.key === 'up' || event.key === 'k') state.selected = Math.max(0, state.selected - 1); + else if (event.key === 'down' || event.key === 'j') state.selected = Math.min(state.conversations.length - 1, state.selected + 1); + else if (event.key === 'enter' || event.key === 'right' || event.key === 'l') handlers.open(state.selected); + } else if (event.key === 'enter') { + send(); + } else if (event.key === 'up' && !state.field.value) { + handlers.scroll(1); + } else if (event.key === 'down' && !state.field.value) { + handlers.scroll(-1); + } else { + const next = editText(state.field, event); + if (next) { + if (next.value !== state.field.value) typed(); + state.field = next; + } + } + redraw(); + }); + app.on('paste', (event) => { + if (state.picker || state.focus !== 'compose') return; + state.field = insertText(state.field, event.text); + redraw(); + }); + app.on('exit', () => abort.abort()); + + app.render((args) => render(args, state, handlers)); + + loadConversations(); + client + .events( + ({ type, data }) => { + if (type === 'NEW_MESSAGE') { + const id = data?.message?.conversation_id; + if (!id) return; + if (id === state.activeId) loadMessages(id); + else { + if (data.message.sender_id !== client.me?.id) state.unread[id] = (state.unread[id] || 0) + 1; + delete state.messages[id]; + } + // Float the chat with news to the top. + const i = state.conversations.findIndex((c) => c.id === id); + if (i > 0) { + const [c] = state.conversations.splice(i, 1); + state.conversations.unshift(c); + state.selected = state.conversations.findIndex((x) => x.id === state.activeId); + } + if (i === -1) loadConversations(); + if (data.message.sender_id !== client.me?.id) delete (state.typing[id] || {})[data.message.sender_id]; + } else if (type === 'USER_TYPING' && data?.conversationId) { + const room = (state.typing[data.conversationId] ||= {}); + if (data.isTyping) { + room[data.userId] = { name: data.displayName || data.username || 'someone', until: Date.now() + 6000 }; + setTimeout(() => { + if (room[data.userId]?.until <= Date.now()) { + delete room[data.userId]; + redraw(); + } + }, 6100); + } else delete room[data.userId]; + } else if (type === 'CONVERSATION_CREATED') { + loadConversations(); + } + redraw(); + }, + { + signal: abort.signal, + onStatus: (s) => { + state.status = s; + redraw(); + }, + }, + ) + .catch((err) => note(err.message)); + + await app.start(); + abort.abort(); +} diff --git a/src/lib/auth/cli-auth.js b/src/lib/auth/cli-auth.js new file mode 100644 index 00000000..1ec8486a --- /dev/null +++ b/src/lib/auth/cli-auth.js @@ -0,0 +1,180 @@ +/** + * `qc login`: OAuth 2.1 authorization code + PKCE for the qc command-line client. + * + * 1. qc opens /cli/authorize with an S256 code_challenge, a loopback + * redirect_uri and an ephemeral ML-KEM-1024 public key. + * 2. The signed-in web app approves: it seals the account keypair to that + * ephemeral key and POSTs /api/cli/authorize, which stores a one-time code. + * 3. qc POSTs /api/cli/token with code + code_verifier and gets its OWN + * session (never a copy of the browser's: Supabase rotates refresh tokens, + * and two holders of one family revoke each other) plus the sealed keys. + * + * Refresh is grant_type=refresh_token on the same endpoint; Supabase rotates it. + */ +import { createHash, randomBytes } from 'node:crypto'; +import { createClient } from '@supabase/supabase-js'; + +export const CODE_TTL_MS = 5 * 60 * 1000; + +/** Where a phone-only or anonymous account's magic-link address lives. */ +const EMAIL_DOMAIN = process.env.CLI_IDENTITY_EMAIL_DOMAIN || 'cli.qrypt.chat'; + +export class CliAuthError extends Error { + /** @param {string} code OAuth error code @param {string} message @param {number} [status] */ + constructor(code, message, status = 400) { + super(message); + this.name = 'CliAuthError'; + this.code = code; + this.status = status; + } +} + +export const b64url = (buf) => Buffer.from(buf).toString('base64url'); + +/** S256: base64url(sha256(verifier)). */ +export function pkceChallenge(verifier) { + return b64url(createHash('sha256').update(verifier).digest()); +} + +export const hashCode = (code) => createHash('sha256').update(code).digest('hex'); + +export function newCode() { + return b64url(randomBytes(32)); +} + +/** RFC 7636: 43-128 chars of [A-Za-z0-9-._~]. */ +export function validVerifier(v) { + return typeof v === 'string' && /^[A-Za-z0-9\-._~]{43,128}$/.test(v); +} + +export function validChallenge(c) { + return typeof c === 'string' && /^[A-Za-z0-9_-]{43}$/.test(c); +} + +/** + * Only a loopback redirect (RFC 8252 7.3), or "oob" when the CLI runs where no + * browser can reach it back (SSH): the page then shows the code to paste. + */ +export function validRedirectUri(uri) { + if (uri === 'oob') return true; + try { + const u = new URL(uri); + return u.protocol === 'http:' && (u.hostname === '127.0.0.1' || u.hostname === '[::1]') && !!u.port && !u.username && !u.password && !u.hash; + } catch { + return false; + } +} + +/** A service-role client (bypasses RLS). */ +export function serviceClient() { + return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, { + auth: { autoRefreshToken: false, persistSession: false } + }); +} + +/** An anon client that never touches cookies: sessions here belong to the CLI. */ +export function anonClient() { + return createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, { + auth: { autoRefreshToken: false, persistSession: false } + }); +} + +/** + * The magic-link bridge is addressed by email. Phone-only and anonymous + * accounts have none, so they get a confirmed synthetic one, the same way a + * Moshpit name does (see name-account.js). It is keyed by the auth id, so it + * can never collide with another account. + */ +export async function sessionEmail(service, authUserId) { + const { data, error } = await service.auth.admin.getUserById(authUserId); + if (error || !data?.user) throw new CliAuthError('server_error', 'Account not found', 500); + if (data.user.email) return data.user.email; + const email = `${authUserId}@${EMAIL_DOMAIN}`; + const { error: updateError } = await service.auth.admin.updateUserById(authUserId, { email, email_confirm: true }); + if (updateError) throw new CliAuthError('server_error', 'Could not prepare a session for this account', 500); + return email; +} + +export function sessionPayload(session) { + return { + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_at: session.expires_at, + expires_in: session.expires_in, + token_type: 'bearer' + }; +} + +/** A fresh session for an account, independent of any browser's. */ +export async function mintSession(service, authUserId) { + const email = await sessionEmail(service, authUserId); + const { data: link, error: linkError } = await service.auth.admin.generateLink({ type: 'magiclink', email }); + if (linkError || !link?.properties?.hashed_token) { + throw new CliAuthError('server_error', 'Could not start a session', 500); + } + const { data, error } = await anonClient().auth.verifyOtp({ type: 'magiclink', token_hash: link.properties.hashed_token }); + if (error || !data?.session) throw new CliAuthError('server_error', 'Could not start a session', 500); + return sessionPayload(data.session); +} + +/** Store a one-time code for an approved request; returns the code itself. */ +export async function issueCode(service, { authUserId, codeChallenge, redirectUri, clientName, keyBlob }) { + if (!validChallenge(codeChallenge)) throw new CliAuthError('invalid_request', 'code_challenge must be an S256 challenge'); + if (!validRedirectUri(redirectUri)) throw new CliAuthError('invalid_request', 'redirect_uri must be a loopback address or "oob"'); + if (typeof keyBlob !== 'string' || keyBlob.length < 32 || keyBlob.length > 200_000) { + throw new CliAuthError('invalid_request', 'key_blob is missing or malformed'); + } + const code = newCode(); + const { error } = await service.from('cli_auth_codes').insert({ + code_hash: hashCode(code), + auth_user_id: authUserId, + code_challenge: codeChallenge, + redirect_uri: redirectUri, + client_name: typeof clientName === 'string' ? clientName.slice(0, 80) : null, + key_blob: keyBlob, + expires_at: new Date(Date.now() + CODE_TTL_MS).toISOString() + }); + if (error) throw new CliAuthError('server_error', 'Could not store the authorization', 500); + return code; +} + +/** + * Trade a code + verifier for a session. Every check runs BEFORE the burn, and + * the burn is a conditional UPDATE, so two racing requests cannot both win. + */ +export async function redeemCode(service, { code, codeVerifier, redirectUri }) { + if (typeof code !== 'string' || !validVerifier(codeVerifier)) { + throw new CliAuthError('invalid_request', 'code and code_verifier are required'); + } + const codeHash = hashCode(code); + const { data: row } = await service.from('cli_auth_codes').select('*').eq('code_hash', codeHash).maybeSingle(); + if (!row || row.consumed_at || new Date(row.expires_at) < new Date()) { + throw new CliAuthError('invalid_grant', 'That code is unknown, used or expired'); + } + if (row.redirect_uri !== redirectUri) throw new CliAuthError('invalid_grant', 'redirect_uri does not match'); + if (pkceChallenge(codeVerifier) !== row.code_challenge) throw new CliAuthError('invalid_grant', 'code_verifier does not match'); + + const { data: burned } = await service + .from('cli_auth_codes') + .update({ consumed_at: new Date().toISOString() }) + .eq('code_hash', codeHash) + .is('consumed_at', null) + .select('code_hash'); + if (!Array.isArray(burned) || burned.length === 0) throw new CliAuthError('invalid_grant', 'That code has already been used'); + + const session = await mintSession(service, row.auth_user_id); + const { data: user } = await service + .from('users') + .select('id, username, display_name') + .eq('auth_user_id', row.auth_user_id) + .maybeSingle(); + return { ...session, key_blob: row.key_blob, user: user ?? null }; +} + +/** grant_type=refresh_token: Supabase rotates the token on every use. */ +export async function refresh(refreshToken) { + if (typeof refreshToken !== 'string' || !refreshToken) throw new CliAuthError('invalid_request', 'refresh_token is required'); + const { data, error } = await anonClient().auth.refreshSession({ refresh_token: refreshToken }); + if (error || !data?.session) throw new CliAuthError('invalid_grant', 'That refresh token is no longer valid; run qc login'); + return sessionPayload(data.session); +} diff --git a/src/lib/auth/cli-match.js b/src/lib/auth/cli-match.js new file mode 100644 index 00000000..22c33a60 --- /dev/null +++ b/src/lib/auth/cli-match.js @@ -0,0 +1,17 @@ +/** + * The confirmation code `qc login` prints and /cli/authorize shows: eight + * characters derived from the PKCE challenge and the CLI's ephemeral key. If + * they differ, the page is approving someone else's terminal. Works in the + * browser and in Node/Bun (both have WebCrypto). + */ +const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +/** @param {string} challenge @param {string} kemPublicKey */ +export async function matchCode(challenge, kemPublicKey) { + const bytes = new Uint8Array( + await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(`qc-login\n${challenge}\n${kemPublicKey}`)) + ); + let out = ''; + for (let i = 0; i < 8; i++) out += ALPHABET[bytes[i] % ALPHABET.length]; + return `${out.slice(0, 4)}-${out.slice(4)}`; +} diff --git a/supabase/migrations/20261006120000_cli_auth_codes.sql b/supabase/migrations/20261006120000_cli_auth_codes.sql new file mode 100644 index 00000000..184080ab --- /dev/null +++ b/supabase/migrations/20261006120000_cli_auth_codes.sql @@ -0,0 +1,26 @@ +-- `qc login`: one-time authorization codes for the qc command-line client. +-- +-- OAuth 2.1 shape: authorization code + PKCE (S256), started from the CLI. The +-- signed-in web app approves a request and stores a code here; the CLI trades +-- code + verifier at /api/cli/token for its own session. A code lives five +-- minutes and is spendable once (consumed_at IS NULL is the burn). +-- +-- key_blob is the account's keypair sealed (ML-KEM-1024 + ChaCha20-Poly1305) to +-- an ephemeral public key the CLI generated for this one login. The server +-- relays it and cannot open it; only code_hash is stored, never the code. +CREATE TABLE IF NOT EXISTS cli_auth_codes ( + code_hash TEXT PRIMARY KEY, + auth_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + code_challenge TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + client_name TEXT, + key_blob TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS cli_auth_codes_expiry ON cli_auth_codes (expires_at); + +-- RLS on, zero policies: service_role only, same shape as name_challenges. +ALTER TABLE cli_auth_codes ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON cli_auth_codes FROM anon, authenticated; diff --git a/tests/cli/cli-auth.test.js b/tests/cli/cli-auth.test.js new file mode 100644 index 00000000..071fb239 --- /dev/null +++ b/tests/cli/cli-auth.test.js @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const verifyOtp = vi.fn(); +const refreshSession = vi.fn(); +vi.mock('@supabase/supabase-js', () => ({ + createClient: () => ({ auth: { verifyOtp, refreshSession } }), +})); + +const { pkceChallenge, validRedirectUri, validVerifier, issueCode, redeemCode, hashCode, refresh, CliAuthError } = await import( + '../../src/lib/auth/cli-auth.js' +); + +/** Just enough of the Supabase query builder for cli_auth_codes and users. */ +function fakeService({ email = 'a@b.c' } = {}) { + const rows = new Map(); + const updates = []; + const service = { + rows, + updates, + auth: { + admin: { + getUserById: vi.fn(async (id) => ({ data: { user: { id, email } } })), + updateUserById: vi.fn(async () => ({ error: null })), + generateLink: vi.fn(async () => ({ data: { properties: { hashed_token: 'h' } } })), + }, + }, + from(table) { + const q = { table, filters: [], isNull: null }; + const builder = { + insert: async (row) => { + rows.set(row.code_hash, { ...row, consumed_at: null }); + return { error: null }; + }, + select: () => builder, + update: (patch) => { + q.patch = patch; + return builder; + }, + eq: (col, val) => { + q.filters.push([col, val]); + if (q.patch) return builder; + return builder; + }, + is: (col) => { + q.isNull = col; + if (q.patch) { + const [, hash] = q.filters[0]; + const row = rows.get(hash); + const won = row && row[col] === null; + if (won) Object.assign(row, q.patch); + updates.push(won); + return { select: async () => ({ data: won ? [{ code_hash: hash }] : [] }) }; + } + return builder; + }, + maybeSingle: async () => { + if (table === 'users') return { data: { id: 'internal-1', username: 'alice', display_name: 'Alice' } }; + const [, hash] = q.filters[0]; + return { data: rows.get(hash) ? { ...rows.get(hash) } : null }; + }, + }; + return builder; + }, + }; + return service; +} + +const VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; +const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; // RFC 7636 appendix B +const REDIRECT = 'http://127.0.0.1:53682/callback'; + +beforeEach(() => { + verifyOtp.mockResolvedValue({ data: { session: { access_token: 'at', refresh_token: 'rt', expires_at: 9, expires_in: 3600 } } }); +}); + +describe('PKCE and redirects', () => { + it('matches the RFC 7636 S256 example', () => { + expect(pkceChallenge(VERIFIER)).toBe(CHALLENGE); + expect(validVerifier(VERIFIER)).toBe(true); + expect(validVerifier('short')).toBe(false); + }); + + it('accepts only loopback redirects or oob', () => { + expect(validRedirectUri(REDIRECT)).toBe(true); + expect(validRedirectUri('http://[::1]:9000/cb')).toBe(true); + expect(validRedirectUri('oob')).toBe(true); + for (const bad of ['https://evil.test/cb', 'http://localhost.evil.test:1/cb', 'http://127.0.0.1/cb', 'http://user@127.0.0.1:1/cb', 'javascript:alert(1)']) { + expect(validRedirectUri(bad)).toBe(false); + } + }); +}); + +describe('codes', () => { + async function issued(service) { + return issueCode(service, { authUserId: 'auth-1', codeChallenge: CHALLENGE, redirectUri: REDIRECT, clientName: 'qc on box', keyBlob: 'x'.repeat(64) }); + } + + it('stores only the hash, and redeems once with the right verifier', async () => { + const service = fakeService(); + const code = await issued(service); + expect(service.rows.has(code)).toBe(false); + expect(service.rows.has(hashCode(code))).toBe(true); + + const token = await redeemCode(service, { code, codeVerifier: VERIFIER, redirectUri: REDIRECT }); + expect(token).toMatchObject({ access_token: 'at', refresh_token: 'rt', key_blob: 'x'.repeat(64), user: { username: 'alice' } }); + + await expect(redeemCode(service, { code, codeVerifier: VERIFIER, redirectUri: REDIRECT })).rejects.toThrow(/unknown, used or expired/); + }); + + it('refuses a wrong verifier or redirect without burning the code', async () => { + const service = fakeService(); + const code = await issued(service); + await expect(redeemCode(service, { code, codeVerifier: 'A'.repeat(43), redirectUri: REDIRECT })).rejects.toThrow(/code_verifier/); + await expect(redeemCode(service, { code, codeVerifier: VERIFIER, redirectUri: 'http://127.0.0.1:1/x' })).rejects.toThrow(/redirect_uri/); + expect(service.updates).toEqual([]); + await expect(redeemCode(service, { code, codeVerifier: VERIFIER, redirectUri: REDIRECT })).resolves.toBeTruthy(); + }); + + it('refuses an expired code', async () => { + const service = fakeService(); + const code = await issued(service); + service.rows.get(hashCode(code)).expires_at = new Date(Date.now() - 1000).toISOString(); + await expect(redeemCode(service, { code, codeVerifier: VERIFIER, redirectUri: REDIRECT })).rejects.toBeInstanceOf(CliAuthError); + }); + + it('gives a phone-only account a synthetic address before minting', async () => { + const service = fakeService({ email: null }); + const code = await issued(service); + await redeemCode(service, { code, codeVerifier: VERIFIER, redirectUri: REDIRECT }); + expect(service.auth.admin.updateUserById).toHaveBeenCalledWith('auth-1', { email: 'auth-1@cli.qrypt.chat', email_confirm: true }); + expect(service.auth.admin.generateLink).toHaveBeenCalledWith({ type: 'magiclink', email: 'auth-1@cli.qrypt.chat' }); + }); + + it('validates what the browser sends', async () => { + const service = fakeService(); + await expect(issueCode(service, { authUserId: 'a', codeChallenge: 'x', redirectUri: REDIRECT, keyBlob: 'x'.repeat(64) })).rejects.toThrow(/S256/); + await expect(issueCode(service, { authUserId: 'a', codeChallenge: CHALLENGE, redirectUri: 'https://evil.test/', keyBlob: 'x'.repeat(64) })).rejects.toThrow(/loopback/); + await expect(issueCode(service, { authUserId: 'a', codeChallenge: CHALLENGE, redirectUri: REDIRECT, keyBlob: '' })).rejects.toThrow(/key_blob/); + }); +}); + +describe('refresh', () => { + it('rotates through Supabase and reports a dead token as invalid_grant', async () => { + refreshSession.mockResolvedValueOnce({ data: { session: { access_token: 'a2', refresh_token: 'r2', expires_at: 1, expires_in: 1 } } }); + expect(await refresh('r1')).toMatchObject({ access_token: 'a2', refresh_token: 'r2' }); + refreshSession.mockResolvedValueOnce({ data: {}, error: { message: 'Invalid Refresh Token' } }); + await expect(refresh('r1')).rejects.toMatchObject({ code: 'invalid_grant' }); + }); +}); diff --git a/tests/cli/qc.test.js b/tests/cli/qc.test.js new file mode 100644 index 00000000..e9e9c27f --- /dev/null +++ b/tests/cli/qc.test.js @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ephemeralKeypair, keyring, openKeyBlob } from '../../src/cli/crypto.js'; +import { QcClient, readSse, conversationTitle } from '../../src/cli/api.js'; +import { parseArgs, findChat } from '../../src/cli/commands.js'; +import { handle } from '../../src/cli/mcp.js'; +import { matchCode } from '../../src/lib/auth/cli-match.js'; + +const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +async function account() { + return { keys1024: await ephemeralKeypair() }; +} + +describe('the login key handoff', () => { + it('opens keys the browser sealed to the one-time key, and nothing else does', async () => { + const me = await account(); + const ephemeral = await ephemeralKeypair(); + // What /cli/authorize does in the browser: seal the export to the CLI's key. + const blob = await keyring(me).encrypt(JSON.stringify({ v: 1, ...me }), ephemeral.publicKey); + expect(blob).not.toContain(me.keys1024.privateKey); + + const opened = await openKeyBlob(blob, ephemeral); + expect(opened.keys1024).toEqual(me.keys1024); + + await expect(openKeyBlob(blob, await ephemeralKeypair())).rejects.toThrow(/Could not open/); + }, 20000); + + it('derives the same confirmation code on both sides', async () => { + const a = await matchCode('challenge-x', 'kem-y'); + expect(a).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/); + expect(await matchCode('challenge-x', 'kem-y')).toBe(a); + expect(await matchCode('challenge-x', 'kem-z')).not.toBe(a); + }); +}); + +describe('QcClient', () => { + it('encrypts a message once per participant, each copy only its owner can read', async () => { + const alice = await account(); + const bob = await account(); + let sent; + const fetch = vi.fn(async (url, init) => { + const path = new URL(url).pathname; + if (path.endsWith('/participants')) return json({ participants: [{ user_id: 'a' }, { user_id: 'b' }, { user_id: 'c' }] }); + if (path === '/api/crypto/public-keys') return json({ public_keys: { a: alice.keys1024.publicKey, b: bob.keys1024.publicKey, c: null } }); + if (path === '/api/messages/send') { + sent = JSON.parse(init.body); + expect(init.headers.Authorization).toBe('Bearer tok'); + return json({ success: true, message: { id: 'm1' } }); + } + return json({ error: 'nope' }, 404); + }); + const client = new QcClient( + { base: 'https://chat.test', access_token: 'tok', refresh_token: 'r', expires_at: Date.now() / 1000 + 3600, user: { id: 'a' }, keys: alice }, + { fetch, save: () => {} }, + ); + const { message, skipped } = await client.send('conv', 'ship it πŸš€'); + expect(message.id).toBe('m1'); + expect(skipped).toBe(1); + expect(Object.keys(sent.encryptedContents).sort()).toEqual(['a', 'b']); + expect(JSON.stringify(sent)).not.toContain('ship it'); + expect(await keyring(bob).decrypt(sent.encryptedContents.b)).toBe('ship it πŸš€'); + expect(await keyring(alice).decrypt(sent.encryptedContents.a)).toBe('ship it πŸš€'); + }, 20000); + + it('decrypts loaded messages and marks its own', async () => { + const alice = await account(); + const content = await keyring(alice).encrypt('hello', alice.keys1024.publicKey); + const fetch = vi.fn(async () => + json({ messages: [{ id: 'm', conversation_id: 'c', sender_id: 'a', sender: { username: 'alice' }, encrypted_content: content, created_at: '2026-10-06T10:00:00Z' }] }), + ); + const client = new QcClient({ access_token: 't', keys: alice, user: { id: 'a' } }, { fetch, save: () => {} }); + const { messages } = await client.messages('c'); + expect(messages[0]).toMatchObject({ text: 'hello', mine: true, sender: 'alice' }); + }, 20000); + + it('refreshes once on a 401, saves the rotated session, and retries', async () => { + const saved = []; + let calls = 0; + const fetch = vi.fn(async (url, init) => { + const path = new URL(url).pathname; + if (path === '/api/cli/token') { + expect(JSON.parse(init.body)).toEqual({ grant_type: 'refresh_token', refresh_token: 'old-r' }); + return json({ access_token: 'new', refresh_token: 'new-r', expires_at: Date.now() / 1000 + 3600 }); + } + calls++; + return init.headers.Authorization === 'Bearer new' ? json({ conversations: [] }) : json({ error: 'expired' }, 401); + }); + const client = new QcClient({ access_token: 'old', refresh_token: 'old-r', keys: await account(), user: { id: 'a' } }, { fetch, save: (s) => saved.push(s) }); + expect(await client.conversations()).toEqual([]); + expect(calls).toBe(2); + expect(saved.at(-1)).toMatchObject({ access_token: 'new', refresh_token: 'new-r' }); + }, 20000); +}); + +describe('the SSE reader', () => { + it('parses frames split across chunks', async () => { + const enc = new TextEncoder(); + const frame = `event: NEW_MESSAGE\ndata: ${JSON.stringify({ type: 'NEW_MESSAGE', data: { message: { id: 'x' } } })}\n\n`; + async function* body() { + yield enc.encode(frame.slice(0, 20)); + yield enc.encode(frame.slice(20) + 'event: ping\ndata: {"type":"ping","data":{}}\n\n'); + } + const events = []; + await readSse(body(), (e) => events.push(e)); + expect(events).toEqual([ + { type: 'NEW_MESSAGE', data: { message: { id: 'x' } } }, + { type: 'ping', data: {} }, + ]); + }); +}); + +describe('arguments and chats', () => { + it('parses flags', () => { + expect(parseArgs(['read', 'team', '-n', '5', '--json'])).toEqual({ _: ['read', 'team'], flags: { n: '5', json: true } }); + expect(parseArgs(['--url', 'http://x', 'chats'])).toEqual({ _: ['chats'], flags: { url: 'http://x' } }); + expect(parseArgs(['send', 'a', '--', '--not-a-flag'])._).toEqual(['send', 'a', '--not-a-flag']); + }); + + it('finds a chat by id, name or a unique fragment', () => { + const chats = [{ id: '1', title: 'Alice' }, { id: '2', title: 'Team chat' }, { id: '3', title: 'Team ops' }]; + expect(findChat(chats, '1').title).toBe('Alice'); + expect(findChat(chats, 'alice').id).toBe('1'); + expect(findChat(chats, 'ops').id).toBe('3'); + expect(() => findChat(chats, 'team')).toThrow(/matches 2 chats/); + expect(() => findChat(chats, 'zzz')).toThrow(/No chat matches/); + }); + + it('names a direct chat after the other people in it', () => { + const c = { participants: [{ user_id: 'me', user: { username: 'me' } }, { user_id: 'b', user: { display_name: 'Bob' } }] }; + expect(conversationTitle(c, { id: 'me' })).toBe('Bob'); + expect(conversationTitle({ name: 'Ops', participants: [] }, { id: 'me' })).toBe('Ops'); + }); +}); + +describe('qc mcp', () => { + const fake = { + conversations: async () => [{ id: 'c1', title: 'Alice', participants: [1, 2] }], + messages: async () => ({ messages: [{ sender: 'alice', mine: false, text: 'hi', at: 't' }] }), + send: async () => ({ message: { id: 'm' }, skipped: 0 }), + }; + + it('lists tools and answers initialize', async () => { + const init = await handle(fake, { id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } }, { version: '1' }); + expect(init.serverInfo.name).toBe('qc'); + const { tools } = await handle(fake, { id: 2, method: 'tools/list' }, { version: '1' }); + expect(tools.map((t) => t.name)).toEqual(['list_chats', 'read_chat', 'send_message']); + }); + + it('calls tools and reports errors as tool errors', async () => { + const read = await handle(fake, { id: 3, method: 'tools/call', params: { name: 'read_chat', arguments: { chat: 'alice' } } }, { version: '1' }); + expect(read.structuredContent.messages).toEqual([{ from: 'alice', text: 'hi', at: 't' }]); + const bad = await handle(fake, { id: 4, method: 'tools/call', params: { name: 'send_message', arguments: { chat: 'nobody', text: 'x' } } }, { version: '1' }); + expect(bad.isError).toBe(true); + await expect(handle(fake, { id: 5, method: 'nope' }, { version: '1' })).rejects.toThrow(/Method not found/); + expect(await handle(fake, { method: 'notifications/initialized' }, { version: '1' })).toBeUndefined(); + }); +}); diff --git a/tests/cli/tui.test.js b/tests/cli/tui.test.js new file mode 100644 index 00000000..d581a1d6 --- /dev/null +++ b/tests/cli/tui.test.js @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { renderToScreen } from '@profullstack/hqtui/testing'; +import { setIconMode } from '@profullstack/hqtui'; +import { initialState, render, transcriptLines } from '../../src/cli/tui.js'; + +setIconMode('unicode'); + +function state() { + const s = initialState({ id: 'me', username: 'anthony' }); + s.loading = false; + s.status = 'live'; + s.conversations = [ + { id: 'c1', title: 'alice', participants: [{ user_id: 'me' }, { user_id: 'a' }] }, + { id: 'c2', title: 'team chat', participants: [] }, + ]; + s.activeId = 'c1'; + s.unread = { c2: 3 }; + s.messages.c1 = [ + { id: '1', senderId: 'a', sender: 'alice', mine: false, text: 'shipped it πŸš€', at: '2026-10-06T14:02:00' }, + { id: '2', senderId: 'a', sender: 'alice', mine: false, text: 'second line in the same run', at: '2026-10-06T14:02:30' }, + { id: '3', senderId: 'me', sender: 'anthony', mine: true, text: 'πŸ”₯πŸ”₯', at: '2026-10-06T14:03:00' }, + ]; + s.typing = { c1: { a: { name: 'alice', until: Date.now() + 5000 } } }; + return s; +} + +const draw = (s, on = {}, size = { width: 100, height: 24 }) => + renderToScreen((args) => render(args, s, on), size); + +describe('the qc client screen', () => { + it('shows the brand, live status, chats with unread counts, transcript, typing and the composer', () => { + const screen = draw(state()); + const text = screen.text(); + expect(text).toContain('qrypt.chat'); + expect(text).toContain('@anthony'); + expect(text).toContain('● live'); + expect(text).toMatch(/team chat\s+●3/); + expect(text).toContain('2 people Β· ML-KEM-1024'); + expect(text).toContain('shipped it πŸš€'); + expect(text).toContain('alice is typing…'); + expect(text).toContain('Message alice'); + expect(text).toContain('you'); + expect(text).toMatch(/ENTER\s+send/i); + }); + + it('groups a run of messages under one name line', () => { + const lines = transcriptLines(state().messages.c1, 60, { border: 0, primary: 1, muted: 2, foreground: 3, accent: 4, info: 5, success: 6, warning: 7, secondary: 8 }); + const headers = lines.filter((l) => l.spans); + expect(headers).toHaveLength(2); // alice once for two messages, then you + expect(lines[0].text).toContain('─'); + }); + + it('opens the emoji picker over the composer', () => { + const s = state(); + s.picker = { query: '', tab: 0, index: 0, offset: 0 }; + const text = draw(s).text(); + expect(text).toContain('Search emoji'); + expect(text).toContain('πŸ˜€'); + }); + + it('a click on a chat opens it; the wheel scrolls the transcript', () => { + const opened = []; + const scrolled = []; + const screen = draw(state(), { open: (i) => opened.push(i), select: () => {}, scroll: (d) => scrolled.push(d) }); + const team = screen.find('team chat'); + screen.click(team.x, team.y); + expect(opened.at(-1) ?? null).toBe(1); + const msg = screen.find('shipped it'); + screen.scroll(msg.x, msg.y, -1); + expect(scrolled).toEqual([1]); + }); + + it('says what to do before anything is open', () => { + const s = initialState({ username: 'x' }); + expect(draw(s).text()).toContain('Loading your chats'); + s.loading = false; + expect(draw(s).text()).toContain('Pick a chat on the left'); + }); +});