diff --git a/README.md b/README.md index 6d00266..59c5037 100644 --- a/README.md +++ b/README.md @@ -96,20 +96,83 @@ Hooks stores data locally by default in `~/.hasna/hooks/` and uses SQLite directly for hook event history. The package owns its database schema and migrations; it does not depend on the deprecated shared runtime or its CLI. The repo includes its own PostgreSQL migration definitions for optional remote -storage deployments. Use the `hooks log` commands to inspect local hook event -data. +storage deployments. Use the `hooks log` commands to inspect hook event data. +In local mode they read SQLite; in explicit API mode they use the authenticated +Hooks `/v1` HTTP authority instead of falling back to local files. + +Hook event *ingestion* follows the same routing: in API mode the observability +hooks (`commandlog`, `sessionlog`, `costwatch`, `errornotify`) `POST` each event +to `/v1/log/events` on the configured authority, so `hooks log tail` sees the +events this machine just produced. If the authority is unreachable, incompletely +configured, or does not answer within the write deadline, the event is spooled +into the local SQLite database rather than dropped, and a warning is written to +stderr. Drain the spool with `hooks storage push` — rows are upserted by event +id, so draining is idempotent. + +Because that spool is also the mirror `hooks storage pull` writes into, `hooks +log clear` in API mode deletes on the authority *and* in the local database. A +purge that stopped at the authority would be undone by the next `hooks storage +push`, which uploads whatever the local file still holds. Both stores are +reported: `cleared_remote` and `cleared_local` name the per-store counts and +`cleared` is the larger of the two, so clearing an unpushed spool the authority +never saw can never be reported as "nothing to clear". (`cleared` is the larger +count rather than their sum because a pulled event exists in both stores and +must not be counted twice.) + +The MCP log tools (`hooks_log_list`, `hooks_log_tail`, `hooks_log_errors`, +`hooks_log_summary`) route exactly like the `hooks log` commands: the authority +in API mode, local SQLite in local mode, and a tool error rather than a stale +local answer when an API authority is configured but cannot be reached. + +Every `/v1` request carries a deadline, so a hung authority can never block an +agent's tool call: hook event writes default to 3s +(`HASNA_HOOKS_API_WRITE_TIMEOUT_MS`, fallback `HOOKS_API_WRITE_TIMEOUT_MS`) and +interactive `hooks log` / `hooks storage` commands default to 30s +(`HASNA_HOOKS_API_TIMEOUT_MS`, fallback `HOOKS_API_TIMEOUT_MS`). + +The `/v1` transport carries data tables only — `hook_events` and `feedback`. +`schema_migrations` and `_meta` are per-database bookkeeping and are never +exported, imported, or accepted by `/v1/storage/import`: replicating a peer's +migration ledger would let a machine on a newer release mark a migration as +applied on an authority that never ran its DDL, permanently suppressing it. ```bash hooks storage status --json HASNA_HOOKS_DATABASE_URL=postgres://... hooks storage push --tables hook_events,feedback --json hooks storage pull --json hooks storage sync --json + +HASNA_HOOKS_STORAGE_MODE=api \ +HASNA_HOOKS_API_URL=https://hooks.example \ +HASNA_HOOKS_API_KEY=... \ +hooks log list --json ``` Configure database storage with `HASNA_HOOKS_DATABASE_URL` or fallback `HOOKS_DATABASE_URL`. Optional storage mode env vars are `HASNA_HOOKS_STORAGE_MODE` and `HOOKS_STORAGE_MODE`, with `local`, `hybrid`, or -`remote` values. +`remote` values for SQLite/PostgreSQL sync. For the HTTP API backend, set +`HASNA_HOOKS_STORAGE_MODE=api` (or `self_hosted`/`cloud`) plus +`HASNA_HOOKS_API_URL` and `HASNA_HOOKS_API_KEY`. API mode disables local +fallback for API-routed commands. In `remote`/`hybrid` mode the HTTP transport +is chosen only when `HASNA_HOOKS_API_URL` is set — an API key on its own never +diverts those modes away from PostgreSQL — and configuring both a database URL +and an API URL prints a precedence warning. + +### Serving the API + +`hooks mcp --http` serves only the shared MCP endpoint at `/mcp`. The Hooks +`/v1` data API reads and can delete hook event history, so it is opt-in: + +```bash +HASNA_HOOKS_API_SERVER_KEY=... hooks mcp --http --api +``` + +`HASNA_HOOKS_API_SERVER_KEY` (fallback `HOOKS_API_SERVER_KEY`) is the credential +this process accepts on `/v1`. It is deliberately separate from the client-side +`HASNA_HOOKS_API_KEY` that the CLI presents to a remote authority, so one secret +never serves both trust roles. Without a server key the `/v1` data routes fail +closed with HTTP 503. ## Runtime model diff --git a/hooks/hook-commandlog/src/hook.ts b/hooks/hook-commandlog/src/hook.ts index d838939..7c7aab1 100644 --- a/hooks/hook-commandlog/src/hook.ts +++ b/hooks/hook-commandlog/src/hook.ts @@ -34,7 +34,7 @@ function respond(output: HookOutput): void { console.log(JSON.stringify(output)); } -export function run(): void { +export async function run(): Promise { const input = readStdinJson(); if (!input) { @@ -51,7 +51,7 @@ export function run(): void { const command = (input.tool_input.command as string) || "(unknown command)"; const exitCode = input.tool_input.exit_code; - writeHookEvent({ + await writeHookEvent({ session_id: input.session_id, hook_name: "commandlog", event_type: "PostToolUse", @@ -65,5 +65,5 @@ export function run(): void { } if (import.meta.main) { - run(); + await run(); } diff --git a/hooks/hook-costwatch/src/hook.ts b/hooks/hook-costwatch/src/hook.ts index 6ac6d94..0183b39 100644 --- a/hooks/hook-costwatch/src/hook.ts +++ b/hooks/hook-costwatch/src/hook.ts @@ -99,7 +99,7 @@ function findSessionTranscript(cwd: string, sessionId: string): string | null { return null; } -export function run(): void { +export async function run(): Promise { const input = readStdinJson(); if (!input) { @@ -136,7 +136,7 @@ export function run(): void { process.stderr.write(`[hook-costwatch] Check your actual usage at https://console.anthropic.com/\n`); } - writeHookEvent({ + await writeHookEvent({ session_id: input.session_id, hook_name: "costwatch", event_type: "Stop", @@ -152,7 +152,7 @@ export function run(): void { } else { process.stderr.write(`[hook-costwatch] Could not estimate session cost (no transcript found).\n`); - writeHookEvent({ + await writeHookEvent({ session_id: input.session_id, hook_name: "costwatch", event_type: "Stop", @@ -171,5 +171,5 @@ export function run(): void { } if (import.meta.main) { - run(); + await run(); } diff --git a/hooks/hook-errornotify/src/hook.ts b/hooks/hook-errornotify/src/hook.ts index 32efc50..141410f 100644 --- a/hooks/hook-errornotify/src/hook.ts +++ b/hooks/hook-errornotify/src/hook.ts @@ -116,7 +116,7 @@ function respond(): void { console.log(JSON.stringify(output)); } -export function run(): void { +export async function run(): Promise { const input = readStdinJson(); if (!input) { @@ -133,7 +133,7 @@ export function run(): void { process.stderr.write(`[hook-errornotify] FAILURE in ${toolContext}\n`); process.stderr.write(`[hook-errornotify] ${message}\n`); - writeHookEvent({ + await writeHookEvent({ session_id: input.session_id, hook_name: "errornotify", event_type: "PostToolUse", @@ -148,5 +148,5 @@ export function run(): void { } if (import.meta.main) { - run(); + await run(); } diff --git a/hooks/hook-sessionlog/src/hook.ts b/hooks/hook-sessionlog/src/hook.ts index 6962a2e..626951d 100644 --- a/hooks/hook-sessionlog/src/hook.ts +++ b/hooks/hook-sessionlog/src/hook.ts @@ -34,7 +34,7 @@ function respond(output: HookOutput): void { console.log(JSON.stringify(output)); } -export function run(): void { +export async function run(): Promise { const input = readStdinJson(); if (!input) { @@ -42,7 +42,7 @@ export function run(): void { return; } - writeHookEvent({ + await writeHookEvent({ session_id: input.session_id, hook_name: "sessionlog", event_type: "PostToolUse", @@ -55,5 +55,5 @@ export function run(): void { } if (import.meta.main) { - run(); + await run(); } diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index acd8e8e..b84a1cb 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -1,10 +1,13 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { join } from "path"; -import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs"; +import { dirname, join } from "path"; +import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from "fs"; import { homedir, tmpdir } from "os"; +import { Database } from "bun:sqlite"; +import { CREATE_HOOK_EVENTS_TABLE } from "../db/schema.js"; const CLI = join(import.meta.dir, "index.tsx"); const SETTINGS_PATH = join(homedir(), ".claude", "settings.json"); +let nextTestPortValue = 24000 + (process.pid % 20000); let settingsBackup: string | null = null; @@ -25,11 +28,32 @@ function restoreSettings(): void { settingsBackup = null; } -async function run(...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { +function cliEnv(overrides: Record = {}): Record { + const env: Record = { + ...process.env, + NO_COLOR: "1", + TMPDIR: process.env.TMPDIR ?? "/tmp", + TEMP: process.env.TEMP ?? "/tmp", + TMP: process.env.TMP ?? "/tmp", + BUN_INSTALL_CACHE_DIR: process.env.BUN_INSTALL_CACHE_DIR ?? "/tmp/bun-cache", + }; + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) delete env[key]; + else env[key] = value; + } + return env; +} + +async function runWithEnv( + args: string[], + env: Record = {}, + cwd?: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { const proc = Bun.spawn(["bun", "run", CLI, ...args], { stdout: "pipe", stderr: "pipe", - env: { ...process.env, NO_COLOR: "1" }, + env: cliEnv(env), + cwd, }); const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), @@ -39,11 +63,115 @@ async function run(...args: string[]): Promise<{ stdout: string; stderr: string; return { stdout, stderr, exitCode }; } +async function run(...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return runWithEnv(args); +} + async function runJson(...args: string[]): Promise { const { stdout } = await run(...args, "--json"); return JSON.parse(stdout.trim()); } +async function runJsonWithEnv(args: string[], env: Record = {}): Promise { + const { stdout } = await runWithEnv([...args, "--json"], env); + return JSON.parse(stdout.trim()); +} + +function seedHookEvent(dbPath: string, row: Partial> = {}): void { + const db = new Database(dbPath); + try { + db.exec(CREATE_HOOK_EVENTS_TABLE); + db.run( + `INSERT INTO hook_events + (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.id ?? "evt_fixture_1", + row.timestamp ?? "2026-07-28T00:00:00.000Z", + row.session_id ?? "session-fixture", + row.hook_name ?? "gitguard", + row.event_type ?? "PreToolUse", + row.tool_name ?? "Bash", + row.tool_input ?? "git status", + row.result ?? "continue", + row.error ?? null, + row.duration_ms ?? 12, + row.project_dir ?? "/tmp/project", + row.metadata ?? null, + ], + ); + } finally { + db.close(); + } +} + +/** + * Budget for tests that drive several real `bun run src/cli/index.tsx` processes + * in sequence. Bun's 5s default is a spawn-count budget, not a signal about the + * behaviour under test, and a loaded machine blows through it. + */ +const CLI_E2E_TIMEOUT_MS = 30_000; + +function remoteHookEvent(row: Record = {}): Record { + return { + id: "evt_remote", + timestamp: "2026-07-28T00:00:00.000Z", + session_id: "session-remote", + hook_name: "gitguard", + event_type: "PreToolUse", + tool_name: "Bash", + tool_input: "git status", + result: "continue", + error: null, + duration_ms: 10, + project_dir: "/tmp/project", + metadata: null, + ...row, + }; +} + +function nextTestPort(): number { + nextTestPortValue += 1; + return nextTestPortValue; +} + +function isAddressInUse(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + return String((error as { code?: unknown }).code) === "EADDRINUSE"; +} + +function serveOnAvailablePort( + fetch: (request: Request) => Response | Promise, + attempts = 100, +): ReturnType { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return Bun.serve({ + hostname: "127.0.0.1", + port: nextTestPort(), + fetch, + }); + } catch (error) { + if (!isAddressInUse(error)) throw error; + lastError = error; + } + } + throw lastError; +} + +function loopbackListenersAvailable(): boolean { + try { + const server = serveOnAvailablePort(() => new Response("ok"), 5); + server.stop(true); + return true; + } catch { + return false; + } +} + +const listenerTest = loopbackListenersAvailable() ? test : test.skip; + describe("CLI", () => { describe("hooks --version", () => { test("prints version", async () => { @@ -589,6 +717,424 @@ describe("CLI", () => { }); }); + describe("hooks log api parity", () => { + test("local log list reads the configured SQLite database", async () => { + const root = mkdtempSync(join(tmpdir(), "hooks-log-local-")); + try { + const dbPath = join(root, "hooks.db"); + seedHookEvent(dbPath, { id: "evt_local", hook_name: "gitguard", tool_input: "git status" }); + + const data = await runJsonWithEnv(["log", "list"], { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "local", + HOOKS_STORAGE_MODE: undefined, + HASNA_HOOKS_API_URL: undefined, + HASNA_HOOKS_API_KEY: undefined, + }); + + expect(data).toHaveLength(1); + expect(data[0]).toMatchObject({ id: "evt_local", hook_name: "gitguard" }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + listenerTest("api log list uses HTTP and never opens the local database", async () => { + const requests: Array<{ path: string; authorization: string | null }> = []; + const server = serveOnAvailablePort( + (request) => { + const url = new URL(request.url); + requests.push({ path: url.pathname, authorization: request.headers.get("authorization") }); + if (url.pathname === "/v1/log/events") { + return Response.json({ + events: [{ + id: "evt_api", + timestamp: "2026-07-28T00:00:00.000Z", + session_id: "session-api", + hook_name: "gitguard", + event_type: "PreToolUse", + tool_name: "Bash", + tool_input: "git status", + result: "continue", + error: null, + duration_ms: 10, + project_dir: "/tmp/project", + metadata: null, + }], + }); + } + return Response.json({ error: "unexpected route" }, { status: 404 }); + }, + ); + const root = mkdtempSync(join(tmpdir(), "hooks-log-api-")); + const dbPath = join(root, "must-not-exist", "hooks.db"); + try { + const data = await runJsonWithEnv(["log", "list"], { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: `http://127.0.0.1:${server.port}`, + HASNA_HOOKS_API_KEY: "fixture-api-key", + }); + + expect(data).toHaveLength(1); + expect(data[0]).toMatchObject({ id: "evt_api", hook_name: "gitguard" }); + expect(requests).toEqual([{ path: "/v1/log/events", authorization: "Bearer fixture-api-key" }]); + expect(existsSync(dirname(dbPath))).toBe(false); + } finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("api log list fails closed when the authority is incomplete", async () => { + const root = mkdtempSync(join(tmpdir(), "hooks-log-api-missing-")); + const dbPath = join(root, "must-not-exist", "hooks.db"); + try { + const result = await runWithEnv(["log", "list", "--json"], { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: undefined, + HASNA_HOOKS_API_KEY: "fixture-api-key", + }); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error).toContain("REMOTE_API_URL_MISSING"); + expect(existsSync(dirname(dbPath))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + listenerTest("api log clear purges the local mirror so a later push cannot resurrect events", async () => { + const authorityEvents: any[] = [ + remoteHookEvent({ id: "evt_a", tool_input: "rm -rf /tmp/secret-workspace" }), + remoteHookEvent({ id: "evt_b", tool_input: "cat /tmp/secret-workspace/token" }), + ]; + const imports: any[] = []; + const server = serveOnAvailablePort(async (request) => { + const url = new URL(request.url); + if (url.pathname === "/v1/storage/export") { + return Response.json({ tables: { hook_events: [...authorityEvents] } }); + } + if (url.pathname === "/v1/storage/import") { + const payload = await request.json() as { tables?: { hook_events?: any[] } }; + imports.push(payload); + // A real authority upserts whatever the client pushes — that is how the + // purged rows came back before the local mirror was cleared too. + const rows = payload.tables?.hook_events ?? []; + for (const row of rows) { + if (!authorityEvents.some((event) => event.id === row.id)) authorityEvents.push(row); + } + return Response.json({ + results: [{ table: "hook_events", rowsRead: rows.length, rowsWritten: rows.length, errors: [] }], + }); + } + if (url.pathname === "/v1/log/events" && request.method === "DELETE") { + const cleared = authorityEvents.length; + authorityEvents.length = 0; + return Response.json({ cleared }); + } + if (url.pathname === "/v1/log/events") { + return Response.json({ events: [...authorityEvents] }); + } + return Response.json({ error: "unexpected route" }, { status: 404 }); + }); + const root = mkdtempSync(join(tmpdir(), "hooks-log-api-clear-")); + const dbPath = join(root, "hooks.db"); + const apiEnv = { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: `http://127.0.0.1:${server.port}`, + HASNA_HOOKS_API_KEY: "fixture-api-key", + }; + const localEnv = { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "local", + HOOKS_STORAGE_MODE: undefined, + HASNA_HOOKS_API_URL: undefined, + HASNA_HOOKS_API_KEY: undefined, + }; + try { + // A routine sync mirrors the authority's rows into the local spool. + const pulled = await runJsonWithEnv(["storage", "pull", "--tables", "hook_events"], apiEnv); + expect(pulled).toEqual([{ table: "hook_events", rowsRead: 2, rowsWritten: 2, errors: [] }]); + + // Both stores held the same two events, so the headline is 2 — not the + // sum of the per-store counts, which would report each pulled event twice. + expect(await runJsonWithEnv(["log", "clear", "--yes"], apiEnv)) + .toMatchObject({ cleared: 2, cleared_remote: 2, cleared_local: 2 }); + // The purge must reach the mirror the next push reads from. + expect(await runJsonWithEnv(["log", "list"], localEnv)).toEqual([]); + + await runJsonWithEnv(["storage", "push", "--tables", "hook_events"], apiEnv); + expect(imports.at(-1).tables.hook_events).toEqual([]); + expect(await runJsonWithEnv(["log", "list"], apiEnv)).toEqual([]); + } finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); + } + }, CLI_E2E_TIMEOUT_MS); + + listenerTest("api log clear purges an unpushed local spool the authority never saw", async () => { + const imports: any[] = []; + const server = serveOnAvailablePort(async (request) => { + const url = new URL(request.url); + if (url.pathname === "/v1/storage/import") { + const payload = await request.json() as { tables?: { hook_events?: any[] } }; + imports.push(payload); + const rows = payload.tables?.hook_events ?? []; + return Response.json({ + results: [{ table: "hook_events", rowsRead: rows.length, rowsWritten: rows.length, errors: [] }], + }); + } + if (url.pathname === "/v1/log/events" && request.method === "DELETE") { + return Response.json({ cleared: 0 }); + } + return Response.json({ error: "unexpected route" }, { status: 404 }); + }); + const root = mkdtempSync(join(tmpdir(), "hooks-log-api-clear-spool-")); + const dbPath = join(root, "hooks.db"); + const apiEnv = { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: `http://127.0.0.1:${server.port}`, + HASNA_HOOKS_API_KEY: "fixture-api-key", + }; + try { + // Spooled locally while the authority was unreachable: never uploaded, so + // the DELETE clears nothing remotely and only the mirror holds the rows. + seedHookEvent(dbPath, { id: "evt_spooled", hook_name: "gitguard" }); + + // Destroying the spool while reporting "Nothing to clear." would lose + // audit history behind a message that says nothing happened. + const human = await runWithEnv(["log", "clear", "--yes"], apiEnv); + expect(human.exitCode).toBe(0); + expect(human.stdout).not.toContain("Nothing to clear."); + expect(human.stdout).toContain("Cleared 1 event(s)"); + expect(human.stdout).toContain("1 in the local mirror"); + + await runJsonWithEnv(["storage", "push", "--tables", "hook_events"], apiEnv); + expect(imports.at(-1).tables.hook_events).toEqual([]); + + // The JSON report counts the local rows and names both stores. + seedHookEvent(dbPath, { id: "evt_spooled_2", hook_name: "gitguard" }); + expect(await runJsonWithEnv(["log", "clear", "--yes"], apiEnv)) + .toMatchObject({ cleared: 1, cleared_remote: 0, cleared_local: 1, hook: null }); + } finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); + } + }, CLI_E2E_TIMEOUT_MS); + + listenerTest("api log clear does not create a local database when no mirror exists", async () => { + const server = serveOnAvailablePort((request) => { + const url = new URL(request.url); + if (url.pathname === "/v1/log/events" && request.method === "DELETE") { + return Response.json({ cleared: 3 }); + } + return Response.json({ error: "unexpected route" }, { status: 404 }); + }); + const root = mkdtempSync(join(tmpdir(), "hooks-log-api-clear-nomirror-")); + const dbPath = join(root, "must-not-exist", "hooks.db"); + try { + const result = await runJsonWithEnv(["log", "clear", "--yes"], { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: `http://127.0.0.1:${server.port}`, + HASNA_HOOKS_API_KEY: "fixture-api-key", + }); + + expect(result).toMatchObject({ cleared: 3, cleared_remote: 3, cleared_local: 0 }); + expect(existsSync(dirname(dbPath))).toBe(false); + } finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); + } + }, CLI_E2E_TIMEOUT_MS); + + listenerTest("api log clear --hook only purges the named hook from the local mirror", async () => { + const imports: any[] = []; + const server = serveOnAvailablePort(async (request) => { + const url = new URL(request.url); + if (url.pathname === "/v1/storage/import") { + imports.push(await request.json()); + return Response.json({ results: [{ table: "hook_events", rowsRead: 0, rowsWritten: 0, errors: [] }] }); + } + if (url.pathname === "/v1/log/events" && request.method === "DELETE") { + return Response.json({ cleared: url.searchParams.get("hook") === "gitguard" ? 1 : 0 }); + } + return Response.json({ error: "unexpected route" }, { status: 404 }); + }); + const root = mkdtempSync(join(tmpdir(), "hooks-log-api-clear-hook-")); + const dbPath = join(root, "hooks.db"); + const apiEnv = { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: `http://127.0.0.1:${server.port}`, + HASNA_HOOKS_API_KEY: "fixture-api-key", + }; + try { + seedHookEvent(dbPath, { id: "evt_guard", hook_name: "gitguard" }); + seedHookEvent(dbPath, { id: "evt_cost", hook_name: "costwatch" }); + + expect(await runJsonWithEnv(["log", "clear", "--hook", "gitguard", "--yes"], apiEnv)) + .toMatchObject({ cleared: 1, cleared_remote: 1, cleared_local: 1, hook: "gitguard" }); + + await runJsonWithEnv(["storage", "push", "--tables", "hook_events"], apiEnv); + expect(imports.at(-1).tables.hook_events.map((row: any) => row.id)).toEqual(["evt_cost"]); + } finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); + } + }, CLI_E2E_TIMEOUT_MS); + }); + + describe("hooks storage api parity", () => { + test("api storage status is diagnostic and never opens local SQLite", async () => { + const root = mkdtempSync(join(tmpdir(), "hooks-storage-api-status-")); + const dbPath = join(root, "must-not-exist", "hooks.db"); + try { + const data = await runJsonWithEnv(["storage", "status"], { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:18888", + HASNA_HOOKS_API_KEY: "fixture-api-key", + }); + + expect(data).toMatchObject({ + ok: true, + mode: "api", + transport: "http-v1", + local_fallback: false, + authority: { v1_base_url: "http://127.0.0.1:18888/v1" }, + }); + expect(existsSync(dirname(dbPath))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + listenerTest("api storage pull imports remote rows through HTTP", async () => { + const requests: Array<{ method: string; path: string; authorization: string | null }> = []; + const server = serveOnAvailablePort( + (request) => { + const url = new URL(request.url); + requests.push({ method: request.method, path: url.pathname, authorization: request.headers.get("authorization") }); + if (url.pathname === "/v1/storage/export") { + return Response.json({ + tables: { + hook_events: [{ + id: "evt_pull", + timestamp: "2026-07-28T00:00:00.000Z", + session_id: "session-pull", + hook_name: "gitguard", + event_type: "PreToolUse", + tool_name: "Bash", + tool_input: "git status", + result: "continue", + error: null, + duration_ms: 10, + project_dir: "/tmp/project", + metadata: null, + }], + }, + }); + } + return Response.json({ error: "unexpected route" }, { status: 404 }); + }, + ); + const root = mkdtempSync(join(tmpdir(), "hooks-storage-api-pull-")); + const dbPath = join(root, "hooks.db"); + try { + const result = await runJsonWithEnv(["storage", "pull", "--tables", "hook_events"], { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: `http://127.0.0.1:${server.port}`, + HASNA_HOOKS_API_KEY: "fixture-api-key", + }); + expect(result).toEqual([{ table: "hook_events", rowsRead: 1, rowsWritten: 1, errors: [] }]); + + const listed = await runJsonWithEnv(["log", "list"], { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "local", + }); + expect(listed[0]).toMatchObject({ id: "evt_pull", hook_name: "gitguard" }); + expect(requests).toEqual([{ method: "GET", path: "/v1/storage/export", authorization: "Bearer fixture-api-key" }]); + } finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); + } + }); + + listenerTest("api storage push exports local rows through HTTP", async () => { + const imports: any[] = []; + const server = serveOnAvailablePort( + async (request) => { + const url = new URL(request.url); + if (url.pathname === "/v1/storage/import") { + imports.push(await request.json()); + return Response.json({ results: [{ table: "hook_events", rowsRead: 1, rowsWritten: 1, errors: [] }] }); + } + return Response.json({ error: "unexpected route" }, { status: 404 }); + }, + ); + const root = mkdtempSync(join(tmpdir(), "hooks-storage-api-push-")); + const dbPath = join(root, "hooks.db"); + try { + seedHookEvent(dbPath, { id: "evt_push", hook_name: "gitguard" }); + const result = await runJsonWithEnv(["storage", "push", "--tables", "hook_events"], { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: `http://127.0.0.1:${server.port}`, + HASNA_HOOKS_API_KEY: "fixture-api-key", + }); + + expect(result).toEqual([{ table: "hook_events", rowsRead: 1, rowsWritten: 1, errors: [] }]); + expect(imports[0].tables.hook_events[0]).toMatchObject({ id: "evt_push", hook_name: "gitguard" }); + } finally { + server.stop(true); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("a stray API key does not divert remote storage push away from PostgreSQL", async () => { + const root = mkdtempSync(join(tmpdir(), "hooks-storage-stray-key-")); + const dbPath = join(root, "hooks.db"); + try { + seedHookEvent(dbPath, { id: "evt_stray", hook_name: "gitguard" }); + const result = await runWithEnv(["storage", "push", "--tables", "hook_events", "--json"], { + HOME: root, + HASNA_HOOKS_DB_PATH: dbPath, + HASNA_HOOKS_STORAGE_MODE: "remote", + // Port 1 is never listening: PostgreSQL is attempted and refused fast. + HASNA_HOOKS_DATABASE_URL: "postgres://hooks:hooks@127.0.0.1:1/hooks", + HOOKS_API_KEY: "strayvalue", + HASNA_HOOKS_API_URL: undefined, + HASNA_HOOKS_API_KEY: undefined, + }); + + expect(result.exitCode).toBe(1); + const error = String(JSON.parse(result.stdout).error); + expect(error).not.toContain("REMOTE_API_URL_MISSING"); + expect(error).toContain("ECONNREFUSED"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + describe("hooks update with installed hooks", () => { test("updates installed hooks via JSON", async () => { backupSettings(); diff --git a/src/cli/cloud-router.test.ts b/src/cli/cloud-router.test.ts new file mode 100644 index 0000000..adec0d5 --- /dev/null +++ b/src/cli/cloud-router.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + DEFAULT_API_TIMEOUT_MS, + DEFAULT_API_WRITE_TIMEOUT_MS, + getHooksApiAuthorityConfigStatus, + getHooksApiClient, + resolveHooksApiTimeouts, + resolveHooksCliStorageMode, +} from "./cloud-router.js"; +import { closeDb } from "../db/index.js"; +import { CREATE_HOOK_EVENTS_TABLE } from "../db/schema.js"; + +type FetchStub = ( + input: Parameters[0], + init?: Parameters[1], +) => Promise; + +function seedHookEvent(dbPath: string, row: Partial> = {}): void { + const db = new Database(dbPath); + try { + db.exec(CREATE_HOOK_EVENTS_TABLE); + db.run( + `INSERT INTO hook_events + (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.id ?? "evt_fixture_1", + row.timestamp ?? "2026-07-28T00:00:00.000Z", + row.session_id ?? "session-fixture", + row.hook_name ?? "gitguard", + row.event_type ?? "PreToolUse", + row.tool_name ?? "Bash", + row.tool_input ?? "git status", + row.result ?? "continue", + row.error ?? null, + row.duration_ms ?? 12, + row.project_dir ?? "/tmp/project", + row.metadata ?? null, + ], + ); + } finally { + db.close(); + } +} + +async function withFetchStub( + stub: FetchStub, + callback: () => Promise, +): Promise { + const originalFetch = globalThis.fetch; + globalThis.fetch = stub as typeof fetch; + try { + return await callback(); + } finally { + globalThis.fetch = originalFetch; + } +} + +async function withDbPath(dbPath: string, callback: () => Promise): Promise { + const originalHasnaDbPath = process.env.HASNA_HOOKS_DB_PATH; + const originalHooksDbPath = process.env.HOOKS_DB_PATH; + process.env.HASNA_HOOKS_DB_PATH = dbPath; + delete process.env.HOOKS_DB_PATH; + try { + return await callback(); + } finally { + closeDb(); + if (originalHasnaDbPath === undefined) delete process.env.HASNA_HOOKS_DB_PATH; + else process.env.HASNA_HOOKS_DB_PATH = originalHasnaDbPath; + if (originalHooksDbPath === undefined) delete process.env.HOOKS_DB_PATH; + else process.env.HOOKS_DB_PATH = originalHooksDbPath; + } +} + +describe("hooks api router", () => { + test("defaults to local even when API URL and key are present without an explicit mode", () => { + expect(resolveHooksCliStorageMode({ + HASNA_HOOKS_API_URL: "https://hooks.example", + HASNA_HOOKS_API_KEY: "fixture-key", + })).toMatchObject({ mode: "local", selected: false }); + expect(getHooksApiClient({ + HASNA_HOOKS_API_URL: "https://hooks.example", + HASNA_HOOKS_API_KEY: "fixture-key", + })).toBeNull(); + }); + + test("api mode resolves an authenticated /v1 authority", () => { + const status = getHooksApiAuthorityConfigStatus({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "https://hooks.example/v1", + HASNA_HOOKS_API_KEY: "fixture-key", + }); + expect(status).toMatchObject({ + selected: true, + ok: true, + mode: "api", + v1_base_url: "https://hooks.example/v1", + local_fallback: false, + }); + }); + + test("explicit api mode fails closed when URL or key is missing", () => { + expect(() => getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_KEY: "fixture-key", + })).toThrow("REMOTE_API_URL_MISSING"); + expect(() => getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "self_hosted", + HASNA_HOOKS_API_URL: "https://hooks.example", + })).toThrow("REMOTE_API_KEY_MISSING"); + }); + + test("legacy remote mode selects API only when API credentials are present", () => { + expect(resolveHooksCliStorageMode({ + HASNA_HOOKS_STORAGE_MODE: "remote", + HASNA_HOOKS_DATABASE_URL: "postgres://example/hooks", + })).toMatchObject({ mode: "remote", selected: false }); + expect(resolveHooksCliStorageMode({ + HASNA_HOOKS_STORAGE_MODE: "remote", + HASNA_HOOKS_API_URL: "https://hooks.example", + HASNA_HOOKS_API_KEY: "fixture-key", + })).toMatchObject({ mode: "remote", selected: true }); + }); + + test.each([ + ["HASNA_HOOKS_API_KEY"], + ["HOOKS_API_KEY"], + ])("a stray %s never diverts legacy remote mode away from PostgreSQL", (keyEnv) => { + const env = { + HASNA_HOOKS_STORAGE_MODE: "remote", + HASNA_HOOKS_DATABASE_URL: "postgres://example/hooks", + [keyEnv]: "strayvalue", + }; + expect(resolveHooksCliStorageMode(env)).toMatchObject({ mode: "remote", selected: false, warnings: [] }); + expect(getHooksApiClient(env)).toBeNull(); + }); + + test("hybrid mode keeps the PostgreSQL path when only an API key is present", () => { + const env = { + HASNA_HOOKS_STORAGE_MODE: "hybrid", + HOOKS_DATABASE_URL: "postgres://example/hooks", + HOOKS_API_KEY: "strayvalue", + }; + expect(resolveHooksCliStorageMode(env)).toMatchObject({ mode: "hybrid", selected: false }); + expect(getHooksApiClient(env)).toBeNull(); + }); + + test("legacy remote mode warns when a database URL and an API URL are both configured", () => { + const env = { + HASNA_HOOKS_STORAGE_MODE: "remote", + HASNA_HOOKS_DATABASE_URL: "postgres://example/hooks", + HASNA_HOOKS_API_URL: "https://hooks.example", + HASNA_HOOKS_API_KEY: "fixture-key", + }; + const resolution = resolveHooksCliStorageMode(env); + expect(resolution).toMatchObject({ mode: "remote", selected: true }); + expect(resolution.warnings).toHaveLength(1); + expect(resolution.warnings[0]).toContain("REMOTE_TRANSPORT_AMBIGUOUS"); + expect(resolution.warnings[0]).toContain("HASNA_HOOKS_DATABASE_URL"); + expect(resolution.warnings[0]).toContain("HASNA_HOOKS_API_URL"); + expect(getHooksApiAuthorityConfigStatus(env).warnings).toEqual(resolution.warnings); + }); + + test("explicit api mode does not warn about an unused database URL", () => { + expect(resolveHooksCliStorageMode({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_DATABASE_URL: "postgres://example/hooks", + HASNA_HOOKS_API_URL: "https://hooks.example", + HASNA_HOOKS_API_KEY: "fixture-key", + }).warnings).toEqual([]); + }); + + test.each([ + "https://user@hooks.example", + "https://hooks.example?x=1", + "https://hooks.example#v1", + "https://hooks.example/api/v1", + "http://hooks.example", + ])("rejects unsafe API authority URL %s", (apiUrl) => { + expect(() => getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: apiUrl, + HASNA_HOOKS_API_KEY: "fixture-key", + })).toThrow("REMOTE_API_URL_INVALID"); + }); + + test("client sends log requests to the configured /v1 authority", async () => { + const requests: Array<{ path: string; search: string; authorization: string | null }> = []; + const client = getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:8847", + HASNA_HOOKS_API_KEY: "fixture-key", + }); + + await withFetchStub(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + requests.push({ + path: url.pathname, + search: url.search, + authorization: new Headers(init?.headers).get("authorization"), + }); + return Response.json({ + events: [{ + id: "evt_api", + timestamp: "2026-07-28T00:00:00.000Z", + session_id: "session-api", + hook_name: "gitguard", + event_type: "PreToolUse", + tool_name: "Bash", + tool_input: "git status", + result: "continue", + error: null, + duration_ms: 10, + project_dir: "/tmp/project", + metadata: null, + }], + }); + }, async () => { + const events = await client!.listHookEvents({ hook: "gitguard", session: "session", limit: 5 }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ id: "evt_api", hook_name: "gitguard" }); + }); + + expect(requests).toEqual([{ + path: "/v1/log/events", + search: "?hook=gitguard&session=session&limit=5", + authorization: "Bearer fixture-key", + }]); + }); + + test("client sends log summary and since-filtered list requests to the /v1 authority", async () => { + const requests: Array<{ path: string; search: string; authorization: string | null }> = []; + const client = getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:8847", + HASNA_HOOKS_API_KEY: "fixture-key", + }); + const summary = { + since: "2026-07-28T00:00:00.000Z", + hooks: [{ hook_name: "gitguard", total: 3, errors: 1, error_rate: "33.3%" }], + totals: { events: 3, errors: 1, hooks_active: 1 }, + }; + + await withFetchStub(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + requests.push({ + path: url.pathname, + search: url.search, + authorization: new Headers(init?.headers).get("authorization"), + }); + if (url.pathname === "/v1/log/summary") return Response.json(summary); + return Response.json({ events: [] }); + }, async () => { + expect(await client!.summarizeHookEvents({ since: "7d" })).toEqual(summary); + expect(await client!.listHookEvents({ since: "30m", limit: 5 })).toEqual([]); + }); + + expect(requests).toEqual([ + { path: "/v1/log/summary", search: "?since=7d", authorization: "Bearer fixture-key" }, + { path: "/v1/log/events", search: "?since=30m&limit=5", authorization: "Bearer fixture-key" }, + ]); + }); + + test("client appends hook events to the configured /v1 authority", async () => { + const requests: Array<{ method: string | undefined; path: string; body: unknown }> = []; + const client = getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:8847", + HASNA_HOOKS_API_KEY: "fixture-key", + }); + const row = { + id: "evt_append", + timestamp: "2026-07-28T00:00:00.000Z", + session_id: "session-append", + hook_name: "commandlog", + event_type: "PostToolUse" as const, + tool_name: "Bash", + tool_input: "git status", + result: null, + error: null, + duration_ms: null, + project_dir: "/tmp/project", + metadata: null, + }; + + await withFetchStub(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + requests.push({ method: init?.method, path: url.pathname, body: JSON.parse(String(init?.body)) }); + return Response.json({ event: row }, { status: 201 }); + }, async () => { + expect(await client!.appendHookEvent(row)).toEqual(row); + }); + + expect(requests).toEqual([{ method: "POST", path: "/v1/log/events", body: row }]); + }); + + test("client storage pull imports remote rows into the local database", async () => { + const root = mkdtempSync(join(tmpdir(), "hooks-router-pull-")); + const dbPath = join(root, "hooks.db"); + try { + const client = getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:8847", + HASNA_HOOKS_API_KEY: "fixture-key", + }); + + await withDbPath(dbPath, async () => { + const result = await withFetchStub(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + expect(url.pathname).toBe("/v1/storage/export"); + expect(url.search).toBe("?tables=hook_events"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer fixture-key"); + return Response.json({ + tables: { + hook_events: [{ + id: "evt_pull", + timestamp: "2026-07-28T00:00:00.000Z", + session_id: "session-pull", + hook_name: "gitguard", + event_type: "PreToolUse", + tool_name: "Bash", + tool_input: "git status", + result: "continue", + error: null, + duration_ms: 10, + project_dir: "/tmp/project", + metadata: null, + }], + }, + }); + }, () => client!.storagePull({ tables: ["hook_events"] })); + + expect(result).toEqual([{ table: "hook_events", rowsRead: 1, rowsWritten: 1, errors: [] }]); + }); + + const db = new Database(dbPath, { readonly: true }); + try { + expect(db.query("SELECT id, hook_name FROM hook_events").all()).toEqual([ + { id: "evt_pull", hook_name: "gitguard" }, + ]); + } finally { + db.close(); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("client storage push exports local rows to the configured authority", async () => { + const root = mkdtempSync(join(tmpdir(), "hooks-router-push-")); + const dbPath = join(root, "hooks.db"); + try { + seedHookEvent(dbPath, { id: "evt_push", hook_name: "gitguard" }); + const imports: any[] = []; + const client = getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:8847", + HASNA_HOOKS_API_KEY: "fixture-key", + }); + + await withDbPath(dbPath, async () => { + const result = await withFetchStub(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + expect(url.pathname).toBe("/v1/storage/import"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer fixture-key"); + imports.push(JSON.parse(String(init?.body))); + return Response.json({ results: [{ table: "hook_events", rowsRead: 1, rowsWritten: 1, errors: [] }] }); + }, () => client!.storagePush({ tables: ["hook_events"] })); + + expect(result).toEqual([{ table: "hook_events", rowsRead: 1, rowsWritten: 1, errors: [] }]); + }); + + expect(imports[0].tables.hook_events[0]).toMatchObject({ id: "evt_push", hook_name: "gitguard" }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("client storage push never transports the local migration ledger", async () => { + const root = mkdtempSync(join(tmpdir(), "hooks-router-push-ledger-")); + const dbPath = join(root, "hooks.db"); + try { + const imports: any[] = []; + const client = getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:8847", + HASNA_HOOKS_API_KEY: "fixture-key", + }); + + await withDbPath(dbPath, async () => { + await withFetchStub(async (_input, init) => { + imports.push(JSON.parse(String(init?.body))); + return Response.json({ results: [] }); + }, () => client!.storagePush()); + }); + + // The local database has a populated schema_migrations table; a default + // push must still carry data tables only. + const db = new Database(dbPath, { readonly: true }); + try { + expect((db.query("SELECT version FROM schema_migrations").all() as unknown[]).length).toBeGreaterThan(0); + } finally { + db.close(); + } + expect(Object.keys(imports[0].tables).sort()).toEqual(["feedback", "hook_events"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("client storage push refuses an explicitly requested bookkeeping table", async () => { + const root = mkdtempSync(join(tmpdir(), "hooks-router-push-refuse-")); + try { + const client = getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:8847", + HASNA_HOOKS_API_KEY: "fixture-key", + }); + await withDbPath(join(root, "hooks.db"), async () => { + await expect(client!.storagePush({ tables: ["schema_migrations"] })) + .rejects.toThrow("does not carry bookkeeping table"); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("resolves request deadlines from the environment and ignores malformed overrides", () => { + expect(resolveHooksApiTimeouts({})).toEqual({ + request: DEFAULT_API_TIMEOUT_MS, + write: DEFAULT_API_WRITE_TIMEOUT_MS, + }); + expect(resolveHooksApiTimeouts({ + HOOKS_API_TIMEOUT_MS: "1200", + HASNA_HOOKS_API_WRITE_TIMEOUT_MS: "250", + })).toEqual({ request: 1200, write: 250 }); + expect(resolveHooksApiTimeouts({ + HASNA_HOOKS_API_TIMEOUT_MS: "not-a-number", + HASNA_HOOKS_API_WRITE_TIMEOUT_MS: "0", + })).toEqual({ request: DEFAULT_API_TIMEOUT_MS, write: DEFAULT_API_WRITE_TIMEOUT_MS }); + }); + + test("client requests fail fast against an authority that accepts the connection and never answers", async () => { + // Distinct from the immediate ECONNREFUSED the other tests exercise: this + // is a wedged authority, which without a deadline blocks the caller forever. + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + fetch: () => new Promise(() => {}), + }); + try { + const client = getHooksApiClient({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: `http://127.0.0.1:${server.port}`, + HASNA_HOOKS_API_KEY: "fixture-key", + HASNA_HOOKS_API_TIMEOUT_MS: "400", + }); + const startedAt = Date.now(); + await expect(client!.listHookEvents()).rejects.toThrow("REMOTE_API_UNREACHABLE"); + expect(Date.now() - startedAt).toBeLessThan(5_000); + } finally { + server.stop(true); + } + }, 15_000); +}); diff --git a/src/cli/cloud-router.ts b/src/cli/cloud-router.ts new file mode 100644 index 0000000..c30537f --- /dev/null +++ b/src/cli/cloud-router.ts @@ -0,0 +1,413 @@ +import type { HookLogSummary } from "../db/log-store.js"; +import type { HookEventRow } from "../db/schema.js"; +import type { StorageRowsPayload, SyncResult } from "../storage.js"; + +type Env = Record; +type HttpMethod = "GET" | "POST" | "DELETE"; + +const API_MODES = new Set(["api", "self_hosted", "cloud"]); +const POSTGRES_COMPAT_MODES = new Set(["remote", "hybrid"]); +const VALID_STORAGE_MODES = new Set(["local", "remote", "hybrid", ...API_MODES]); +const API_URL_ENV = ["HASNA_HOOKS_API_URL", "HOOKS_API_URL"] as const; +const API_KEY_ENV = ["HASNA_HOOKS_API_KEY", "HOOKS_API_KEY"] as const; +const DATABASE_URL_ENV = ["HASNA_HOOKS_DATABASE_URL", "HOOKS_DATABASE_URL"] as const; +const API_TIMEOUT_ENV = ["HASNA_HOOKS_API_TIMEOUT_MS", "HOOKS_API_TIMEOUT_MS"] as const; +const API_WRITE_TIMEOUT_ENV = ["HASNA_HOOKS_API_WRITE_TIMEOUT_MS", "HOOKS_API_WRITE_TIMEOUT_MS"] as const; + +/** Deadline for interactive `hooks log` / `hooks storage` commands. */ +export const DEFAULT_API_TIMEOUT_MS = 30_000; +/** + * Deadline for the hook event write path. Every agent tool call blocks on this + * request, so an authority that accepts the connection and never answers must + * fall through to the local spool in seconds rather than stalling the agent + * until its own hook timeout kills the process and the event is lost. + */ +export const DEFAULT_API_WRITE_TIMEOUT_MS = 3_000; + +export interface HooksApiTimeouts { + request: number; + write: number; +} + +export interface HooksCliStorageModeResolution { + mode: string; + selected: boolean; + source: "HASNA_HOOKS_STORAGE_MODE" | "HOOKS_STORAGE_MODE" | "default"; + warnings: string[]; +} + +export interface HooksApiAuthorityConfigStatus { + selected: boolean; + ok: boolean; + mode: string; + api_url_configured: boolean; + api_key_configured: boolean; + v1_base_url: string | null; + issues: string[]; + warnings: string[]; + local_fallback: false; +} + +export interface HooksApiClient { + baseUrl: string; + appendHookEvent(event: HookEventRow): Promise; + listHookEvents(options?: { hook?: string; session?: string; since?: string; limit?: number }): Promise; + searchHookEvents(options: { text: string; limit?: number }): Promise; + tailHookEvents(options?: { limit?: number }): Promise; + listHookErrors(options?: { since?: string; limit?: number }): Promise; + summarizeHookEvents(options?: { since?: string }): Promise; + clearHookEvents(options?: { hook?: string }): Promise; + storageStatus(): Promise; + storagePush(options?: { tables?: string[] }): Promise; + storagePull(options?: { tables?: string[] }): Promise; + storageSync(options?: { tables?: string[] }): Promise<{ pull: SyncResult[]; push: SyncResult[] }>; +} + +function clean(value: string | undefined): string | null { + const normalized = value?.trim().toLowerCase(); + return normalized || null; +} + +function firstConfigured(env: Env, names: readonly string[]): string | null { + for (const name of names) { + const value = env[name]?.trim(); + if (value) return value; + } + return null; +} + +function firstConfiguredName(env: Env, names: readonly string[]): string | null { + for (const name of names) { + if (env[name]?.trim()) return name; + } + return null; +} + +/** + * The API endpoint — never the credential alone — decides whether a legacy + * `remote`/`hybrid` mode is routed over HTTP. A stray `HOOKS_API_KEY` in the + * environment must not hijack the PostgreSQL storage path. + */ +function apiAuthorityConfigured(env: Env): boolean { + return Boolean(firstConfigured(env, API_URL_ENV)); +} + +export function resolveHooksCliStorageMode(env: Env = process.env as Env): HooksCliStorageModeResolution { + for (const source of ["HASNA_HOOKS_STORAGE_MODE", "HOOKS_STORAGE_MODE"] as const) { + if (env[source] !== undefined && env[source]!.trim() === "") { + throw new Error( + `REMOTE_STORAGE_MODE_INVALID: ${source} must not be blank; local SQLite fallback is disabled for invalid routing state`, + ); + } + } + + const canonical = clean(env.HASNA_HOOKS_STORAGE_MODE); + const fallback = clean(env.HOOKS_STORAGE_MODE); + for (const [source, value] of [ + ["HASNA_HOOKS_STORAGE_MODE", canonical], + ["HOOKS_STORAGE_MODE", fallback], + ] as const) { + if (value && !VALID_STORAGE_MODES.has(value)) { + throw new Error( + `REMOTE_STORAGE_MODE_INVALID: ${source}=${value} must be local, remote, hybrid, api, self_hosted, or cloud; ` + + "local SQLite fallback is disabled", + ); + } + } + + const mode = canonical ?? fallback ?? "local"; + const postgresCompat = POSTGRES_COMPAT_MODES.has(mode); + const selected = API_MODES.has(mode) || (postgresCompat && apiAuthorityConfigured(env)); + + const warnings: string[] = []; + if (selected && postgresCompat) { + const databaseUrlEnv = firstConfiguredName(env, DATABASE_URL_ENV); + if (databaseUrlEnv) { + warnings.push( + `REMOTE_TRANSPORT_AMBIGUOUS: ${mode} mode has both ${databaseUrlEnv} and ` + + `${firstConfiguredName(env, API_URL_ENV)} configured; the HTTP /v1 authority takes precedence ` + + "and PostgreSQL sync is not used", + ); + } + } + + return { + mode, + selected, + source: canonical ? "HASNA_HOOKS_STORAGE_MODE" : fallback ? "HOOKS_STORAGE_MODE" : "default", + warnings, + }; +} + +export function getHooksApiAuthorityConfigStatus(env: Env = process.env as Env): HooksApiAuthorityConfigStatus { + let resolution: HooksCliStorageModeResolution; + try { + resolution = resolveHooksCliStorageMode(env); + } catch (error) { + return { + selected: true, + ok: false, + mode: clean(env.HASNA_HOOKS_STORAGE_MODE) ?? clean(env.HOOKS_STORAGE_MODE) ?? "invalid", + api_url_configured: Boolean(firstConfigured(env, API_URL_ENV)), + api_key_configured: Boolean(firstConfigured(env, API_KEY_ENV)), + v1_base_url: null, + issues: [error instanceof Error ? error.message : String(error)], + warnings: [], + local_fallback: false, + }; + } + + if (!resolution.selected) { + return { + selected: false, + ok: true, + mode: resolution.mode, + api_url_configured: false, + api_key_configured: false, + v1_base_url: null, + issues: [], + warnings: resolution.warnings, + local_fallback: false, + }; + } + + const issues: string[] = []; + const rawApiUrl = firstConfigured(env, API_URL_ENV); + let apiUrl: string | null = null; + try { + apiUrl = normalizeHooksApiUrl(rawApiUrl ?? undefined); + } catch (error) { + issues.push(error instanceof Error ? error.message : String(error)); + } + const apiKeyConfigured = Boolean(firstConfigured(env, API_KEY_ENV)); + if (!apiUrl && issues.length === 0) { + issues.push("REMOTE_API_URL_MISSING: api Hooks storage requires HASNA_HOOKS_API_URL; local SQLite fallback is disabled"); + } + if (!apiKeyConfigured) { + issues.push("REMOTE_API_KEY_MISSING: api Hooks storage requires HASNA_HOOKS_API_KEY; local SQLite fallback is disabled"); + } + + return { + selected: true, + ok: issues.length === 0, + mode: resolution.mode, + api_url_configured: Boolean(rawApiUrl), + api_key_configured: apiKeyConfigured, + v1_base_url: apiUrl ? `${apiUrl}/v1` : null, + issues, + warnings: resolution.warnings, + local_fallback: false, + }; +} + +export function getHooksApiClient(env: Env = process.env as Env): HooksApiClient | null { + const status = getHooksApiAuthorityConfigStatus(env); + if (!status.selected) return null; + if (!status.ok) throw new Error(status.issues[0]); + const apiKey = firstConfigured(env, API_KEY_ENV)!; + return new HttpHooksApiClient(status.v1_base_url!, apiKey, resolveHooksApiTimeouts(env)); +} + +/** + * Request deadlines for the API client. A malformed override falls back to the + * default rather than throwing: a typo in a performance knob must not be able + * to disable the deadline that keeps hook writes from blocking forever. + */ +export function resolveHooksApiTimeouts(env: Env = process.env as Env): HooksApiTimeouts { + return { + request: resolveTimeoutMs(env, API_TIMEOUT_ENV, DEFAULT_API_TIMEOUT_MS), + write: resolveTimeoutMs(env, API_WRITE_TIMEOUT_ENV, DEFAULT_API_WRITE_TIMEOUT_MS), + }; +} + +function resolveTimeoutMs(env: Env, names: readonly string[], fallback: number): number { + const raw = firstConfigured(env, names); + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.trunc(parsed); +} + +function normalizeHooksApiUrl(value: string | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error("REMOTE_API_URL_INVALID: HASNA_HOOKS_API_URL must be an absolute http(s) URL; local SQLite fallback is disabled"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("REMOTE_API_URL_INVALID: HASNA_HOOKS_API_URL must be an absolute http(s) URL; local SQLite fallback is disabled"); + } + if (url.username || url.password) { + throw new Error("REMOTE_API_URL_INVALID: HASNA_HOOKS_API_URL must not contain userinfo; local SQLite fallback is disabled"); + } + if (url.search || url.hash) { + throw new Error("REMOTE_API_URL_INVALID: HASNA_HOOKS_API_URL must not contain a query or fragment; local SQLite fallback is disabled"); + } + if (url.pathname !== "/" && url.pathname !== "/v1" && url.pathname !== "/v1/") { + throw new Error("REMOTE_API_URL_INVALID: HASNA_HOOKS_API_URL must be an authority root or end in /v1; local SQLite fallback is disabled"); + } + const hostname = url.hostname.toLowerCase(); + const loopback = hostname === "localhost" || hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(hostname); + if (url.protocol === "http:" && !loopback) { + throw new Error("REMOTE_API_URL_INVALID: plaintext HTTP is allowed only for loopback Hooks authorities; local SQLite fallback is disabled"); + } + return url.origin; +} + +class HttpHooksApiClient implements HooksApiClient { + constructor( + readonly baseUrl: string, + private readonly apiKey: string, + private readonly timeouts: HooksApiTimeouts, + ) {} + + async appendHookEvent(event: HookEventRow): Promise { + const data = await this.request<{ event: HookEventRow }>("POST", "/log/events", event, this.timeouts.write); + return data.event; + } + + async listHookEvents(options: { hook?: string; session?: string; since?: string; limit?: number } = {}): Promise { + const data = await this.request<{ events: HookEventRow[] }>("GET", `/log/events${queryString(options)}`); + return data.events; + } + + async searchHookEvents(options: { text: string; limit?: number }): Promise { + const data = await this.request<{ events: HookEventRow[] }>("GET", `/log/search${queryString({ q: options.text, limit: options.limit })}`); + return data.events; + } + + async tailHookEvents(options: { limit?: number } = {}): Promise { + const data = await this.request<{ events: HookEventRow[] }>("GET", `/log/events${queryString({ limit: options.limit })}`); + return data.events; + } + + async listHookErrors(options: { since?: string; limit?: number } = {}): Promise { + const data = await this.request<{ events: HookEventRow[] }>("GET", `/log/errors${queryString(options)}`); + return data.events; + } + + async summarizeHookEvents(options: { since?: string } = {}): Promise { + return this.request("GET", `/log/summary${queryString(options)}`); + } + + async clearHookEvents(options: { hook?: string } = {}): Promise { + const data = await this.request<{ cleared: number }>("DELETE", `/log/events${queryString(options)}`); + return data.cleared; + } + + async storageStatus(): Promise { + return this.request("GET", "/storage/status"); + } + + async storagePush(options: { tables?: string[] } = {}): Promise { + // Imported lazily: hook processes route event writes through this client and + // must not pay for the PostgreSQL adapter that ../storage.js pulls in. + const { storageExportRows } = await import("../storage.js"); + const payload = storageExportRows({ tables: options.tables }); + const data = await this.request<{ results: SyncResult[] }>("POST", "/storage/import", payload); + return data.results; + } + + async storagePull(options: { tables?: string[] } = {}): Promise { + const { storageImportRows } = await import("../storage.js"); + const payload = await this.storageExport(options); + return storageImportRows(payload, { direction: "pull" }); + } + + async storageSync(options: { tables?: string[] } = {}): Promise<{ pull: SyncResult[]; push: SyncResult[] }> { + const pull = await this.storagePull(options); + const push = await this.storagePush(options); + return { pull, push }; + } + + private async storageExport(options: { tables?: string[] } = {}): Promise { + return this.request("GET", `/storage/export${queryString({ tables: options.tables?.join(",") })}`); + } + + private async request( + method: HttpMethod, + path: string, + body?: unknown, + timeoutMs: number = this.timeouts.request, + ): Promise { + // The deadline covers the response body too, so an authority that answers + // its headers and then stalls the stream is classified the same way. + const signal = AbortSignal.timeout(timeoutMs); + let response: Response; + try { + response = await fetch(`${this.baseUrl}${path}`, { + method, + redirect: "manual", + signal, + headers: { + authorization: `Bearer ${this.apiKey}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (error) { + throw this.unreachable(path, timeoutMs, signal.aborted, error); + } + + if (!response.ok) { + await classifyRemoteResponse(this.baseUrl, path, response); + } + if (response.status === 204) return undefined as T; + try { + return await response.json() as T; + } catch (error) { + if (signal.aborted) throw this.unreachable(path, timeoutMs, true, error); + throw error; + } + } + + private unreachable(path: string, timeoutMs: number, timedOut: boolean, cause: unknown): Error { + const reason = timedOut ? `did not respond within ${timeoutMs}ms` : "could not be reached"; + return new Error( + `REMOTE_API_UNREACHABLE: configured Hooks authority ${authorityBase(this.baseUrl)} ${reason} for ${path}; ` + + "local SQLite fallback is disabled", + { cause }, + ); + } +} + +function queryString(values: Record): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) { + if (value !== undefined && value !== "") params.set(key, String(value)); + } + const rendered = params.toString(); + return rendered ? `?${rendered}` : ""; +} + +async function classifyRemoteResponse(baseUrl: string, path: string, response: Response): Promise { + let message = ""; + try { + const body = await response.json() as { error?: unknown }; + message = typeof body.error === "string" ? body.error : ""; + } catch {} + + if (response.status === 401) { + throw new Error(`REMOTE_API_UNAUTHORIZED: configured Hooks authority ${authorityBase(baseUrl)} rejected HASNA_HOOKS_API_KEY for ${path}; local SQLite fallback is disabled`); + } + if (response.status === 403) { + throw new Error(`REMOTE_API_FORBIDDEN: configured Hooks authority ${authorityBase(baseUrl)} denied ${path}; local SQLite fallback is disabled`); + } + if (response.status >= 300 && response.status < 400) { + throw new Error(`REMOTE_API_REDIRECT_REJECTED: configured Hooks authority ${authorityBase(baseUrl)} redirected ${path}; authenticated redirects are disabled`); + } + if (response.status === 404) { + throw new Error(`REMOTE_API_INCOMPATIBLE: configured Hooks authority ${authorityBase(baseUrl)} does not expose /v1${path}; local SQLite fallback is disabled`); + } + if (response.status >= 500) { + throw new Error(`REMOTE_API_UNAVAILABLE: configured Hooks authority ${authorityBase(baseUrl)} returned HTTP ${response.status} for ${path}${message ? `: ${message}` : ""}; local SQLite fallback is disabled`); + } + throw new Error(`REMOTE_API_ERROR: configured Hooks authority ${authorityBase(baseUrl)} returned HTTP ${response.status} for ${path}${message ? `: ${message}` : ""}; local SQLite fallback is disabled`); +} + +function authorityBase(baseUrl: string): string { + return baseUrl.replace(/\/v1\/?$/, ""); +} diff --git a/src/cli/index.tsx b/src/cli/index.tsx index cfaffcf..9699c31 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -45,6 +45,7 @@ import { exportProfiles, importProfiles, } from "../lib/profiles.js"; +import type { HooksApiClient } from "./cloud-router.js"; const program = new Command(); @@ -121,6 +122,30 @@ function printDisclosureHint(hidden: number, detailCommand: string, options: { i } } +/** + * Resolve the configured Hooks API client, surfacing any routing warnings (such + * as a PostgreSQL URL and an HTTP authority both being configured) on stderr so + * the chosen transport is never silent. Returns null when the command should + * read and write local SQLite. + */ +async function loadHooksApiClient(): Promise { + const { getHooksApiAuthorityConfigStatus, getHooksApiClient } = await import("./cloud-router.js"); + for (const warning of getHooksApiAuthorityConfigStatus().warnings) { + console.error(chalk.yellow(`! ${warning}`)); + } + return getHooksApiClient(); +} + +function failCommand(error: unknown, options: { json?: boolean } = {}): void { + const message = error instanceof Error ? error.message : String(error); + if (options.json) { + console.log(JSON.stringify({ error: message })); + } else { + console.error(chalk.red(`✗ ${message}`)); + } + process.exitCode = 1; +} + /** Levenshtein distance for did-you-mean suggestions */ function editDistance(a: string, b: string): number { const m = a.length, n = b.length; @@ -1048,7 +1073,7 @@ program // Log command group — query hook events from SQLite const logCmd = program .command("log") - .description("Query hook event logs from SQLite (~/.hasna/hooks/hooks.db)"); + .description("Query hook event logs from local SQLite or the configured Hooks API"); logCmd .command("list") @@ -1058,19 +1083,27 @@ logCmd .option("-n, --limit ", "Number of rows to show", "50") .option("-j, --json", "Output as JSON", false) .action(async (options: { hook?: string; session?: string; limit: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - const limit = parseInt(options.limit) || 50; - - let sql = "SELECT * FROM hook_events WHERE 1=1"; - const params: string[] = []; - - if (options.hook) { sql += " AND hook_name = ?"; params.push(options.hook); } - if (options.session) { sql += " AND session_id LIKE ?"; params.push(`${options.session}%`); } - sql += " ORDER BY timestamp DESC LIMIT ?"; - params.push(String(limit)); - - const rows = db.query(sql).all(...params) as any[]; + let rows: any[]; + try { + const client = await loadHooksApiClient(); + if (client) { + rows = await client.listHookEvents({ + hook: options.hook, + session: options.session, + limit: parseInt(options.limit) || 50, + }); + } else { + const { listHookEvents } = await import("../db/log-store.js"); + rows = listHookEvents({ + hook: options.hook, + session: options.session, + limit: parseInt(options.limit) || 50, + }); + } + } catch (error) { + failCommand(error, options); + return; + } if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } if (rows.length === 0) { console.log(chalk.dim("No events found.")); return; } @@ -1091,13 +1124,19 @@ logCmd .option("-n, --limit ", "Number of rows to show", "50") .option("-j, --json", "Output as JSON", false) .action(async (text: string, options: { limit: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - const limit = parseInt(options.limit) || 50; - const q = `%${text}%`; - const rows = db.query( - "SELECT * FROM hook_events WHERE tool_input LIKE ? OR error LIKE ? ORDER BY timestamp DESC LIMIT ?" - ).all(q, q, limit) as any[]; + let rows: any[]; + try { + const client = await loadHooksApiClient(); + if (client) { + rows = await client.searchHookEvents({ text, limit: parseInt(options.limit) || 50 }); + } else { + const { searchHookEvents } = await import("../db/log-store.js"); + rows = searchHookEvents({ text, limit: parseInt(options.limit) || 50 }); + } + } catch (error) { + failCommand(error, options); + return; + } if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } if (rows.length === 0) { console.log(chalk.dim(`No events matching "${text}".`)); return; } @@ -1117,12 +1156,19 @@ logCmd .option("-n ", "Number of rows", "20") .option("-j, --json", "Output as JSON", false) .action(async (options: { n: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - const limit = parseInt(options.n) || 20; - const rows = db.query( - "SELECT * FROM hook_events ORDER BY timestamp DESC LIMIT ?" - ).all(limit) as any[]; + let rows: any[]; + try { + const client = await loadHooksApiClient(); + if (client) { + rows = await client.tailHookEvents({ limit: parseInt(options.n) || 20 }); + } else { + const { tailHookEvents } = await import("../db/log-store.js"); + rows = tailHookEvents(parseInt(options.n) || 20); + } + } catch (error) { + failCommand(error, options); + return; + } if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } if (rows.length === 0) { console.log(chalk.dim("No events yet.")); return; } @@ -1144,29 +1190,20 @@ logCmd .option("-n, --limit ", "Number of rows to show", "50") .option("-j, --json", "Output as JSON", false) .action(async (options: { since: string; limit: string; json: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - const limit = parseInt(options.limit) || 50; - - // Parse duration string to milliseconds - function parseDuration(s: string): number { - const m = s.match(/^(\d+)(s|m|h|d)$/); - if (!m) return 24 * 60 * 60 * 1000; - const n = parseInt(m[1]); - switch (m[2]) { - case "s": return n * 1000; - case "m": return n * 60 * 1000; - case "h": return n * 60 * 60 * 1000; - case "d": return n * 24 * 60 * 60 * 1000; - default: return 24 * 60 * 60 * 1000; + let rows: any[]; + try { + const client = await loadHooksApiClient(); + if (client) { + rows = await client.listHookErrors({ since: options.since, limit: parseInt(options.limit) || 50 }); + } else { + const { listHookErrors } = await import("../db/log-store.js"); + rows = listHookErrors({ since: options.since, limit: parseInt(options.limit) || 50 }); } + } catch (error) { + failCommand(error, options); + return; } - const since = new Date(Date.now() - parseDuration(options.since)).toISOString(); - const rows = db.query( - "SELECT * FROM hook_events WHERE error IS NOT NULL AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?" - ).all(since, limit) as any[]; - if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; } if (rows.length === 0) { console.log(chalk.dim(`No errors in the last ${options.since}.`)); return; } @@ -1183,30 +1220,87 @@ logCmd .description("Delete hook event logs") .option("--hook ", "Only delete events for this hook") .option("-y, --yes", "Skip confirmation prompt", false) - .action(async (options: { hook?: string; yes: boolean }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); + .option("-j, --json", "Output as JSON", false) + .action(async (options: { hook?: string; yes: boolean; json: boolean }) => { + let count: number; + // Set only on the API path, which destroys rows in two stores. The report + // has to name both: an operator who is told "0" while a local spool was + // deleted has lost audit history without being warned. + let apiCleared: { remote: number; local: number } | null = null; + try { + const client = await loadHooksApiClient(); + if (client) { + if (!options.yes) { + if (options.json) console.log(JSON.stringify({ cleared: 0, confirmed: false, hook: options.hook ?? null })); + else { + const scope = options.hook ? `hook "${options.hook}"` : "all hooks"; + console.log(chalk.yellow(`About to delete event logs for ${scope} on the configured Hooks API and in the local mirror.`)); + console.log(chalk.dim("Re-run with --yes to confirm.")); + } + return; + } + const remote = await client.clearHookEvents({ hook: options.hook }); + // The authority is not the only copy: `storage pull` mirrors its rows + // into local SQLite and `storage push` uploads whatever that file still + // holds, so a delete that stopped at the authority would be reversed by + // the next routine sync. Purge the mirror too, and do it even when the + // authority reported nothing — rows spooled while it was unreachable + // live only locally and would otherwise be pushed after the purge. + const { clearLocalHookEventMirror } = await import("../db/log-store.js"); + const local = clearLocalHookEventMirror({ hook: options.hook }); + apiCleared = { remote, local }; + // The headline is the larger of the two, not their sum: the mirror holds + // the rows `storage pull` copied down from the authority, so adding the + // counts would report one pulled event twice. The maximum is exact in + // both pure cases — a mirror of the authority (local === remote) and an + // unpushed spool it never saw (remote === 0) — and it can never claim + // nothing was cleared while local rows were destroyed. Both per-store + // counts are reported alongside it so the split is never hidden. + count = Math.max(remote, local); + } else { + const { clearHookEvents } = await import("../db/log-store.js"); + if (!options.yes) { + const { getDb } = await import("../db/index.js"); + const db = getDb(); + const countRow = options.hook + ? db.query("SELECT COUNT(*) as n FROM hook_events WHERE hook_name = ?").get(options.hook) as any + : db.query("SELECT COUNT(*) as n FROM hook_events").get() as any; + count = countRow?.n ?? 0; + } else { + count = clearHookEvents({ hook: options.hook }); + } + } + } catch (error) { + failCommand(error, options); + return; + } - const countRow = options.hook - ? db.query("SELECT COUNT(*) as n FROM hook_events WHERE hook_name = ?").get(options.hook) as any - : db.query("SELECT COUNT(*) as n FROM hook_events").get() as any; - const count = countRow?.n ?? 0; + const perStore = apiCleared ? { cleared_remote: apiCleared.remote, cleared_local: apiCleared.local } : {}; - if (count === 0) { console.log(chalk.dim("Nothing to clear.")); return; } + if (count === 0) { + if (options.json) console.log(JSON.stringify({ cleared: 0, hook: options.hook ?? null, ...perStore })); + else console.log(chalk.dim("Nothing to clear.")); + return; + } if (!options.yes) { + if (options.json) { + console.log(JSON.stringify({ cleared: 0, confirmed: false, hook: options.hook ?? null, would_clear: count })); + return; + } const scope = options.hook ? `hook "${options.hook}"` : "all hooks"; console.log(chalk.yellow(`About to delete ${count} event(s) for ${scope}.`)); console.log(chalk.dim("Re-run with --yes to confirm.")); return; } - if (options.hook) { - db.run("DELETE FROM hook_events WHERE hook_name = ?", [options.hook]); - } else { - db.run("DELETE FROM hook_events"); + if (options.json) { console.log(JSON.stringify({ cleared: count, hook: options.hook ?? null, ...perStore })); return; } + if (apiCleared) { + console.log(chalk.green( + `✓ Cleared ${count} event(s) — ${apiCleared.remote} on the Hooks API and ${apiCleared.local} in the local mirror.`, + )); + return; } - console.log(chalk.green(`✓ Cleared ${count} event(s).`)); }); @@ -1219,6 +1313,34 @@ storageCmd .description("Show storage sync status") .option("-j, --json", "Output as JSON", false) .action(async (options: { json: boolean }) => { + const { getHooksApiAuthorityConfigStatus } = await import("./cloud-router.js"); + const apiStatus = getHooksApiAuthorityConfigStatus(); + if (apiStatus.selected) { + const status = { + configured: apiStatus.ok, + ok: apiStatus.ok, + mode: apiStatus.mode, + transport: "http-v1", + service: "hooks", + authority: apiStatus, + local_fallback: false, + }; + if (options.json) { + console.log(JSON.stringify(status, null, 2)); + } else { + console.log(chalk.bold("\n Storage Status\n")); + console.log(` Mode: ${apiStatus.mode}`); + console.log(" Transport: authenticated HTTP /v1"); + console.log(` Authority: ${apiStatus.v1_base_url ?? "not configured"}`); + console.log(` API key: ${apiStatus.api_key_configured ? "configured" : "not configured"}`); + console.log(" Local fallback: disabled"); + console.log(" Network: not used (configuration diagnostic only)"); + for (const warning of apiStatus.warnings) console.error(chalk.yellow(` ${warning}`)); + for (const issue of apiStatus.issues) console.error(chalk.red(` ${issue}`)); + } + if (!apiStatus.ok) process.exitCode = 1; + return; + } const { getStorageStatus } = await import("../storage.js"); const status = getStorageStatus(); if (options.json) { @@ -1240,7 +1362,11 @@ storageCmd .action(async (options: { tables?: string; json: boolean }) => { try { const { parseStorageTables, storagePush } = await import("../storage.js"); - const results = await storagePush({ tables: parseStorageTables(options.tables) }); + const tables = parseStorageTables(options.tables); + const client = await loadHooksApiClient(); + const results = client + ? await client.storagePush({ tables }) + : await storagePush({ tables }); if (options.json) { console.log(JSON.stringify(results, null, 2)); return; @@ -1263,7 +1389,11 @@ storageCmd .action(async (options: { tables?: string; json: boolean }) => { try { const { parseStorageTables, storagePull } = await import("../storage.js"); - const results = await storagePull({ tables: parseStorageTables(options.tables) }); + const tables = parseStorageTables(options.tables); + const client = await loadHooksApiClient(); + const results = client + ? await client.storagePull({ tables }) + : await storagePull({ tables }); if (options.json) { console.log(JSON.stringify(results, null, 2)); return; @@ -1286,7 +1416,11 @@ storageCmd .action(async (options: { tables?: string; json: boolean }) => { try { const { parseStorageTables, storageSync } = await import("../storage.js"); - const result = await storageSync({ tables: parseStorageTables(options.tables) }); + const tables = parseStorageTables(options.tables); + const client = await loadHooksApiClient(); + const result = client + ? await client.storageSync({ tables }) + : await storageSync({ tables }); if (options.json) { console.log(JSON.stringify(result, null, 2)); return; @@ -1308,9 +1442,10 @@ program .option("-s, --stdio", "Use stdio transport (one process per agent)", false) .option("--sse", "Use legacy SSE transport (port 39427)", false) .option("--http", "Use Streamable HTTP transport (explicit; this is also the default)", false) + .option("--api", "Also serve the Hooks /v1 data API (off by default; requires HASNA_HOOKS_API_SERVER_KEY)", false) .option("-p, --port ", "Port for HTTP/SSE transport (defaults to 8847 for HTTP, 39427 for SSE)") .description("Start MCP server for AI agent integration (default: shared Streamable HTTP)") - .action(async (options: { stdio: boolean; sse: boolean; http: boolean; port?: string }) => { + .action(async (options: { stdio: boolean; sse: boolean; http: boolean; api: boolean; port?: string }) => { if (options.stdio) { const { startStdioServer } = await import("../mcp/server.js"); await startStdioServer(); @@ -1322,7 +1457,12 @@ program const { createHooksServer } = await import("../mcp/server.js"); const { resolveMcpHttpPort, startMcpHttpServer } = await import("../mcp/http.js"); const args = options.port ? ["--port", options.port] : []; - startMcpHttpServer({ name: "hooks", port: resolveMcpHttpPort(args), buildServer: createHooksServer }); + startMcpHttpServer({ + name: "hooks", + port: resolveMcpHttpPort(args), + buildServer: createHooksServer, + api: options.api, + }); } }); registerEventsCommands(program, { source: "hooks" }); diff --git a/src/db/log-store.ts b/src/db/log-store.ts new file mode 100644 index 0000000..af7eb6c --- /dev/null +++ b/src/db/log-store.ts @@ -0,0 +1,215 @@ +import type { Database } from "bun:sqlite"; +import { existsSync } from "fs"; +import { getDb, getDbPath } from "./index.js"; +import type { HookEventRow } from "./schema.js"; + +export interface HookEventInput extends Partial> { + session_id: string; + hook_name: string; + event_type: HookEventRow["event_type"]; +} + +export interface HookLogListOptions { + hook?: string; + session?: string; + since?: string; + limit?: number; +} + +export interface HookLogSearchOptions { + text: string; + limit?: number; +} + +export interface HookLogErrorsOptions { + since?: string; + limit?: number; +} + +export interface HookLogSummary { + since: string; + hooks: Array<{ hook_name: string; total: number; errors: number; error_rate: string }>; + totals: { events: number; errors: number; hooks_active: number }; +} + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 1000; +const TOOL_INPUT_MAX_LENGTH = 500; + +/** + * Normalize a hook event into the exact row shape both write paths persist — + * the local SQLite writer and the `/v1/log/events` ingestion route — so an event + * looks identical whichever backend accepted it. + */ +export function buildHookEventRow(event: HookEventInput): HookEventRow { + return { + id: event.id ?? crypto.randomUUID().replace(/-/g, "").slice(0, 21), + timestamp: event.timestamp ?? new Date().toISOString(), + session_id: event.session_id, + hook_name: event.hook_name, + event_type: event.event_type, + tool_name: event.tool_name ?? null, + tool_input: event.tool_input ? event.tool_input.slice(0, TOOL_INPUT_MAX_LENGTH) : null, + result: event.result ?? null, + error: event.error ?? null, + duration_ms: event.duration_ms ?? null, + project_dir: event.project_dir ?? null, + metadata: event.metadata ?? null, + }; +} + +export function insertHookEvent(event: HookEventInput, db: Database = getDb()): HookEventRow { + const row = buildHookEventRow(event); + // REPLACE keyed on id keeps ingestion idempotent when a client retries a POST. + db.run( + `INSERT OR REPLACE INTO hook_events + (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.id, + row.timestamp, + row.session_id, + row.hook_name, + row.event_type, + row.tool_name, + row.tool_input, + row.result, + row.error, + row.duration_ms, + row.project_dir, + row.metadata, + ], + ); + return row; +} + +export function normalizeLogLimit(value: string | number | undefined, fallback = DEFAULT_LIMIT): number { + const parsed = typeof value === "number" ? value : value ? Number.parseInt(value, 10) : fallback; + if (!Number.isSafeInteger(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, MAX_LIMIT); +} + +export function parseLogSince(value: string | undefined, fallback = "24h"): string { + const input = value?.trim() || fallback; + if (/^\d{4}-\d{2}-\d{2}T/.test(input)) return input; + const match = input.match(/^(\d+)(s|m|h|d)$/); + if (!match) return new Date(Date.now() - durationMs(fallback)).toISOString(); + return new Date(Date.now() - durationMs(input)).toISOString(); +} + +export function listHookEvents(options: HookLogListOptions = {}, db: Database = getDb()): HookEventRow[] { + const params: Array = []; + let sql = "SELECT * FROM hook_events WHERE 1=1"; + + if (options.hook) { + sql += " AND hook_name = ?"; + params.push(options.hook); + } + if (options.session) { + sql += " AND session_id LIKE ?"; + params.push(`${options.session}%`); + } + if (options.since) { + sql += " AND timestamp >= ?"; + params.push(parseLogSince(options.since)); + } + sql += " ORDER BY timestamp DESC LIMIT ?"; + params.push(normalizeLogLimit(options.limit)); + + return db.query(sql).all(...params) as HookEventRow[]; +} + +export function searchHookEvents(options: HookLogSearchOptions, db: Database = getDb()): HookEventRow[] { + const limit = normalizeLogLimit(options.limit); + const query = `%${options.text}%`; + return db.query( + "SELECT * FROM hook_events WHERE tool_input LIKE ? OR error LIKE ? ORDER BY timestamp DESC LIMIT ?", + ).all(query, query, limit) as HookEventRow[]; +} + +export function tailHookEvents(limit?: number, db: Database = getDb()): HookEventRow[] { + return db.query("SELECT * FROM hook_events ORDER BY timestamp DESC LIMIT ?") + .all(normalizeLogLimit(limit, 20)) as HookEventRow[]; +} + +export function listHookErrors(options: HookLogErrorsOptions = {}, db: Database = getDb()): HookEventRow[] { + const since = parseLogSince(options.since); + return db.query( + "SELECT * FROM hook_events WHERE error IS NOT NULL AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?", + ).all(since, normalizeLogLimit(options.limit)) as HookEventRow[]; +} + +export function summarizeHookEvents(options: { since?: string } = {}, db: Database = getDb()): HookLogSummary { + const since = parseLogSince(options.since); + const totals = db.query( + "SELECT hook_name, COUNT(*) as total, SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) as errors FROM hook_events WHERE timestamp >= ? GROUP BY hook_name ORDER BY total DESC", + ).all(since) as Array<{ hook_name: string; total: number; errors: number | null }>; + + const hooks = totals.map((row) => { + const total = Number(row.total); + const errors = Number(row.errors ?? 0); + return { + hook_name: row.hook_name, + total, + errors, + error_rate: total > 0 ? `${((errors / total) * 100).toFixed(1)}%` : "0%", + }; + }); + + return { + since, + hooks, + totals: { + events: hooks.reduce((sum, row) => sum + row.total, 0), + errors: hooks.reduce((sum, row) => sum + row.errors, 0), + hooks_active: hooks.length, + }, + }; +} + +export function clearHookEvents(options: { hook?: string } = {}, db: Database = getDb()): number { + const countRow = options.hook + ? db.query("SELECT COUNT(*) as n FROM hook_events WHERE hook_name = ?").get(options.hook) as { n?: number | bigint } | null + : db.query("SELECT COUNT(*) as n FROM hook_events").get() as { n?: number | bigint } | null; + const count = Number(countRow?.n ?? 0); + if (count === 0) return 0; + + if (options.hook) { + db.run("DELETE FROM hook_events WHERE hook_name = ?", [options.hook]); + } else { + db.run("DELETE FROM hook_events"); + } + return count; +} + +/** + * Delete the local copy of events an API authority has already purged. + * + * Under an API authority the local SQLite file is a spool and a pull mirror, not + * a second source of truth: `storage pull` writes authority rows into it and + * `storage push` uploads everything it still holds. A clear that only reached + * the authority is therefore undone by the next routine sync, so the purge has + * to reach both sides. + * + * Returns 0 without touching the filesystem when no local database exists — + * `getDb()` would create the file and its schema, and an API-mode client with + * no spool must not grow one just to empty it. + */ +export function clearLocalHookEventMirror(options: { hook?: string } = {}): number { + const path = getDbPath(); + if (path !== ":memory:" && !existsSync(path)) return 0; + return clearHookEvents(options); +} + +function durationMs(value: string): number { + const match = value.match(/^(\d+)(s|m|h|d)$/); + if (!match) return 24 * 60 * 60 * 1000; + const amount = Number.parseInt(match[1]!, 10); + switch (match[2]) { + case "s": return amount * 1000; + case "m": return amount * 60 * 1000; + case "h": return amount * 60 * 60 * 1000; + case "d": return amount * 24 * 60 * 60 * 1000; + default: return 24 * 60 * 60 * 1000; + } +} diff --git a/src/db/storage-sync.ts b/src/db/storage-sync.ts index 2182d26..7546f3e 100644 --- a/src/db/storage-sync.ts +++ b/src/db/storage-sync.ts @@ -12,8 +12,17 @@ export const STORAGE_TABLES = [ export const HOOKS_STORAGE_TABLES = STORAGE_TABLES; -type StorageTable = (typeof STORAGE_TABLES)[number]; -type Row = Record; +/** + * The only tables the `/v1` HTTP transport carries. `schema_migrations` and + * `_meta` are per-database bookkeeping: replicating a peer's migration ledger + * would let a client running a newer release mark a migration as applied on an + * authority that never ran its DDL, permanently suppressing it. + */ +export const DATA_SYNC_TABLES = ["hook_events", "feedback"] as const; + +export type StorageTable = (typeof STORAGE_TABLES)[number]; +export type DataSyncTable = (typeof DATA_SYNC_TABLES)[number]; +export type StorageRow = Record; export type StorageMode = "local" | "hybrid" | "remote"; @@ -34,6 +43,10 @@ export interface SyncMeta { direction: "push" | "pull"; } +export interface StorageRowsPayload { + tables: Partial>; +} + export const HOOKS_STORAGE_ENV = "HASNA_HOOKS_DATABASE_URL"; export const HOOKS_STORAGE_FALLBACK_ENV = "HOOKS_DATABASE_URL"; export const HOOKS_STORAGE_MODE_ENV = "HASNA_HOOKS_STORAGE_MODE"; @@ -144,6 +157,63 @@ export async function storageSync(options?: { tables?: string[] }): Promise<{ pu return { pull, push }; } +/** Builds a `/v1` payload; bookkeeping tables are never exported. */ +export function storageExportRows(options?: { tables?: string[] }, db: Database = getDb()): StorageRowsPayload { + const tables: Partial> = {}; + for (const table of resolveDataSyncTables(options?.tables)) { + tables[table] = tableExists(db, table) + ? db.query(`SELECT * FROM ${quoteIdent(table)}`).all() as StorageRow[] + : []; + } + return { tables }; +} + +/** + * Applies a `/v1` payload. A bookkeeping table sent by a peer — an older client + * or a hand-rolled request — is refused with an error in its `SyncResult` + * instead of being upserted, so the local migration ledger is never written by + * anything but this process's own migration runner. + */ +export function storageImportRows( + payload: StorageRowsPayload, + options: { direction?: "push" | "pull" } = {}, + db: Database = getDb(), +): SyncResult[] { + const incomingTables = payload.tables ?? {}; + const tableNames = Object.keys(incomingTables); + if (tableNames.length === 0) return []; + const tables = resolveTables(tableNames); + const results: SyncResult[] = []; + for (const table of tables) { + const result: SyncResult = { table, rowsRead: 0, rowsWritten: 0, errors: [] }; + try { + if (!isDataSyncTable(table)) { + const incoming = incomingTables[table]; + result.rowsRead = Array.isArray(incoming) ? incoming.length : 0; + throw new Error(bookkeepingTableRefusal([table])); + } + if (!tableExists(db, table)) { + results.push(result); + continue; + } + const rows = incomingTables[table] ?? []; + if (!Array.isArray(rows)) { + throw new Error(`Invalid rows for ${table}: expected array`); + } + result.rowsRead = rows.length; + if (rows.length > 0) { + const columns = filterLocalColumns(db, table, Object.keys(rows[0]!)); + result.rowsWritten = upsertSqlite(db, table, columns, rows); + } + } catch (error) { + result.errors.push(error instanceof Error ? error.message : String(error)); + } + results.push(result); + } + recordSyncMeta(db, options.direction ?? "pull", results); + return results; +} + export function getSyncMetaAll(): SyncMeta[] { const db = getDb(); ensureSyncMetaTable(db); @@ -172,6 +242,27 @@ export function resolveTables(tables?: string[]): StorageTable[] { return requested as StorageTable[]; } +export function isDataSyncTable(table: string): table is DataSyncTable { + return (DATA_SYNC_TABLES as readonly string[]).includes(table); +} + +/** + * Table resolution for the `/v1` transport: defaults to the data tables and + * refuses bookkeeping tables even when they are named explicitly. + */ +export function resolveDataSyncTables(tables?: string[]): DataSyncTable[] { + if (!tables || tables.length === 0) return [...DATA_SYNC_TABLES]; + const requested = resolveTables(tables); + const bookkeeping = requested.filter((table) => !isDataSyncTable(table)); + if (bookkeeping.length > 0) throw new Error(bookkeepingTableRefusal(bookkeeping)); + return requested as DataSyncTable[]; +} + +function bookkeepingTableRefusal(tables: string[]): string { + return `Hooks /v1 sync does not carry bookkeeping table(s): ${tables.join(", ")}; ` + + "migration state is per-database and is never replicated between peers"; +} + export function parseStorageTables(value?: string | string[] | null): StorageTable[] | undefined { if (!value) return undefined; return resolveTables(Array.isArray(value) ? value : value.split(",")); @@ -181,7 +272,7 @@ async function pushTable(db: Database, remote: PgAdapterAsync, table: StorageTab const result: SyncResult = { table, rowsRead: 0, rowsWritten: 0, errors: [] }; try { if (!tableExists(db, table)) return result; - const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all() as Row[]; + const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all() as StorageRow[]; result.rowsRead = rows.length; if (rows.length === 0) return result; const remoteColumns = await getRemoteColumns(remote, table); @@ -197,7 +288,7 @@ async function pullTable(remote: PgAdapterAsync, db: Database, table: StorageTab const result: SyncResult = { table, rowsRead: 0, rowsWritten: 0, errors: [] }; try { if (!tableExists(db, table)) return result; - const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`) as Row[]; + const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`) as StorageRow[]; result.rowsRead = rows.length; if (rows.length === 0) return result; const columns = filterLocalColumns(db, table, Object.keys(rows[0]!)); @@ -227,7 +318,7 @@ function filterLocalColumns(db: Database, table: string, columns: string[]): str return columns.filter((column) => allowed.has(column)); } -async function upsertPg(remote: PgAdapterAsync, table: StorageTable, columns: string[], rows: Row[], remoteColumns: Map): Promise { +async function upsertPg(remote: PgAdapterAsync, table: StorageTable, columns: string[], rows: StorageRow[], remoteColumns: Map): Promise { if (columns.length === 0) return 0; const primaryKeys = PRIMARY_KEYS[table]; const columnList = columns.map(quoteIdent).join(", "); @@ -249,7 +340,7 @@ async function upsertPg(remote: PgAdapterAsync, table: StorageTable, columns: st return rows.length; } -function upsertSqlite(db: Database, table: StorageTable, columns: string[], rows: Row[]): number { +function upsertSqlite(db: Database, table: StorageTable, columns: string[], rows: StorageRow[]): number { if (columns.length === 0) return 0; const primaryKeys = PRIMARY_KEYS[table]; const columnList = columns.map(quoteIdent).join(", "); @@ -264,7 +355,7 @@ function upsertSqlite(db: Database, table: StorageTable, columns: string[], rows `INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ); - const insert = db.transaction((batch: Row[]) => { + const insert = db.transaction((batch: StorageRow[]) => { for (const row of batch) statement.run(...columns.map((column) => coerceForSqlite(row[column]))); }); insert(rows); diff --git a/src/index.ts b/src/index.ts index feb7cb5..f21ddd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -188,6 +188,7 @@ export { } from "./lib/profiles.js"; export { + DATA_SYNC_TABLES, HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV, HOOKS_STORAGE_MODE_ENV, @@ -203,11 +204,15 @@ export { getStoragePg, getStorageStatus, getSyncMetaAll, + isDataSyncTable, parseStorageTables, + resolveDataSyncTables, resolveTables, runStorageMigrations, + storageExportRows, + storageImportRows, storagePull, storagePush, storageSync, } from "./storage.js"; -export type { StorageEnv, StorageMode, StorageStatus, SyncMeta, SyncResult } from "./storage.js"; +export type { DataSyncTable, StorageEnv, StorageMode, StorageRow, StorageRowsPayload, StorageStatus, SyncMeta, SyncResult } from "./storage.js"; diff --git a/src/lib/db-writer.test.ts b/src/lib/db-writer.test.ts new file mode 100644 index 0000000..c9a2ad0 --- /dev/null +++ b/src/lib/db-writer.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { existsSync, mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { writeHookEvent, type HookEventInput } from "./db-writer.js"; +import { closeDb } from "../db/index.js"; + +type FetchStub = ( + input: Parameters[0], + init?: Parameters[1], +) => Promise; + +const EVENT: HookEventInput = { + session_id: "session-writer", + hook_name: "commandlog", + event_type: "PostToolUse", + tool_name: "Bash", + tool_input: "git status", + project_dir: "/tmp/project", +}; + +const ROUTING_ENV = [ + "HASNA_HOOKS_STORAGE_MODE", + "HOOKS_STORAGE_MODE", + "HASNA_HOOKS_API_URL", + "HOOKS_API_URL", + "HASNA_HOOKS_API_KEY", + "HOOKS_API_KEY", + "HASNA_HOOKS_DB_PATH", + "HOOKS_DB_PATH", + "HASNA_HOOKS_API_TIMEOUT_MS", + "HOOKS_API_TIMEOUT_MS", + "HASNA_HOOKS_API_WRITE_TIMEOUT_MS", + "HOOKS_API_WRITE_TIMEOUT_MS", +] as const; + +async function withEnv(overrides: Record, callback: () => Promise): Promise { + const original = new Map(); + for (const name of ROUTING_ENV) { + original.set(name, process.env[name]); + delete process.env[name]; + } + for (const [name, value] of Object.entries(overrides)) { + if (value !== undefined) process.env[name] = value; + } + try { + return await callback(); + } finally { + closeDb(); + for (const [name, value] of original) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +} + +async function withFetchStub(stub: FetchStub, callback: () => Promise): Promise { + const originalFetch = globalThis.fetch; + globalThis.fetch = stub as typeof fetch; + try { + return await callback(); + } finally { + globalThis.fetch = originalFetch; + } +} + +function readHookEvents(dbPath: string): Array> { + const db = new Database(dbPath, { readonly: true }); + try { + return db.query("SELECT * FROM hook_events").all() as Array>; + } finally { + db.close(); + } +} + +async function withTempRoot(prefix: string, callback: (root: string) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), prefix)); + try { + return await callback(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe("writeHookEvent", () => { + test("local mode writes the event to SQLite", async () => { + await withTempRoot("hooks-writer-local-", async (root) => { + const dbPath = join(root, "hooks.db"); + await withEnv({ HASNA_HOOKS_STORAGE_MODE: "local", HASNA_HOOKS_DB_PATH: dbPath }, () => writeHookEvent(EVENT)); + + const rows = readHookEvents(dbPath); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + session_id: "session-writer", + hook_name: "commandlog", + event_type: "PostToolUse", + tool_input: "git status", + }); + }); + }); + + test("api mode posts the event to the /v1 authority and never opens local SQLite", async () => { + await withTempRoot("hooks-writer-api-", async (root) => { + const dbDir = join(root, "must-not-exist"); + const requests: Array<{ method: string | undefined; path: string; authorization: string | null; body: any }> = []; + + await withEnv({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:8847", + HASNA_HOOKS_API_KEY: "fixture-api-key", + HASNA_HOOKS_DB_PATH: join(dbDir, "hooks.db"), + }, () => withFetchStub(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const body = JSON.parse(String(init?.body)); + requests.push({ + method: init?.method, + path: url.pathname, + authorization: new Headers(init?.headers).get("authorization"), + body, + }); + return Response.json({ event: body }, { status: 201 }); + }, () => writeHookEvent(EVENT))); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + method: "POST", + path: "/v1/log/events", + authorization: "Bearer fixture-api-key", + }); + expect(requests[0]!.body).toMatchObject({ + session_id: "session-writer", + hook_name: "commandlog", + event_type: "PostToolUse", + tool_input: "git status", + }); + expect(typeof requests[0]!.body.id).toBe("string"); + expect(typeof requests[0]!.body.timestamp).toBe("string"); + expect(existsSync(dbDir)).toBe(false); + }); + }); + + test("api mode spools to local SQLite when the authority is unreachable", async () => { + await withTempRoot("hooks-writer-spool-", async (root) => { + const dbPath = join(root, "hooks.db"); + await withEnv({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: "http://127.0.0.1:8847", + HASNA_HOOKS_API_KEY: "fixture-api-key", + HASNA_HOOKS_DB_PATH: dbPath, + }, () => withFetchStub( + async () => { throw new Error("connection refused"); }, + () => writeHookEvent(EVENT), + )); + + const rows = readHookEvents(dbPath); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ session_id: "session-writer", hook_name: "commandlog" }); + }); + }); + + test("api mode spools to local SQLite when the authority never answers", async () => { + // A wedged authority accepts the TCP connection and returns nothing. Without + // a write deadline the hook blocks until the agent kills it and the event is + // lost, so this asserts the spool guarantee, not just the fast-failure one. + await withTempRoot("hooks-writer-hung-", async (root) => { + const dbPath = join(root, "hooks.db"); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + fetch: () => new Promise(() => {}), + }); + let elapsedMs = 0; + try { + const startedAt = Date.now(); + await withEnv({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_URL: `http://127.0.0.1:${server.port}`, + HASNA_HOOKS_API_KEY: "fixture-api-key", + HASNA_HOOKS_API_WRITE_TIMEOUT_MS: "400", + HASNA_HOOKS_DB_PATH: dbPath, + }, () => writeHookEvent(EVENT)); + elapsedMs = Date.now() - startedAt; + } finally { + server.stop(true); + } + + expect(elapsedMs).toBeLessThan(5_000); + const rows = readHookEvents(dbPath); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ session_id: "session-writer", hook_name: "commandlog" }); + }); + }, 15_000); + + test("api mode spools to local SQLite when the authority is misconfigured", async () => { + await withTempRoot("hooks-writer-misconfigured-", async (root) => { + const dbPath = join(root, "hooks.db"); + await withEnv({ + HASNA_HOOKS_STORAGE_MODE: "api", + HASNA_HOOKS_API_KEY: "fixture-api-key", + HASNA_HOOKS_DB_PATH: dbPath, + }, () => writeHookEvent(EVENT)); + + const rows = readHookEvents(dbPath); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ session_id: "session-writer", hook_name: "commandlog" }); + }); + }); +}); diff --git a/src/lib/db-writer.ts b/src/lib/db-writer.ts index bf72379..34c53d3 100644 --- a/src/lib/db-writer.ts +++ b/src/lib/db-writer.ts @@ -1,45 +1,65 @@ /** * Shared hook DB writer — single write path for all observability hooks. + * + * In local mode the event is inserted straight into SQLite. In an API storage + * mode the event is POSTed to the configured Hooks `/v1` authority so that the + * `hooks log` commands — which read from that same authority — can see it. If + * the authority is unreachable, misconfigured, or does not answer within the + * write deadline (`DEFAULT_API_WRITE_TIMEOUT_MS`, overridable with + * `HASNA_HOOKS_API_WRITE_TIMEOUT_MS`), the event is spooled into the local + * database instead of being dropped; `hooks storage push` drains that spool to + * the authority (row upserts are keyed on the event id, so draining is + * idempotent). The deadline is what makes the spool guarantee hold against a + * hung authority: this function runs inside every agent tool call, so it must + * never block for longer than that. + * * Never throws: errors are written to stderr only. */ -import { getDb } from "../db"; +import type { HooksApiClient } from "../cli/cloud-router"; +import { insertHookEvent, buildHookEventRow, type HookEventInput as HookEventRowInput } from "../db/log-store"; import type { HookEventRow } from "../db/schema"; -export type HookEventInput = Omit & { - timestamp?: string; -}; +export type HookEventInput = Omit; -function nanoid(): string { - return crypto.randomUUID().replace(/-/g, "").slice(0, 21); +async function resolveApiClient(): Promise { + try { + const { getHooksApiClient } = await import("../cli/cloud-router"); + return getHooksApiClient(); + } catch (err) { + process.stderr.write(`[hooks db-writer] Hooks API routing unavailable, spooling locally: ${err}\n`); + return null; + } } -export function writeHookEvent(event: HookEventInput): void { +function spool(row: HookEventRow): void { try { - const db = getDb(); - const id = nanoid(); - const timestamp = event.timestamp ?? new Date().toISOString(); + insertHookEvent(row); + } catch (err) { + process.stderr.write(`[hooks db-writer] failed to write event: ${err}\n`); + } +} - db.run( - `INSERT INTO hook_events - (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - id, - timestamp, - event.session_id, - event.hook_name, - event.event_type, - event.tool_name ?? null, - event.tool_input ? event.tool_input.slice(0, 500) : null, - event.result ?? null, - event.error ?? null, - event.duration_ms ?? null, - event.project_dir ?? null, - event.metadata ?? null, - ] - ); +export async function writeHookEvent(event: HookEventInput): Promise { + let row: HookEventRow; + try { + row = buildHookEventRow(event); } catch (err) { process.stderr.write(`[hooks db-writer] failed to write event: ${err}\n`); + return; } + + const client = await resolveApiClient(); + if (client) { + try { + await client.appendHookEvent(row); + return; + } catch (err) { + process.stderr.write( + `[hooks db-writer] Hooks API write failed, spooling locally for 'hooks storage push': ${err}\n`, + ); + } + } + + spool(row); } diff --git a/src/mcp/http.test.ts b/src/mcp/http.test.ts index 2b6a2fc..46024b9 100644 --- a/src/mcp/http.test.ts +++ b/src/mcp/http.test.ts @@ -2,17 +2,56 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; import { Client } from "@modelcontextprotocol/sdk/client"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { createHooksServer } from "./server.js"; -import { handleMcpRequest, resolveMcpHttpPort, DEFAULT_MCP_HTTP_PORT } from "./http.js"; +import { handleMcpHttpRequest, handleMcpRequest, resolveMcpHttpPort, DEFAULT_MCP_HTTP_PORT } from "./http.js"; +import { handleHooksApiRequest } from "../server/api.js"; describe("hooks MCP HTTP transport", () => { - let httpServer: ReturnType; + let httpServer: ReturnType | undefined; let port: number; + function isAddressInUse(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + return String((error as { code?: unknown }).code) === "EADDRINUSE"; + } + + function serveOnAvailablePort( + fetch: (request: Request) => Response | Promise, + attempts = 100, + ): ReturnType { + let lastError: unknown; + const basePort = 25000 + (process.pid % 20000); + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return Bun.serve({ + hostname: "127.0.0.1", + port: basePort + attempt, + fetch, + }); + } catch (error) { + if (!isAddressInUse(error)) throw error; + lastError = error; + } + } + throw lastError; + } + + function loopbackListenersAvailable(): boolean { + try { + const server = serveOnAvailablePort(() => new Response("ok"), 5); + server.stop(true); + return true; + } catch { + return false; + } + } + + const listenerTestsEnabled = loopbackListenersAvailable(); + const listenerTest = listenerTestsEnabled ? test : test.skip; + beforeAll(() => { - httpServer = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - async fetch(req) { + if (!listenerTestsEnabled) return; + httpServer = serveOnAvailablePort( + async (req) => { const url = new URL(req.url); if (url.pathname === "/health" && req.method === "GET") { return Response.json({ status: "ok", name: "hooks" }); @@ -20,14 +59,17 @@ describe("hooks MCP HTTP transport", () => { if (url.pathname === "/mcp") { return handleMcpRequest(req, createHooksServer); } + if (url.pathname.startsWith("/v1/")) { + return handleHooksApiRequest(req, { name: "hooks", env: { HASNA_HOOKS_API_SERVER_KEY: "fixture-server-key" } }); + } return new Response("Not Found", { status: 404 }); }, - }); - port = httpServer.port!; + ); + port = httpServer.port ?? 0; }); afterAll(() => { - httpServer.stop(); + httpServer?.stop(true); }); test("default port is 8847", () => { @@ -36,13 +78,81 @@ describe("hooks MCP HTTP transport", () => { expect(resolveMcpHttpPort(["--port", "9001"])).toBe(9001); }); - test("GET /health returns 200", async () => { + listenerTest("GET /health returns 200", async () => { const res = await fetch(`http://127.0.0.1:${port}/health`); expect(res.status).toBe(200); expect(await res.json()).toEqual({ status: "ok", name: "hooks" }); }); - test("MCP initialize + list tools over Streamable HTTP", async () => { + test("GET /v1/health returns API health without auth", async () => { + const res = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/health"), + { name: "hooks", env: { HASNA_HOOKS_API_SERVER_KEY: "fixture-server-key" } }, + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ status: "ok", name: "hooks" }); + }); + + test("/v1 data routes require bearer auth", async () => { + const res = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/events"), + { name: "hooks", env: { HASNA_HOOKS_API_SERVER_KEY: "fixture-server-key" } }, + ); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "Unauthorized" }); + }); + + describe("/v1 data API mount gate", () => { + const routes: Array<[string, string]> = [ + ["GET", "/v1/log/events"], + ["DELETE", "/v1/log/events"], + ["GET", "/v1/storage/export"], + ["POST", "/v1/storage/import"], + ]; + + test.each(routes)("%s %s is not served without --api", async (method, path) => { + const res = await handleMcpHttpRequest( + new Request(`http://127.0.0.1${path}`, { method }), + { name: "hooks", buildServer: createHooksServer }, + ); + expect(res.status).toBe(404); + expect(await res.text()).toBe("Not Found"); + }); + + test("GET /v1/log/events is served with --api", async () => { + const res = await handleMcpHttpRequest( + new Request("http://127.0.0.1/v1/log/events"), + { name: "hooks", buildServer: createHooksServer, api: true }, + ); + expect(res.status).not.toBe(404); + }); + }); + + test("the /v1 server key is not the client API key", async () => { + const clientKeyOnly = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/events", { + headers: { authorization: "Bearer client-key" }, + }), + { name: "hooks", env: { HASNA_HOOKS_API_KEY: "client-key", HOOKS_API_KEY: "client-key" } }, + ); + expect(clientKeyOnly.status).toBe(503); + expect(await clientKeyOnly.json()).toEqual({ + error: "HASNA_HOOKS_API_SERVER_KEY is required for Hooks /v1 data routes", + }); + + const clientKeyAgainstServerKey = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/events", { + headers: { authorization: "Bearer client-key" }, + }), + { + name: "hooks", + env: { HASNA_HOOKS_API_KEY: "client-key", HASNA_HOOKS_API_SERVER_KEY: "fixture-server-key" }, + }, + ); + expect(clientKeyAgainstServerKey.status).toBe(401); + }); + + listenerTest("MCP initialize + list tools over Streamable HTTP", async () => { const client = new Client({ name: "hooks-http-test", version: "0.0.0" }); const transport = new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${port}/mcp`), @@ -53,7 +163,7 @@ describe("hooks MCP HTTP transport", () => { await client.close(); }); - test("serves multiple concurrent clients from one process", async () => { + listenerTest("serves multiple concurrent clients from one process", async () => { const clients = await Promise.all( [1, 2, 3].map(async () => { const client = new Client({ name: "hooks-http-concurrent", version: "0.0.0" }); diff --git a/src/mcp/http.ts b/src/mcp/http.ts index c87f6bf..8b9865d 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -35,28 +35,51 @@ export async function handleMcpRequest( return transport.handleRequest(req); } +/** + * Route one request for the MCP HTTP server. + * + * The Hooks `/v1` data API reads and destroys hook event history, so it is only + * mounted when the operator opts in with `hooks mcp --http --api`. Without that + * flag `/v1/*` is indistinguishable from any other unknown path. + */ +export async function handleMcpHttpRequest( + req: Request, + options: { name: string; buildServer: () => McpServer; api?: boolean }, +): Promise { + const { name, buildServer, api = false } = options; + const url = new URL(req.url); + if (url.pathname === "/health" && req.method === "GET") { + return Response.json(healthPayload(name)); + } + if (api && url.pathname.startsWith("/v1/")) { + const { handleHooksApiRequest } = await import("../server/api.js"); + return handleHooksApiRequest(req, { name }); + } + if (url.pathname === "/mcp") { + return handleMcpRequest(req, buildServer); + } + return new Response("Not Found", { status: 404 }); +} + export function startMcpHttpServer(options: { name: string; port: number; buildServer: () => McpServer; + api?: boolean; }): ReturnType { - const { name, port, buildServer } = options; + const { name, port, buildServer, api = false } = options; const server = Bun.serve({ hostname: MCP_HTTP_HOST, port, - async fetch(req) { - const url = new URL(req.url); - if (url.pathname === "/health" && req.method === "GET") { - return Response.json(healthPayload(name)); - } - if (url.pathname === "/mcp") { - return handleMcpRequest(req, buildServer); - } - return new Response("Not Found", { status: 404 }); + fetch(req) { + return handleMcpHttpRequest(req, { name, buildServer, api }); }, }); console.error(`${name}-mcp HTTP listening on http://${MCP_HTTP_HOST}:${port}/mcp`); + if (api) { + console.error(`${name} /v1 data API enabled on http://${MCP_HTTP_HOST}:${port}/v1 (requires HASNA_HOOKS_API_SERVER_KEY)`); + } return server; } diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index b6ae471..142c402 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -49,6 +49,68 @@ function listItems(data: any): any[] { return Array.isArray(data) ? data : data.hooks ?? data.results ?? []; } +function loopbackListenerAvailable(port: number): boolean { + try { + const server = Bun.serve({ + hostname: "127.0.0.1", + port, + fetch() { + return new Response("ok"); + }, + }); + server.stop(true); + return true; + } catch { + return false; + } +} + +// Kept clear of the range src/cli/cli.test.ts allocates from, so the two suites +// never race for the same port inside one `bun test` process. +let nextAuthorityPort = 45000 + (process.pid % 15000); + +function serveOnAvailablePort( + fetch: (request: Request) => Response | Promise, + attempts = 100, +): ReturnType { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt++) { + nextAuthorityPort += 1; + try { + return Bun.serve({ hostname: "127.0.0.1", port: nextAuthorityPort, fetch }); + } catch (error) { + if (String((error as { code?: unknown }).code) !== "EADDRINUSE") throw error; + lastError = error; + } + } + throw lastError; +} + +function authorityListenersAvailable(): boolean { + try { + const server = serveOnAvailablePort(() => new Response("ok"), 5); + server.stop(true); + return true; + } catch { + return false; + } +} + +function overrideEnv(values: Record): () => void { + const previous: Record = {}; + for (const [key, value] of Object.entries(values)) { + previous[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + return () => { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; +} + function seedLogDb(rowCount: number, options: { withErrors?: boolean } = {}): () => void { closeDb(); const previousHasnaPath = process.env.HASNA_HOOKS_DB_PATH; @@ -859,6 +921,20 @@ describe("MCP server", () => { } }); + test("hooks_log_summary aggregates local events per hook", async () => { + const cleanup = seedLogDb(5, { withErrors: true }); + try { + const data = parseResult(await client.callTool({ name: "hooks_log_summary", arguments: {} })); + expect(data.hooks).toEqual([ + { hook_name: "gitguard", total: 5, errors: 5, error_rate: "100.0%" }, + ]); + expect(data.totals).toEqual({ events: 5, errors: 5, hooks_active: 1 }); + expect(data.since).toMatch(/^\d{4}-\d{2}-\d{2}T/); + } finally { + cleanup(); + } + }); + // --- compact mode --- test("hooks_list compact returns minimal fields", async () => { @@ -891,10 +967,189 @@ describe("MCP server", () => { }); }); + /** + * In an API storage mode the hook write path POSTs events to the configured + * `/v1` authority and never touches local SQLite, so an MCP log tool that read + * the local file would answer "no events" for work that just happened. Each + * test seeds the local database with rows the authority does not have, so a + * tool that fell back to SQLite returns visibly wrong events rather than an + * ambiguous empty list. + */ + describe("log tools under an API authority", () => { + const authorityTest = authorityListenersAvailable() ? test : test.skip; + + const AUTHORITY_EVENT = { + id: "evt_authority", + timestamp: new Date().toISOString(), + session_id: "session-authority", + hook_name: "authorityhook", + event_type: "PreToolUse", + tool_name: "Bash", + tool_input: "git status", + result: "continue", + error: "authority failure", + duration_ms: 10, + project_dir: "/tmp/project", + metadata: null, + }; + + type SeenRequest = { method: string; path: string; authorization: string | null }; + + async function withApiAuthority( + handler: (request: Request) => Response | Promise, + body: (client: Client, requests: SeenRequest[]) => Promise, + ): Promise { + const requests: SeenRequest[] = []; + const authority = serveOnAvailablePort((request) => { + const url = new URL(request.url); + requests.push({ + method: request.method, + path: url.pathname, + authorization: request.headers.get("authorization"), + }); + return handler(request); + }); + const restoreDb = seedLogDb(3, { withErrors: true }); + const restoreEnv = overrideEnv({ + HASNA_HOOKS_STORAGE_MODE: "api", + HOOKS_STORAGE_MODE: undefined, + HASNA_HOOKS_API_URL: `http://127.0.0.1:${authority.port}`, + HOOKS_API_URL: undefined, + HASNA_HOOKS_API_KEY: "fixture-api-key", + HOOKS_API_KEY: undefined, + }); + const apiServer = createHooksServer(); + const apiClient = new Client({ name: "test-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([ + apiClient.connect(clientTransport), + apiServer.connect(serverTransport), + ]); + try { + return await body(apiClient, requests); + } finally { + await apiClient.close(); + restoreEnv(); + restoreDb(); + authority.stop(true); + } + } + + authorityTest("hooks_log_list reads the authority, not the local spool", async () => { + await withApiAuthority( + (request) => { + const url = new URL(request.url); + if (url.pathname === "/v1/log/events") return Response.json({ events: [AUTHORITY_EVENT] }); + return Response.json({ error: "unexpected route" }, { status: 404 }); + }, + async (client, requests) => { + const data = parseResult(await client.callTool({ name: "hooks_log_list", arguments: {} })); + expect(data.count).toBe(1); + expect(data.events.map((event: any) => event.id)).toEqual(["evt_authority"]); + expect(requests).toEqual([ + { method: "GET", path: "/v1/log/events", authorization: "Bearer fixture-api-key" }, + ]); + }, + ); + }); + + authorityTest("hooks_log_list forwards its filters to the authority", async () => { + await withApiAuthority( + (request) => { + const url = new URL(request.url); + if (url.pathname !== "/v1/log/events") return Response.json({ error: "unexpected route" }, { status: 404 }); + expect(url.searchParams.get("hook")).toBe("authorityhook"); + expect(url.searchParams.get("session")).toBe("session-auth"); + expect(url.searchParams.get("since")).toBe("30m"); + expect(url.searchParams.get("limit")).toBe("7"); + return Response.json({ events: [AUTHORITY_EVENT] }); + }, + async (client) => { + const data = parseResult(await client.callTool({ + name: "hooks_log_list", + arguments: { hook_name: "authorityhook", session_id: "session-auth", since: "30m", limit: 7 }, + })); + expect(data.count).toBe(1); + }, + ); + }); + + authorityTest("hooks_log_tail reads the authority, not the local spool", async () => { + await withApiAuthority( + (request) => { + const url = new URL(request.url); + if (url.pathname === "/v1/log/events") return Response.json({ events: [AUTHORITY_EVENT] }); + return Response.json({ error: "unexpected route" }, { status: 404 }); + }, + async (client, requests) => { + const data = parseResult(await client.callTool({ name: "hooks_log_tail", arguments: {} })); + expect(data.count).toBe(1); + expect(data.events.map((event: any) => event.id)).toEqual(["evt_authority"]); + expect(requests.map((seen) => seen.path)).toEqual(["/v1/log/events"]); + }, + ); + }); + + authorityTest("hooks_log_errors reads the authority, not the local spool", async () => { + await withApiAuthority( + (request) => { + const url = new URL(request.url); + if (url.pathname === "/v1/log/errors") return Response.json({ events: [AUTHORITY_EVENT] }); + return Response.json({ error: "unexpected route" }, { status: 404 }); + }, + async (client, requests) => { + const data = parseResult(await client.callTool({ name: "hooks_log_errors", arguments: {} })); + expect(data.count).toBe(1); + expect(data.events.map((event: any) => event.id)).toEqual(["evt_authority"]); + expect(requests.map((seen) => seen.path)).toEqual(["/v1/log/errors"]); + }, + ); + }); + + authorityTest("hooks_log_summary reads the authority, not the local spool", async () => { + const summary = { + since: "2026-07-28T00:00:00.000Z", + hooks: [{ hook_name: "authorityhook", total: 4, errors: 1, error_rate: "25.0%" }], + totals: { events: 4, errors: 1, hooks_active: 1 }, + }; + await withApiAuthority( + (request) => { + const url = new URL(request.url); + if (url.pathname === "/v1/log/summary") return Response.json(summary); + return Response.json({ error: "unexpected route" }, { status: 404 }); + }, + async (client, requests) => { + const data = parseResult(await client.callTool({ name: "hooks_log_summary", arguments: {} })); + expect(data).toEqual(summary); + expect(requests.map((seen) => seen.path)).toEqual(["/v1/log/summary"]); + }, + ); + }); + + authorityTest("log tools fail closed instead of serving local rows when the authority errors", async () => { + await withApiAuthority( + () => Response.json({ error: "boom" }, { status: 503 }), + async (client) => { + for (const name of ["hooks_log_list", "hooks_log_tail", "hooks_log_errors", "hooks_log_summary"]) { + const result = await client.callTool({ name, arguments: {} }); + expect(result.isError).toBe(true); + const data = parseResult(result); + expect(data.error).toContain("REMOTE_API_UNAVAILABLE"); + expect(data.error).toContain("local SQLite fallback is disabled"); + expect(data).not.toHaveProperty("events"); + } + }, + ); + }); + }); + describe("SSE HTTP endpoints", () => { + const sseEndpointTestsEnabled = loopbackListenerAvailable(TEST_PORT); + const sseEndpointTest = sseEndpointTestsEnabled ? test : test.skip; let serverProcess: any; beforeAll(async () => { + if (!sseEndpointTestsEnabled) return; serverProcess = Bun.spawn( ["bun", "run", join(import.meta.dir, "..", "cli", "index.tsx"), "mcp", "--sse", "--port", String(TEST_PORT)], { stdout: "pipe", stderr: "pipe" } @@ -909,13 +1164,14 @@ describe("MCP server", () => { }); afterAll(async () => { + if (!sseEndpointTestsEnabled) return; if (serverProcess) { serverProcess.kill(); await serverProcess.exited; } }); - test("root endpoint returns server info", async () => { + sseEndpointTest("root endpoint returns server info", async () => { const res = await fetch(`http://localhost:${TEST_PORT}/`); expect(res.status).toBe(200); const data = await res.json(); @@ -924,7 +1180,7 @@ describe("MCP server", () => { expect(data.port).toBe(TEST_PORT); }); - test("SSE endpoint returns event-stream", async () => { + sseEndpointTest("SSE endpoint returns event-stream", async () => { const res = await fetch(`http://localhost:${TEST_PORT}/sse`); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("text/event-stream"); @@ -934,12 +1190,12 @@ describe("MCP server", () => { } }); - test("messages endpoint rejects without sessionId", async () => { + sseEndpointTest("messages endpoint rejects without sessionId", async () => { const res = await fetch(`http://localhost:${TEST_PORT}/messages`, { method: "POST", body: "{}" }); expect(res.status).toBe(400); }); - test("messages endpoint rejects invalid sessionId", async () => { + sseEndpointTest("messages endpoint rejects invalid sessionId", async () => { const res = await fetch(`http://localhost:${TEST_PORT}/messages?sessionId=invalid`, { method: "POST", body: "{}" }); expect(res.status).toBe(400); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0a69d6f..c2fc767 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -57,9 +57,54 @@ import { storagePush, storageSync, } from "../storage.js"; +import type { HooksApiClient } from "../cli/cloud-router.js"; export const MCP_PORT = 39427; +/** + * Resolve the configured Hooks `/v1` authority for the log query tools, or null + * in local mode where SQLite is the source of truth. + * + * The hook write path (`src/lib/db-writer.ts`) POSTs events to that authority in + * an API storage mode, so a tool that queried local SQLite would answer "no + * events" for a session whose events all landed remotely. These tools therefore + * route exactly like `hooks log …` in `src/cli/index.tsx`, and a misconfigured + * or unreachable authority surfaces as a tool error rather than an empty result + * set — local SQLite fallback is disabled for API-routed reads. + */ +async function loadHooksApiClient(): Promise { + const { getHooksApiAuthorityConfigStatus, getHooksApiClient } = await import("../cli/cloud-router.js"); + // stderr, never stdout: stdout is the stdio transport's protocol channel. + for (const warning of getHooksApiAuthorityConfigStatus().warnings) { + process.stderr.write(`[hooks mcp] ${warning}\n`); + } + return getHooksApiClient(); +} + +function toolFailure(error: unknown) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), + }], + isError: true, + }; +} + +function logEventsResult(rows: any[], compact: boolean) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + events: compact ? rows.map(compactEvent) : rows, + count: rows.length, + compact, + hint: compact ? "Use compact:false for full tool_input/output fields." : undefined, + }), + }], + }; +} + function formatInstallResults(results: InstallResult[], extra?: Record) { const installed = results.filter((r) => r.success).map((r) => r.hook); const failed = results.filter((r) => !r.success).map((r) => ({ hook: r.hook, error: r.error })); @@ -787,7 +832,7 @@ export function createHooksServer(): McpServer { defineTool( "hooks_log_list", - "List hook events from SQLite. Compact summaries by default; set compact:false for full event rows.", + "List hook events from the configured Hooks authority, or local SQLite in local storage mode. Compact summaries by default; set compact:false for full event rows.", { hook_name: z.string().optional().describe("Filter by hook name (e.g. 'sessionlog', 'costwatch')"), session_id: z.string().optional().describe("Filter by session ID prefix"), @@ -796,154 +841,76 @@ export function createHooksServer(): McpServer { compact: z.boolean().default(true).describe("Return compact event summaries by default. Set false for full rows."), }, async ({ hook_name, session_id, limit, since, compact }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); const maxRows = boundedLimit(limit, compact ? 20 : 50, compact ? 100 : 500); - - function parseDuration(s: string): string | null { - const m = s.match(/^(\d+)(s|m|h|d)$/); - if (!m) return null; - const n = parseInt(m[1]); - const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2] as "s"|"m"|"h"|"d"]!; - return new Date(Date.now() - n * ms).toISOString(); - } - - let sql = "SELECT * FROM hook_events WHERE 1=1"; - const params: (string | number)[] = []; - - if (hook_name) { sql += " AND hook_name = ?"; params.push(hook_name); } - if (session_id) { sql += " AND session_id LIKE ?"; params.push(`${session_id}%`); } - if (since) { - const ts = since.match(/^\d{4}/) ? since : parseDuration(since); - if (ts) { sql += " AND timestamp >= ?"; params.push(ts); } + const query = { hook: hook_name, session: session_id, since, limit: maxRows }; + try { + const client = await loadHooksApiClient(); + if (client) return logEventsResult(await client.listHookEvents(query), compact); + const { listHookEvents } = await import("../db/log-store.js"); + return logEventsResult(listHookEvents(query), compact); + } catch (error) { + return toolFailure(error); } - sql += " ORDER BY timestamp DESC LIMIT ?"; - params.push(maxRows); - - const rows = db.query(sql).all(...params) as any[]; - return { - content: [{ - type: "text", - text: JSON.stringify({ - events: compact ? rows.map(compactEvent) : rows, - count: rows.length, - compact, - hint: compact ? "Use compact:false for full tool_input/output fields." : undefined, - }), - }], - }; } ); defineTool( "hooks_log_tail", - "Show recent hook events from SQLite. Compact summaries by default.", + "Show recent hook events from the configured Hooks authority, or local SQLite in local storage mode. Compact summaries by default.", { n: z.number().default(20).describe("Number of most recent events to return"), compact: z.boolean().default(true).describe("Return compact event summaries by default. Set false for full rows."), }, async ({ n, compact }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); const maxRows = boundedLimit(n, 20, compact ? 100 : 500); - const rows = db.query("SELECT * FROM hook_events ORDER BY timestamp DESC LIMIT ?").all(maxRows) as any[]; - return { - content: [{ - type: "text", - text: JSON.stringify({ - events: compact ? rows.map(compactEvent) : rows, - count: rows.length, - compact, - hint: compact ? "Use compact:false for full tool_input/output fields." : undefined, - }), - }], - }; + try { + const client = await loadHooksApiClient(); + if (client) return logEventsResult(await client.tailHookEvents({ limit: maxRows }), compact); + const { tailHookEvents } = await import("../db/log-store.js"); + return logEventsResult(tailHookEvents(maxRows), compact); + } catch (error) { + return toolFailure(error); + } } ); defineTool( "hooks_log_errors", - "Show hook events that contain errors. Compact summaries by default.", + "Show hook events that contain errors, from the configured Hooks authority or local SQLite in local storage mode. Compact summaries by default.", { since: z.string().default("24h").describe("Duration string (e.g. '1h', '30m', '7d') or ISO timestamp"), limit: z.number().optional().describe("Max number of error events to return. Defaults to 20 compact rows or 50 full rows."), compact: z.boolean().default(true).describe("Return compact event summaries by default. Set false for full rows."), }, async ({ since, limit, compact }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); const maxRows = boundedLimit(limit, compact ? 20 : 50, compact ? 100 : 500); - - function parseDuration(s: string): string { - const m = s.match(/^(\d+)(s|m|h|d)$/); - if (!m) return s; - const n = parseInt(m[1]); - const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2] as "s"|"m"|"h"|"d"]!; - return new Date(Date.now() - n * ms).toISOString(); + try { + const client = await loadHooksApiClient(); + if (client) return logEventsResult(await client.listHookErrors({ since, limit: maxRows }), compact); + const { listHookErrors } = await import("../db/log-store.js"); + return logEventsResult(listHookErrors({ since, limit: maxRows }), compact); + } catch (error) { + return toolFailure(error); } - - const ts = since.match(/^\d{4}/) ? since : parseDuration(since); - const rows = db.query( - "SELECT * FROM hook_events WHERE error IS NOT NULL AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?" - ).all(ts, maxRows) as any[]; - return { - content: [{ - type: "text", - text: JSON.stringify({ - events: compact ? rows.map(compactEvent) : rows, - count: rows.length, - compact, - hint: compact ? "Use compact:false for full tool_input/output fields." : undefined, - }), - }], - }; } ); defineTool( "hooks_log_summary", - "Summarize hook execution: counts per hook, error rates, and recent activity.", + "Summarize hook execution: counts per hook, error rates, and recent activity. Reads the configured Hooks authority, or local SQLite in local storage mode.", { since: z.string().default("24h").describe("Duration string (e.g. '1h', '24h', '7d') or ISO timestamp"), }, async ({ since }) => { - const { getDb } = await import("../db/index.js"); - const db = getDb(); - - function parseDuration(s: string): string { - const m = s.match(/^(\d+)(s|m|h|d)$/); - if (!m) return s; - const n = parseInt(m[1]); - const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2] as "s"|"m"|"h"|"d"]!; - return new Date(Date.now() - n * ms).toISOString(); + try { + const client = await loadHooksApiClient(); + const summary = client + ? await client.summarizeHookEvents({ since }) + : (await import("../db/log-store.js")).summarizeHookEvents({ since }); + return { content: [{ type: "text" as const, text: JSON.stringify(summary) }] }; + } catch (error) { + return toolFailure(error); } - - const ts = since.match(/^\d{4}/) ? since : parseDuration(since); - - const totals = db.query( - "SELECT hook_name, COUNT(*) as total, SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END) as errors FROM hook_events WHERE timestamp >= ? GROUP BY hook_name ORDER BY total DESC" - ).all(ts) as { hook_name: string; total: number; errors: number }[]; - - const summary = totals.map((r) => ({ - hook_name: r.hook_name, - total: r.total, - errors: r.errors, - error_rate: r.total > 0 ? ((r.errors / r.total) * 100).toFixed(1) + "%" : "0%", - })); - - const grandTotal = totals.reduce((s, r) => s + r.total, 0); - const grandErrors = totals.reduce((s, r) => s + r.errors, 0); - - return { - content: [{ - type: "text", - text: JSON.stringify({ - since: ts, - hooks: summary, - totals: { events: grandTotal, errors: grandErrors, hooks_active: totals.length }, - }), - }], - }; } ); @@ -975,6 +942,12 @@ export function createHooksServer(): McpServer { async (params) => ({ content: [{ type: "text" as const, text: JSON.stringify(await storageSync(params.tables ? { tables: params.tables } : undefined)) }] }), ); + // Unlike the log query tools this one always writes local SQLite, in every + // storage mode. `feedback` is one of the two DATA_SYNC_TABLES, so the row is + // spooled exactly like an unreachable-authority hook event and reaches the + // authority on the next `hooks storage push`; there is no `/v1` feedback + // route to send it to directly, and no read surface that could answer from + // the wrong store. defineTool( "send_feedback", "Send feedback about this service", diff --git a/src/server/api.test.ts b/src/server/api.test.ts new file mode 100644 index 0000000..c37272c --- /dev/null +++ b/src/server/api.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { handleHooksApiRequest } from "./api.js"; +import { closeDb } from "../db/index.js"; + +const SERVER_ENV = { HASNA_HOOKS_API_SERVER_KEY: "fixture-server-key" }; +const AUTH = { authorization: "Bearer fixture-server-key", "content-type": "application/json" }; + +async function withDbPath(dbPath: string, callback: () => Promise): Promise { + const originalHasnaDbPath = process.env.HASNA_HOOKS_DB_PATH; + const originalHooksDbPath = process.env.HOOKS_DB_PATH; + closeDb(); + process.env.HASNA_HOOKS_DB_PATH = dbPath; + delete process.env.HOOKS_DB_PATH; + try { + return await callback(); + } finally { + closeDb(); + if (originalHasnaDbPath === undefined) delete process.env.HASNA_HOOKS_DB_PATH; + else process.env.HASNA_HOOKS_DB_PATH = originalHasnaDbPath; + if (originalHooksDbPath === undefined) delete process.env.HOOKS_DB_PATH; + else process.env.HOOKS_DB_PATH = originalHooksDbPath; + } +} + +async function withTempRoot(prefix: string, callback: (root: string) => Promise): Promise { + const root = mkdtempSync(join(tmpdir(), prefix)); + try { + return await callback(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function post(body: unknown): Request { + return new Request("http://127.0.0.1/v1/log/events", { + method: "POST", + headers: AUTH, + body: JSON.stringify(body), + }); +} + +describe("Hooks /v1 log ingestion", () => { + test("POST /v1/log/events persists the event and it is readable back", async () => { + await withTempRoot("hooks-api-ingest-", async (root) => { + const dbPath = join(root, "hooks.db"); + await withDbPath(dbPath, async () => { + const created = await handleHooksApiRequest(post({ + session_id: "session-ingest", + hook_name: "commandlog", + event_type: "PostToolUse", + tool_name: "Bash", + tool_input: "git status", + project_dir: "/tmp/project", + }), { env: SERVER_ENV }); + + expect(created.status).toBe(201); + const { event } = await created.json() as { event: { id: string; timestamp: string } }; + expect(typeof event.id).toBe("string"); + expect(typeof event.timestamp).toBe("string"); + + const listed = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/events", { headers: AUTH }), + { env: SERVER_ENV }, + ); + expect(listed.status).toBe(200); + const { events } = await listed.json() as { events: Array> }; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + id: event.id, + session_id: "session-ingest", + hook_name: "commandlog", + tool_input: "git status", + }); + }); + + const db = new Database(dbPath, { readonly: true }); + try { + expect(db.query("SELECT COUNT(*) as n FROM hook_events").get()).toEqual({ n: 1 }); + } finally { + db.close(); + } + }); + }); + + test("POST /v1/log/events is idempotent for a retried event id", async () => { + await withTempRoot("hooks-api-ingest-retry-", async (root) => { + await withDbPath(join(root, "hooks.db"), async () => { + const event = { + id: "evt_retry", + timestamp: "2026-07-28T00:00:00.000Z", + session_id: "session-retry", + hook_name: "commandlog", + event_type: "PostToolUse", + }; + expect((await handleHooksApiRequest(post(event), { env: SERVER_ENV })).status).toBe(201); + expect((await handleHooksApiRequest(post(event), { env: SERVER_ENV })).status).toBe(201); + + const listed = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/events", { headers: AUTH }), + { env: SERVER_ENV }, + ); + const { events } = await listed.json() as { events: unknown[] }; + expect(events).toHaveLength(1); + }); + }); + }); + + test("POST /v1/log/events rejects an invalid event type", async () => { + await withTempRoot("hooks-api-ingest-invalid-", async (root) => { + await withDbPath(join(root, "hooks.db"), async () => { + const res = await handleHooksApiRequest(post({ + session_id: "session-invalid", + hook_name: "commandlog", + event_type: "NotAnEvent", + }), { env: SERVER_ENV }); + expect(res.status).toBe(400); + }); + }); + }); + + test("GET /v1/log/events honours the since filter", async () => { + await withTempRoot("hooks-api-since-", async (root) => { + await withDbPath(join(root, "hooks.db"), async () => { + await handleHooksApiRequest(post({ + id: "evt_recent", + timestamp: new Date().toISOString(), + session_id: "session-since", + hook_name: "commandlog", + event_type: "PostToolUse", + }), { env: SERVER_ENV }); + await handleHooksApiRequest(post({ + id: "evt_ancient", + timestamp: "2020-01-01T00:00:00.000Z", + session_id: "session-since", + hook_name: "commandlog", + event_type: "PostToolUse", + }), { env: SERVER_ENV }); + + const filtered = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/events?since=1h", { headers: AUTH }), + { env: SERVER_ENV }, + ); + const { events } = await filtered.json() as { events: Array<{ id: string }> }; + expect(events.map((event) => event.id)).toEqual(["evt_recent"]); + + const unfiltered = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/events", { headers: AUTH }), + { env: SERVER_ENV }, + ); + const all = await unfiltered.json() as { events: Array<{ id: string }> }; + expect(all.events.map((event) => event.id).sort()).toEqual(["evt_ancient", "evt_recent"]); + }); + }); + }); + + test("GET /v1/log/summary aggregates events per hook", async () => { + await withTempRoot("hooks-api-summary-", async (root) => { + await withDbPath(join(root, "hooks.db"), async () => { + await handleHooksApiRequest(post({ + session_id: "session-summary", + hook_name: "commandlog", + event_type: "PostToolUse", + }), { env: SERVER_ENV }); + await handleHooksApiRequest(post({ + session_id: "session-summary", + hook_name: "commandlog", + event_type: "PostToolUse", + error: "boom", + }), { env: SERVER_ENV }); + + const res = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/summary", { headers: AUTH }), + { env: SERVER_ENV }, + ); + expect(res.status).toBe(200); + const summary = await res.json() as { + since: string; + hooks: Array<{ hook_name: string; total: number; errors: number; error_rate: string }>; + totals: { events: number; errors: number; hooks_active: number }; + }; + expect(summary.hooks).toEqual([ + { hook_name: "commandlog", total: 2, errors: 1, error_rate: "50.0%" }, + ]); + expect(summary.totals).toEqual({ events: 2, errors: 1, hooks_active: 1 }); + expect(summary.since).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + }); + }); + + test("GET /v1/log/summary requires the server key", async () => { + const res = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/summary", { headers: { authorization: "Bearer wrong-key" } }), + { env: SERVER_ENV }, + ); + expect(res.status).toBe(401); + }); + + test("POST /v1/log/events requires the server key", async () => { + const res = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/log/events", { + method: "POST", + headers: { authorization: "Bearer client-key", "content-type": "application/json" }, + body: JSON.stringify({ session_id: "s", hook_name: "h", event_type: "Stop" }), + }), + { env: { HASNA_HOOKS_API_KEY: "client-key" } }, + ); + expect(res.status).toBe(503); + }); +}); + +function readMigrationLedger(dbPath: string): string[] { + const db = new Database(dbPath, { readonly: true }); + try { + return (db.query("SELECT version FROM schema_migrations ORDER BY version").all() as Array<{ version: string }>) + .map((row) => row.version); + } finally { + db.close(); + } +} + +describe("Hooks /v1 storage sync", () => { + test("POST /v1/storage/import refuses a schema_migrations payload and leaves the ledger untouched", async () => { + await withTempRoot("hooks-api-ledger-", async (root) => { + const dbPath = join(root, "hooks.db"); + let ledgerBefore: string[] = []; + + await withDbPath(dbPath, async () => { + // Opening the authority's database applies its own migrations. + await handleHooksApiRequest(post({ + session_id: "session-ledger", + hook_name: "commandlog", + event_type: "PostToolUse", + }), { env: SERVER_ENV }); + ledgerBefore = readMigrationLedger(dbPath); + expect(ledgerBefore.length).toBeGreaterThan(0); + + const res = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/storage/import", { + method: "POST", + headers: AUTH, + body: JSON.stringify({ + tables: { + schema_migrations: [{ version: "004_future", applied_at: "2026-07-28T00:00:00.000Z" }], + _meta: [{ key: "peer", value: "client" }], + }, + }), + }), + { env: SERVER_ENV }, + ); + + expect(res.status).toBe(200); + const { results } = await res.json() as { results: Array<{ table: string; rowsWritten: number; errors: string[] }> }; + for (const result of results) { + expect(result.rowsWritten).toBe(0); + expect(result.errors.join(" ")).toContain("does not carry bookkeeping table"); + } + expect(results.map((result) => result.table).sort()).toEqual(["_meta", "schema_migrations"]); + }); + + expect(readMigrationLedger(dbPath)).toEqual(ledgerBefore); + }); + }); + + test("GET /v1/storage/export never carries bookkeeping tables", async () => { + await withTempRoot("hooks-api-export-", async (root) => { + await withDbPath(join(root, "hooks.db"), async () => { + const res = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/storage/export", { headers: AUTH }), + { env: SERVER_ENV }, + ); + expect(res.status).toBe(200); + const { tables } = await res.json() as { tables: Record }; + expect(Object.keys(tables).sort()).toEqual(["feedback", "hook_events"]); + }); + }); + }); + + test("GET /v1/storage/export rejects an explicitly requested bookkeeping table", async () => { + await withTempRoot("hooks-api-export-reject-", async (root) => { + await withDbPath(join(root, "hooks.db"), async () => { + const res = await handleHooksApiRequest( + new Request("http://127.0.0.1/v1/storage/export?tables=schema_migrations", { headers: AUTH }), + { env: SERVER_ENV }, + ); + expect(res.status).toBe(400); + const { error } = await res.json() as { error: string }; + expect(error).toContain("does not carry bookkeeping table"); + }); + }); + }); +}); diff --git a/src/server/api.ts b/src/server/api.ts new file mode 100644 index 0000000..ccb9cc3 --- /dev/null +++ b/src/server/api.ts @@ -0,0 +1,124 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { + clearHookEvents, + insertHookEvent, + listHookErrors, + listHookEvents, + normalizeLogLimit, + searchHookEvents, + summarizeHookEvents, + type HookEventInput, +} from "../db/log-store.js"; +import { + getStorageStatus, + parseStorageTables, + storageExportRows, + storageImportRows, + type StorageRowsPayload, +} from "../storage.js"; + +type Env = Record; + +/** + * The credential this process accepts as the /v1 admin key. It is deliberately + * NOT `HASNA_HOOKS_API_KEY`: that variable is the CLIENT bearer token for a + * remote authority, and one secret must not serve both trust roles. + */ +export const HOOKS_API_SERVER_KEY_ENV = "HASNA_HOOKS_API_SERVER_KEY"; +export const HOOKS_API_SERVER_KEY_FALLBACK_ENV = "HOOKS_API_SERVER_KEY"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +let pkg = { name: "@hasna/hooks", version: "0.0.0" }; +try { + for (const rel of ["../../package.json", "../package.json", "../../../package.json"]) { + const path = join(__dirname, rel); + if (existsSync(path)) { + pkg = JSON.parse(readFileSync(path, "utf-8")); + break; + } + } +} catch {} + +export async function handleHooksApiRequest(req: Request, options: { name?: string; env?: Env } = {}): Promise { + const env = options.env ?? process.env as Env; + const url = new URL(req.url); + const name = options.name ?? "hooks"; + + if (url.pathname === "/v1/health" && req.method === "GET") { + return json({ status: "ok", name, version: pkg.version }); + } + + const authFailure = requireApiAuth(req, env); + if (authFailure) return authFailure; + + try { + if (url.pathname === "/v1/log/events" && req.method === "GET") { + const events = listHookEvents({ + hook: url.searchParams.get("hook") ?? undefined, + session: url.searchParams.get("session") ?? undefined, + since: url.searchParams.get("since") ?? undefined, + limit: normalizeLogLimit(url.searchParams.get("limit") ?? undefined), + }); + return json({ events, count: events.length }); + } + if (url.pathname === "/v1/log/events" && req.method === "POST") { + const event = await req.json() as HookEventInput; + return json({ event: insertHookEvent(event) }, 201); + } + if (url.pathname === "/v1/log/events" && req.method === "DELETE") { + const cleared = clearHookEvents({ hook: url.searchParams.get("hook") ?? undefined }); + return json({ cleared }); + } + if (url.pathname === "/v1/log/search" && req.method === "GET") { + const text = url.searchParams.get("q") ?? ""; + const events = text + ? searchHookEvents({ text, limit: normalizeLogLimit(url.searchParams.get("limit") ?? undefined) }) + : []; + return json({ events, count: events.length }); + } + if (url.pathname === "/v1/log/errors" && req.method === "GET") { + const events = listHookErrors({ + since: url.searchParams.get("since") ?? undefined, + limit: normalizeLogLimit(url.searchParams.get("limit") ?? undefined), + }); + return json({ events, count: events.length }); + } + if (url.pathname === "/v1/log/summary" && req.method === "GET") { + return json(summarizeHookEvents({ since: url.searchParams.get("since") ?? undefined })); + } + if (url.pathname === "/v1/storage/status" && req.method === "GET") { + return json({ ...getStorageStatus(), transport: "api-http" }); + } + if (url.pathname === "/v1/storage/export" && req.method === "GET") { + const tables = parseStorageTables(url.searchParams.get("tables")); + return json(storageExportRows({ tables })); + } + if (url.pathname === "/v1/storage/import" && req.method === "POST") { + const payload = await req.json() as StorageRowsPayload; + const results = storageImportRows(payload, { direction: "push" }); + return json({ results }); + } + } catch (error) { + return json({ error: error instanceof Error ? error.message : String(error) }, 400); + } + + return json({ error: "Not Found" }, 404); +} + +function requireApiAuth(req: Request, env: Env): Response | null { + const expected = (env[HOOKS_API_SERVER_KEY_ENV] ?? env[HOOKS_API_SERVER_KEY_FALLBACK_ENV])?.trim(); + if (!expected) { + return json({ error: `${HOOKS_API_SERVER_KEY_ENV} is required for Hooks /v1 data routes` }, 503); + } + const authorization = req.headers.get("authorization") ?? ""; + if (authorization !== `Bearer ${expected}`) { + return json({ error: "Unauthorized" }, 401); + } + return null; +} + +function json(body: unknown, status = 200): Response { + return Response.json(body, { status }); +} diff --git a/src/storage.ts b/src/storage.ts index 929e27e..29ce9da 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,4 +1,5 @@ export { + DATA_SYNC_TABLES, HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV, HOOKS_STORAGE_MODE_ENV, @@ -14,13 +15,17 @@ export { getStoragePg, getStorageStatus, getSyncMetaAll, + isDataSyncTable, parseStorageTables, + resolveDataSyncTables, resolveTables, runStorageMigrations, + storageExportRows, + storageImportRows, storagePull, storagePush, storageSync, } from "./db/storage-sync.js"; -export type { StorageEnv, StorageMode, StorageStatus, SyncMeta, SyncResult } from "./db/storage-sync.js"; +export type { DataSyncTable, StorageEnv, StorageMode, StorageRow, StorageRowsPayload, StorageStatus, SyncMeta, SyncResult } from "./db/storage-sync.js"; export { PgAdapterAsync } from "./db/remote-storage.js"; export { PG_MIGRATIONS } from "./db/pg-migrations.js";