From ff23890809983b4a8a06064dc6b94332c99796d6 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 25 Sep 2026 22:27:46 +0200 Subject: [PATCH] feat(agent-api): bearer token check and owner-resolution helper Co-Authored-By: Claude Haiku 4.5 --- .env.example | 7 +++++ src/lib/agent/auth.test.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/lib/agent/auth.ts | 28 ++++++++++++++++++++ src/lib/agent/owner.ts | 17 ++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 src/lib/agent/auth.test.ts create mode 100644 src/lib/agent/auth.ts create mode 100644 src/lib/agent/owner.ts diff --git a/.env.example b/.env.example index b160d0c..1f2ec27 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,10 @@ NEXT_PUBLIC_APP_URL=https://planner.example.com # scripts/seed-demo.ts, and the /api/client/* route handlers. PB_SUPERUSER_EMAIL=you@example.com PB_SUPERUSER_PASSWORD=replace-me + +# A long random secret for the agent API (see docs/superpowers/specs/ +# 2026-09-25-phase-8-agent-api-design.md). Generate with: +# openssl rand -hex 32 +# Anyone holding this can read every project and write comments/subtasks/ +# moves as the owner — treat it like the superuser credentials above. +AGENT_API_TOKEN=replace-me diff --git a/src/lib/agent/auth.test.ts b/src/lib/agent/auth.test.ts new file mode 100644 index 0000000..ab216d8 --- /dev/null +++ b/src/lib/agent/auth.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { requireAgentToken } from "@/lib/agent/auth"; + +describe("requireAgentToken", () => { + const originalToken = process.env.AGENT_API_TOKEN; + + afterEach(() => { + if (originalToken === undefined) delete process.env.AGENT_API_TOKEN; + else process.env.AGENT_API_TOKEN = originalToken; + }); + + it("refuses with 500 when the token is not configured", async () => { + delete process.env.AGENT_API_TOKEN; + const request = new Request("http://localhost/api/agent/projects"); + + const result = requireAgentToken(request); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(500); + }); + + it("refuses a request with no Authorization header", () => { + process.env.AGENT_API_TOKEN = "secret123"; + const request = new Request("http://localhost/api/agent/projects"); + + const result = requireAgentToken(request); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(401); + }); + + it("refuses a request with the wrong token", () => { + process.env.AGENT_API_TOKEN = "secret123"; + const request = new Request("http://localhost/api/agent/projects", { + headers: { authorization: "Bearer wrong" }, + }); + + const result = requireAgentToken(request); + + expect(result).not.toBeNull(); + expect(result!.status).toBe(401); + }); + + it("allows a request with the exact right token", () => { + process.env.AGENT_API_TOKEN = "secret123"; + const request = new Request("http://localhost/api/agent/projects", { + headers: { authorization: "Bearer secret123" }, + }); + + const result = requireAgentToken(request); + + expect(result).toBeNull(); + }); +}); diff --git a/src/lib/agent/auth.ts b/src/lib/agent/auth.ts new file mode 100644 index 0000000..db9cec6 --- /dev/null +++ b/src/lib/agent/auth.ts @@ -0,0 +1,28 @@ +import "server-only"; + +import { jsonError } from "@/lib/http/json"; + +/** + * Checks the Authorization header against AGENT_API_TOKEN. + * + * Same pattern as /api/cron/reconcile's CRON_SECRET check — a second secret + * of the same kind, for a second machine caller. Returns the Response to + * send when the caller must be refused, or null when the request may + * proceed, so every route starts with: + * + * const denied = requireAgentToken(request); + * if (denied) return denied; + */ +export function requireAgentToken(request: Request): Response | null { + const secret = process.env.AGENT_API_TOKEN; + if (!secret) { + console.error("[agent-api] AGENT_API_TOKEN is not set; refusing all requests"); + return jsonError("Not configured", 500); + } + + if (request.headers.get("authorization") !== `Bearer ${secret}`) { + return jsonError("Unauthorized", 401); + } + + return null; +} diff --git a/src/lib/agent/owner.ts b/src/lib/agent/owner.ts new file mode 100644 index 0000000..38bd146 --- /dev/null +++ b/src/lib/agent/owner.ts @@ -0,0 +1,17 @@ +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import type { UserRecord } from "@/lib/pb/types"; + +/** + * The app's one owner account. + * + * Every agent-initiated write (a comment, an activity entry) is attributed to + * this account — the agent API's whole trust model is "this token IS the + * owner, acting through tooling," so there is never an identity in the + * request to attribute a write to instead. + */ +export async function getOwnerUser(): Promise { + const pb = await getAdminPb(); + return pb.collection("users").getFirstListItem('role = "owner"'); +}