@syntheos/chiasm 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,310 @@
1
+ import "./tracing.ts";
2
+ import { createServer, type ServerResponse } from "node:http";
3
+ import { createReadStream, existsSync } from "node:fs";
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { stat } from "node:fs/promises";
6
+ import { extname, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { initDb } from "./db/schema.ts";
9
+ import { pruneTaskUpdates, lookupAgentKey, createAgentKey, listAgentKeys, revokeAgentKey, markStaleTasks } from "./db/queries.ts";
10
+ import { emitEvent } from "./axon.ts";
11
+ import { handleTaskRoutes } from "./routes/tasks.ts";
12
+
13
+ const DB_PATH = process.env.DB_PATH ?? "./chiasm.db";
14
+ const ADMIN_KEY = process.env.CHIASM_API_KEY;
15
+ const AUTH_DISABLED = process.env.CHIASM_AUTH === "disabled";
16
+ const HOST = process.env.HOST ?? "0.0.0.0";
17
+ const CORS_ALLOW_ORIGIN = process.env.CORS_ALLOW_ORIGIN;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Startup self-check: fail closed if auth is not explicitly configured
21
+ // ---------------------------------------------------------------------------
22
+ if (!ADMIN_KEY && !AUTH_DISABLED) {
23
+ console.error("FATAL: CHIASM_API_KEY is not set.");
24
+ console.error(" Set CHIASM_API_KEY to enable auth (recommended), or");
25
+ console.error(" set CHIASM_AUTH=disabled to explicitly run without auth.");
26
+ process.exit(1);
27
+ }
28
+
29
+ function envInt(value: string | undefined, fallback: number): number {
30
+ const parsed = Number.parseInt(value ?? "", 10);
31
+ return Number.isFinite(parsed) ? parsed : fallback;
32
+ }
33
+
34
+ const PORT = envInt(process.env.PORT, 4300);
35
+ const BODY_MAX_BYTES = envInt(process.env.BODY_MAX_BYTES, 64 * 1024);
36
+ const TASKS_DEFAULT_LIMIT = envInt(process.env.TASKS_DEFAULT_LIMIT, 500);
37
+ const TASKS_MAX_LIMIT = envInt(process.env.TASKS_MAX_LIMIT, 1000);
38
+ const FEED_DEFAULT_LIMIT = envInt(process.env.FEED_DEFAULT_LIMIT, 50);
39
+ const FEED_MAX_LIMIT = envInt(process.env.FEED_MAX_LIMIT, 200);
40
+ const TASK_UPDATE_MAX_ROWS = envInt(process.env.TASK_UPDATE_MAX_ROWS, 5000);
41
+ const TASK_UPDATE_MAX_AGE_DAYS = envInt(process.env.TASK_UPDATE_MAX_AGE_DAYS, 30);
42
+
43
+ const FRONTEND_BUILD_DIR = resolve(fileURLToPath(new URL("../frontend/build/", import.meta.url)));
44
+ const FRONTEND_INDEX_FILE = resolve(FRONTEND_BUILD_DIR, "index.html");
45
+ const HAS_FRONTEND_BUILD = existsSync(FRONTEND_INDEX_FILE);
46
+
47
+ const MIME_TYPES: Record<string, string> = {
48
+ ".css": "text/css; charset=utf-8",
49
+ ".html": "text/html; charset=utf-8",
50
+ ".ico": "image/x-icon",
51
+ ".jpg": "image/jpeg",
52
+ ".js": "application/javascript; charset=utf-8",
53
+ ".json": "application/json; charset=utf-8",
54
+ ".png": "image/png",
55
+ ".svg": "image/svg+xml; charset=utf-8",
56
+ ".txt": "text/plain; charset=utf-8",
57
+ ".webp": "image/webp",
58
+ };
59
+
60
+ const db = initDb(DB_PATH);
61
+ pruneTaskUpdates(db, TASK_UPDATE_MAX_ROWS, TASK_UPDATE_MAX_AGE_DAYS);
62
+ setInterval(() => {
63
+ pruneTaskUpdates(db, TASK_UPDATE_MAX_ROWS, TASK_UPDATE_MAX_AGE_DAYS);
64
+ }, 5 * 60 * 1000).unref();
65
+
66
+ // Heartbeat stale-check: mark tasks as stale if heartbeat is overdue
67
+ const HEARTBEAT_CHECK_MS = envInt(process.env.HEARTBEAT_CHECK_MS, 60_000);
68
+ setInterval(() => {
69
+ const staleTasks = markStaleTasks(db, 2);
70
+ for (const task of staleTasks) {
71
+ emitEvent("tasks", "task.stale", {
72
+ agent: task.agent, project: task.project,
73
+ title: task.title, task_id: task.id,
74
+ });
75
+ }
76
+ }, HEARTBEAT_CHECK_MS).unref();
77
+
78
+ // ============================================================================
79
+ // AUTH - per-agent keys with admin escalation
80
+ // ============================================================================
81
+
82
+ import type { AuthIdentity } from "./types.ts";
83
+ export type { AuthIdentity } from "./types.ts";
84
+
85
+ function hashKey(key: string): string {
86
+ return createHash("sha256").update(key).digest("hex");
87
+ }
88
+
89
+ function resolveAuth(authHeader: string | undefined): AuthIdentity | null {
90
+ // Explicit opt-in to no-auth mode
91
+ if (AUTH_DISABLED) return { role: "admin", agent: null };
92
+
93
+ if (!authHeader?.startsWith("Bearer ")) return null;
94
+ const token = authHeader.slice(7);
95
+
96
+ // Check admin key first
97
+ if (token === ADMIN_KEY) return { role: "admin", agent: null };
98
+
99
+ // Check per-agent keys
100
+ const keyRecord = lookupAgentKey(db, hashKey(token));
101
+ if (keyRecord) return { role: "agent", agent: keyRecord.agent };
102
+
103
+ return null;
104
+ }
105
+
106
+ // ============================================================================
107
+ // ROUTING HELPERS
108
+ // ============================================================================
109
+
110
+ function isApiRequest(pathname: string): boolean {
111
+ return pathname === "/health" || pathname === "/tasks" || pathname === "/feed"
112
+ || /^\/tasks(\/\d+)?(\/\w+)?(\/\d+)?$/.test(pathname)
113
+ || pathname.startsWith("/admin/")
114
+ || pathname.startsWith("/claims")
115
+ || pathname.startsWith("/queue");
116
+ }
117
+
118
+ function applyCors(reqOrigin: string | undefined, res: ServerResponse) {
119
+ if (!CORS_ALLOW_ORIGIN) return;
120
+ if (CORS_ALLOW_ORIGIN === "*" || reqOrigin === CORS_ALLOW_ORIGIN) {
121
+ res.setHeader("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN === "*" ? "*" : reqOrigin ?? CORS_ALLOW_ORIGIN);
122
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
123
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
124
+ res.setHeader("Vary", "Origin");
125
+ }
126
+ }
127
+
128
+ function jsonResponse(res: ServerResponse, data: unknown, status = 200) {
129
+ res.writeHead(status, { "Content-Type": "application/json" });
130
+ res.end(JSON.stringify(data));
131
+ }
132
+
133
+ async function readJsonBody(req: import("node:http").IncomingMessage, maxBytes: number): Promise<Record<string, unknown>> {
134
+ return new Promise((resolve, reject) => {
135
+ const chunks: Buffer[] = [];
136
+ let total = 0;
137
+ let settled = false;
138
+ const onData = (chunk: Buffer) => {
139
+ if (settled) return;
140
+ total += chunk.length;
141
+ if (total > maxBytes) { settled = true; req.off("data", onData); req.off("end", onEnd); req.resume(); reject(new Error("Request body too large")); return; }
142
+ chunks.push(chunk);
143
+ };
144
+ const onEnd = () => {
145
+ if (settled) return;
146
+ settled = true;
147
+ if (chunks.length === 0) { resolve({}); return; }
148
+ try {
149
+ const parsed = JSON.parse(Buffer.concat(chunks).toString());
150
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { reject(new Error("Request body must be a JSON object")); return; }
151
+ resolve(parsed as Record<string, unknown>);
152
+ } catch { reject(new Error("Invalid JSON")); }
153
+ };
154
+ const onError = (err: Error) => { if (!settled) { settled = true; reject(err); } };
155
+ req.on("data", onData);
156
+ req.on("end", onEnd);
157
+ req.on("error", onError);
158
+ });
159
+ }
160
+
161
+ // ============================================================================
162
+ // ADMIN ROUTES - key management (admin-only)
163
+ // ============================================================================
164
+
165
+ async function handleAdminRoutes(
166
+ req: import("node:http").IncomingMessage,
167
+ res: ServerResponse,
168
+ pathname: string,
169
+ identity: AuthIdentity,
170
+ ) {
171
+ if (identity.role !== "admin") {
172
+ return jsonResponse(res, { error: "Admin access required" }, 403);
173
+ }
174
+
175
+ // POST /admin/keys - create agent key
176
+ if (pathname === "/admin/keys" && req.method === "POST") {
177
+ try {
178
+ const body = await readJsonBody(req, BODY_MAX_BYTES);
179
+ const agent = body.agent;
180
+ if (!agent || typeof agent !== "string") {
181
+ return jsonResponse(res, { error: "agent (string) required" }, 400);
182
+ }
183
+ const rawKey = "mc_" + randomBytes(32).toString("hex");
184
+ const record = createAgentKey(db, agent, hashKey(rawKey), rawKey.slice(0, 11));
185
+ return jsonResponse(res, {
186
+ id: record.id,
187
+ agent: record.agent,
188
+ key: rawKey,
189
+ prefix: record.key_prefix,
190
+ created_at: record.created_at,
191
+ warning: "Store this key now. It cannot be retrieved again.",
192
+ }, 201);
193
+ } catch (err) {
194
+ return jsonResponse(res, { error: err instanceof Error ? err.message : "Bad request" }, 400);
195
+ }
196
+ }
197
+
198
+ // GET /admin/keys - list keys (no secrets)
199
+ if (pathname === "/admin/keys" && req.method === "GET") {
200
+ return jsonResponse(res, listAgentKeys(db));
201
+ }
202
+
203
+ // DELETE /admin/keys/:id - revoke key
204
+ const keyMatch = pathname.match(/^\/admin\/keys\/(\d+)$/);
205
+ if (keyMatch && req.method === "DELETE") {
206
+ const revoked = revokeAgentKey(db, parseInt(keyMatch[1], 10));
207
+ if (!revoked) return jsonResponse(res, { error: "Key not found" }, 404);
208
+ return jsonResponse(res, { ok: true, revoked: true });
209
+ }
210
+
211
+ return jsonResponse(res, { error: "Not found" }, 404);
212
+ }
213
+
214
+ // ============================================================================
215
+ // STATIC FILE SERVING
216
+ // ============================================================================
217
+
218
+ function sendFile(res: ServerResponse, filePath: string, method: string) {
219
+ const contentType = MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
220
+ res.writeHead(200, { "Content-Type": contentType });
221
+ if (method === "HEAD") { res.end(); return; }
222
+ const stream = createReadStream(filePath);
223
+ stream.on("error", () => { if (!res.writableEnded) res.end(); });
224
+ stream.pipe(res);
225
+ }
226
+
227
+ async function serveFrontend(pathname: string, method: string, res: ServerResponse) {
228
+ if (!HAS_FRONTEND_BUILD || (method !== "GET" && method !== "HEAD")) return false;
229
+ const decodedPathname = decodeURIComponent(pathname);
230
+ const requestedPath = decodedPathname === "/" ? "/index.html" : decodedPathname;
231
+ const resolvedPath = resolve(FRONTEND_BUILD_DIR, `.${requestedPath}`);
232
+ const buildDirWithSep = FRONTEND_BUILD_DIR.endsWith(sep) ? FRONTEND_BUILD_DIR : FRONTEND_BUILD_DIR + sep;
233
+ if (resolvedPath !== FRONTEND_BUILD_DIR && !resolvedPath.startsWith(buildDirWithSep)) return false;
234
+ try {
235
+ const fileStat = await stat(resolvedPath);
236
+ if (fileStat.isFile()) { sendFile(res, resolvedPath, method); return true; }
237
+ } catch { /* fall through */ }
238
+ if (extname(pathname)) return false;
239
+ sendFile(res, FRONTEND_INDEX_FILE, method);
240
+ return true;
241
+ }
242
+
243
+ // ============================================================================
244
+ // HTTP SERVER
245
+ // ============================================================================
246
+
247
+ const server = createServer(async (req, res) => {
248
+ applyCors(req.headers.origin, res);
249
+
250
+ if (req.method === "OPTIONS") {
251
+ res.writeHead(204);
252
+ return res.end();
253
+ }
254
+
255
+ try {
256
+ const url = new URL(req.url!, `http://${req.headers.host}`);
257
+ const pathname = url.pathname;
258
+
259
+ // Health check - always open
260
+ if (pathname === "/health" && req.method === "GET") {
261
+ res.writeHead(200, { "Content-Type": "application/json" });
262
+ return res.end(JSON.stringify({ status: "ok", version: "0.2.0" }));
263
+ }
264
+
265
+ // Auth gate for all API requests
266
+ if (isApiRequest(pathname)) {
267
+ const identity = resolveAuth(req.headers.authorization);
268
+ if (!identity) {
269
+ res.writeHead(401, { "Content-Type": "application/json" });
270
+ return res.end(JSON.stringify({ error: "Unauthorized" }));
271
+ }
272
+
273
+ // Admin routes
274
+ if (pathname.startsWith("/admin/")) {
275
+ return handleAdminRoutes(req, res, pathname, identity);
276
+ }
277
+
278
+ // Task routes - pass identity for agent enforcement
279
+ handleTaskRoutes(db, req, res, pathname, {
280
+ bodyMaxBytes: BODY_MAX_BYTES,
281
+ tasksDefaultLimit: TASKS_DEFAULT_LIMIT,
282
+ tasksMaxLimit: TASKS_MAX_LIMIT,
283
+ feedDefaultLimit: FEED_DEFAULT_LIMIT,
284
+ feedMaxLimit: FEED_MAX_LIMIT,
285
+ taskUpdateMaxRows: TASK_UPDATE_MAX_ROWS,
286
+ taskUpdateMaxAgeDays: TASK_UPDATE_MAX_AGE_DAYS,
287
+ }, identity);
288
+ return;
289
+ }
290
+
291
+ if (await serveFrontend(pathname, req.method ?? "GET", res)) return;
292
+
293
+ res.writeHead(404, { "Content-Type": "application/json" });
294
+ res.end(JSON.stringify({ error: "Not found" }));
295
+ } catch (err) {
296
+ console.error("Unhandled request error:", err);
297
+ if (!res.headersSent) {
298
+ res.writeHead(500, { "Content-Type": "application/json" });
299
+ }
300
+ res.end(JSON.stringify({ error: "Internal server error" }));
301
+ }
302
+ });
303
+
304
+ server.listen(PORT, HOST, () => {
305
+ console.log(`Chiasm running on http://${HOST}:${PORT}`);
306
+ console.log(`Database: ${DB_PATH}`);
307
+ console.log(`Auth: ${AUTH_DISABLED ? "DISABLED (CHIASM_AUTH=disabled)" : "enabled (admin + per-agent keys)"}`);
308
+ console.log(`CORS: ${CORS_ALLOW_ORIGIN ?? "disabled (same-origin only)"}`);
309
+ console.log(`Frontend: ${HAS_FRONTEND_BUILD ? `serving ${FRONTEND_BUILD_DIR}` : "build not found"}`);
310
+ });
package/src/tracing.ts ADDED
@@ -0,0 +1,79 @@
1
+ // ============================================================================
2
+ // TRACING -- OpenTelemetry distributed tracing for Chiasm
3
+ // Must be imported BEFORE all other modules in server.ts
4
+ // ============================================================================
5
+
6
+ import { NodeSDK } from "@opentelemetry/sdk-node";
7
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
8
+ import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
9
+ import { resourceFromAttributes } from "@opentelemetry/resources";
10
+ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, SEMRESATTRS_DEPLOYMENT_ENVIRONMENT } from "@opentelemetry/semantic-conventions";
11
+ import { trace, SpanStatusCode, type Span } from "@opentelemetry/api";
12
+ import type { IncomingMessage } from "node:http";
13
+
14
+ const OTLP_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.CHIASM_OTLP_ENDPOINT || "";
15
+ const SERVICE_ENV = process.env.OTEL_DEPLOYMENT_ENVIRONMENT || process.env.CHIASM_ENV || "production";
16
+
17
+ const IGNORED_PATHS = new Set(["/health"]);
18
+
19
+ let sdk: NodeSDK | null = null;
20
+
21
+ if (OTLP_ENDPOINT) {
22
+ sdk = new NodeSDK({
23
+ resource: resourceFromAttributes({
24
+ [ATTR_SERVICE_NAME]: "chiasm",
25
+ [ATTR_SERVICE_VERSION]: "0.2.0",
26
+ [SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: SERVICE_ENV,
27
+ }),
28
+ traceExporter: new OTLPTraceExporter({
29
+ url: `${OTLP_ENDPOINT}/v1/traces`,
30
+ }),
31
+ instrumentations: [
32
+ new HttpInstrumentation({
33
+ ignoreIncomingRequestHook: (req: IncomingMessage) => IGNORED_PATHS.has(req.url?.split("?")[0] || ""),
34
+ }),
35
+ ],
36
+ });
37
+ sdk.start();
38
+ console.log(JSON.stringify({ level: "info", ts: new Date().toISOString(), msg: "otel_tracing_enabled", endpoint: OTLP_ENDPOINT, service: "chiasm" }));
39
+
40
+ process.on("SIGTERM", () => { sdk?.shutdown(); });
41
+ process.on("SIGINT", () => { sdk?.shutdown(); });
42
+ }
43
+
44
+ const tracer = trace.getTracer("chiasm");
45
+
46
+ export function startSpan(name: string, attributes?: Record<string, string | number | boolean>): Span {
47
+ return tracer.startSpan(name, { attributes });
48
+ }
49
+
50
+ export function withSpan<T>(name: string, attributes: Record<string, string | number | boolean>, fn: (span: Span) => T): T {
51
+ return tracer.startActiveSpan(name, { attributes }, (span) => {
52
+ try {
53
+ const result = fn(span);
54
+ if (result instanceof Promise) {
55
+ return (result as any).then((v: T) => {
56
+ span.setStatus({ code: SpanStatusCode.OK });
57
+ span.end();
58
+ return v;
59
+ }).catch((e: Error) => {
60
+ span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
61
+ span.recordException(e);
62
+ span.end();
63
+ throw e;
64
+ });
65
+ }
66
+ span.setStatus({ code: SpanStatusCode.OK });
67
+ span.end();
68
+ return result;
69
+ } catch (e: any) {
70
+ span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
71
+ span.recordException(e);
72
+ span.end();
73
+ throw e;
74
+ }
75
+ });
76
+ }
77
+
78
+ export { tracer, SpanStatusCode };
79
+ export type { Span };
package/src/types.ts ADDED
@@ -0,0 +1,4 @@
1
+ export interface AuthIdentity {
2
+ role: "admin" | "agent";
3
+ agent: string | null;
4
+ }
@@ -0,0 +1,281 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Test configuration
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const TEST_PORT = 14300;
10
+ const TEST_ADMIN_KEY = "test-admin-key-" + randomBytes(16).toString("hex");
11
+ const TEST_DB_PATH = ":memory:";
12
+
13
+ // Agent keys we will provision via the admin API
14
+ const AGENTS = ["claude-code", "opencode", "gpt"] as const;
15
+ const agentKeys: Record<string, string> = {};
16
+
17
+ let serverProcess: ReturnType<typeof import("node:child_process").spawn> | null = null;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ async function api(
24
+ method: string,
25
+ path: string,
26
+ opts: { body?: unknown; key?: string } = {},
27
+ ): Promise<{ status: number; data: unknown }> {
28
+ const headers: Record<string, string> = {};
29
+ if (opts.key) headers["Authorization"] = `Bearer ${opts.key}`;
30
+ if (opts.body) headers["Content-Type"] = "application/json";
31
+
32
+ const res = await fetch(`http://127.0.0.1:${TEST_PORT}${path}`, {
33
+ method,
34
+ headers,
35
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
36
+ });
37
+
38
+ const text = await res.text();
39
+ let data: unknown;
40
+ try {
41
+ data = JSON.parse(text);
42
+ } catch {
43
+ data = text;
44
+ }
45
+
46
+ return { status: res.status, data };
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Server lifecycle
51
+ // ---------------------------------------------------------------------------
52
+
53
+ async function waitForServer(timeoutMs = 10_000): Promise<void> {
54
+ const start = Date.now();
55
+ while (Date.now() - start < timeoutMs) {
56
+ try {
57
+ const res = await fetch(`http://127.0.0.1:${TEST_PORT}/health`);
58
+ if (res.ok) return;
59
+ } catch { /* not ready yet */ }
60
+ await new Promise((r) => setTimeout(r, 200));
61
+ }
62
+ throw new Error("Server did not start in time");
63
+ }
64
+
65
+ before(async () => {
66
+ const { spawn } = await import("node:child_process");
67
+ serverProcess = spawn(
68
+ process.execPath,
69
+ ["--experimental-strip-types", "src/server.ts"],
70
+ {
71
+ env: {
72
+ ...process.env,
73
+ PORT: String(TEST_PORT),
74
+ HOST: "127.0.0.1",
75
+ DB_PATH: TEST_DB_PATH,
76
+ CHIASM_API_KEY: TEST_ADMIN_KEY,
77
+ },
78
+ stdio: ["ignore", "pipe", "pipe"],
79
+ },
80
+ );
81
+
82
+ // Collect stderr for debugging if needed
83
+ let stderr = "";
84
+ serverProcess.stderr?.on("data", (chunk: Buffer) => {
85
+ stderr += chunk.toString();
86
+ });
87
+
88
+ serverProcess.on("exit", (code) => {
89
+ if (code && code !== 0 && code !== null) {
90
+ console.error("Server exited with code", code);
91
+ if (stderr) console.error(stderr);
92
+ }
93
+ });
94
+
95
+ await waitForServer();
96
+
97
+ // Provision agent keys
98
+ for (const agent of AGENTS) {
99
+ const { status, data } = await api("POST", "/admin/keys", {
100
+ key: TEST_ADMIN_KEY,
101
+ body: { agent },
102
+ });
103
+ assert.equal(status, 201, `Failed to create key for ${agent}`);
104
+ agentKeys[agent] = (data as { key: string }).key;
105
+ }
106
+ });
107
+
108
+ after(() => {
109
+ if (serverProcess) {
110
+ serverProcess.kill("SIGTERM");
111
+ serverProcess = null;
112
+ }
113
+ });
114
+
115
+ // ===========================================================================
116
+ // AUTH GATING
117
+ // ===========================================================================
118
+
119
+ describe("auth gating", () => {
120
+ it("rejects unauthenticated GET /tasks", async () => {
121
+ const { status, data } = await api("GET", "/tasks");
122
+ assert.equal(status, 401);
123
+ assert.deepEqual(data, { error: "Unauthorized" });
124
+ });
125
+
126
+ it("rejects unauthenticated GET /feed", async () => {
127
+ const { status } = await api("GET", "/feed");
128
+ assert.equal(status, 401);
129
+ });
130
+
131
+ it("rejects unauthenticated POST /tasks", async () => {
132
+ const { status } = await api("POST", "/tasks", {
133
+ body: { agent: "test", project: "test", title: "test" },
134
+ });
135
+ assert.equal(status, 401);
136
+ });
137
+
138
+ it("rejects unauthenticated GET /admin/keys", async () => {
139
+ const { status } = await api("GET", "/admin/keys");
140
+ assert.equal(status, 401);
141
+ });
142
+
143
+ it("rejects invalid bearer token", async () => {
144
+ const { status } = await api("GET", "/tasks", { key: "bogus-key" });
145
+ assert.equal(status, 401);
146
+ });
147
+
148
+ it("allows health check without auth", async () => {
149
+ const { status, data } = await api("GET", "/health");
150
+ assert.equal(status, 200);
151
+ assert.equal((data as { status: string }).status, "ok");
152
+ });
153
+
154
+ it("allows admin key to access /tasks", async () => {
155
+ const { status, data } = await api("GET", "/tasks", { key: TEST_ADMIN_KEY });
156
+ assert.equal(status, 200);
157
+ assert.ok(Array.isArray(data));
158
+ });
159
+
160
+ it("allows agent key to access /tasks", async () => {
161
+ const { status, data } = await api("GET", "/tasks", {
162
+ key: agentKeys["opencode"],
163
+ });
164
+ assert.equal(status, 200);
165
+ assert.ok(Array.isArray(data));
166
+ });
167
+ });
168
+
169
+ // ===========================================================================
170
+ // AGENT OWNERSHIP
171
+ // ===========================================================================
172
+
173
+ describe("agent ownership enforcement", () => {
174
+ let taskId: number;
175
+
176
+ it("agent key can create task for itself", async () => {
177
+ const { status, data } = await api("POST", "/tasks", {
178
+ key: agentKeys["claude-code"],
179
+ body: { agent: "claude-code", project: "test-proj", title: "Test task" },
180
+ });
181
+ assert.equal(status, 201);
182
+ taskId = (data as { id: number }).id;
183
+ assert.ok(taskId > 0);
184
+ });
185
+
186
+ it("agent key cannot create task for another agent", async () => {
187
+ const { status, data } = await api("POST", "/tasks", {
188
+ key: agentKeys["opencode"],
189
+ body: { agent: "claude-code", project: "test-proj", title: "Impersonation" },
190
+ });
191
+ assert.equal(status, 403);
192
+ assert.ok((data as { error: string }).error.includes("cannot create"));
193
+ });
194
+
195
+ it("agent key can update its own task", async () => {
196
+ const { status, data } = await api("PATCH", `/tasks/${taskId}`, {
197
+ key: agentKeys["claude-code"],
198
+ body: { summary: "Updated by owner" },
199
+ });
200
+ assert.equal(status, 200);
201
+ assert.equal((data as { summary: string }).summary, "Updated by owner");
202
+ });
203
+
204
+ it("agent key cannot update another agent's task", async () => {
205
+ const { status } = await api("PATCH", `/tasks/${taskId}`, {
206
+ key: agentKeys["gpt"],
207
+ body: { summary: "Hijacked" },
208
+ });
209
+ assert.equal(status, 403);
210
+ });
211
+
212
+ it("agent key cannot delete another agent's task", async () => {
213
+ const { status } = await api("DELETE", `/tasks/${taskId}`, {
214
+ key: agentKeys["gpt"],
215
+ });
216
+ assert.equal(status, 403);
217
+ });
218
+
219
+ it("admin key can update any task", async () => {
220
+ const { status, data } = await api("PATCH", `/tasks/${taskId}`, {
221
+ key: TEST_ADMIN_KEY,
222
+ body: { summary: "Admin override" },
223
+ });
224
+ assert.equal(status, 200);
225
+ assert.equal((data as { summary: string }).summary, "Admin override");
226
+ });
227
+
228
+ it("admin key can delete any task", async () => {
229
+ const { status } = await api("DELETE", `/tasks/${taskId}`, {
230
+ key: TEST_ADMIN_KEY,
231
+ });
232
+ assert.equal(status, 200);
233
+ });
234
+
235
+ it("any agent can read all tasks", async () => {
236
+ // Create a task as claude-code
237
+ await api("POST", "/tasks", {
238
+ key: agentKeys["claude-code"],
239
+ body: { agent: "claude-code", project: "p", title: "Readable by all" },
240
+ });
241
+
242
+ // Read as gpt
243
+ const { status, data } = await api("GET", "/tasks", {
244
+ key: agentKeys["gpt"],
245
+ });
246
+ assert.equal(status, 200);
247
+ const tasks = data as Array<{ agent: string }>;
248
+ assert.ok(tasks.some((t) => t.agent === "claude-code"));
249
+ });
250
+ });
251
+
252
+ // ===========================================================================
253
+ // ADMIN ROUTES
254
+ // ===========================================================================
255
+
256
+ describe("admin route protection", () => {
257
+ it("agent key cannot access GET /admin/keys", async () => {
258
+ const { status, data } = await api("GET", "/admin/keys", {
259
+ key: agentKeys["opencode"],
260
+ });
261
+ assert.equal(status, 403);
262
+ assert.deepEqual(data, { error: "Admin access required" });
263
+ });
264
+
265
+ it("agent key cannot create agent keys", async () => {
266
+ const { status } = await api("POST", "/admin/keys", {
267
+ key: agentKeys["opencode"],
268
+ body: { agent: "evil" },
269
+ });
270
+ assert.equal(status, 403);
271
+ });
272
+
273
+ it("admin key can list agent keys", async () => {
274
+ const { status, data } = await api("GET", "/admin/keys", {
275
+ key: TEST_ADMIN_KEY,
276
+ });
277
+ assert.equal(status, 200);
278
+ assert.ok(Array.isArray(data));
279
+ assert.ok((data as Array<unknown>).length >= AGENTS.length);
280
+ });
281
+ });