Skip to content

Commit 7be1970

Browse files
author
StackMemory Bot (CLI)
committed
feat(provenant): API key auth + webhook decision endpoint
Add authentication and a real-time webhook endpoint to Provenant's REST API so external systems (e.g. a coding harness) can push decisions directly into the knowledge graph. - API key auth via PROVENANT_API_KEY env var - Auth required for POST /api/decisions and POST /api/webhook/decision - POST /api/webhook/decision accepts decision payloads with optional source attribution for provenance - startServer() now returns the http.Server instance - Add REST API tests (4 tests, all passing)
1 parent 1162f2c commit 7be1970

3 files changed

Lines changed: 210 additions & 3 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import { mkdtempSync, rmSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
import { startServer } from '../api/server.js';
6+
import type { Server } from 'node:http';
7+
8+
function request(
9+
port: number,
10+
path: string,
11+
opts: { method?: string; body?: unknown; token?: string } = {}
12+
): Promise<{ status: number; body: unknown }> {
13+
return new Promise((resolve, reject) => {
14+
const method = opts.method ?? 'GET';
15+
const headers: Record<string, string> = {
16+
Accept: 'application/json',
17+
'Content-Type': 'application/json',
18+
};
19+
if (opts.token) {
20+
headers['Authorization'] = `Bearer ${opts.token}`;
21+
}
22+
23+
const req = require('node:http').request(
24+
{
25+
hostname: '127.0.0.1',
26+
port,
27+
path,
28+
method,
29+
headers,
30+
},
31+
(res: any) => {
32+
const chunks: Buffer[] = [];
33+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
34+
res.on('end', () => {
35+
const text = Buffer.concat(chunks).toString();
36+
try {
37+
resolve({ status: res.statusCode ?? 0, body: JSON.parse(text) });
38+
} catch {
39+
resolve({ status: res.statusCode ?? 0, body: text });
40+
}
41+
});
42+
}
43+
);
44+
45+
req.on('error', reject);
46+
if (opts.body) {
47+
req.write(JSON.stringify(opts.body));
48+
}
49+
req.end();
50+
});
51+
}
52+
53+
describe('REST API', () => {
54+
let tmpDir: string;
55+
let server: Server;
56+
let port: number;
57+
58+
beforeEach(async () => {
59+
tmpDir = mkdtempSync(join(tmpdir(), 'provenant-api-test-'));
60+
port = 10_000 + Math.floor(Math.random() * 10_000);
61+
server = startServer({
62+
port,
63+
dbPath: join(tmpDir, 'api.db'),
64+
apiKey: 'test-api-key',
65+
});
66+
await new Promise<void>((resolve) => server.on('listening', resolve));
67+
});
68+
69+
afterEach(async () => {
70+
await new Promise<void>((resolve) => server.close(() => resolve()));
71+
rmSync(tmpDir, { recursive: true, force: true });
72+
});
73+
74+
it('GET /api/status returns status without auth', async () => {
75+
const res = await request(port, '/api/status');
76+
expect(res.status).toBe(200);
77+
expect(res.body).toMatchObject({ nodeCount: 0 });
78+
});
79+
80+
it('POST /api/decisions rejects without auth', async () => {
81+
const res = await request(port, '/api/decisions', {
82+
method: 'POST',
83+
body: { content: 'Use PostgreSQL' },
84+
});
85+
expect(res.status).toBe(401);
86+
});
87+
88+
it('POST /api/decisions creates a node with valid auth', async () => {
89+
const res = await request(port, '/api/decisions', {
90+
method: 'POST',
91+
body: { content: 'Use PostgreSQL', actor: 'qa' },
92+
token: 'test-api-key',
93+
});
94+
expect(res.status).toBe(201);
95+
expect(res.body).toMatchObject({ content: 'Use PostgreSQL', actor: 'qa' });
96+
});
97+
98+
it('POST /api/webhook/decision stores decision with source provenance', async () => {
99+
const res = await request(port, '/api/webhook/decision', {
100+
method: 'POST',
101+
body: {
102+
content: 'SOP-101 satisfied: frame stack consistent',
103+
actor: 'harness',
104+
source: 'harness-pi',
105+
source_id: 'run-123',
106+
confidence: 0.9,
107+
},
108+
token: 'test-api-key',
109+
});
110+
expect(res.status).toBe(201);
111+
expect(res.body).toMatchObject({
112+
content: 'SOP-101 satisfied: frame stack consistent',
113+
sourceLinked: true,
114+
});
115+
116+
// Node should be searchable
117+
const search = await request(
118+
port,
119+
'/api/nodes?keywords=SOP-101,frame'
120+
);
121+
expect(search.status).toBe(200);
122+
expect(Array.isArray(search.body)).toBe(true);
123+
expect((search.body as any[]).length).toBe(1);
124+
});
125+
});

packages/provenant/src/api/server.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
createServer,
3+
type Server,
34
type IncomingMessage,
45
type ServerResponse,
56
} from 'node:http';
@@ -8,6 +9,7 @@ import { Database } from '../schema/database.js';
89
interface ServerConfig {
910
port: number;
1011
dbPath: string;
12+
apiKey?: string; // if set, required for mutation endpoints and webhooks
1113
}
1214

1315
function parseJson(req: IncomingMessage): Promise<unknown> {
@@ -40,8 +42,32 @@ function parsePath(url: string): string {
4042
return idx >= 0 ? url.slice(0, idx) : url;
4143
}
4244

43-
export function startServer(config: ServerConfig): void {
45+
function getAuthHeader(req: IncomingMessage): string | undefined {
46+
const auth = req.headers['authorization'] ?? '';
47+
if (typeof auth !== 'string') return undefined;
48+
if (auth.toLowerCase().startsWith('bearer ')) {
49+
return auth.slice(7).trim();
50+
}
51+
return auth.trim() || undefined;
52+
}
53+
54+
function requireAuth(
55+
req: IncomingMessage,
56+
res: ServerResponse,
57+
apiKey: string | undefined
58+
): boolean {
59+
if (!apiKey) return true; // auth disabled
60+
const provided = getAuthHeader(req);
61+
if (!provided || provided !== apiKey) {
62+
json(res, 401, { error: 'Unauthorized' });
63+
return false;
64+
}
65+
return true;
66+
}
67+
68+
export function startServer(config: ServerConfig): Server {
4469
const db = new Database(config.dbPath);
70+
const authEnabled = !!config.apiKey;
4571

4672
const server = createServer(async (req, res) => {
4773
const method = req.method ?? 'GET';
@@ -95,6 +121,8 @@ export function startServer(config: ServerConfig): void {
95121

96122
// POST /api/decisions
97123
if (method === 'POST' && path === '/api/decisions') {
124+
if (!requireAuth(req, res, config.apiKey)) return;
125+
98126
const body = (await parseJson(req)) as {
99127
content?: string;
100128
actor?: string;
@@ -115,6 +143,56 @@ export function startServer(config: ServerConfig): void {
115143
return;
116144
}
117145

146+
// POST /api/webhook/decision
147+
// External systems (e.g. a coding harness) can push decisions in real time.
148+
if (method === 'POST' && path === '/api/webhook/decision') {
149+
if (!requireAuth(req, res, config.apiKey)) return;
150+
151+
const body = (await parseJson(req)) as {
152+
content?: string;
153+
actor?: string;
154+
source?: string;
155+
source_id?: string;
156+
confidence?: number;
157+
metadata?: Record<string, unknown>;
158+
};
159+
if (!body.content) {
160+
json(res, 400, { error: 'content is required' });
161+
return;
162+
}
163+
164+
const node = db.insertNode({
165+
type: 'decision',
166+
content: body.content,
167+
embedding: null,
168+
actor: body.actor ?? null,
169+
confidence: body.confidence ?? 0.75,
170+
});
171+
172+
// Optionally link to an external source for provenance.
173+
if (body.source && body.source_id) {
174+
const source = db.insertSource({
175+
system: body.source,
176+
external_id: body.source_id,
177+
raw_payload: JSON.stringify(body.metadata ?? {}),
178+
hash: body.source_id,
179+
});
180+
db.linkNodeToSource(
181+
node.id,
182+
source.id,
183+
body.source,
184+
body.source_id
185+
);
186+
}
187+
188+
json(res, 201, {
189+
...node,
190+
embedding: undefined,
191+
sourceLinked: !!(body.source && body.source_id),
192+
});
193+
return;
194+
}
195+
118196
// GET /api/contradictions
119197
if (method === 'GET' && path === '/api/contradictions') {
120198
const contradictions = db.getPendingContradictions();
@@ -132,11 +210,13 @@ export function startServer(config: ServerConfig): void {
132210
server.listen(config.port, () => {
133211
console.log(`Provenant API server listening on port ${config.port}`);
134212
console.log(` Database: ${config.dbPath}`);
213+
console.log(` Auth: ${authEnabled ? 'enabled (PROVENANT_API_KEY set)' : 'disabled'}`);
135214
console.log(` Endpoints:`);
136215
console.log(` GET /api/status`);
137216
console.log(` GET /api/nodes?keywords=...&limit=...&actor=...`);
138217
console.log(` GET /api/nodes/:id`);
139-
console.log(` POST /api/decisions`);
218+
console.log(` POST /api/decisions ${authEnabled ? '(auth required)' : ''}`);
219+
console.log(` POST /api/webhook/decision ${authEnabled ? '(auth required)' : ''}`);
140220
console.log(` GET /api/contradictions`);
141221
});
142222

@@ -151,4 +231,6 @@ export function startServer(config: ServerConfig): void {
151231
server.close();
152232
process.exit(0);
153233
});
234+
235+
return server;
154236
}

packages/provenant/src/cli/commands/serve.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@ export function serve(opts: ServeOpts): void {
1414
console.error(`Invalid port: ${opts.port}`);
1515
process.exit(1);
1616
}
17-
startServer({ port, dbPath: opts.db });
17+
startServer({ port, dbPath: opts.db, apiKey: process.env['PROVENANT_API_KEY'] });
1818
}

0 commit comments

Comments
 (0)