From 8164445837cff0ed23334ac89ebac86029705c92 Mon Sep 17 00:00:00 2001 From: hasna Date: Tue, 28 Jul 2026 01:17:38 +0300 Subject: [PATCH 1/5] feat: CLI audit + local/api parity: open-hooks --- README.md | 16 +- src/cli/cli.test.ts | 316 ++++++++++++++++++++++++++++++++++- src/cli/cloud-router.test.ts | 265 +++++++++++++++++++++++++++++ src/cli/cloud-router.ts | 310 ++++++++++++++++++++++++++++++++++ src/cli/index.tsx | 227 ++++++++++++++++++------- src/db/log-store.ts | 136 +++++++++++++++ src/db/storage-sync.ts | 63 ++++++- src/index.ts | 4 +- src/mcp/http.test.ts | 82 +++++++-- src/mcp/http.ts | 4 + src/mcp/server.test.ts | 28 +++- src/server/api.ts | 105 ++++++++++++ src/storage.ts | 4 +- 13 files changed, 1464 insertions(+), 96 deletions(-) create mode 100644 src/cli/cloud-router.test.ts create mode 100644 src/cli/cloud-router.ts create mode 100644 src/db/log-store.ts create mode 100644 src/server/api.ts diff --git a/README.md b/README.md index 6d00266..be3e49f 100644 --- a/README.md +++ b/README.md @@ -96,20 +96,30 @@ 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. ```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. The existing `hooks mcp --http` server exposes +the shared MCP endpoint at `/mcp` and the Hooks API routes under `/v1`. ## Runtime model diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index acd8e8e..66fd96d 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,90 @@ 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(); + } +} + +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 +692,211 @@ 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 }); + } + }); + }); + + 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 }); + } + }); + }); + 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..9fa2c01 --- /dev/null +++ b/src/cli/cloud-router.test.ts @@ -0,0 +1,265 @@ +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 { + getHooksApiAuthorityConfigStatus, + getHooksApiClient, + 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([ + "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 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 }); + } + }); +}); diff --git a/src/cli/cloud-router.ts b/src/cli/cloud-router.ts new file mode 100644 index 0000000..edb8217 --- /dev/null +++ b/src/cli/cloud-router.ts @@ -0,0 +1,310 @@ +import type { HookEventRow } from "../db/schema.js"; +import { + storageExportRows, + storageImportRows, + type StorageRowsPayload, + type 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]); + +export interface HooksCliStorageModeResolution { + mode: string; + selected: boolean; + source: "HASNA_HOOKS_STORAGE_MODE" | "HOOKS_STORAGE_MODE" | "default"; +} + +export interface HooksApiAuthorityConfigStatus { + selected: boolean; + ok: boolean; + mode: string; + api_url_configured: boolean; + api_key_configured: boolean; + v1_base_url: string | null; + issues: string[]; + local_fallback: false; +} + +export interface HooksApiClient { + baseUrl: string; + listHookEvents(options?: { hook?: string; session?: string; limit?: number }): Promise; + searchHookEvents(options: { text: string; limit?: number }): Promise; + tailHookEvents(options?: { limit?: number }): Promise; + listHookErrors(options?: { since?: string; limit?: number }): 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 apiConfigPresent(env: Env): boolean { + return Boolean(firstConfigured(env, ["HASNA_HOOKS_API_URL", "HOOKS_API_URL"])) || + Boolean(firstConfigured(env, ["HASNA_HOOKS_API_KEY", "HOOKS_API_KEY"])); +} + +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"; + return { + mode, + selected: API_MODES.has(mode) || (POSTGRES_COMPAT_MODES.has(mode) && apiConfigPresent(env)), + source: canonical ? "HASNA_HOOKS_STORAGE_MODE" : fallback ? "HOOKS_STORAGE_MODE" : "default", + }; +} + +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, ["HASNA_HOOKS_API_URL", "HOOKS_API_URL"])), + api_key_configured: Boolean(firstConfigured(env, ["HASNA_HOOKS_API_KEY", "HOOKS_API_KEY"])), + v1_base_url: null, + issues: [error instanceof Error ? error.message : String(error)], + 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: [], + local_fallback: false, + }; + } + + const issues: string[] = []; + const rawApiUrl = firstConfigured(env, ["HASNA_HOOKS_API_URL", "HOOKS_API_URL"]); + 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, ["HASNA_HOOKS_API_KEY", "HOOKS_API_KEY"])); + 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, + 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, ["HASNA_HOOKS_API_KEY", "HOOKS_API_KEY"])!; + return new HttpHooksApiClient(status.v1_base_url!, apiKey); +} + +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, + ) {} + + async listHookEvents(options: { hook?: string; session?: 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 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 { + 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 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): Promise { + let response: Response; + try { + response = await fetch(`${this.baseUrl}${path}`, { + method, + redirect: "manual", + headers: { + authorization: `Bearer ${this.apiKey}`, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (error) { + throw new Error( + `REMOTE_API_UNREACHABLE: configured Hooks authority ${authorityBase(this.baseUrl)} could not be reached for ${path}; ` + + "local SQLite fallback is disabled", + { cause: error }, + ); + } + + if (!response.ok) { + await classifyRemoteResponse(this.baseUrl, path, response); + } + if (response.status === 204) return undefined as T; + return response.json() as Promise; + } +} + +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..39b81ed 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -121,6 +121,16 @@ function printDisclosureHint(hidden: number, detailCommand: string, options: { i } } +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 +1058,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 +1068,28 @@ 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 { getHooksApiClient } = await import("./cloud-router.js"); + const client = getHooksApiClient(); + 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 +1110,20 @@ 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 { getHooksApiClient } = await import("./cloud-router.js"); + const client = getHooksApiClient(); + 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 +1143,20 @@ 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 { getHooksApiClient } = await import("./cloud-router.js"); + const client = getHooksApiClient(); + 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 +1178,21 @@ 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 { getHooksApiClient } = await import("./cloud-router.js"); + const client = getHooksApiClient(); + 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 +1209,59 @@ 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(); - - 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; + .option("-j, --json", "Output as JSON", false) + .action(async (options: { hook?: string; yes: boolean; json: boolean }) => { + let count: number; + try { + const { getHooksApiClient } = await import("./cloud-router.js"); + const client = getHooksApiClient(); + 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.`)); + console.log(chalk.dim("Re-run with --yes to confirm.")); + } + return; + } + count = await client.clearHookEvents({ hook: options.hook }); + } 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; + } - 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 })); + 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 })); return; } console.log(chalk.green(`✓ Cleared ${count} event(s).`)); }); @@ -1219,6 +1274,33 @@ 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 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 +1322,12 @@ 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 { getHooksApiClient } = await import("./cloud-router.js"); + const client = getHooksApiClient(); + const results = client + ? await client.storagePush({ tables }) + : await storagePush({ tables }); if (options.json) { console.log(JSON.stringify(results, null, 2)); return; @@ -1263,7 +1350,12 @@ 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 { getHooksApiClient } = await import("./cloud-router.js"); + const client = getHooksApiClient(); + const results = client + ? await client.storagePull({ tables }) + : await storagePull({ tables }); if (options.json) { console.log(JSON.stringify(results, null, 2)); return; @@ -1286,7 +1378,12 @@ 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 { getHooksApiClient } = await import("./cloud-router.js"); + const client = getHooksApiClient(); + const result = client + ? await client.storageSync({ tables }) + : await storageSync({ tables }); if (options.json) { console.log(JSON.stringify(result, null, 2)); return; diff --git a/src/db/log-store.ts b/src/db/log-store.ts new file mode 100644 index 0000000..ca5630b --- /dev/null +++ b/src/db/log-store.ts @@ -0,0 +1,136 @@ +import type { Database } from "bun:sqlite"; +import { getDb } from "./index.js"; +import type { HookEventRow } from "./schema.js"; + +export interface HookLogListOptions { + hook?: string; + session?: 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; + +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}%`); + } + 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; +} + +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..09393b8 100644 --- a/src/db/storage-sync.ts +++ b/src/db/storage-sync.ts @@ -12,8 +12,8 @@ export const STORAGE_TABLES = [ export const HOOKS_STORAGE_TABLES = STORAGE_TABLES; -type StorageTable = (typeof STORAGE_TABLES)[number]; -type Row = Record; +export type StorageTable = (typeof STORAGE_TABLES)[number]; +export type StorageRow = Record; export type StorageMode = "local" | "hybrid" | "remote"; @@ -34,6 +34,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 +148,51 @@ export async function storageSync(options?: { tables?: string[] }): Promise<{ pu return { pull, push }; } +export function storageExportRows(options?: { tables?: string[] }, db: Database = getDb()): StorageRowsPayload { + const tables: Partial> = {}; + for (const table of resolveTables(options?.tables)) { + tables[table] = tableExists(db, table) + ? db.query(`SELECT * FROM ${quoteIdent(table)}`).all() as StorageRow[] + : []; + } + return { tables }; +} + +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 (!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); @@ -181,7 +230,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 +246,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 +276,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 +298,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 +313,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..d9caf4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -206,8 +206,10 @@ export { parseStorageTables, resolveTables, runStorageMigrations, + storageExportRows, + storageImportRows, storagePull, storagePush, storageSync, } from "./storage.js"; -export type { StorageEnv, StorageMode, StorageStatus, SyncMeta, SyncResult } from "./storage.js"; +export type { StorageEnv, StorageMode, StorageRow, StorageRowsPayload, StorageStatus, SyncMeta, SyncResult } from "./storage.js"; diff --git a/src/mcp/http.test.ts b/src/mcp/http.test.ts index 2b6a2fc..aa7ccb4 100644 --- a/src/mcp/http.test.ts +++ b/src/mcp/http.test.ts @@ -3,16 +3,55 @@ 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 { 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_KEY: "fixture-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,31 @@ 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_KEY: "fixture-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_KEY: "fixture-key" } }, + ); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "Unauthorized" }); + }); + + 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 +113,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..0776e14 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -50,6 +50,10 @@ export function startMcpHttpServer(options: { if (url.pathname === "/health" && req.method === "GET") { return Response.json(healthPayload(name)); } + if (url.pathname.startsWith("/v1/")) { + const { handleHooksApiRequest } = await import("../server/api.js"); + return handleHooksApiRequest(req, { name }); + } if (url.pathname === "/mcp") { return handleMcpRequest(req, buildServer); } diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index b6ae471..5a82175 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -49,6 +49,22 @@ 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; + } +} + function seedLogDb(rowCount: number, options: { withErrors?: boolean } = {}): () => void { closeDb(); const previousHasnaPath = process.env.HASNA_HOOKS_DB_PATH; @@ -892,9 +908,12 @@ describe("MCP server", () => { }); 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 +928,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 +944,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 +954,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/server/api.ts b/src/server/api.ts new file mode 100644 index 0000000..48e88fd --- /dev/null +++ b/src/server/api.ts @@ -0,0 +1,105 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { + clearHookEvents, + listHookErrors, + listHookEvents, + normalizeLogLimit, + searchHookEvents, +} from "../db/log-store.js"; +import { + getStorageStatus, + parseStorageTables, + storageExportRows, + storageImportRows, + type StorageRowsPayload, +} from "../storage.js"; + +type Env = Record; + +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, + limit: normalizeLogLimit(url.searchParams.get("limit") ?? undefined), + }); + return json({ events, count: events.length }); + } + 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/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.HASNA_HOOKS_API_KEY ?? env.HOOKS_API_KEY)?.trim(); + if (!expected) { + return json({ error: "HASNA_HOOKS_API_KEY 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..783736f 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -17,10 +17,12 @@ export { parseStorageTables, 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 { 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"; From 761f648cf6f44b8fa3c9dbda98d74ac6fbc7b1ca Mon Sep 17 00:00:00 2001 From: hasna Date: Tue, 28 Jul 2026 16:18:19 +0300 Subject: [PATCH 2/5] fix: route hook ingestion through the API authority and harden /v1 exposure Addresses three P1 review findings on the local/api parity work. 1. Hook event ingestion was never ported to API mode, so `hooks log` read the remote authority while every hook kept writing to local SQLite that no api- mode command could see. `writeHookEvent` now resolves the same router the read path uses and POSTs to `/v1/log/events`; on an unreachable or incompletely configured authority the event is spooled to local SQLite (and reported on stderr) instead of dropped, and `hooks storage push` drains that spool idempotently. Adds the `POST /v1/log/events` route and a shared `buildHookEventRow`/`insertHookEvent` pair so both write paths persist the identical row. 2. `apiConfigPresent()` treated a bare API key as "the user wants HTTP", so a stray `HOOKS_API_KEY` hijacked the PostgreSQL remote/hybrid path and then failed with REMOTE_API_URL_MISSING. Routing now keys on the API URL alone, and a remote/hybrid environment carrying both a database URL and an API URL emits an explicit precedence warning instead of silently misrouting. 3. `hooks mcp --http` mounted the destructive `/v1` data API unconditionally and accepted the client-side `HASNA_HOOKS_API_KEY` as its admin credential. The mount is now behind an explicit `--api` flag that defaults to off, and the server authenticates against the separate `HASNA_HOOKS_API_SERVER_KEY` so one secret no longer serves both trust roles. Each fix is covered by a test that fails when the fix is reverted. --- README.md | 29 ++++- hooks/hook-commandlog/src/hook.ts | 6 +- hooks/hook-costwatch/src/hook.ts | 8 +- hooks/hook-errornotify/src/hook.ts | 6 +- hooks/hook-sessionlog/src/hook.ts | 6 +- src/cli/cli.test.ts | 25 +++++ src/cli/cloud-router.test.ts | 81 ++++++++++++++ src/cli/cloud-router.ts | 70 +++++++++--- src/cli/index.tsx | 50 ++++++--- src/db/log-store.ts | 54 +++++++++ src/lib/db-writer.test.ts | 172 +++++++++++++++++++++++++++++ src/lib/db-writer.ts | 74 ++++++++----- src/mcp/http.test.ts | 58 +++++++++- src/mcp/http.ts | 47 +++++--- src/server/api.test.ts | 136 +++++++++++++++++++++++ src/server/api.ts | 18 ++- 16 files changed, 743 insertions(+), 97 deletions(-) create mode 100644 src/lib/db-writer.test.ts create mode 100644 src/server/api.test.ts diff --git a/README.md b/README.md index be3e49f..02f85ed 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,14 @@ 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 or +incompletely configured, 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. + ```bash hooks storage status --json HASNA_HOOKS_DATABASE_URL=postgres://... hooks storage push --tables hook_events,feedback --json @@ -118,8 +126,25 @@ Configure database storage with `HASNA_HOOKS_DATABASE_URL` or fallback `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. The existing `hooks mcp --http` server exposes -the shared MCP endpoint at `/mcp` and the Hooks API routes under `/v1`. +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 66fd96d..0b7977e 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -895,6 +895,31 @@ describe("CLI", () => { 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", () => { diff --git a/src/cli/cloud-router.test.ts b/src/cli/cloud-router.test.ts index 9fa2c01..ba522c4 100644 --- a/src/cli/cloud-router.test.ts +++ b/src/cli/cloud-router.test.ts @@ -123,6 +123,54 @@ describe("hooks api router", () => { })).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", @@ -181,6 +229,39 @@ describe("hooks api router", () => { }]); }); + 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"); diff --git a/src/cli/cloud-router.ts b/src/cli/cloud-router.ts index edb8217..3df0c45 100644 --- a/src/cli/cloud-router.ts +++ b/src/cli/cloud-router.ts @@ -1,10 +1,5 @@ import type { HookEventRow } from "../db/schema.js"; -import { - storageExportRows, - storageImportRows, - type StorageRowsPayload, - type SyncResult, -} from "../storage.js"; +import type { StorageRowsPayload, SyncResult } from "../storage.js"; type Env = Record; type HttpMethod = "GET" | "POST" | "DELETE"; @@ -12,11 +7,15 @@ 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; export interface HooksCliStorageModeResolution { mode: string; selected: boolean; source: "HASNA_HOOKS_STORAGE_MODE" | "HOOKS_STORAGE_MODE" | "default"; + warnings: string[]; } export interface HooksApiAuthorityConfigStatus { @@ -27,11 +26,13 @@ export interface HooksApiAuthorityConfigStatus { 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; limit?: number }): Promise; searchHookEvents(options: { text: string; limit?: number }): Promise; tailHookEvents(options?: { limit?: number }): Promise; @@ -56,9 +57,20 @@ function firstConfigured(env: Env, names: readonly string[]): string | null { return null; } -function apiConfigPresent(env: Env): boolean { - return Boolean(firstConfigured(env, ["HASNA_HOOKS_API_URL", "HOOKS_API_URL"])) || - Boolean(firstConfigured(env, ["HASNA_HOOKS_API_KEY", "HOOKS_API_KEY"])); +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 { @@ -85,10 +97,26 @@ export function resolveHooksCliStorageMode(env: Env = process.env as Env): Hooks } 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: API_MODES.has(mode) || (POSTGRES_COMPAT_MODES.has(mode) && apiConfigPresent(env)), + selected, source: canonical ? "HASNA_HOOKS_STORAGE_MODE" : fallback ? "HOOKS_STORAGE_MODE" : "default", + warnings, }; } @@ -101,10 +129,11 @@ export function getHooksApiAuthorityConfigStatus(env: Env = process.env as Env): selected: true, ok: false, mode: clean(env.HASNA_HOOKS_STORAGE_MODE) ?? clean(env.HOOKS_STORAGE_MODE) ?? "invalid", - api_url_configured: Boolean(firstConfigured(env, ["HASNA_HOOKS_API_URL", "HOOKS_API_URL"])), - api_key_configured: Boolean(firstConfigured(env, ["HASNA_HOOKS_API_KEY", "HOOKS_API_KEY"])), + 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, }; } @@ -118,19 +147,20 @@ export function getHooksApiAuthorityConfigStatus(env: Env = process.env as Env): api_key_configured: false, v1_base_url: null, issues: [], + warnings: resolution.warnings, local_fallback: false, }; } const issues: string[] = []; - const rawApiUrl = firstConfigured(env, ["HASNA_HOOKS_API_URL", "HOOKS_API_URL"]); + 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, ["HASNA_HOOKS_API_KEY", "HOOKS_API_KEY"])); + 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"); } @@ -146,6 +176,7 @@ export function getHooksApiAuthorityConfigStatus(env: Env = process.env as Env): api_key_configured: apiKeyConfigured, v1_base_url: apiUrl ? `${apiUrl}/v1` : null, issues, + warnings: resolution.warnings, local_fallback: false, }; } @@ -154,7 +185,7 @@ export function getHooksApiClient(env: Env = process.env as Env): HooksApiClient const status = getHooksApiAuthorityConfigStatus(env); if (!status.selected) return null; if (!status.ok) throw new Error(status.issues[0]); - const apiKey = firstConfigured(env, ["HASNA_HOOKS_API_KEY", "HOOKS_API_KEY"])!; + const apiKey = firstConfigured(env, API_KEY_ENV)!; return new HttpHooksApiClient(status.v1_base_url!, apiKey); } @@ -193,6 +224,11 @@ class HttpHooksApiClient implements HooksApiClient { private readonly apiKey: string, ) {} + async appendHookEvent(event: HookEventRow): Promise { + const data = await this.request<{ event: HookEventRow }>("POST", "/log/events", event); + return data.event; + } + async listHookEvents(options: { hook?: string; session?: string; limit?: number } = {}): Promise { const data = await this.request<{ events: HookEventRow[] }>("GET", `/log/events${queryString(options)}`); return data.events; @@ -223,12 +259,16 @@ class HttpHooksApiClient implements HooksApiClient { } 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" }); } diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 39b81ed..719ca99 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,20 @@ 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) { @@ -1070,8 +1085,7 @@ logCmd .action(async (options: { hook?: string; session?: string; limit: string; json: boolean }) => { let rows: any[]; try { - const { getHooksApiClient } = await import("./cloud-router.js"); - const client = getHooksApiClient(); + const client = await loadHooksApiClient(); if (client) { rows = await client.listHookEvents({ hook: options.hook, @@ -1112,8 +1126,7 @@ logCmd .action(async (text: string, options: { limit: string; json: boolean }) => { let rows: any[]; try { - const { getHooksApiClient } = await import("./cloud-router.js"); - const client = getHooksApiClient(); + const client = await loadHooksApiClient(); if (client) { rows = await client.searchHookEvents({ text, limit: parseInt(options.limit) || 50 }); } else { @@ -1145,8 +1158,7 @@ logCmd .action(async (options: { n: string; json: boolean }) => { let rows: any[]; try { - const { getHooksApiClient } = await import("./cloud-router.js"); - const client = getHooksApiClient(); + const client = await loadHooksApiClient(); if (client) { rows = await client.tailHookEvents({ limit: parseInt(options.n) || 20 }); } else { @@ -1180,8 +1192,7 @@ logCmd .action(async (options: { since: string; limit: string; json: boolean }) => { let rows: any[]; try { - const { getHooksApiClient } = await import("./cloud-router.js"); - const client = getHooksApiClient(); + const client = await loadHooksApiClient(); if (client) { rows = await client.listHookErrors({ since: options.since, limit: parseInt(options.limit) || 50 }); } else { @@ -1213,8 +1224,7 @@ logCmd .action(async (options: { hook?: string; yes: boolean; json: boolean }) => { let count: number; try { - const { getHooksApiClient } = await import("./cloud-router.js"); - const client = getHooksApiClient(); + const client = await loadHooksApiClient(); if (client) { if (!options.yes) { if (options.json) console.log(JSON.stringify({ cleared: 0, confirmed: false, hook: options.hook ?? null })); @@ -1296,6 +1306,7 @@ storageCmd 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; @@ -1323,8 +1334,7 @@ storageCmd try { const { parseStorageTables, storagePush } = await import("../storage.js"); const tables = parseStorageTables(options.tables); - const { getHooksApiClient } = await import("./cloud-router.js"); - const client = getHooksApiClient(); + const client = await loadHooksApiClient(); const results = client ? await client.storagePush({ tables }) : await storagePush({ tables }); @@ -1351,8 +1361,7 @@ storageCmd try { const { parseStorageTables, storagePull } = await import("../storage.js"); const tables = parseStorageTables(options.tables); - const { getHooksApiClient } = await import("./cloud-router.js"); - const client = getHooksApiClient(); + const client = await loadHooksApiClient(); const results = client ? await client.storagePull({ tables }) : await storagePull({ tables }); @@ -1379,8 +1388,7 @@ storageCmd try { const { parseStorageTables, storageSync } = await import("../storage.js"); const tables = parseStorageTables(options.tables); - const { getHooksApiClient } = await import("./cloud-router.js"); - const client = getHooksApiClient(); + const client = await loadHooksApiClient(); const result = client ? await client.storageSync({ tables }) : await storageSync({ tables }); @@ -1405,9 +1413,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(); @@ -1419,7 +1428,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 index ca5630b..86786e0 100644 --- a/src/db/log-store.ts +++ b/src/db/log-store.ts @@ -2,6 +2,12 @@ import type { Database } from "bun:sqlite"; import { getDb } 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; @@ -26,6 +32,54 @@ export interface HookLogSummary { 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; diff --git a/src/lib/db-writer.test.ts b/src/lib/db-writer.test.ts new file mode 100644 index 0000000..48c86bd --- /dev/null +++ b/src/lib/db-writer.test.ts @@ -0,0 +1,172 @@ +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", +] 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 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..6e553c1 100644 --- a/src/lib/db-writer.ts +++ b/src/lib/db-writer.ts @@ -1,45 +1,61 @@ /** * 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 or misconfigured 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). + * * 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 aa7ccb4..46024b9 100644 --- a/src/mcp/http.test.ts +++ b/src/mcp/http.test.ts @@ -2,7 +2,7 @@ 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", () => { @@ -60,7 +60,7 @@ describe("hooks MCP HTTP transport", () => { return handleMcpRequest(req, createHooksServer); } if (url.pathname.startsWith("/v1/")) { - return handleHooksApiRequest(req, { name: "hooks", env: { HASNA_HOOKS_API_KEY: "fixture-key" } }); + return handleHooksApiRequest(req, { name: "hooks", env: { HASNA_HOOKS_API_SERVER_KEY: "fixture-server-key" } }); } return new Response("Not Found", { status: 404 }); }, @@ -87,7 +87,7 @@ describe("hooks MCP HTTP transport", () => { 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_KEY: "fixture-key" } }, + { 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" }); @@ -96,12 +96,62 @@ describe("hooks MCP HTTP transport", () => { 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_KEY: "fixture-key" } }, + { 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( diff --git a/src/mcp/http.ts b/src/mcp/http.ts index 0776e14..8b9865d 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -35,32 +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.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 }); + 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/server/api.test.ts b/src/server/api.test.ts new file mode 100644 index 0000000..4e69619 --- /dev/null +++ b/src/server/api.test.ts @@ -0,0 +1,136 @@ +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("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); + }); +}); diff --git a/src/server/api.ts b/src/server/api.ts index 48e88fd..43a86c6 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -3,10 +3,12 @@ import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { clearHookEvents, + insertHookEvent, listHookErrors, listHookEvents, normalizeLogLimit, searchHookEvents, + type HookEventInput, } from "../db/log-store.js"; import { getStorageStatus, @@ -18,6 +20,14 @@ import { 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 { @@ -51,6 +61,10 @@ export async function handleHooksApiRequest(req: Request, options: { name?: stri }); 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 }); @@ -89,9 +103,9 @@ export async function handleHooksApiRequest(req: Request, options: { name?: stri } function requireApiAuth(req: Request, env: Env): Response | null { - const expected = (env.HASNA_HOOKS_API_KEY ?? env.HOOKS_API_KEY)?.trim(); + const expected = (env[HOOKS_API_SERVER_KEY_ENV] ?? env[HOOKS_API_SERVER_KEY_FALLBACK_ENV])?.trim(); if (!expected) { - return json({ error: "HASNA_HOOKS_API_KEY is required for Hooks /v1 data routes" }, 503); + 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}`) { From 9f6eac0d284e174c6b661e74d3c6b519fe4b390f Mon Sep 17 00:00:00 2001 From: hasna Date: Tue, 28 Jul 2026 16:55:27 +0300 Subject: [PATCH 3/5] fix: keep bookkeeping tables off /v1 sync and bound every API request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewer findings on the new HTTP storage/ingestion transport. schema_migrations transport (P1): `hooks storage push` sent the client's migration ledger to the authority, so a machine on a newer release could mark a migration as applied on an authority that never ran its DDL — permanently suppressing it, including the CHECK-constraint rebuilds that admit SessionStart/SessionEnd/UserPromptSubmit. The /v1 transport now carries a DATA_SYNC_TABLES allowlist (hook_events, feedback): storageExportRows defaults to it and refuses an explicitly named bookkeeping table, and storageImportRows returns an error in the SyncResult instead of upserting schema_migrations or _meta. The PostgreSQL sync path is unchanged. Unbounded fetch (P1): no request carried a deadline, so an authority that accepted the connection and never answered blocked writeHookEvent forever — every agent tool call stalled until the agent's own hook timeout killed the process and the event was dropped, contradicting the documented spool-rather-than-drop guarantee. Every /v1 request now carries an AbortSignal.timeout: 3s on the hook write path (HASNA_HOOKS_API_WRITE_TIMEOUT_MS) and 30s for interactive CLI commands (HASNA_HOOKS_API_TIMEOUT_MS). The abort surfaces as the existing REMOTE_API_UNREACHABLE classification, so db-writer's catch spools as before; a malformed override falls back to the default rather than disabling the deadline. Regression tests, each verified to fail with its fix reverted: the authority refuses a schema_migrations payload and its ledger is unchanged, the default push payload carries data tables only, and writeHookEvent resolves within the deadline against a never-responding Bun.serve with the row landing in local SQLite. --- README.md | 21 +++++++-- src/cli/cloud-router.test.ts | 91 ++++++++++++++++++++++++++++++++++++ src/cli/cloud-router.ts | 75 +++++++++++++++++++++++++---- src/db/storage-sync.ts | 44 ++++++++++++++++- src/index.ts | 5 +- src/lib/db-writer.test.ts | 38 +++++++++++++++ src/lib/db-writer.ts | 12 +++-- src/server/api.test.ts | 82 ++++++++++++++++++++++++++++++++ src/storage.ts | 5 +- 9 files changed, 353 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 02f85ed..4fba997 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,23 @@ 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 or -incompletely configured, 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. +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. + +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 diff --git a/src/cli/cloud-router.test.ts b/src/cli/cloud-router.test.ts index ba522c4..6a5adaa 100644 --- a/src/cli/cloud-router.test.ts +++ b/src/cli/cloud-router.test.ts @@ -4,8 +4,11 @@ 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"; @@ -343,4 +346,92 @@ describe("hooks api router", () => { 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 index 3df0c45..ceaee91 100644 --- a/src/cli/cloud-router.ts +++ b/src/cli/cloud-router.ts @@ -10,6 +10,23 @@ 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; @@ -186,7 +203,27 @@ export function getHooksApiClient(env: Env = process.env as Env): HooksApiClient 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); + 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 { @@ -222,10 +259,11 @@ 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); + const data = await this.request<{ event: HookEventRow }>("POST", "/log/events", event, this.timeouts.write); return data.event; } @@ -283,12 +321,21 @@ class HttpHooksApiClient implements HooksApiClient { return this.request("GET", `/storage/export${queryString({ tables: options.tables?.join(",") })}`); } - private async request(method: HttpMethod, path: string, body?: unknown): Promise { + 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" }), @@ -296,18 +343,28 @@ class HttpHooksApiClient implements HooksApiClient { body: body === undefined ? undefined : JSON.stringify(body), }); } catch (error) { - throw new Error( - `REMOTE_API_UNREACHABLE: configured Hooks authority ${authorityBase(this.baseUrl)} could not be reached for ${path}; ` + - "local SQLite fallback is disabled", - { cause: 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; - return response.json() as Promise; + 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 }, + ); } } diff --git a/src/db/storage-sync.ts b/src/db/storage-sync.ts index 09393b8..7546f3e 100644 --- a/src/db/storage-sync.ts +++ b/src/db/storage-sync.ts @@ -12,7 +12,16 @@ export const STORAGE_TABLES = [ export const HOOKS_STORAGE_TABLES = STORAGE_TABLES; +/** + * 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"; @@ -148,9 +157,10 @@ 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 resolveTables(options?.tables)) { + for (const table of resolveDataSyncTables(options?.tables)) { tables[table] = tableExists(db, table) ? db.query(`SELECT * FROM ${quoteIdent(table)}`).all() as StorageRow[] : []; @@ -158,6 +168,12 @@ export function storageExportRows(options?: { tables?: string[] }, db: Database 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" } = {}, @@ -171,6 +187,11 @@ export function storageImportRows( 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; @@ -221,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(",")); diff --git a/src/index.ts b/src/index.ts index d9caf4f..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,7 +204,9 @@ export { getStoragePg, getStorageStatus, getSyncMetaAll, + isDataSyncTable, parseStorageTables, + resolveDataSyncTables, resolveTables, runStorageMigrations, storageExportRows, @@ -212,4 +215,4 @@ export { storagePush, storageSync, } from "./storage.js"; -export type { StorageEnv, StorageMode, StorageRow, StorageRowsPayload, 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 index 48c86bd..c9a2ad0 100644 --- a/src/lib/db-writer.test.ts +++ b/src/lib/db-writer.test.ts @@ -29,6 +29,10 @@ const ROUTING_ENV = [ "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 { @@ -155,6 +159,40 @@ describe("writeHookEvent", () => { }); }); + 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"); diff --git a/src/lib/db-writer.ts b/src/lib/db-writer.ts index 6e553c1..34c53d3 100644 --- a/src/lib/db-writer.ts +++ b/src/lib/db-writer.ts @@ -4,10 +4,14 @@ * 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 or misconfigured 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 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. */ diff --git a/src/server/api.test.ts b/src/server/api.test.ts index 4e69619..26000a6 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -134,3 +134,85 @@ describe("Hooks /v1 log ingestion", () => { 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/storage.ts b/src/storage.ts index 783736f..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,7 +15,9 @@ export { getStoragePg, getStorageStatus, getSyncMetaAll, + isDataSyncTable, parseStorageTables, + resolveDataSyncTables, resolveTables, runStorageMigrations, storageExportRows, @@ -23,6 +26,6 @@ export { storagePush, storageSync, } from "./db/storage-sync.js"; -export type { StorageEnv, StorageMode, StorageRow, StorageRowsPayload, 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"; From 1457760f56b154d70fe334bc9f39d850f9254e72 Mon Sep 17 00:00:00 2001 From: hasna Date: Wed, 29 Jul 2026 09:16:47 +0300 Subject: [PATCH 4/5] fix: make `hooks log clear` purge the local mirror in API mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under an API authority the local SQLite file is a spool and a pull mirror, not a second source of truth: `hooks storage pull` writes authority rows into it and `hooks storage push` uploads everything it still holds. `log clear` deleted only on the authority, so the documented drain workflow (`hooks storage push`) re-uploaded the events an operator had just purged and `hooks log list` showed them again. `hooks log list` in local mode also kept showing rows the operator believed were gone. Clear the mirror after the remote DELETE succeeds, scoped by `--hook` the same way the remote delete is. The purge runs even when the authority reported nothing cleared: rows spooled while it was unreachable exist only locally and would otherwise be pushed straight after the purge. `clearLocalHookEventMirror` 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. Regression coverage in src/cli/cli.test.ts drives the real CLI against a loopback authority: pull -> clear -> push -> list returns [], an unpushed spool is not pushed after a clear, `--hook` leaves other hooks' rows alone, and a clear with no mirror creates no local database. --- README.md | 5 ++ src/cli/cli.test.ts | 198 ++++++++++++++++++++++++++++++++++++++++++++ src/cli/index.tsx | 10 ++- src/db/log-store.ts | 22 ++++- 4 files changed, 233 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4fba997..ced3b83 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,11 @@ 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. + 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 diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 0b7977e..3ccda40 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -105,6 +105,31 @@ function seedHookEvent(dbPath: string, row: Partial = {}): 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; @@ -782,6 +807,179 @@ describe("CLI", () => { 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: [] }]); + + expect(await runJsonWithEnv(["log", "clear", "--yes"], apiEnv)).toMatchObject({ cleared: 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" }); + + expect(await runJsonWithEnv(["log", "clear", "--yes"], apiEnv)).toMatchObject({ cleared: 0 }); + + await runJsonWithEnv(["storage", "push", "--tables", "hook_events"], apiEnv); + expect(imports.at(-1).tables.hook_events).toEqual([]); + } 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 }); + 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 }); + + 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", () => { diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 719ca99..33281cc 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -1230,12 +1230,20 @@ logCmd 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.`)); + 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; } count = 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"); + clearLocalHookEventMirror({ hook: options.hook }); } else { const { clearHookEvents } = await import("../db/log-store.js"); if (!options.yes) { diff --git a/src/db/log-store.ts b/src/db/log-store.ts index 86786e0..50745ef 100644 --- a/src/db/log-store.ts +++ b/src/db/log-store.ts @@ -1,5 +1,6 @@ import type { Database } from "bun:sqlite"; -import { getDb } from "./index.js"; +import { existsSync } from "fs"; +import { getDb, getDbPath } from "./index.js"; import type { HookEventRow } from "./schema.js"; export interface HookEventInput extends Partial> { @@ -176,6 +177,25 @@ export function clearHookEvents(options: { hook?: string } = {}, db: Database = 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; From e77a40eebb37a99732ce66e6c5ae9598de97c9fc Mon Sep 17 00:00:00 2001 From: hasna Date: Wed, 29 Jul 2026 10:05:33 +0300 Subject: [PATCH 5/5] fix: report both stores in `hooks log clear` and route MCP log tools through the API authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hooks log clear --yes` in API mode discarded the return value of `clearLocalHookEventMirror()`, so purging an unpushed local spool the authority never held printed "Nothing to clear." / `cleared: 0` while deleting every local row — audit history destroyed behind a message saying nothing happened. The command now reports `cleared_remote` and `cleared_local` alongside a headline `cleared` that is the larger of the two, not their sum: `storage pull` mirrors authority rows into the same local table, so adding the counts would report one pulled event twice. The four MCP log tools (`hooks_log_list`, `hooks_log_tail`, `hooks_log_errors`, `hooks_log_summary`) still read local SQLite, which the API write path no longer writes to, so they answered "no events" for work that had just landed on the authority. They now route through the same client as `hooks log …` and fail closed with the `REMOTE_*` message instead of serving an empty local result set. Supporting parity: `since` filtering on `GET /v1/log/events`, and a new `GET /v1/log/summary` route backed by the existing `summarizeHookEvents()`. `send_feedback` deliberately keeps writing local SQLite — `feedback` is a DATA_SYNC_TABLE, so the row spools and drains on the next `storage push`. --- README.md | 12 +- src/cli/cli.test.ts | 23 +++- src/cli/cloud-router.test.ts | 33 +++++ src/cli/cloud-router.ts | 10 +- src/cli/index.tsx | 29 ++++- src/db/log-store.ts | 5 + src/mcp/server.test.ts | 236 +++++++++++++++++++++++++++++++++++ src/mcp/server.ts | 199 +++++++++++++---------------- src/server/api.test.ts | 77 ++++++++++++ src/server/api.ts | 5 + 10 files changed, 505 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index ced3b83..59c5037 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,17 @@ 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. +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 diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 3ccda40..b84a1cb 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -864,7 +864,10 @@ describe("CLI", () => { const pulled = await runJsonWithEnv(["storage", "pull", "--tables", "hook_events"], apiEnv); expect(pulled).toEqual([{ table: "hook_events", rowsRead: 2, rowsWritten: 2, errors: [] }]); - expect(await runJsonWithEnv(["log", "clear", "--yes"], apiEnv)).toMatchObject({ cleared: 2 }); + // 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([]); @@ -908,10 +911,21 @@ describe("CLI", () => { // the DELETE clears nothing remotely and only the mirror holds the rows. seedHookEvent(dbPath, { id: "evt_spooled", hook_name: "gitguard" }); - expect(await runJsonWithEnv(["log", "clear", "--yes"], apiEnv)).toMatchObject({ cleared: 0 }); + // 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 }); @@ -937,7 +951,7 @@ describe("CLI", () => { HASNA_HOOKS_API_KEY: "fixture-api-key", }); - expect(result).toMatchObject({ cleared: 3 }); + expect(result).toMatchObject({ cleared: 3, cleared_remote: 3, cleared_local: 0 }); expect(existsSync(dirname(dbPath))).toBe(false); } finally { server.stop(true); @@ -971,7 +985,8 @@ describe("CLI", () => { 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 }); + 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"]); diff --git a/src/cli/cloud-router.test.ts b/src/cli/cloud-router.test.ts index 6a5adaa..adec0d5 100644 --- a/src/cli/cloud-router.test.ts +++ b/src/cli/cloud-router.test.ts @@ -232,6 +232,39 @@ describe("hooks api router", () => { }]); }); + 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({ diff --git a/src/cli/cloud-router.ts b/src/cli/cloud-router.ts index ceaee91..c30537f 100644 --- a/src/cli/cloud-router.ts +++ b/src/cli/cloud-router.ts @@ -1,3 +1,4 @@ +import type { HookLogSummary } from "../db/log-store.js"; import type { HookEventRow } from "../db/schema.js"; import type { StorageRowsPayload, SyncResult } from "../storage.js"; @@ -50,10 +51,11 @@ export interface HooksApiAuthorityConfigStatus { export interface HooksApiClient { baseUrl: string; appendHookEvent(event: HookEventRow): Promise; - listHookEvents(options?: { hook?: string; session?: string; limit?: number }): 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; @@ -267,7 +269,7 @@ class HttpHooksApiClient implements HooksApiClient { return data.event; } - async listHookEvents(options: { hook?: string; session?: string; limit?: number } = {}): Promise { + 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; } @@ -287,6 +289,10 @@ class HttpHooksApiClient implements HooksApiClient { 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; diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 33281cc..9699c31 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -1223,6 +1223,10 @@ logCmd .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) { @@ -1235,7 +1239,7 @@ logCmd } return; } - count = await client.clearHookEvents({ hook: options.hook }); + 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 @@ -1243,7 +1247,16 @@ logCmd // 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"); - clearLocalHookEventMirror({ hook: options.hook }); + 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) { @@ -1262,8 +1275,10 @@ logCmd return; } + const perStore = apiCleared ? { cleared_remote: apiCleared.remote, cleared_local: apiCleared.local } : {}; + if (count === 0) { - if (options.json) console.log(JSON.stringify({ cleared: 0, hook: options.hook ?? null })); + if (options.json) console.log(JSON.stringify({ cleared: 0, hook: options.hook ?? null, ...perStore })); else console.log(chalk.dim("Nothing to clear.")); return; } @@ -1279,7 +1294,13 @@ logCmd return; } - if (options.json) { console.log(JSON.stringify({ cleared: count, hook: options.hook ?? null })); return; } + 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).`)); }); diff --git a/src/db/log-store.ts b/src/db/log-store.ts index 50745ef..af7eb6c 100644 --- a/src/db/log-store.ts +++ b/src/db/log-store.ts @@ -12,6 +12,7 @@ export interface HookEventInput extends Partial 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; @@ -875,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 () => { @@ -907,6 +967,182 @@ 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; 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 index 26000a6..c37272c 100644 --- a/src/server/api.test.ts +++ b/src/server/api.test.ts @@ -122,6 +122,83 @@ describe("Hooks /v1 log ingestion", () => { }); }); + 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", { diff --git a/src/server/api.ts b/src/server/api.ts index 43a86c6..ccb9cc3 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -8,6 +8,7 @@ import { listHookEvents, normalizeLogLimit, searchHookEvents, + summarizeHookEvents, type HookEventInput, } from "../db/log-store.js"; import { @@ -57,6 +58,7 @@ export async function handleHooksApiRequest(req: Request, options: { name?: stri 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 }); @@ -83,6 +85,9 @@ export async function handleHooksApiRequest(req: Request, options: { name?: stri }); 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" }); }