diff --git a/.env.example b/.env.example index 325e49a6fb..57a4a2d323 100644 --- a/.env.example +++ b/.env.example @@ -118,6 +118,13 @@ GITEA_ALLOWED_USERS= # If not set, falls back to BOT_DISPLAY_NAME then config.botName # GITEA_BOT_MENTION=archon +# Authentication (JWT) +# Set this to enable JWT-based authentication. Must be at least 32 characters. +# Without this, all API access bypasses auth (full admin access for any caller — dev/test only). +# Generate a secure value: +# openssl rand -base64 32 +# JWT_SECRET=your-secret-key-min-32-chars-change-this + # Server PORT=3000 # HOST=0.0.0.0 # Bind address (default: 0.0.0.0). Set to 127.0.0.1 to restrict to localhost only. diff --git a/CLAUDE.md b/CLAUDE.md index f38cb29a98..5cb1d51470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ ## Core Principles **Single-Developer Tool** -- No multi-tenant complexity +- Designed for a single operator; multi-user authentication is opt-in via `JWT_SECRET` **Platform Agnostic** - Unified conversation interface across Slack/Telegram/GitHub/cli/web @@ -249,6 +249,10 @@ bun run cli serve bun run cli serve --port 4000 bun run cli serve --download-only # Download without starting +# Log in to an Archon server (stores credentials to ~/.archon/auth.json) +bun run cli login +bun run cli login --server-url http://my-server:3090 + # Show version bun run cli version ``` @@ -375,7 +379,7 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api'; ### Database Schema -**8 Tables (all prefixed with `remote_agent_`):** +**10 Tables (all prefixed with `remote_agent_`):** 1. **`codebases`** - Repository metadata and commands (JSONB) 2. **`conversations`** - Track platform conversations with titles and soft-delete support 3. **`sessions`** - Track AI SDK sessions with resume capability @@ -384,6 +388,8 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api'; 6. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors) 7. **`messages`** - Conversation message history with tool call metadata (JSONB) 8. **`codebase_env_vars`** - Per-project env vars injected into Claude SDK subprocess env (managed via Web UI or `env:` in config) +9. **`users`** - User accounts for JWT authentication (username, password hash, role: admin|user) +10. **`project_members`** - Junction table: user-codebase access grants (roles: owner, member) **Key Patterns:** - Conversation ID format: Platform-specific (`thread_ts`, `chat_id`, `user/repo#123`) diff --git a/bun.lock b/bun.lock index 43f419a191..ba757b7ed6 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/adapters": { "name": "@archon/adapters", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/core": "workspace:*", "@archon/git": "workspace:*", @@ -41,7 +41,7 @@ }, "packages/cli": { "name": "@archon/cli", - "version": "0.2.13", + "version": "0.3.5", "bin": { "archon": "./src/cli.ts", }, @@ -62,7 +62,7 @@ }, "packages/core": { "name": "@archon/core", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.89", "@archon/git": "workspace:*", @@ -70,6 +70,7 @@ "@archon/paths": "workspace:*", "@archon/workflows": "workspace:*", "@openai/codex-sdk": "^0.116.0", + "jose": "^5.9.6", "pg": "^8.11.0", "zod": "^3", }, @@ -83,7 +84,7 @@ }, "packages/docs-web": { "name": "@archon/docs-web", - "version": "0.2.12", + "version": "0.3.5", "dependencies": { "@astrojs/starlight": "^0.38.0", "astro": "^6.1.0", @@ -92,7 +93,7 @@ }, "packages/git": { "name": "@archon/git", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/paths": "workspace:*", }, @@ -102,7 +103,7 @@ }, "packages/isolation": { "name": "@archon/isolation", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", @@ -113,7 +114,7 @@ }, "packages/paths": { "name": "@archon/paths", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "pino": "^9", "pino-pretty": "^13", @@ -124,7 +125,7 @@ }, "packages/server": { "name": "@archon/server", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "@archon/adapters": "workspace:*", "@archon/core": "workspace:*", @@ -142,7 +143,7 @@ }, "packages/web": { "name": "@archon/web", - "version": "0.2.0", + "version": "0.3.5", "dependencies": { "@dagrejs/dagre": "^2.0.4", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -194,7 +195,7 @@ }, "packages/workflows": { "name": "@archon/workflows", - "version": "0.1.0", + "version": "0.3.5", "dependencies": { "@archon/git": "workspace:*", "@archon/paths": "workspace:*", @@ -1580,7 +1581,7 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], @@ -2504,6 +2505,8 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], + "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@redocly/openapi-core/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], diff --git a/migrations/000_combined.sql b/migrations/000_combined.sql index 176963b40e..af1f600b36 100644 --- a/migrations/000_combined.sql +++ b/migrations/000_combined.sql @@ -1,16 +1,18 @@ -- Remote Coding Agent - Combined Schema --- Version: Combined (final state after migrations 001-020) +-- Version: Combined (final state after migrations 001-022) -- Description: Complete database schema (idempotent - safe to run multiple times) -- --- 8 Tables: +-- 10 Tables: -- 1. remote_agent_codebases --- 1b. remote_agent_codebase_env_vars --- 2. remote_agent_conversations --- 3. remote_agent_sessions --- 4. remote_agent_isolation_environments --- 5. remote_agent_workflow_runs --- 6. remote_agent_workflow_events --- 7. remote_agent_messages +-- 2. remote_agent_codebase_env_vars +-- 3. remote_agent_users +-- 4. remote_agent_project_members +-- 5. remote_agent_conversations +-- 6. remote_agent_sessions +-- 7. remote_agent_isolation_environments +-- 8. remote_agent_workflow_runs +-- 9. remote_agent_workflow_events +-- 10. remote_agent_messages -- -- Dropped tables (via migrations): -- - remote_agent_command_templates (017) @@ -40,7 +42,7 @@ COMMENT ON TABLE remote_agent_codebases IS 'Repository metadata: name, URL, working directory, AI assistant type, and command paths (JSONB)'; -- ============================================================================ --- Table 1b: Codebase Env Vars +-- Table 2: Codebase Env Vars -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_codebase_env_vars ( @@ -60,7 +62,41 @@ COMMENT ON TABLE remote_agent_codebase_env_vars IS 'Per-project env vars merged into Options.env on Claude SDK calls. Managed via Web UI or config.'; -- ============================================================================ --- Table 2: Conversations +-- Table 3: Users (must precede conversations — FK dependency) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS remote_agent_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name VARCHAR(100), + role VARCHAR(20) NOT NULL DEFAULT 'user', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE remote_agent_users IS + 'User accounts for authentication. First registered user gets admin role.'; + +-- ============================================================================ +-- Table 4: Project Members (must precede conversations — referenced in future queries) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS remote_agent_project_members ( + user_id UUID NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, + codebase_id UUID NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL DEFAULT 'member', + PRIMARY KEY (user_id, codebase_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_members_user_id + ON remote_agent_project_members(user_id); + +COMMENT ON TABLE remote_agent_project_members IS + 'Junction table: which users have access to which codebases. Roles: owner, member.'; + +-- ============================================================================ +-- Table 5: Conversations (now safe — remote_agent_users exists) -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_conversations ( @@ -74,6 +110,7 @@ CREATE TABLE IF NOT EXISTS remote_agent_conversations ( title VARCHAR(255), deleted_at TIMESTAMP WITH TIME ZONE, hidden BOOLEAN DEFAULT FALSE, + user_id UUID REFERENCES remote_agent_users(id) ON DELETE SET NULL, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), @@ -86,12 +123,14 @@ CREATE INDEX IF NOT EXISTS idx_conversations_hidden ON remote_agent_conversations(hidden); CREATE INDEX IF NOT EXISTS idx_conversations_codebase ON remote_agent_conversations(codebase_id) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_conversations_user_id + ON remote_agent_conversations(user_id) WHERE deleted_at IS NULL; COMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS 'UUID reference to isolation_environments table (the only isolation reference)'; -- ============================================================================ --- Table 3: Sessions +-- Table 6: Sessions -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_sessions ( @@ -126,7 +165,7 @@ COMMENT ON COLUMN remote_agent_sessions.ended_reason IS 'Why this session was deactivated: reset-requested, cwd-changed, conversation-closed, etc.'; -- ============================================================================ --- Table 4: Isolation Environments +-- Table 7: Isolation Environments -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_isolation_environments ( @@ -180,7 +219,7 @@ COMMENT ON COLUMN remote_agent_isolation_environments.workflow_id IS 'Identifier for the work (issue number, PR number, thread hash, etc.)'; -- ============================================================================ --- Table 5: Workflow Runs +-- Table 8: Workflow Runs -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_workflow_runs ( @@ -215,7 +254,7 @@ COMMENT ON TABLE remote_agent_workflow_runs IS 'Tracks workflow execution state for resumption and observability'; -- ============================================================================ --- Table 6: Workflow Events +-- Table 9: Workflow Events -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_workflow_events ( @@ -237,7 +276,7 @@ COMMENT ON TABLE remote_agent_workflow_events IS 'Lean UI-relevant workflow events for observability (step transitions, artifacts, errors)'; -- ============================================================================ --- Table 7: Messages +-- Table 10: Messages -- ============================================================================ CREATE TABLE IF NOT EXISTS remote_agent_messages ( @@ -312,3 +351,7 @@ ALTER TABLE remote_agent_sessions -- From migration 021: allow_env_keys on codebases ALTER TABLE remote_agent_codebases ADD COLUMN IF NOT EXISTS allow_env_keys BOOLEAN NOT NULL DEFAULT FALSE; + +-- From migration 022: multi-user auth (user_id on conversations) +ALTER TABLE remote_agent_conversations + ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES remote_agent_users(id) ON DELETE SET NULL; diff --git a/migrations/022_multi_user_auth.sql b/migrations/022_multi_user_auth.sql new file mode 100644 index 0000000000..f7d1098833 --- /dev/null +++ b/migrations/022_multi_user_auth.sql @@ -0,0 +1,32 @@ +-- Multi-user authentication: users, project memberships, conversation ownership +-- Tables: remote_agent_users, remote_agent_project_members +-- Column: remote_agent_conversations.user_id + +-- Users table +CREATE TABLE IF NOT EXISTS remote_agent_users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Project members junction +CREATE TABLE IF NOT EXISTS remote_agent_project_members ( + user_id TEXT NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, + codebase_id TEXT NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + PRIMARY KEY (user_id, codebase_id) +); + +-- Add user_id to conversations (nullable — existing rows keep NULL) +ALTER TABLE remote_agent_conversations + ADD COLUMN IF NOT EXISTS user_id TEXT REFERENCES remote_agent_users(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_conversations_user_id + ON remote_agent_conversations(user_id) WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_project_members_user_id + ON remote_agent_project_members(user_id); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 96c0209666..e968cf334a 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -66,6 +66,7 @@ import { chatCommand } from './commands/chat'; import { setupCommand } from './commands/setup'; import { validateWorkflowsCommand, validateCommandsCommand } from './commands/validate'; import { serveCommand } from './commands/serve'; +import { loginCommand } from './commands/login'; import { closeDatabase } from '@archon/core'; import { setLogLevel, @@ -104,6 +105,7 @@ Commands: isolation cleanup --merged Remove environments with branches merged into main continue [msg] Continue work on an existing worktree with prior context complete [...] Complete branch lifecycle (remove worktree + branches) + login Log in to an Archon server serve Start the web UI server (downloads web UI on first run) validate workflows [name] Validate workflow definitions and their references validate commands [name] Validate command files @@ -207,6 +209,7 @@ async function main(): Promise { 'allow-env-keys': { type: 'boolean' }, port: { type: 'string' }, 'download-only': { type: 'boolean' }, + 'server-url': { type: 'string' }, }, allowPositionals: true, strict: false, // Allow unknown flags to pass through @@ -241,7 +244,7 @@ async function main(): Promise { const subcommand = positionals[1]; // Commands that don't require git repo validation - const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve']; + const noGitCommands = ['version', 'help', 'setup', 'chat', 'continue', 'serve', 'login']; const requiresGitRepo = !noGitCommands.includes(command ?? ''); try { @@ -548,6 +551,11 @@ async function main(): Promise { break; } + case 'login': { + const serverUrl = values['server-url'] as string | undefined; + return await loginCommand({ serverUrl }); + } + case 'serve': { const servePort = values.port !== undefined ? Number(values.port) : undefined; const downloadOnly = Boolean(values['download-only']); diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts new file mode 100644 index 0000000000..ff3f04c278 --- /dev/null +++ b/packages/cli/src/commands/login.ts @@ -0,0 +1,84 @@ +import { join } from 'path'; +import { writeFileSync, mkdirSync } from 'fs'; +import { createInterface } from 'readline'; + +interface AuthFile { + accessToken: string; + refreshToken: string; + username: string; + serverUrl: string; +} + +async function readLine(prompt: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => { + rl.question(prompt, answer => { + rl.close(); + resolve(answer); + }); + }); +} + +async function readPassword(prompt: string): Promise { + process.stdout.write(prompt); + // output: undefined with terminal: false suppresses echo so the password is not shown in the terminal. + // We avoid `output: null` due to TypeScript type constraints, but setting terminal to false + // prevents readline from writing input characters back to stdout. + const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false }); + return new Promise(resolve => { + rl.question('', answer => { + rl.close(); + process.stdout.write('\n'); // advance to next line after silent entry + resolve(answer); + }); + }); +} + +export interface LoginOptions { + serverUrl?: string; +} + +export async function loginCommand(opts: LoginOptions): Promise { + const serverUrl = opts.serverUrl ?? process.env.ARCHON_SERVER_URL ?? 'http://localhost:3090'; + + const username = await readLine('Username: '); + const password = await readPassword('Password: '); + + try { + const res = await fetch(`${serverUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + if (!res.ok) { + console.error('Login failed: Invalid username or password'); + return 1; + } + + const data = (await res.json()) as { + user: { username: string }; + accessToken: string; + refreshToken: string; + }; + const authFile: AuthFile = { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + username: data.user.username, + serverUrl, + }; + + const archonHome = process.env.ARCHON_HOME ?? join(process.env.HOME ?? '~', '.archon'); + mkdirSync(archonHome, { recursive: true }); + writeFileSync(join(archonHome, 'auth.json'), JSON.stringify(authFile, null, 2), { + mode: 0o600, + }); + + console.log(`Logged in as ${data.user.username}`); + console.log(`Credentials saved to ${join(archonHome, 'auth.json')}`); + return 0; + } catch (error) { + console.error(`Login error: ${(error as Error).message}`); + return 1; + } +} diff --git a/packages/core/package.json b/packages/core/package.json index d0d93635b6..c9b482f911 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,6 +9,7 @@ "./types": "./src/types/index.ts", "./db": "./src/db/index.ts", "./db/*": "./src/db/*.ts", + "./auth": "./src/auth/index.ts", "./clients": "./src/clients/index.ts", "./operations": "./src/operations/index.ts", "./operations/*": "./src/operations/*.ts", @@ -23,7 +24,7 @@ "./state/*": "./src/state/*.ts" }, "scripts": { - "test": "bun test src/clients/codex-binary-guard.test.ts && bun test src/utils/codex-binary-resolver.test.ts && bun test src/utils/codex-binary-resolver-dev.test.ts && bun test src/clients/claude.test.ts src/clients/codex.test.ts src/clients/factory.test.ts && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-allowlist.test.ts src/utils/env-leak-scanner.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", + "test": "bun test src/clients/codex-binary-guard.test.ts && bun test src/utils/codex-binary-resolver.test.ts && bun test src/utils/codex-binary-resolver-dev.test.ts && bun test src/clients/claude.test.ts src/clients/codex.test.ts src/clients/factory.test.ts && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-allowlist.test.ts src/utils/env-leak-scanner.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts && bun test src/auth/jwt.test.ts && bun test src/db/users.test.ts", "type-check": "bun x tsc --noEmit", "build": "echo 'No build needed - Bun runs TypeScript directly'" }, @@ -34,6 +35,7 @@ "@archon/paths": "workspace:*", "@archon/workflows": "workspace:*", "@openai/codex-sdk": "^0.116.0", + "jose": "^5.9.6", "pg": "^8.11.0", "zod": "^3" }, diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts new file mode 100644 index 0000000000..cf22d1578e --- /dev/null +++ b/packages/core/src/auth/index.ts @@ -0,0 +1,3 @@ +export { hashPassword, verifyPassword } from './password'; +export { generateAccessToken, generateRefreshToken, verifyToken } from './jwt'; +export type { TokenPayload } from './jwt'; diff --git a/packages/core/src/auth/jwt.test.ts b/packages/core/src/auth/jwt.test.ts new file mode 100644 index 0000000000..6873ea1912 --- /dev/null +++ b/packages/core/src/auth/jwt.test.ts @@ -0,0 +1,84 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; + +const ORIGINAL_SECRET = process.env.JWT_SECRET; + +describe('jwt', () => { + beforeEach(() => { + process.env.JWT_SECRET = 'test-secret-at-least-32-chars-long!!'; + }); + + afterEach(() => { + if (ORIGINAL_SECRET === undefined) { + delete process.env.JWT_SECRET; + } else { + process.env.JWT_SECRET = ORIGINAL_SECRET; + } + }); + + test('generateAccessToken returns a verifiable JWT with correct payload', async () => { + const { generateAccessToken, verifyToken } = await import('./jwt'); + const payload = { userId: 'user-123', role: 'admin' as const }; + const token = await generateAccessToken(payload); + const decoded = await verifyToken(token); + expect(decoded.userId).toBe('user-123'); + expect(decoded.role).toBe('admin'); + }); + + test('generateRefreshToken returns a verifiable JWT with correct payload', async () => { + const { generateRefreshToken, verifyToken } = await import('./jwt'); + const payload = { userId: 'user-456', role: 'user' as const }; + const token = await generateRefreshToken(payload); + const decoded = await verifyToken(token); + expect(decoded.userId).toBe('user-456'); + expect(decoded.role).toBe('user'); + }); + + test('verifyToken throws on tampered signature', async () => { + const { generateAccessToken, verifyToken } = await import('./jwt'); + const token = await generateAccessToken({ userId: 'u1', role: 'user' }); + const tampered = token.slice(0, -5) + 'XXXXX'; + await expect(verifyToken(tampered)).rejects.toThrow(); + }); + + test('verifyToken throws on missing role claim', async () => { + const { verifyToken } = await import('./jwt'); + const { SignJWT } = await import('jose'); + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const noRole = await new SignJWT({ userId: 'u1' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(secret); + await expect(verifyToken(noRole)).rejects.toThrow('Invalid token payload'); + }); + + test('verifyToken throws on invalid role value', async () => { + const { verifyToken } = await import('./jwt'); + const { SignJWT } = await import('jose'); + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const badRole = await new SignJWT({ userId: 'u1', role: 'superadmin' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime('1h') + .sign(secret); + await expect(verifyToken(badRole)).rejects.toThrow('Invalid token payload'); + }); + + test('generateAccessToken throws when JWT_SECRET is not set', async () => { + delete process.env.JWT_SECRET; + const { generateAccessToken } = await import('./jwt'); + await expect(generateAccessToken({ userId: 'u', role: 'user' })).rejects.toThrow( + 'JWT_SECRET environment variable is required' + ); + }); + + test('verifyToken throws on expired token', async () => { + const { verifyToken } = await import('./jwt'); + const { SignJWT } = await import('jose'); + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + // Use 1 second expiry and manually create expired JWT + const expired = await new SignJWT({ userId: 'u1', role: 'user' }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime(Math.floor(Date.now() / 1000) - 10) // 10 seconds in the past + .sign(secret); + await expect(verifyToken(expired)).rejects.toThrow(); + }); +}); diff --git a/packages/core/src/auth/jwt.ts b/packages/core/src/auth/jwt.ts new file mode 100644 index 0000000000..b3988e6eee --- /dev/null +++ b/packages/core/src/auth/jwt.ts @@ -0,0 +1,38 @@ +import { SignJWT, jwtVerify } from 'jose'; + +function getSecret(): Uint8Array { + const secret = process.env.JWT_SECRET; + if (!secret) throw new Error('JWT_SECRET environment variable is required'); + return new TextEncoder().encode(secret); +} + +export interface TokenPayload { + userId: string; + role: 'admin' | 'user'; +} + +async function signToken(payload: TokenPayload, expirationTime: string): Promise { + return new SignJWT({ userId: payload.userId, role: payload.role }) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime(expirationTime) + .setIssuedAt() + .sign(getSecret()); +} + +export async function generateAccessToken(payload: TokenPayload): Promise { + return signToken(payload, '1h'); +} + +export async function generateRefreshToken(payload: TokenPayload): Promise { + return signToken(payload, '7d'); +} + +export async function verifyToken(token: string): Promise { + const { payload } = await jwtVerify(token, getSecret()); + const userId = payload.userId; + const role = payload.role; + if (typeof userId !== 'string' || (role !== 'admin' && role !== 'user')) { + throw new Error('Invalid token payload'); + } + return { userId, role }; +} diff --git a/packages/core/src/auth/password.ts b/packages/core/src/auth/password.ts new file mode 100644 index 0000000000..419230cda0 --- /dev/null +++ b/packages/core/src/auth/password.ts @@ -0,0 +1,7 @@ +export async function hashPassword(plain: string): Promise { + return Bun.password.hash(plain); +} + +export async function verifyPassword(plain: string, hash: string): Promise { + return Bun.password.verify(plain, hash); +} diff --git a/packages/core/src/db/adapters/sqlite.ts b/packages/core/src/db/adapters/sqlite.ts index 2864e4fc43..82005c56a9 100644 --- a/packages/core/src/db/adapters/sqlite.ts +++ b/packages/core/src/db/adapters/sqlite.ts @@ -216,6 +216,23 @@ export class SqliteAdapter implements IDatabase { getLog().warn({ err: e as Error }, 'db.sqlite_migration_session_columns_failed'); } + // Conversations: user_id (multi-user auth) + try { + const convCols2 = this.db + .prepare("PRAGMA table_info('remote_agent_conversations')") + .all() as { name: string }[]; + const convColNames2 = new Set(convCols2.map(c => c.name)); + if (!convColNames2.has('user_id')) { + this.db.run('ALTER TABLE remote_agent_conversations ADD COLUMN user_id TEXT'); + } + // Create index after column exists (can't be in createSchema for existing DBs) + this.db.run( + 'CREATE INDEX IF NOT EXISTS idx_conversations_user_id ON remote_agent_conversations(user_id) WHERE deleted_at IS NULL' + ); + } catch (e: unknown) { + getLog().warn({ err: e as Error }, 'db.sqlite_migration_conversations_user_id_failed'); + } + // Codebases columns (added in #983 — env-leak gate consent bit) try { const cbCols = this.db.prepare("PRAGMA table_info('remote_agent_codebases')").all() as { @@ -269,7 +286,26 @@ export class SqliteAdapter implements IDatabase { UNIQUE(codebase_id, key) ); - -- Conversations table + -- Users table (must precede conversations — FK dependency) + CREATE TABLE IF NOT EXISTS remote_agent_users ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + -- Project members table (must precede conversations for logical ordering) + CREATE TABLE IF NOT EXISTS remote_agent_project_members ( + user_id TEXT NOT NULL REFERENCES remote_agent_users(id) ON DELETE CASCADE, + codebase_id TEXT NOT NULL REFERENCES remote_agent_codebases(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + PRIMARY KEY (user_id, codebase_id) + ); + + -- Conversations table (now safe — remote_agent_users exists) CREATE TABLE IF NOT EXISTS remote_agent_conversations ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), platform_type TEXT NOT NULL, @@ -281,6 +317,7 @@ export class SqliteAdapter implements IDatabase { title TEXT, deleted_at TEXT, hidden INTEGER DEFAULT 0, + user_id TEXT REFERENCES remote_agent_users(id) ON DELETE SET NULL, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), last_activity_at TEXT DEFAULT (datetime('now')), @@ -383,6 +420,9 @@ export class SqliteAdapter implements IDatabase { CREATE INDEX IF NOT EXISTS idx_sessions_codebase ON remote_agent_sessions(codebase_id); CREATE INDEX IF NOT EXISTS idx_isolation_env_status ON remote_agent_isolation_environments(status); + CREATE INDEX IF NOT EXISTS idx_project_members_codebase ON remote_agent_project_members(codebase_id); + CREATE INDEX IF NOT EXISTS idx_project_members_user_id ON remote_agent_project_members(user_id); + -- From PG migration 009: staleness detection for running workflows CREATE INDEX IF NOT EXISTS idx_workflow_runs_last_activity ON remote_agent_workflow_runs(last_activity_at) WHERE status = 'running'; diff --git a/packages/core/src/db/codebases.ts b/packages/core/src/db/codebases.ts index b9f45578b6..c0327c8830 100644 --- a/packages/core/src/db/codebases.ts +++ b/packages/core/src/db/codebases.ts @@ -180,6 +180,18 @@ export async function listCodebases(): Promise { return result.rows; } +/** Returns only codebases the given user is a member of (via remote_agent_project_members). Admins use listCodebases(). */ +export async function listCodebasesForUser(userId: string): Promise { + const result = await pool.query( + `SELECT c.* FROM remote_agent_codebases c + INNER JOIN remote_agent_project_members pm ON pm.codebase_id = c.id + WHERE pm.user_id = $1 + ORDER BY c.name`, + [userId] + ); + return result.rows; +} + export async function deleteCodebase(id: string): Promise { getLog().debug({ codebaseId: id }, 'db.codebase_delete_cascade_started'); // First, unlink any sessions referencing this codebase (FK has no cascade) diff --git a/packages/core/src/db/conversations.ts b/packages/core/src/db/conversations.ts index 0a7a237da3..c25e726022 100644 --- a/packages/core/src/db/conversations.ts +++ b/packages/core/src/db/conversations.ts @@ -219,6 +219,44 @@ export async function listConversations( return result.rows; } +/** + * Returns only conversations owned by the given user (via user_id). + * Admins use listConversations(). Supports optional filtering by platform type and codebase. + */ +export async function listConversationsForUser( + userId: string, + limit = 50, + platformType?: string, + codebaseId?: string, + excludeEmpty = false +): Promise { + const params: unknown[] = [userId]; + let sql = + 'SELECT * FROM remote_agent_conversations WHERE deleted_at IS NULL AND (hidden IS NULL OR hidden = false) AND user_id = $1'; + + if (excludeEmpty) { + sql += + ' AND (title IS NOT NULL OR EXISTS (SELECT 1 FROM remote_agent_messages WHERE conversation_id = remote_agent_conversations.id LIMIT 1))'; + } + + if (platformType) { + params.push(platformType); + sql += ` AND platform_type = $${String(params.length)}`; + } + + if (codebaseId) { + params.push(codebaseId); + sql += ` AND codebase_id = $${String(params.length)}`; + } + + sql += ' ORDER BY last_activity_at DESC NULLS LAST'; + params.push(limit); + sql += ` LIMIT $${String(params.length)}`; + + const result = await pool.query(sql, params); + return result.rows; +} + /** * Update last_activity_at for staleness tracking */ @@ -230,6 +268,18 @@ export async function touchConversation(id: string): Promise { ); } +/** + * Assign a user as the owner of a conversation. + * Called once after conversation creation to establish user_id for scoped listing. + */ +export async function setConversationUserId(id: string, userId: string): Promise { + const dialect = getDialect(); + await pool.query( + `UPDATE remote_agent_conversations SET user_id = $1, updated_at = ${dialect.now()} WHERE id = $2`, + [userId, id] + ); +} + /** * Update conversation title */ diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts index 10a72a2412..ac1e6ecdab 100644 --- a/packages/core/src/db/index.ts +++ b/packages/core/src/db/index.ts @@ -19,6 +19,7 @@ export * as codebaseDb from './codebases'; export * as sessionDb from './sessions'; export * as isolationEnvDb from './isolation-environments'; export * as workflowDb from './workflows'; +export * as usersDb from './users'; // Also export individual functions for direct imports export * from './conversations'; diff --git a/packages/core/src/db/users.test.ts b/packages/core/src/db/users.test.ts new file mode 100644 index 0000000000..0510858894 --- /dev/null +++ b/packages/core/src/db/users.test.ts @@ -0,0 +1,116 @@ +import { mock, describe, test, expect, beforeEach } from 'bun:test'; +import { createQueryResult, mockPostgresDialect } from '../test/mocks/database'; + +const mockQuery = mock(() => Promise.resolve(createQueryResult([]))); + +mock.module('./connection', () => ({ + pool: { query: mockQuery }, + getDialect: () => mockPostgresDialect, +})); + +mock.module('@archon/paths', () => ({ + createLogger: () => ({ + info: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + debug: mock(() => undefined), + fatal: mock(() => undefined), + trace: mock(() => undefined), + }), +})); + +import { createUser, getUserByUsername, getUserById, countUsers, isMember } from './users'; + +describe('users db', () => { + beforeEach(() => { + mockQuery.mockClear(); + }); + + describe('countUsers', () => { + test('returns 0 when table is empty — PostgreSQL returns count as string', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: '0' }])); + expect(await countUsers()).toBe(0); + }); + + test('returns correct value when count is a numeric string', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: '5' }])); + expect(await countUsers()).toBe(5); + }); + + test('returns correct value when count is already a number', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: 3 }])); + expect(await countUsers()).toBe(3); + }); + + test('returns 0 when rows array is empty', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + expect(await countUsers()).toBe(0); + }); + }); + + describe('createUser', () => { + test('returns the created user row', async () => { + const mockRow = { + id: 'uuid-1', + username: 'alice', + password_hash: 'h', + display_name: null, + role: 'admin', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }; + mockQuery.mockResolvedValueOnce(createQueryResult([mockRow])); + const result = await createUser({ username: 'alice', password_hash: 'h', role: 'admin' }); + expect(result.username).toBe('alice'); + expect(result.role).toBe('admin'); + }); + + test('throws with descriptive message when INSERT returns no row', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + await expect(createUser({ username: 'alice', password_hash: 'h' })).rejects.toThrow( + 'Failed to create user: INSERT succeeded but no row returned' + ); + }); + }); + + describe('getUserByUsername', () => { + test('returns null when user not found', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + expect(await getUserByUsername('nobody')).toBeNull(); + }); + + test('returns user when found', async () => { + const mockRow = { + id: 'uuid-1', + username: 'alice', + password_hash: 'h', + display_name: null, + role: 'user', + created_at: '2026-01-01', + updated_at: '2026-01-01', + }; + mockQuery.mockResolvedValueOnce(createQueryResult([mockRow])); + const result = await getUserByUsername('alice'); + expect(result?.username).toBe('alice'); + }); + }); + + describe('getUserById', () => { + test('returns null when user not found', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([])); + expect(await getUserById('non-existent')).toBeNull(); + }); + }); + + describe('isMember', () => { + test('returns true when membership row exists', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: '1' }])); + expect(await isMember('user-1', 'codebase-1')).toBe(true); + }); + + test('returns false when no membership', async () => { + mockQuery.mockResolvedValueOnce(createQueryResult([{ count: '0' }])); + expect(await isMember('user-1', 'codebase-1')).toBe(false); + }); + }); +}); diff --git a/packages/core/src/db/users.ts b/packages/core/src/db/users.ts new file mode 100644 index 0000000000..af4760494e --- /dev/null +++ b/packages/core/src/db/users.ts @@ -0,0 +1,84 @@ +import { pool, getDialect } from './connection'; +import type { User, ProjectMember } from '../types'; +import { createLogger } from '@archon/paths'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('db.users'); + return cachedLog; +} + +export async function createUser(data: { + username: string; + password_hash: string; + display_name?: string; + role?: 'admin' | 'user'; +}): Promise { + const dialect = getDialect(); + const id = dialect.generateUuid(); + const now = dialect.now(); + const role = data.role ?? 'user'; + + const result = await pool.query( + `INSERT INTO remote_agent_users (id, username, password_hash, display_name, role, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, ${now}, ${now}) RETURNING *`, + [id, data.username, data.password_hash, data.display_name ?? null, role] + ); + if (!result.rows[0]) { + throw new Error('Failed to create user: INSERT succeeded but no row returned'); + } + getLog().info({ userId: id, username: data.username, role }, 'user.create_completed'); + return result.rows[0]; +} + +export async function getUserByUsername(username: string): Promise { + const result = await pool.query('SELECT * FROM remote_agent_users WHERE username = $1', [ + username, + ]); + return result.rows[0] ?? null; +} + +export async function getUserById(id: string): Promise { + const result = await pool.query('SELECT * FROM remote_agent_users WHERE id = $1', [id]); + return result.rows[0] ?? null; +} + +export async function countUsers(): Promise { + const result = await pool.query<{ count: string | number }>( + 'SELECT COUNT(*) as count FROM remote_agent_users' + ); + return Number(result.rows[0]?.count ?? 0); +} + +export async function createProjectMember( + userId: string, + codebaseId: string, + role: 'owner' | 'member' +): Promise { + const result = await pool.query( + `INSERT INTO remote_agent_project_members (user_id, codebase_id, role) + VALUES ($1, $2, $3) RETURNING *`, + [userId, codebaseId, role] + ); + if (!result.rows[0]) { + throw new Error('Failed to create project member: INSERT succeeded but no row returned'); + } + getLog().info({ userId, codebaseId, role }, 'project_member.create_completed'); + return result.rows[0]; +} + +export async function getUserCodebaseIds(userId: string): Promise { + const result = await pool.query<{ codebase_id: string }>( + 'SELECT codebase_id FROM remote_agent_project_members WHERE user_id = $1', + [userId] + ); + return result.rows.map(r => r.codebase_id); +} + +export async function isMember(userId: string, codebaseId: string): Promise { + const result = await pool.query<{ count: string | number }>( + 'SELECT COUNT(*) as count FROM remote_agent_project_members WHERE user_id = $1 AND codebase_id = $2', + [userId, codebaseId] + ); + return Number(result.rows[0]?.count ?? 0) > 0; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e212eb10c9..044b3e4ebc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,8 @@ export { type HandleMessageContext, type AttachedFile, type Codebase, + type User, + type ProjectMember, type Session, type CommandResult, type IPlatformAdapter, diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 549891f35e..7e46086701 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -29,6 +29,7 @@ export interface Conversation { hidden: boolean; deleted_at: Date | null; last_activity_at: Date | null; // For staleness detection + user_id: string | null; // FK to remote_agent_users (multi-user auth ownership) created_at: Date; updated_at: Date; } @@ -63,6 +64,22 @@ export interface Codebase { updated_at: Date; } +export interface User { + id: string; + username: string; + password_hash: string; + display_name: string | null; + role: 'admin' | 'user'; + created_at: string; + updated_at: string; +} + +export interface ProjectMember { + user_id: string; + codebase_id: string; + role: 'owner' | 'member'; +} + export const sessionMetadataSchema = z .object({ lastCommand: z.string().optional(), diff --git a/packages/docs-web/src/content/docs/getting-started/configuration.md b/packages/docs-web/src/content/docs/getting-started/configuration.md index ec836f1202..757615d1ea 100644 --- a/packages/docs-web/src/content/docs/getting-started/configuration.md +++ b/packages/docs-web/src/content/docs/getting-started/configuration.md @@ -19,6 +19,7 @@ Set these in your shell or `.env` file: | `CLAUDE_API_KEY` | No | Anthropic API key for pay-per-use (alternative to global auth) | | `CODEX_ACCESS_TOKEN` | Yes (for Codex) | Codex access token (see [AI Assistants](/getting-started/ai-assistants/)) | | `DATABASE_URL` | No | PostgreSQL connection string (default: SQLite) | +| `JWT_SECRET` | No | Secret key for JWT signing (min 32 chars). Set to enable authentication. Generate with `openssl rand -base64 32`. | | `LOG_LEVEL` | No | `debug`, `info` (default), `warn`, `error` | | `PORT` | No | Server port (default: 3090, Docker: 3000) | diff --git a/packages/docs-web/src/content/docs/reference/api.md b/packages/docs-web/src/content/docs/reference/api.md index 0e2fa8aa37..303be15667 100644 --- a/packages/docs-web/src/content/docs/reference/api.md +++ b/packages/docs-web/src/content/docs/reference/api.md @@ -32,7 +32,29 @@ You can feed this into tools like Swagger UI or use it to generate typed API cli ## Authentication -None. Archon is a single-developer tool -- there is no authentication on the API by default. If you expose Archon on a network, use a reverse proxy or firewall to restrict access. +Archon supports optional JWT-based authentication. Set `JWT_SECRET` in your environment to enable it. + +When `JWT_SECRET` is set, all `/api/*` endpoints (except `/api/auth/login`, `/api/auth/register`, +`/api/auth/refresh`, `/api/health`, `/api/health/db`, and `/api/openapi.json`) require a `Bearer` token in the +`Authorization` header. SSE streaming endpoints (`/api/stream/*`) are currently exempt pending a follow-up. + +When `JWT_SECRET` is **not** set (default), all endpoints are accessible without authentication — +backward-compatible default for single-developer local use. In this mode every caller is treated as admin. + +### Auth Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/auth/register` | Create a new user account (first user gets `admin` role) | +| POST | `/api/auth/login` | Log in; returns `accessToken` (1h) and `refreshToken` (7d) | +| POST | `/api/auth/refresh` | Exchange a refresh token for a new access token | +| GET | `/api/auth/me` | Return the currently authenticated user | + +Include the token in all subsequent requests: + +```bash +curl -H "Authorization: Bearer " http://localhost:3090/api/conversations +``` --- diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index f2821a1b8b..0b72e98fe9 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -50,7 +50,7 @@ archon workflow run plan --cwd /path/to/repo --branch feature-auth "Add OAuth su archon workflow run assist --cwd /path/to/repo --no-worktree "Quick question" ``` -**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, `setup`, and `serve` commands work anywhere. +**Note:** Workflow and isolation commands require running from within a git repository. Running from subdirectories automatically resolves to the repo root. The `version`, `help`, `chat`, `setup`, `serve`, and `login` commands work anywhere. ## Commands @@ -303,6 +303,27 @@ archon complete feature-auth --force # bypass uncommitted-changes check Use this after a PR is merged and you no longer need the worktree or branches. Accepts multiple branch names in one call. +### `login` + +Authenticate against an Archon server and save credentials locally. Required when `JWT_SECRET` is configured on the server. + +```bash +archon login +archon login --server-url http://my-server:3090 +``` + +Prompts for username and password, calls `/api/auth/login`, and writes the access token, +refresh token, username, and server URL to `~/.archon/auth.json` (mode `0600`). + +**Flags:** + +| Flag | Effect | +|------|--------| +| `--server-url ` | Override server URL (default: `ARCHON_SERVER_URL` env var or `http://localhost:3090`) | + +**Note:** The CLI does not currently auto-refresh expired tokens. Re-run `archon login` when +your access token expires (default lifetime: 1 hour). + ### `serve` Start the web UI server. On first run, downloads a pre-built web UI tarball from the matching GitHub release, verifies the SHA-256 checksum, and extracts it. Subsequent runs use the cached copy. diff --git a/packages/docs-web/src/content/docs/reference/database.md b/packages/docs-web/src/content/docs/reference/database.md index 6cab854622..042a019ec4 100644 --- a/packages/docs-web/src/content/docs/reference/database.md +++ b/packages/docs-web/src/content/docs/reference/database.md @@ -64,6 +64,8 @@ psql $DATABASE_URL < migrations/017_drop_command_templates.sql psql $DATABASE_URL < migrations/018_fix_workflow_status_default.sql psql $DATABASE_URL < migrations/019_workflow_resume_path.sql psql $DATABASE_URL < migrations/020_codebase_env_vars.sql +psql $DATABASE_URL < migrations/021_allow_env_keys.sql +psql $DATABASE_URL < migrations/022_multi_user_auth.sql ``` ## Local PostgreSQL via Docker @@ -94,6 +96,8 @@ docker compose exec postgres psql -U postgres -d remote_coding_agent \i /migrations/018_fix_workflow_status_default.sql \i /migrations/019_workflow_resume_path.sql \i /migrations/020_codebase_env_vars.sql +\i /migrations/021_allow_env_keys.sql +\i /migrations/022_multi_user_auth.sql \q ``` @@ -101,6 +105,8 @@ Or from your host machine (requires `psql` installed): ```bash psql postgresql://postgres:postgres@localhost:5432/remote_coding_agent < migrations/020_codebase_env_vars.sql +psql postgresql://postgres:postgres@localhost:5432/remote_coding_agent < migrations/021_allow_env_keys.sql +psql postgresql://postgres:postgres@localhost:5432/remote_coding_agent < migrations/022_multi_user_auth.sql # ... and so on for each migration not yet applied ``` @@ -119,7 +125,7 @@ psql $DATABASE_URL -c "\dt" ## Schema Overview -The database has 8 tables, all prefixed with `remote_agent_`: +The database has 10 tables, all prefixed with `remote_agent_`: 1. **`remote_agent_codebases`** - Repository metadata - Commands stored as JSONB: `{command_name: {path, description}}` @@ -160,6 +166,14 @@ The database has 8 tables, all prefixed with `remote_agent_`: - Injected into Claude SDK subprocess environment at execution time - Managed via Web UI Settings panel; `env:` in `.archon/config.yaml` for CLI users +9. **`remote_agent_users`** - User accounts for authentication + - Username, bcrypt password hash, display name, role (`admin` | `user`) + - First registered user automatically receives the `admin` role + +10. **`remote_agent_project_members`** - Codebase access control + - Junction table: user-codebase, roles: `owner`, `member` + - Admins see all codebases; members see only their assigned codebases + ## Migration List | Migration | Description | @@ -185,3 +199,5 @@ The database has 8 tables, all prefixed with `remote_agent_`: | `018_fix_workflow_status_default.sql` | Fix workflow status default value | | `019_workflow_resume_path.sql` | Workflow resume path support | | `020_codebase_env_vars.sql` | Per-project environment variables | +| `021_allow_env_keys.sql` | Per-codebase env-leak gate consent bit | +| `022_multi_user_auth.sql` | Multi-user auth: users table, project members, conversation user_id | diff --git a/packages/docs-web/src/content/docs/reference/security.md b/packages/docs-web/src/content/docs/reference/security.md index 26e26d169a..b9cfbf1428 100644 --- a/packages/docs-web/src/content/docs/reference/security.md +++ b/packages/docs-web/src/content/docs/reference/security.md @@ -89,7 +89,7 @@ Each platform adapter supports an optional user whitelist via environment variab - Every incoming message or webhook is checked before processing. - Unauthorized users are silently rejected -- no error response is sent back. - Unauthorized attempts are logged with masked user identifiers for auditing. -- The Web UI has no built-in user authentication. Use `CADDY_BASIC_AUTH` or form auth when exposing it publicly (see [Docker / Deployment](/reference/configuration/#docker--deployment) variables). +- When `JWT_SECRET` is set, the Web UI requires users to log in via the built-in `/login` page before accessing any protected routes. When `JWT_SECRET` is not set (default), the server operates in open-access mode — suitable for local single-developer use only. For network-exposed deployments, always set `JWT_SECRET`. You can additionally use `CADDY_BASIC_AUTH` or form auth for deployments without `JWT_SECRET` (see [Docker / Deployment](/reference/configuration/#docker--deployment) variables). ## Webhook Security diff --git a/packages/server/package.json b/packages/server/package.json index 7de8c49955..d1e56e37b2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "bun --watch src/index.ts", "start": "bun src/index.ts", - "test": "bun test src/routes/api.workflows.test.ts && bun test src/routes/api.conversations.test.ts && bun test src/routes/api.codebases.test.ts && bun test src/routes/api.messages.test.ts && bun test src/routes/api.health.test.ts && bun test src/routes/api.workflow-runs.test.ts && bun test src/adapters/web/transport.test.ts && bun test src/adapters/web/persistence.test.ts", + "test": "bun test src/routes/api.workflows.test.ts && bun test src/routes/api.conversations.test.ts && bun test src/routes/api.codebases.test.ts && bun test src/routes/api.messages.test.ts && bun test src/routes/api.health.test.ts && bun test src/routes/api.workflow-runs.test.ts && bun test src/adapters/web/transport.test.ts && bun test src/adapters/web/persistence.test.ts && bun test src/middleware/auth.test.ts && bun test src/routes/api.auth.test.ts", "type-check": "bun x tsc --noEmit", "setup-auth": "bun src/scripts/setup-auth.ts" }, diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7152aec8b4..54b391679e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -58,6 +58,8 @@ import { MessagePersistence } from './adapters/web/persistence'; import { SSETransport } from './adapters/web/transport'; import { WorkflowEventBridge } from './adapters/web/workflow-bridge'; import { registerApiRoutes } from './routes/api'; +import { registerAuthRoutes } from './routes/auth'; +import { authMiddleware } from './middleware/auth'; import { handleMessage, pool, @@ -487,6 +489,12 @@ export async function startServer(opts: ServerOptions = {}): Promise { return c.json({ error: 'Internal server error' }, 500); }); + // Auth middleware (after CORS, before routes) + app.use('/api/*', authMiddleware); + + // Register auth routes first (login/register/refresh are public) + registerAuthRoutes(app); + // Register Web UI API routes registerApiRoutes(app, webAdapter, lockManager); diff --git a/packages/server/src/middleware/auth.test.ts b/packages/server/src/middleware/auth.test.ts new file mode 100644 index 0000000000..1a8804a0be --- /dev/null +++ b/packages/server/src/middleware/auth.test.ts @@ -0,0 +1,135 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Mock setup — must be declared before any dynamic imports of mocked modules +// --------------------------------------------------------------------------- + +const mockVerifyToken = mock(async (_token: string) => ({ + userId: 'user-abc', + role: 'user' as const, +})); + +mock.module('@archon/core/auth', () => ({ + verifyToken: mockVerifyToken, +})); + +mock.module('@archon/paths', () => ({ + createLogger: () => ({ + debug: mock(() => undefined), + info: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + fatal: mock(() => undefined), + trace: mock(() => undefined), + }), +})); + +import { Hono } from 'hono'; +import { authMiddleware } from './auth'; + +function makeApp(): Hono { + const app = new Hono(); + app.use('/api/*', authMiddleware); + app.get('/api/protected', c => c.json({ ok: true })); + app.post('/api/auth/login', c => c.json({ ok: true })); + app.post('/api/auth/register', c => c.json({ ok: true })); + app.post('/api/auth/refresh', c => c.json({ ok: true })); + app.get('/api/health', c => c.json({ ok: true })); + app.get('/api/health/db', c => c.json({ ok: true })); + app.get('/api/openapi.json', c => c.json({ ok: true })); + app.get('/api/stream/conv-1', c => c.json({ ok: true })); + app.post('/webhooks/github', c => c.json({ ok: true })); + return app; +} + +describe('authMiddleware', () => { + beforeEach(() => { + mockVerifyToken.mockClear(); + mockVerifyToken.mockImplementation(async (_token: string) => ({ + userId: 'user-abc', + role: 'user' as const, + })); + }); + + test('passes /api/auth/login without a token', async () => { + const res = await makeApp().request('/api/auth/login', { method: 'POST' }); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/auth/register without a token', async () => { + const res = await makeApp().request('/api/auth/register', { method: 'POST' }); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/auth/refresh without a token', async () => { + const res = await makeApp().request('/api/auth/refresh', { method: 'POST' }); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/health without a token', async () => { + const res = await makeApp().request('/api/health'); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/openapi.json without a token', async () => { + const res = await makeApp().request('/api/openapi.json'); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /api/stream/* without a token', async () => { + const res = await makeApp().request('/api/stream/conv-1'); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('passes /webhooks/* without a token', async () => { + const res = await makeApp().request('/webhooks/github', { method: 'POST' }); + expect(res.status).toBe(200); + expect(mockVerifyToken).not.toHaveBeenCalled(); + }); + + test('returns 401 when Authorization header is missing', async () => { + const res = await makeApp().request('/api/protected'); + expect(res.status).toBe(401); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Unauthorized'); + }); + + test('returns 401 when Authorization header lacks Bearer prefix', async () => { + const res = await makeApp().request('/api/protected', { + headers: { Authorization: 'Basic dXNlcjpwYXNz' }, + }); + expect(res.status).toBe(401); + }); + + test('returns 401 when token verification fails', async () => { + mockVerifyToken.mockRejectedValueOnce(new Error('Token expired')); + const res = await makeApp().request('/api/protected', { + headers: { Authorization: 'Bearer bad-token' }, + }); + expect(res.status).toBe(401); + }); + + test('sets userId and userRole on valid token and calls next', async () => { + let capturedUserId: string | undefined; + let capturedRole: string | undefined; + const app = new Hono(); + app.use('/api/*', authMiddleware); + app.get('/api/protected', c => { + capturedUserId = c.get('userId') as string; + capturedRole = c.get('userRole') as string; + return c.json({ ok: true }); + }); + const res = await app.request('/api/protected', { + headers: { Authorization: 'Bearer valid-token' }, + }); + expect(res.status).toBe(200); + expect(capturedUserId).toBe('user-abc'); + expect(capturedRole).toBe('user'); + }); +}); diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts new file mode 100644 index 0000000000..c1f84861a7 --- /dev/null +++ b/packages/server/src/middleware/auth.ts @@ -0,0 +1,55 @@ +import type { Context, Next } from 'hono'; +import { verifyToken } from '@archon/core/auth'; +import { createLogger } from '@archon/paths'; + +const log = createLogger('middleware.auth'); + +const PUBLIC_PATHS = new Set([ + '/api/auth/login', + '/api/auth/register', + '/api/auth/refresh', + '/api/health', + '/api/health/db', + '/api/openapi.json', +]); + +const PUBLIC_PREFIXES = [ + // Webhooks: authenticated via HMAC signature (not JWT) + '/webhooks/', + // SSE streaming: bypassed entirely — EventSource cannot send custom headers. + // KNOWN GAP: any caller knowing a conversationId can subscribe without a token. + // TODO: Add token-in-query-param auth for SSE in a follow-up. + '/api/stream/', +]; + +export async function authMiddleware(c: Context, next: Next): Promise { + const path = new URL(c.req.url).pathname; + + if (PUBLIC_PATHS.has(path) || PUBLIC_PREFIXES.some(p => path.startsWith(p))) { + await next(); + return; + } + + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + c.res = c.json({ error: 'Unauthorized' }, 401); + return; + } + + const token = authHeader.slice(7); + try { + const payload = await verifyToken(token); + c.set('userId', payload.userId); + c.set('userRole', payload.role); + await next(); + } catch (e) { + const err = e as Error; + // Misconfiguration (JWT_SECRET missing) must be visible at warn level to aid diagnosis + if (err.message?.includes('JWT_SECRET')) { + log.warn({ err, path }, 'auth.jwt_secret_missing'); + } else { + log.debug({ path, errorType: err.constructor?.name }, 'auth.token_invalid'); + } + c.res = c.json({ error: 'Unauthorized' }, 401); + } +} diff --git a/packages/server/src/routes/api.auth.test.ts b/packages/server/src/routes/api.auth.test.ts new file mode 100644 index 0000000000..6fc4c1c87f --- /dev/null +++ b/packages/server/src/routes/api.auth.test.ts @@ -0,0 +1,258 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test'; +import { OpenAPIHono } from '@hono/zod-openapi'; + +// --------------------------------------------------------------------------- +// Mock setup — must be declared before any dynamic imports of mocked modules +// --------------------------------------------------------------------------- + +const mockCountUsers = mock(async () => 0); +const mockGetUserByUsername = mock( + async (_u: string) => + null as null | { + id: string; + username: string; + password_hash: string; + display_name: null; + role: 'admin' | 'user'; + created_at: string; + updated_at: string; + } +); +const mockCreateUser = mock( + async (data: { + username: string; + password_hash: string; + display_name?: string; + role?: 'admin' | 'user'; + }) => ({ + id: 'user-uuid-1', + username: data.username, + password_hash: data.password_hash, + display_name: data.display_name ?? null, + role: data.role ?? 'user', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }) +); +const mockGetUserById = mock( + async (_id: string) => + null as null | { + id: string; + username: string; + password_hash: string; + display_name: null; + role: 'admin' | 'user'; + created_at: string; + updated_at: string; + } +); + +mock.module('@archon/core/db/users', () => ({ + countUsers: mockCountUsers, + getUserByUsername: mockGetUserByUsername, + createUser: mockCreateUser, + getUserById: mockGetUserById, +})); + +mock.module('@archon/core/auth', () => ({ + hashPassword: mock(async (p: string) => `hashed:${p}`), + verifyPassword: mock(async (plain: string, hash: string) => hash === `hashed:${plain}`), + generateAccessToken: mock(async () => 'access-token-123'), + generateRefreshToken: mock(async () => 'refresh-token-456'), + verifyToken: mock(async () => ({ userId: 'user-uuid-1', role: 'user' as const })), +})); + +mock.module('@archon/paths', () => ({ + createLogger: () => ({ + info: mock(() => undefined), + error: mock(() => undefined), + warn: mock(() => undefined), + debug: mock(() => undefined), + fatal: mock(() => undefined), + trace: mock(() => undefined), + }), +})); + +import { registerAuthRoutes } from './auth'; +import { validationErrorHook } from './openapi-defaults'; + +function makeApp(): OpenAPIHono { + const app = new OpenAPIHono({ defaultHook: validationErrorHook }); + registerAuthRoutes(app); + return app; +} + +// --------------------------------------------------------------------------- +// POST /api/auth/register +// --------------------------------------------------------------------------- + +describe('POST /api/auth/register', () => { + beforeEach(() => { + mockCountUsers.mockClear(); + mockGetUserByUsername.mockClear(); + mockCreateUser.mockClear(); + }); + + test('first user receives admin role', async () => { + mockCountUsers.mockResolvedValueOnce(0); + mockGetUserByUsername.mockResolvedValueOnce(null); + + const res = await makeApp().request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as { user: { role: string } }; + expect(body.user.role).toBe('admin'); + const createArg = mockCreateUser.mock.calls[0]?.[0] as { role?: string }; + expect(createArg.role).toBe('admin'); + }); + + test('second user receives user role', async () => { + mockCountUsers.mockResolvedValueOnce(1); + mockGetUserByUsername.mockResolvedValueOnce(null); + + const res = await makeApp().request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'bob', password: 'password456' }), + }); + + expect(res.status).toBe(201); + const createArg = mockCreateUser.mock.calls[0]?.[0] as { role?: string }; + expect(createArg.role).toBe('user'); + }); + + test('returns 409 when username is already taken', async () => { + mockGetUserByUsername.mockResolvedValueOnce({ + id: 'existing', + username: 'alice', + password_hash: 'h', + display_name: null, + role: 'admin' as const, + created_at: '', + updated_at: '', + }); + + const res = await makeApp().request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }); + + expect(res.status).toBe(409); + expect(mockCreateUser).not.toHaveBeenCalled(); + }); + + test('rejects passwords shorter than 8 characters', async () => { + const res = await makeApp().request('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'short' }), + }); + expect(res.status).toBe(400); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/auth/login +// --------------------------------------------------------------------------- + +describe('POST /api/auth/login', () => { + beforeEach(() => { + mockGetUserByUsername.mockClear(); + }); + + test('returns tokens on valid credentials', async () => { + mockGetUserByUsername.mockResolvedValueOnce({ + id: 'user-uuid-1', + username: 'alice', + password_hash: 'hashed:password123', + display_name: null, + role: 'admin' as const, + created_at: '', + updated_at: '', + }); + + const res = await makeApp().request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'password123' }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { accessToken: string; refreshToken: string }; + expect(body.accessToken).toBe('access-token-123'); + expect(body.refreshToken).toBe('refresh-token-456'); + }); + + test('returns 401 when user does not exist', async () => { + mockGetUserByUsername.mockResolvedValueOnce(null); + const res = await makeApp().request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'nobody', password: 'password123' }), + }); + expect(res.status).toBe(401); + }); + + test('returns 401 when password is wrong', async () => { + mockGetUserByUsername.mockResolvedValueOnce({ + id: 'user-uuid-1', + username: 'alice', + password_hash: 'hashed:correct', + display_name: null, + role: 'admin' as const, + created_at: '', + updated_at: '', + }); + const res = await makeApp().request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'wrong' }), + }); + expect(res.status).toBe(401); + }); +}); + +// --------------------------------------------------------------------------- +// POST /api/auth/refresh +// --------------------------------------------------------------------------- + +describe('POST /api/auth/refresh', () => { + beforeEach(() => { + mockGetUserById.mockClear(); + }); + + test('returns new tokens for a valid refresh token', async () => { + mockGetUserById.mockResolvedValueOnce({ + id: 'user-uuid-1', + username: 'alice', + password_hash: 'h', + display_name: null, + role: 'user' as const, + created_at: '', + updated_at: '', + }); + const res = await makeApp().request('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: 'valid-refresh-token' }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { accessToken: string }; + expect(body.accessToken).toBeTruthy(); + }); + + test('returns 401 when user no longer exists in DB', async () => { + mockGetUserById.mockResolvedValueOnce(null); + const res = await makeApp().request('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: 'valid-refresh-token' }), + }); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/server/src/routes/api.codebases.test.ts b/packages/server/src/routes/api.codebases.test.ts index 0265a359e1..91b0454bea 100644 --- a/packages/server/src/routes/api.codebases.test.ts +++ b/packages/server/src/routes/api.codebases.test.ts @@ -108,6 +108,8 @@ mock.module('@archon/git', () => ({ mock.module('@archon/core/db/conversations', () => ({ findConversationByPlatformId: mock(async () => null), listConversations: mock(async () => []), + listConversationsForUser: mock(async () => []), + setConversationUserId: mock(async () => {}), getOrCreateConversation: mock(async () => ({ id: 'internal-uuid-123', platform_conversation_id: 'web-test-abc', @@ -117,6 +119,7 @@ mock.module('@archon/core/db/conversations', () => ({ platform_type: 'web', deleted_at: null, codebase_id: null, + user_id: null, })), softDeleteConversation: mock(async () => {}), updateConversationTitle: mock(async () => {}), @@ -126,11 +129,20 @@ mock.module('@archon/core/db/conversations', () => ({ const mockUpdateCodebaseAllowEnvKeys = mock(async (_id: string, _v: boolean) => {}); mock.module('@archon/core/db/codebases', () => ({ listCodebases: mockListCodebases, + listCodebasesForUser: mock(async () => []), getCodebase: mockGetCodebase, deleteCodebase: mockDeleteCodebase, updateCodebaseAllowEnvKeys: mockUpdateCodebaseAllowEnvKeys, })); +mock.module('@archon/core/db/users', () => ({ + createProjectMember: mock(async () => ({ + user_id: 'user-1', + codebase_id: 'codebase-1', + role: 'owner', + })), +})); + mock.module('@archon/core/db/isolation-environments', () => ({ listByCodebase: mockListByCodebase, updateStatus: mockUpdateStatus, diff --git a/packages/server/src/routes/api.conversations.test.ts b/packages/server/src/routes/api.conversations.test.ts index c5b53d9122..5e3f0d56e9 100644 --- a/packages/server/src/routes/api.conversations.test.ts +++ b/packages/server/src/routes/api.conversations.test.ts @@ -64,6 +64,8 @@ mock.module('@archon/core/db/conversations', () => ({ softDeleteConversation: mockSoftDeleteConversation, updateConversationTitle: mockUpdateConversationTitle, listConversations: mock(async () => []), + listConversationsForUser: mock(async () => []), + setConversationUserId: mock(async () => {}), getOrCreateConversation: mock(async () => ({ id: 'internal-uuid-123', platform_conversation_id: 'web-test-abc', @@ -73,6 +75,7 @@ mock.module('@archon/core/db/conversations', () => ({ platform_type: 'web', deleted_at: null, codebase_id: null, + user_id: null, })), })); @@ -87,9 +90,18 @@ mock.module('@archon/core/db/messages', () => ({ })); mock.module('@archon/core/db/codebases', () => ({ listCodebases: mock(async () => [{ default_cwd: '/tmp/project' }]), + listCodebasesForUser: mock(async () => []), getCodebase: mock(async () => null), })); +mock.module('@archon/core/db/users', () => ({ + createProjectMember: mock(async () => ({ + user_id: 'user-1', + codebase_id: 'codebase-1', + role: 'owner', + })), +})); + import { registerApiRoutes } from './api'; const MOCK_CONV = { diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index cfade2c012..94f788f96c 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -63,6 +63,7 @@ function getLog(): ReturnType { } import * as conversationDb from '@archon/core/db/conversations'; import * as codebaseDb from '@archon/core/db/codebases'; +import * as usersDb from '@archon/core/db/users'; import * as envVarDb from '@archon/core/db/env-vars'; import * as isolationEnvDb from '@archon/core/db/isolation-environments'; import * as workflowDb from '@archon/core/db/workflows'; @@ -121,6 +122,7 @@ import { configResponseSchema, codebaseEnvironmentsResponseSchema, } from './schemas/config.schemas'; +import { getValidatedBody } from './utils'; // Read app version once at module load (root package.json is 4 levels up from src/routes/) let appVersion = 'unknown'; @@ -868,6 +870,16 @@ export function registerApiRoutes( return c.json({ error: message, ...(detail ? { detail } : {}) }, status); } + function getUserId(c: Context): { userId: string | undefined; isAdmin: boolean } { + const userId = c.get('userId') as string | undefined; + const userRole = c.get('userRole') as string | undefined; + // When JWT_SECRET is unset, authMiddleware is effectively disabled: no userId is + // injected into the context. We fall back to isAdmin=true so that existing tests + // and local dev setups without auth configured continue to work. + // WARNING: In production, JWT_SECRET must be set — without it every caller is treated as admin. + return { userId, isAdmin: !userId || userRole === 'admin' }; + } + /** * Validate that a caller-supplied `cwd` is rooted at a registered codebase path. * This prevents path traversal — callers cannot read/write outside known project roots. @@ -1038,17 +1050,22 @@ export function registerApiRoutes( return { accepted: true, status: result.status }; } - // GET /api/conversations - List conversations + // GET /api/conversations - List conversations (scoped by user) registerOpenApiRoute(getConversationsRoute, async c => { try { const platformType = c.req.query('platform') ?? undefined; const codebaseId = c.req.query('codebaseId') ?? undefined; - const conversations = await conversationDb.listConversations( - 50, - platformType, - codebaseId, - true - ); + const { userId, isAdmin } = getUserId(c); + const conversations = + isAdmin || !userId + ? await conversationDb.listConversations(50, platformType, codebaseId, true) + : await conversationDb.listConversationsForUser( + userId, + 50, + platformType, + codebaseId, + true + ); return c.json(conversations); } catch (error) { getLog().error({ err: error }, 'list_conversations_failed'); @@ -1064,6 +1081,12 @@ export function registerApiRoutes( if (!conv) { return apiError(c, 404, 'Conversation not found'); } + // Ownership check: non-admin users can only access their own conversations. + // Return 404 (not 403) to avoid leaking existence of other users' conversations. + const { userId, isAdmin } = getUserId(c); + if (!isAdmin && userId && conv.user_id !== userId) { + return apiError(c, 404, 'Conversation not found'); + } return c.json(conv); } catch (error) { getLog().error({ err: error, platformId }, 'get_conversation_failed'); @@ -1094,6 +1117,12 @@ export function registerApiRoutes( ); webAdapter.setConversationDbId(conversation.platform_conversation_id, conversation.id); + // Set user_id on the conversation for ownership scoping + const { userId } = getUserId(c); + if (userId) { + await conversationDb.setConversationUserId(conversation.id, userId); + } + // If message provided, dispatch it atomically (avoids ghost "Untitled" conversations) if (message) { try { @@ -1144,6 +1173,11 @@ export function registerApiRoutes( if (!conv) { return apiError(c, 404, 'Conversation not found'); } + // Ownership check: non-admin users can only modify their own conversations. + const { userId, isAdmin } = getUserId(c); + if (!isAdmin && userId && conv.user_id !== userId) { + return apiError(c, 404, 'Conversation not found'); + } if (title !== undefined) { await conversationDb.updateConversationTitle(conv.id, title.slice(0, 255)); } @@ -1165,6 +1199,11 @@ export function registerApiRoutes( if (!conv) { return apiError(c, 404, 'Conversation not found'); } + // Ownership check: non-admin users can only delete their own conversations. + const { userId, isAdmin } = getUserId(c); + if (!isAdmin && userId && conv.user_id !== userId) { + return apiError(c, 404, 'Conversation not found'); + } await conversationDb.softDeleteConversation(conv.id); return c.json({ success: true }); } catch (error) { @@ -1454,10 +1493,14 @@ export function registerApiRoutes( }); }); - // GET /api/codebases - List codebases + // GET /api/codebases - List codebases (scoped by user membership) registerOpenApiRoute(listCodebasesRoute, async c => { try { - const codebases = await codebaseDb.listCodebases(); + const { userId, isAdmin } = getUserId(c); + const codebases = + isAdmin || !userId + ? await codebaseDb.listCodebases() + : await codebaseDb.listCodebasesForUser(userId); // Deduplicate by repository_url (keep most recently updated) const normalizeUrl = (url: string): string => url.replace(/\.git$/, ''); @@ -1536,6 +1579,26 @@ export function registerApiRoutes( return apiError(c, 500, 'Codebase created but not found'); } + // Auto-add creator as owner of the project + const { userId } = getUserId(c); + if (userId) { + try { + await usersDb.createProjectMember(userId, result.codebaseId, 'owner'); + } catch (e) { + const err = e as Error & { code?: string }; + // PostgreSQL unique violation: 23505; SQLite: UNIQUE constraint failed + const isDuplicate = + err.code === '23505' || (err.message?.includes('UNIQUE constraint failed') ?? false); + if (!isDuplicate) { + // Unexpected error — log for debugging but don't fail the codebase creation + getLog().warn( + { err, userId, codebaseId: result.codebaseId }, + 'codebase.owner_membership_failed' + ); + } + } + } + return c.json(codebase, result.alreadyExisted ? 200 : 201); } catch (error) { if (error instanceof EnvLeakError) { @@ -1725,11 +1788,6 @@ export function registerApiRoutes( app.openapi(route, handler as never); } - /** Access Zod-validated body from a handler registered via registerOpenApiRoute. */ - function getValidatedBody(c: Context, _schema: z.ZodType): T { - return (c.req as unknown as { valid(k: 'json'): T }).valid('json'); - } - // Serve OpenAPI spec app.doc('/api/openapi.json', { openapi: '3.0.0', diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts new file mode 100644 index 0000000000..acc8b5057e --- /dev/null +++ b/packages/server/src/routes/auth.ts @@ -0,0 +1,248 @@ +import { OpenAPIHono, createRoute } from '@hono/zod-openapi'; +import type { Context } from 'hono'; +import * as usersDb from '@archon/core/db/users'; +import { + hashPassword, + verifyPassword, + generateAccessToken, + generateRefreshToken, + verifyToken, +} from '@archon/core/auth'; +import type { User } from '@archon/core'; +import { createLogger } from '@archon/paths'; +import { getValidatedBody } from './utils'; +import { errorSchema } from './schemas/common.schemas'; +import { + registerBodySchema, + loginBodySchema, + refreshBodySchema, + authResponseSchema, + refreshResponseSchema, + userSchema, + type UserResponse, +} from './schemas/auth.schemas'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('api.auth'); + return cachedLog; +} + +function jsonError(description: string): { + content: { 'application/json': { schema: typeof errorSchema } }; + description: string; +} { + return { content: { 'application/json': { schema: errorSchema } }, description }; +} + +function sanitizeUser(user: User): UserResponse { + return { + id: user.id, + username: user.username, + displayName: user.display_name, + role: user.role, + createdAt: user.created_at, + }; +} + +// Route definitions +const registerRoute = createRoute({ + method: 'post', + path: '/api/auth/register', + tags: ['Auth'], + summary: 'Register a new user', + request: { + body: { content: { 'application/json': { schema: registerBodySchema } }, required: true }, + }, + responses: { + 201: { + content: { 'application/json': { schema: authResponseSchema } }, + description: 'User created', + }, + 409: jsonError('Username already taken'), + 500: jsonError('Server error'), + }, +}); + +const loginRoute = createRoute({ + method: 'post', + path: '/api/auth/login', + tags: ['Auth'], + summary: 'Login with username and password', + request: { + body: { content: { 'application/json': { schema: loginBodySchema } }, required: true }, + }, + responses: { + 200: { + content: { 'application/json': { schema: authResponseSchema } }, + description: 'OK', + }, + 401: jsonError('Invalid credentials'), + 500: jsonError('Server error'), + }, +}); + +const refreshRoute = createRoute({ + method: 'post', + path: '/api/auth/refresh', + tags: ['Auth'], + summary: 'Refresh access token', + request: { + body: { content: { 'application/json': { schema: refreshBodySchema } }, required: true }, + }, + responses: { + 200: { + content: { 'application/json': { schema: refreshResponseSchema } }, + description: 'OK', + }, + 401: jsonError('Invalid refresh token'), + 500: jsonError('Server error'), + }, +}); + +const meRoute = createRoute({ + method: 'get', + path: '/api/auth/me', + tags: ['Auth'], + summary: 'Get current user info', + responses: { + 200: { + content: { 'application/json': { schema: userSchema } }, + description: 'OK', + }, + 401: jsonError('Unauthorized'), + 500: jsonError('Server error'), + }, +}); + +export function registerAuthRoutes(app: OpenAPIHono): void { + function registerOpenApiRoute( + route: ReturnType, + handler: (c: Context) => Response | Promise + ): void { + app.openapi(route, handler as never); + } + + // POST /api/auth/register + registerOpenApiRoute(registerRoute, async (c: Context) => { + try { + const body = getValidatedBody<{ username: string; password: string; displayName?: string }>( + c + ); + + const existing = await usersDb.getUserByUsername(body.username); + if (existing) { + return c.json({ error: 'Username already taken' }, 409); + } + + // NOTE: countUsers() + createUser() is non-atomic. Two simultaneous registrations + // on a fresh install could both see count=0 and both receive role:'admin'. + // Acceptable for a single-developer tool (YAGNI — no concurrent first-registration risk). + // If multi-tenant use is ever added, replace with a DB-level constraint. + const userCount = await usersDb.countUsers(); + const role = userCount === 0 ? 'admin' : 'user'; + + const passwordHash = await hashPassword(body.password); + const user = await usersDb.createUser({ + username: body.username, + password_hash: passwordHash, + display_name: body.displayName, + role, + }); + + const tokenPayload = { userId: user.id, role: user.role }; + const [accessToken, refreshToken] = await Promise.all([ + generateAccessToken(tokenPayload), + generateRefreshToken(tokenPayload), + ]); + + getLog().info({ userId: user.id, role }, 'auth.register_completed'); + return c.json({ user: sanitizeUser(user), accessToken, refreshToken }, 201); + } catch (error) { + getLog().error({ err: error }, 'auth.register_failed'); + return c.json({ error: 'Registration failed' }, 500); + } + }); + + // POST /api/auth/login + registerOpenApiRoute(loginRoute, async (c: Context) => { + try { + const body = getValidatedBody<{ username: string; password: string }>(c); + + getLog().info({ username: body.username }, 'auth.login_started'); + + const user = await usersDb.getUserByUsername(body.username); + if (!user) { + return c.json({ error: 'Invalid username or password' }, 401); + } + + const valid = await verifyPassword(body.password, user.password_hash); + if (!valid) { + return c.json({ error: 'Invalid username or password' }, 401); + } + + const tokenPayload = { userId: user.id, role: user.role }; + const [accessToken, refreshToken] = await Promise.all([ + generateAccessToken(tokenPayload), + generateRefreshToken(tokenPayload), + ]); + + getLog().info({ userId: user.id }, 'auth.login_completed'); + return c.json({ user: sanitizeUser(user), accessToken, refreshToken }); + } catch (error) { + getLog().error({ err: error }, 'auth.login_failed'); + return c.json({ error: 'Login failed' }, 500); + } + }); + + // POST /api/auth/refresh + registerOpenApiRoute(refreshRoute, async (c: Context) => { + try { + const body = getValidatedBody<{ refreshToken: string }>(c); + + let payload; + try { + payload = await verifyToken(body.refreshToken); + } catch { + return c.json({ error: 'Invalid refresh token' }, 401); + } + + const user = await usersDb.getUserById(payload.userId); + if (!user) { + return c.json({ error: 'Invalid refresh token' }, 401); + } + + const tokenPayload = { userId: user.id, role: user.role }; + const [accessToken, refreshToken] = await Promise.all([ + generateAccessToken(tokenPayload), + generateRefreshToken(tokenPayload), + ]); + + getLog().info({ userId: user.id }, 'auth.refresh_completed'); + return c.json({ accessToken, refreshToken }); + } catch (error) { + getLog().error({ err: error }, 'auth.refresh_failed'); + return c.json({ error: 'Token refresh failed' }, 500); + } + }); + + // GET /api/auth/me + registerOpenApiRoute(meRoute, async (c: Context) => { + try { + const userId = c.get('userId') as string | undefined; + if (!userId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const user = await usersDb.getUserById(userId); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + return c.json(sanitizeUser(user)); + } catch (error) { + getLog().error({ err: error }, 'auth.me_failed'); + return c.json({ error: 'Failed to get user info' }, 500); + } + }); +} diff --git a/packages/server/src/routes/schemas/auth.schemas.ts b/packages/server/src/routes/schemas/auth.schemas.ts new file mode 100644 index 0000000000..875d60970f --- /dev/null +++ b/packages/server/src/routes/schemas/auth.schemas.ts @@ -0,0 +1,56 @@ +import { z } from '@hono/zod-openapi'; + +export const registerBodySchema = z + .object({ + username: z.string().min(3).max(50), + password: z.string().min(8), + displayName: z.string().max(100).optional(), + }) + .strict() + .openapi('RegisterBody'); + +export const loginBodySchema = z + .object({ + username: z.string(), + password: z.string(), + }) + .strict() + .openapi('LoginBody'); + +export const refreshBodySchema = z + .object({ + refreshToken: z.string(), + }) + .strict() + .openapi('RefreshBody'); + +export const userSchema = z + .object({ + id: z.string(), + username: z.string(), + displayName: z.string().nullable(), + role: z.enum(['admin', 'user']), + createdAt: z.string(), + }) + .openapi('User'); + +export const authResponseSchema = z + .object({ + user: userSchema, + accessToken: z.string(), + refreshToken: z.string(), + }) + .openapi('AuthResponse'); + +export const refreshResponseSchema = z + .object({ + accessToken: z.string(), + refreshToken: z.string(), + }) + .openapi('RefreshResponse'); + +export type RegisterBody = z.infer; +export type LoginBody = z.infer; +export type AuthResponse = z.infer; +export type RefreshResponse = z.infer; +export type UserResponse = z.infer; diff --git a/packages/server/src/routes/utils.ts b/packages/server/src/routes/utils.ts new file mode 100644 index 0000000000..5609ea308e --- /dev/null +++ b/packages/server/src/routes/utils.ts @@ -0,0 +1,7 @@ +import type { Context } from 'hono'; +import type { z } from '@hono/zod-openapi'; + +/** Access Zod-validated body from a handler registered via app.openapi(). */ +export function getValidatedBody(c: Context, _schema?: z.ZodType): T { + return (c.req as unknown as { valid(k: 'json'): T }).valid('json'); +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index d308640c9e..b41aab413e 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; import { QueryClientProvider } from '@tanstack/react-query'; import { Layout } from '@/components/layout/Layout'; import { ProjectProvider } from '@/contexts/ProjectContext'; +import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { queryClient } from '@/lib/query-client'; import { DashboardPage } from '@/routes/DashboardPage'; import { ChatPage } from '@/routes/ChatPage'; @@ -11,6 +12,7 @@ import { WorkflowsPage } from '@/routes/WorkflowsPage'; import { WorkflowExecutionPage } from '@/routes/WorkflowExecutionPage'; import { WorkflowBuilderPage } from '@/routes/WorkflowBuilderPage'; import { SettingsPage } from '@/routes/SettingsPage'; +import { LoginPage } from '@/routes/LoginPage'; interface ErrorBoundaryState { hasError: boolean; @@ -60,27 +62,54 @@ class ErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryStat } } +function RequireAuth({ children }: { children: ReactNode }): React.ReactElement { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + export function App(): React.ReactElement { return ( - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); diff --git a/packages/web/src/components/layout/TopNav.tsx b/packages/web/src/components/layout/TopNav.tsx index 45924f5004..76ff03e8ca 100644 --- a/packages/web/src/components/layout/TopNav.tsx +++ b/packages/web/src/components/layout/TopNav.tsx @@ -1,8 +1,9 @@ -import { NavLink, Link } from 'react-router'; +import { NavLink, Link, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; -import { LayoutDashboard, MessageSquare, Workflow, Settings } from 'lucide-react'; +import { LayoutDashboard, MessageSquare, Workflow, Settings, LogOut } from 'lucide-react'; import { listWorkflowRuns, getUpdateCheck } from '@/lib/api'; import { cn } from '@/lib/utils'; +import { useAuth } from '@/contexts/AuthContext'; const tabs = [ { to: '/chat', end: false, icon: MessageSquare, label: 'Chat' }, @@ -12,6 +13,9 @@ const tabs = [ ] as const; export function TopNav(): React.ReactElement { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + const { data: runningRuns } = useQuery({ queryKey: ['workflowRuns', { status: 'running' }], queryFn: () => listWorkflowRuns({ status: 'running', limit: 1 }), @@ -58,7 +62,25 @@ export function TopNav(): React.ReactElement { )} ))} - +
+ {user && ( +
+ {user.username} + +
+ )} +
+ v{import.meta.env.VITE_APP_VERSION as string} {updateCheck?.updateAvailable && updateCheck.releaseUrl && ( void; + logout: () => void; + isAuthenticated: boolean; + isLoading: boolean; +} + +const REFRESH_TOKEN_KEY = 'archon-refresh-token'; + +const authContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement { + const [accessToken, setAccessToken] = useState(null); + const [refreshTokenState, setRefreshTokenState] = useState(() => { + try { + return localStorage.getItem(REFRESH_TOKEN_KEY); + } catch { + return null; + } + }); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const initializedRef = useRef(false); + + function clearTokens(): void { + setAccessToken(null); + setRefreshTokenState(null); + setUser(null); + setApiToken(null); + try { + localStorage.removeItem(REFRESH_TOKEN_KEY); + } catch { + /* best-effort */ + } + } + + // On mount, try to restore session via refresh token + useEffect(() => { + if (initializedRef.current) return; + initializedRef.current = true; + + if (!refreshTokenState) { + setIsLoading(false); + return; + } + refreshSession(refreshTokenState) + .then(data => { + setAccessToken(data.accessToken); + setRefreshTokenState(data.refreshToken); + setApiToken(data.accessToken); + try { + localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken); + } catch { + /* best-effort */ + } + }) + .catch((err: unknown) => { + // Expected on token expiry; unexpected on server errors — log for diagnostics + console.warn('[AuthContext] Session restore failed:', err); + clearTokens(); + }) + .finally(() => { + setIsLoading(false); + }); + }, [refreshTokenState]); + + // Fetch user profile once access token is set + useEffect(() => { + if (!accessToken) return; + getCurrentUser(accessToken) + .then(u => { + setUser(u); + }) + .catch((err: unknown) => { + console.warn('[AuthContext] Failed to fetch user profile:', err); + clearTokens(); + }); + }, [accessToken]); + + // Listen for 401 events from fetchJSON + useEffect(() => { + const handler = (): void => { + clearTokens(); + }; + window.addEventListener('archon:unauthorized', handler); + return (): void => { + window.removeEventListener('archon:unauthorized', handler); + }; + }, []); + + const login = useCallback((at: string, rt: string, u: UserResponse): void => { + setAccessToken(at); + setRefreshTokenState(rt); + setUser(u); + setApiToken(at); + try { + localStorage.setItem(REFRESH_TOKEN_KEY, rt); + } catch { + /* best-effort */ + } + }, []); + + const logout = useCallback((): void => { + clearTokens(); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(authContext); + if (!ctx) throw new Error('useAuth must be used within an AuthProvider'); + return ctx; +} diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 6c81aa66b1..e27dabd882 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -57,9 +57,47 @@ export interface HealthResponse { is_docker: boolean; } +// Module-level auth token — set by AuthContext via setApiToken() +let currentToken: string | null = null; + +export function setApiToken(token: string | null): void { + currentToken = token; +} + +export function getApiToken(): string | null { + return currentToken; +} + +export interface UserResponse { + id: string; + username: string; + displayName: string | null; + role: 'admin' | 'user'; + createdAt: string; +} + +export interface AuthTokenResponse { + user: UserResponse; + accessToken: string; + refreshToken: string; +} + +export interface RefreshTokenResponse { + accessToken: string; + refreshToken: string; +} + async function fetchJSON(url: string, options?: RequestInit): Promise { - const res = await fetch(url, options); + const headers = new Headers(options?.headers); + if (currentToken && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${currentToken}`); + } + + const res = await fetch(url, { ...options, headers }); if (!res.ok) { + if (res.status === 401) { + window.dispatchEvent(new CustomEvent('archon:unauthorized')); + } const body = await res.text(); const truncated = body.length > 200 ? body.slice(0, 200) + '...' : body; const path = new URL(url, window.location.origin).pathname; @@ -70,6 +108,49 @@ async function fetchJSON(url: string, options?: RequestInit): Promise { return res.json() as Promise; } +// Auth API functions +export async function loginApi(username: string, password: string): Promise { + return fetchJSON('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); +} + +export async function registerApi( + username: string, + password: string, + displayName?: string +): Promise { + return fetchJSON('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, displayName }), + }); +} + +export async function refreshSession(refreshToken: string): Promise { + // Use raw fetch rather than fetchJSON to avoid accidentally injecting a stale + // in-memory access token into the refresh request via fetchJSON's auto-inject logic. + const res = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), + }); + if (!res.ok) { + throw new Error('Token refresh failed'); + } + return res.json() as Promise; +} + +export async function getCurrentUser(token: string): Promise { + const res = await fetch('/api/auth/me', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) throw new Error('Failed to get current user'); + return res.json() as Promise; +} + // Conversations export async function listConversations(codebaseId?: string): Promise { const params = new URLSearchParams(); diff --git a/packages/web/src/routes/LoginPage.tsx b/packages/web/src/routes/LoginPage.tsx new file mode 100644 index 0000000000..c34d364d1b --- /dev/null +++ b/packages/web/src/routes/LoginPage.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useAuth } from '@/contexts/AuthContext'; +import { loginApi, registerApi } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +type Mode = 'login' | 'register'; + +export function LoginPage(): React.ReactElement { + const [mode, setMode] = useState('login'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const auth = useAuth(); + const navigate = useNavigate(); + + async function handleSubmit(e: React.FormEvent): Promise { + e.preventDefault(); + setError(''); + setIsSubmitting(true); + + try { + const result = + mode === 'login' + ? await loginApi(username, password) + : await registerApi(username, password, displayName || undefined); + + auth.login(result.accessToken, result.refreshToken, result.user); + navigate('/chat'); + } catch (err) { + const msg = (err as Error).message; + if (msg.includes('401')) { + setError('Invalid username or password'); + } else if (msg.includes('409')) { + setError('Username already taken'); + } else { + setError(msg); + } + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+
+ A +
+

Archon

+

+ {mode === 'login' ? 'Sign in to your account' : 'Create a new account'} +

+
+ +
void handleSubmit(e)} className="space-y-4"> +
+ + { + setUsername(e.target.value); + }} + required + minLength={mode === 'register' ? 3 : undefined} + autoFocus + /> +
+ +
+ + { + setPassword(e.target.value); + }} + required + minLength={mode === 'register' ? 8 : undefined} + /> +
+ + {mode === 'register' && ( +
+ + { + setDisplayName(e.target.value); + }} + /> +
+ )} + + {error &&

{error}

} + + +
+ +
+ {mode === 'login' ? ( + <> + Don't have an account?{' '} + + + ) : ( + <> + Already have an account?{' '} + + + )} +
+
+
+ ); +}