feat(agent-api): bearer token check and owner-resolution helper

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-25 22:27:46 +02:00
parent 1c57d2290d
commit ff23890809
4 changed files with 106 additions and 0 deletions

View file

@ -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

View file

@ -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();
});
});

28
src/lib/agent/auth.ts Normal file
View file

@ -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;
}

17
src/lib/agent/owner.ts Normal file
View file

@ -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<UserRecord> {
const pb = await getAdminPb();
return pb.collection("users").getFirstListItem<UserRecord>('role = "owner"');
}