PRD Reference: docs/plans/mobileapp.md
Master Plan Reference: docs/plans/mobileapp-implementation.md
Phase 3 builds the core GraphQL server infrastructure: the context factory, server entry point (yoga + HTTPS + WebSocket), headless bootstrap sequence, authentication system (API key generation, verification plugin, brute force protection, WebSocket auth), path guard plugin, TLS certificate generation, config loader, and headless CLI flag handling in src/main/index.ts. At the end of this phase, hive --headless starts a functional (but mostly empty) GraphQL server with authentication and TLS.
- Phase 1 completed: shared types, EventBus, dependencies installed.
- Phase 2 completed: SDL schema files, codegen generating resolver types.
- Key files from Phase 2:
src/server/__generated__/resolvers-types.ts, all.graphqlschema files.
| File | Purpose |
|---|---|
src/main/index.ts |
Electron main entry — currently 466 lines. app.whenReady() at line 315, createWindow() at line 356, CLI flag parsing at line 42-43. Headless branch goes BEFORE createWindow(). |
src/main/db/index.ts |
getDatabase() singleton — returns DatabaseService |
src/main/services/agent-sdk-manager.ts |
AgentSdkManager class — manages OpenCode + ClaudeCode implementers |
src/main/services/claude-code-implementer.ts |
ClaudeCodeImplementer class |
src/main/services/claude-binary-resolver.ts |
resolveClaudeBinaryPath() |
src/server/event-bus.ts |
getEventBus() singleton (from Phase 1) |
src/server/__generated__/resolvers-types.ts |
Generated resolver types (from Phase 2) |
Dual startup mode:
- GUI mode (default):
hive→ creates BrowserWindow, registers IPC handlers, normal desktop app - Headless mode (new):
hive --headless --port 8443→ no window, starts GraphQL server
Both modes share the same services (Database, Git, OpenCode, Scripts, Terminal). The difference is:
- GUI mode:
createWindow()+ IPC handlers - Headless mode:
headlessBootstrap()+ GraphQL server
Auth model: 256-bit random API key with hive_ prefix. SHA-256 hash stored in SQLite settings table. Timing-safe comparison. Brute force protection: 5 failures per IP per 60s → 300s block. Authenticated users are NEVER rate-limited.
TLS: Self-signed ECDSA P-256 cert, auto-generated on first headless run. Stored at ~/.hive/tls/. Fingerprint stored in settings for QR pairing.
Goal: Create the context interface that carries all services to resolvers.
Definition of Done: GraphQLContext type defined, importable, compiles clean.
Tasks:
-
[server]Createsrc/server/context.tsexportingGraphQLContextinterface:import type { DatabaseService } from '../main/db/database' import type { AgentSdkManager } from '../main/services/agent-sdk-manager' import type { EventBus } from './event-bus' export interface GraphQLContext { db: DatabaseService sdkManager: AgentSdkManager eventBus: EventBus clientIp: string authenticated: boolean }
Note: Import paths may need adjustment based on actual file locations. The
DatabaseServicetype should be imported from wherever it's defined (checksrc/main/db/for the actual export). -
[server]Verify it compiles:pnpm build
Verification:
pnpm buildGoal: Create the startGraphQLServer function that creates the yoga instance + HTTPS server + WebSocket server.
Definition of Done: Function exists, can be called with options (port, cert, key, context), starts HTTPS+WS server. Returns a handle for graceful shutdown.
Tasks:
-
[server]Createsrc/server/index.tswithstartGraphQLServer(opts)function:- Import
createYogaandcreateSchemafromgraphql-yoga - Import
useServerfromgraphql-ws/lib/use/ws - Import
createServerfromnode:https - Import
WebSocketServerfromws - Read SDL schema files (use
readFileSyncto load all.graphqlfiles fromsrc/server/schema/) - Create yoga instance with
createSchema({ typeDefs, resolvers }) - Create HTTPS server with TLS cert/key
- Create WebSocketServer on the same server at yoga's endpoint path
- Wire
graphql-wsusinguseServer() - Return handle with
close()method
Key implementation notes:
- The resolver merger starts empty (
{}) — resolvers are added incrementally in Phases 4-8 - Use
yoga.graphqlEndpointfor the WS path - The HTTPS server needs
readFileSync(opts.tlsCert)andreadFileSync(opts.tlsKey) - WebSocket auth is handled in Session 25
- Import
-
[server]Createsrc/server/resolvers/index.ts— empty resolver merger:export function mergeResolvers() { return {} }
This will be incrementally populated as resolver files are added in Phases 4-8.
-
[server]Verify it compiles:pnpm build
Verification:
pnpm buildGoal: Create the bootstrap sequence for headless mode.
Definition of Done: headlessBootstrap() function that initializes services and starts the GraphQL server.
Tasks:
-
[server]Createsrc/server/headless-bootstrap.tswithheadlessBootstrap(opts)function:- Load config from
~/.hive/headless.json(Session 28) - Initialize database: call
getDatabase()(same as GUI mode uses insrc/main/index.tsline 338) - Run
fixPath()(same as GUI mode, line 318) - Resolve Claude binary path:
resolveClaudeBinaryPath() - Create
AgentSdkManager(same pattern assrc/main/index.tslines 372-409, but withoutmainWindow) - Get EventBus singleton:
getEventBus() - Ensure TLS certs exist (generate if needed — Session 27)
- Ensure API key exists (generate if needed — Session 22)
- Display API key + QR code on first run (Session 94)
- Call
startGraphQLServer()with assembled options - Log startup info: port, bind address, cert fingerprint
The opts parameter:
interface HeadlessBootstrapOpts { port?: number bind?: string }
- Load config from
-
[server]Verify it compiles:pnpm build
Note: This function will evolve as Sessions 22-28 are implemented. Initial version can stub out TLS/auth/config with TODOs.
Goal: Implement key generation, hashing, and storage utilities.
Definition of Done: Can generate hive_ prefixed keys, hash them, store hash in DB, verify keys with timing-safe comparison.
Tasks:
-
[server]Create key utilities insrc/server/plugins/auth.ts:import crypto from 'node:crypto' export function generateApiKey(): string { return 'hive_' + crypto.randomBytes(32).toString('base64url') } export function hashApiKey(key: string): string { return crypto.createHash('sha256').update(key).digest('hex') } export function verifyApiKey(key: string, storedHash: string): boolean { const keyHash = hashApiKey(key) const keyBuf = Buffer.from(keyHash, 'hex') const storedBuf = Buffer.from(storedHash, 'hex') if (keyBuf.length !== storedBuf.length) return false return crypto.timingSafeEqual(keyBuf, storedBuf) }
-
[server]Write tests intest/server/auth-key.test.ts:generateApiKey()returns string starting withhive_generateApiKey()returns different keys each callgenerateApiKey()key length is reasonable (> 40 chars)hashApiKey()returns consistent hash for same inputhashApiKey()returns 64 hex chars (SHA-256)verifyApiKey()returns true for correct keyverifyApiKey()returns false for wrong keyverifyApiKey()returns false for empty keyverifyApiKey()returns false for hash length mismatch
-
[server]Run tests:pnpm vitest run test/server/auth-key.test.ts
Verification:
pnpm vitest run test/server/auth-key.test.tsGoal: Create yoga plugin that verifies Bearer token on every request.
Definition of Done: Plugin rejects unauthenticated requests with 401, accepts valid Bearer token.
Tasks:
-
[server]ImplementcreateAuthPlugin(getKeyHash)insrc/server/plugins/auth.ts:- This is a graphql-yoga plugin using the
useExtendContextoronRequesthook - Reads
Authorization: Bearer hive_...header from each request - Extracts the key after "Bearer "
- Calls
verifyApiKey(key, storedHash)to compare - If invalid: throws GraphQL error with code UNAUTHENTICATED (401)
- If valid: extends context with
authenticated: true - The
getKeyHashis a function() => stringthat reads the hash from DB settings
- This is a graphql-yoga plugin using the
-
[server]Write tests intest/server/auth-plugin.test.ts:- Request with valid Bearer token → 200 OK,
authenticated: truein context - Request with no Authorization header → 401 UNAUTHENTICATED error
- Request with invalid Bearer token → 401
- Request with malformed header (no "Bearer" prefix) → 401
- Request with "Bearer " but empty token → 401
- Request with valid Bearer token → 200 OK,
-
[server]Run tests:pnpm vitest run test/server/auth-plugin.test.ts
Verification:
pnpm vitest run test/server/auth-plugin.test.tsGoal: Block IPs after 5 failed auth attempts within 60 seconds.
Definition of Done: 5 failures per IP per minute → 300s block. Authenticated users NEVER rate-limited.
Tasks:
-
[server]Implement brute force tracker insrc/server/plugins/auth.ts:interface BruteForceEntry { attempts: number firstAttempt: number blockedUntil: number } const bruteForceMap = new Map<string, BruteForceEntry>()
- On failed auth: increment attempts for IP. If ≥5 within 60s → set
blockedUntil = Date.now() + 300_000 - On blocked IP requesting → return 429 Too Many Requests BEFORE even checking key
- On successful auth → do NOT track or rate-limit at all
- Cleanup stale entries every 60s (entries older than blockSec + windowSec)
- Make thresholds configurable via config:
maxAttempts,windowSec,blockSec
- On failed auth: increment attempts for IP. If ≥5 within 60s → set
-
[server]Write tests intest/server/auth-brute-force.test.ts:- 4 failed attempts → still allowed
- 5th failed attempt from same IP → blocked (429)
- Blocked IP → 429 response (even with correct key during block period)
- After block period expires → unblocked
- Successful auth from same IP → never blocked/tracked
- Different IPs tracked independently
- Stale entries cleaned up
-
[server]Run tests:pnpm vitest run test/server/auth-brute-force.test.ts
Verification:
pnpm vitest run test/server/auth-brute-force.test.tsGoal: Verify API key during WebSocket handshake via connectionParams.
Definition of Done: Invalid key → connection rejected before upgrade. Valid key → connection accepted with authenticated context.
Tasks:
-
[server]Insrc/server/index.ts, configuregraphql-ws'suseServeroptions:useServer({ execute: (args) => args.rootValue, subscribe: (args) => args.rootValue, context: (ctx) => ({ ...baseContext, clientIp: ctx.extra.request.socket.remoteAddress || 'unknown', authenticated: true // already verified in onConnect }), onConnect: (ctx) => { const apiKey = ctx.connectionParams?.apiKey as string | undefined if (!apiKey) return false const hash = getKeyHash() if (!verifyApiKey(apiKey, hash)) return false return true // accepted } }, wss)
-
[server]Write test intest/server/auth-ws.test.ts:- WS connect with valid
apiKeyin connectionParams → connection established - WS connect with invalid
apiKey→ connection rejected (4403 or similar) - WS connect with no connectionParams → connection rejected
- WS connect with empty apiKey → connection rejected
- WS connect with valid
-
[server]Run tests:pnpm vitest run test/server/auth-ws.test.ts
Verification:
pnpm vitest run test/server/auth-ws.test.tsGoal: Prevent path traversal attacks by validating all file/directory path arguments.
Definition of Done: Paths outside allowed roots rejected, ../ traversals blocked.
Tasks:
-
[server]Createsrc/server/plugins/path-guard.ts:import { resolve, normalize } from 'node:path' import { realpathSync } from 'node:fs' export class PathGuard { private allowedRoots: string[] constructor(roots: string[]) { this.allowedRoots = roots.map(r => normalize(resolve(r))) } addRoot(root: string): void { this.allowedRoots.push(normalize(resolve(root))) } validatePath(inputPath: string): boolean { if (!inputPath || inputPath.trim() === '') return false const resolved = normalize(resolve(inputPath)) return this.allowedRoots.some(root => resolved.startsWith(root)) } }
- Create a yoga plugin that inspects GraphQL variables for known path argument names:
worktreePath,filePath,dirPath,cwd,path,projectPath - For each found path, calls
pathGuard.validatePath() - If invalid: throws GraphQL error "Path not allowed"
- Allowed roots populated from: all project paths in DB,
~/.hive/
- Create a yoga plugin that inspects GraphQL variables for known path argument names:
-
[server]Write tests intest/server/path-guard.test.ts:- Valid path under allowed root → accepted
- Path with
../escaping root → rejected - Absolute path outside all roots → rejected
- Path to
~/.hive/→ accepted (always allowed) - Empty path → rejected
- Null/undefined → rejected
- Path exactly matching root → accepted
- Deeply nested valid path → accepted
-
[server]Run tests:pnpm vitest run test/server/path-guard.test.ts
Verification:
pnpm vitest run test/server/path-guard.test.tsGoal: Auto-generate self-signed ECDSA P-256 TLS certificates on first headless run.
Definition of Done: Certs generated to ~/.hive/tls/, fingerprint stored in settings, idempotent.
Tasks:
-
[server]Createsrc/server/tls.ts:generateTlsCerts(outputDir: string):- Creates
~/.hive/tls/directory if not existing - Uses Node.js
crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' })to generate ECDSA P-256 key pair - Creates self-signed X.509 certificate (10-year validity)
- NOTE: Node.js doesn't have built-in X.509 cert creation. Use
crypto.X509Certificatefor reading, but for creation either use a small library or shell out toopenssl(preferred for simplicity):openssl req -new -x509 -key server.key -out server.crt -days 3650 -subj "/CN=hive-headless" - Alternatively, generate via
openssl ecparam -genkey -name prime256v1 | openssl ec -out server.keyandopenssl req -new -x509 -key server.key -out server.crt -days 3650 -subj "/CN=hive-headless"
- Creates
getCertFingerprint(certPath: string): string:- Reads cert, computes SHA-256 fingerprint of DER-encoded cert
- Returns fingerprint as hex string (64 chars)
ensureTlsCerts(db):- Check if certs exist at configured paths
- If not: generate them, compute fingerprint, store in settings as
headless_cert_fingerprint - If yes: verify they're valid, return existing fingerprint
- Idempotent: does NOT overwrite existing certs
-
[server]Write tests intest/server/tls.test.ts:- Certs generated to temp dir (use
os.tmpdir()) - Both
server.crtandserver.keyfiles created - Fingerprint is 64 hex chars
- Re-running does NOT overwrite existing certs (check mtime)
- Generated cert is readable by Node.js
tls.createSecureContext
- Certs generated to temp dir (use
-
[server]Run tests:pnpm vitest run test/server/tls.test.ts
Verification:
pnpm vitest run test/server/tls.test.tsGoal: Load ~/.hive/headless.json with sensible defaults.
Definition of Done: Config merges user settings with defaults, handles missing file, handles invalid JSON.
Tasks:
-
[server]Createsrc/server/config.ts:import { readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { homedir } from 'node:os' export interface HeadlessConfig { port: number bindAddress: string tls: { certPath: string keyPath: string } security: { bruteForceMaxAttempts: number bruteForceWindowSec: number bruteForceBlockSec: number inactivityTimeoutMin: number allowedIps: string[] } } const DEFAULTS: HeadlessConfig = { port: 8443, bindAddress: '0.0.0.0', tls: { certPath: join(homedir(), '.hive', 'tls', 'server.crt'), keyPath: join(homedir(), '.hive', 'tls', 'server.key') }, security: { bruteForceMaxAttempts: 5, bruteForceWindowSec: 60, bruteForceBlockSec: 300, inactivityTimeoutMin: 30, allowedIps: [] } } export function loadHeadlessConfig(): HeadlessConfig { const configPath = join(homedir(), '.hive', 'headless.json') if (!existsSync(configPath)) return { ...DEFAULTS } try { const raw = readFileSync(configPath, 'utf-8') const parsed = JSON.parse(raw) return deepMerge(DEFAULTS, parsed) } catch { // Invalid JSON — log warning, return defaults console.warn('Failed to parse ~/.hive/headless.json, using defaults') return { ...DEFAULTS } } }
- Implement
deepMergeutility for nested config objects
- Implement
-
[server]Write tests intest/server/config.test.ts:- Missing config file → returns defaults
- Empty config file → returns defaults
- Partial config (just
port: 9443) → merged with defaults, port is 9443, rest is defaults - Invalid JSON → returns defaults with warning
- Custom security settings → properly merged
- Nested TLS paths → properly merged
-
[server]Run tests:pnpm vitest run test/server/config.test.ts
Verification:
pnpm vitest run test/server/config.test.tsGoal: Parse --headless and related flags from process.argv in src/main/index.ts.
Definition of Done: CLI flags parsed, available to startup logic, desktop mode completely unaffected.
Tasks:
-
[server]Modifysrc/main/index.ts— add flag parsing after existingcliArgs(line 43):const isHeadless = cliArgs.includes('--headless') const headlessPort = cliArgs.includes('--port') ? parseInt(cliArgs[cliArgs.indexOf('--port') + 1]) : undefined const headlessBind = cliArgs.includes('--bind') ? cliArgs[cliArgs.indexOf('--bind') + 1] : undefined const isRotateKey = cliArgs.includes('--rotate-key') const isRegenCerts = cliArgs.includes('--regen-certs') const isShowStatus = cliArgs.includes('--show-status') const isKill = cliArgs.includes('--kill') const isUnlock = cliArgs.includes('--unlock')
-
[server]Verifypnpm buildsucceeds. Desktop mode is completely unaffected (all new code is behindisHeadlesschecks not yet wired).
Verification:
pnpm build && pnpm testGoal: Add the --headless branch to app.whenReady() that starts GraphQL server instead of creating a window.
Definition of Done: hive --headless starts server, hive (no flag) starts desktop as before.
Tasks:
-
[server]Modifysrc/main/index.ts— inapp.whenReady()callback, AFTERgetDatabase()and BEFOREcreateWindow()(around line 340-356), add headless branch:if (isHeadless) { log.info('Starting in headless mode') const { headlessBootstrap } = await import('../server/headless-bootstrap') await headlessBootstrap({ port: headlessPort, bind: headlessBind }) return // Skip ALL window/IPC/menu setup below }
- The
returnensures NONE of the window/IPC/menu code runs in headless mode. - The dynamic import ensures the server code is only loaded when needed.
- Note:
app.whenReady()callback must beasyncfor this to work.
- The
-
[server]Also need to handle the IPC handler registrations that happen BEFOREcreateWindow():registerDatabaseHandlers(),registerProjectHandlers(), etc. at lines 342-348 should still run in headless mode (database handlers are needed)- OR the headless bootstrap handles its own DB init
- Decision: Move the headless check BEFORE the IPC registrations to keep things clean. The headless bootstrap initializes DB on its own.
-
[server]Verifypnpm buildsucceeds. -
[server]Verifypnpm test— all existing tests still pass.
Verification:
pnpm build && pnpm testGoal: Implement --rotate-key, --regen-certs, --show-status, --kill, --unlock.
Definition of Done: Each CLI command works as one-shot operation (run and exit).
Tasks:
-
[server]Insrc/main/index.tsheadless branch, BEFORE callingheadlessBootstrap(), handle one-shot commands:if (isHeadless) { log.info('Starting in headless mode') // Handle one-shot management commands if (isRotateKey || isRegenCerts || isShowStatus || isKill || isUnlock) { const { handleManagementCommand } = await import('../server/headless-bootstrap') await handleManagementCommand({ rotateKey: isRotateKey, regenCerts: isRegenCerts, showStatus: isShowStatus, kill: isKill, unlock: isUnlock }) app.quit() return } // Normal headless startup const { headlessBootstrap } = await import('../server/headless-bootstrap') await headlessBootstrap({ port: headlessPort, bind: headlessBind }) return }
-
[server]ImplementhandleManagementCommand(opts)insrc/server/headless-bootstrap.ts:--rotate-key: Initialize DB, generate new API key, store hash, display new key + QR code, log rotation--regen-certs: Delete old certs from~/.hive/tls/, regenerate new certs, update fingerprint in DB--show-status: Read~/.hive/hive-headless.status.json, pretty-print to stdout (uptime, connections, request count, locked state)--kill: Read PID file~/.hive/hive-headless.pid, send SIGTERM to running process--unlock: Initialize DB, delete auto-lock state from settings table
-
[server]Each command callsapp.quit()after completing (they don't start the server). -
[server]Verifypnpm buildsucceeds.
Verification:
pnpm buildsrc/server/
index.ts — startGraphQLServer() with yoga + HTTPS + WS
context.ts — GraphQLContext interface
headless-bootstrap.ts — headlessBootstrap() + handleManagementCommand()
config.ts — loadHeadlessConfig() with defaults
tls.ts — generateTlsCerts(), getCertFingerprint(), ensureTlsCerts()
plugins/
auth.ts — generateApiKey(), hashApiKey(), verifyApiKey(), createAuthPlugin(), brute force tracker
path-guard.ts — PathGuard class + yoga plugin
resolvers/
index.ts — Empty resolver merger (placeholder)
test/server/
auth-key.test.ts — API key generation/verification tests
auth-plugin.test.ts — Auth plugin tests
auth-brute-force.test.ts — Brute force protection tests
auth-ws.test.ts — WebSocket auth tests
path-guard.test.ts — Path traversal prevention tests
tls.test.ts — TLS cert generation tests
config.test.ts — Config loader tests
| File | Lines Changed | What |
|---|---|---|
src/main/index.ts |
~25 | CLI flag parsing + headless startup branch + management commands |
Phase 4 (DB Resolvers) will implement the simplest resolvers — pure database CRUD operations that wrap the existing DatabaseService methods.