@vellumai/credential-executor 0.4.55
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/Dockerfile +55 -0
- package/bun.lock +37 -0
- package/package.json +32 -0
- package/src/__tests__/command-executor.test.ts +1333 -0
- package/src/__tests__/command-validator.test.ts +708 -0
- package/src/__tests__/command-workspace.test.ts +997 -0
- package/src/__tests__/grant-store.test.ts +467 -0
- package/src/__tests__/http-executor.test.ts +1251 -0
- package/src/__tests__/http-policy.test.ts +970 -0
- package/src/__tests__/local-materializers.test.ts +826 -0
- package/src/__tests__/managed-materializers.test.ts +961 -0
- package/src/__tests__/toolstore.test.ts +539 -0
- package/src/__tests__/transport.test.ts +388 -0
- package/src/audit/store.ts +188 -0
- package/src/commands/auth-adapters.ts +169 -0
- package/src/commands/executor.ts +840 -0
- package/src/commands/output-scan.ts +157 -0
- package/src/commands/profiles.ts +282 -0
- package/src/commands/validator.ts +438 -0
- package/src/commands/workspace.ts +512 -0
- package/src/grants/index.ts +17 -0
- package/src/grants/persistent-store.ts +247 -0
- package/src/grants/rpc-handlers.ts +269 -0
- package/src/grants/temporary-store.ts +219 -0
- package/src/http/audit.ts +84 -0
- package/src/http/executor.ts +540 -0
- package/src/http/path-template.ts +179 -0
- package/src/http/policy.ts +256 -0
- package/src/http/response-filter.ts +233 -0
- package/src/index.ts +106 -0
- package/src/main.ts +263 -0
- package/src/managed-main.ts +420 -0
- package/src/materializers/local.ts +300 -0
- package/src/materializers/managed-platform.ts +270 -0
- package/src/paths.ts +137 -0
- package/src/server.ts +636 -0
- package/src/subjects/local.ts +177 -0
- package/src/subjects/managed.ts +290 -0
- package/src/toolstore/integrity.ts +94 -0
- package/src/toolstore/manifest.ts +154 -0
- package/src/toolstore/publish.ts +342 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CES transport tests.
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* 1. The managed entrypoint never opens a general localhost command API —
|
|
6
|
+
* the only command transport is via the accepted Unix socket stream.
|
|
7
|
+
* 2. Health probes are served on a dedicated HTTP port, separate from
|
|
8
|
+
* the command transport.
|
|
9
|
+
* 3. Local stdio transports are not accidentally inherited by shell
|
|
10
|
+
* subprocesses (the entrypoint does not open TCP listeners).
|
|
11
|
+
* 4. The CES RPC server correctly performs handshake and dispatches methods.
|
|
12
|
+
* 5. CES private data paths are correctly resolved for both modes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { PassThrough } from "node:stream";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
CES_PROTOCOL_VERSION,
|
|
22
|
+
type HandshakeAck,
|
|
23
|
+
type RpcEnvelope,
|
|
24
|
+
} from "@vellumai/ces-contracts";
|
|
25
|
+
|
|
26
|
+
import { getCesDataRoot, getBootstrapSocketPath, getHealthPort } from "../paths.js";
|
|
27
|
+
import { CesRpcServer, type RpcHandlerRegistry } from "../server.js";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a CesRpcServer wired to in-memory PassThrough streams for testing.
|
|
35
|
+
*/
|
|
36
|
+
function createTestServer(handlers: RpcHandlerRegistry = {}) {
|
|
37
|
+
const input = new PassThrough();
|
|
38
|
+
const output = new PassThrough();
|
|
39
|
+
const logs: string[] = [];
|
|
40
|
+
|
|
41
|
+
const server = new CesRpcServer({
|
|
42
|
+
input,
|
|
43
|
+
output,
|
|
44
|
+
handlers,
|
|
45
|
+
logger: {
|
|
46
|
+
log: (msg: string) => logs.push(`LOG: ${msg}`),
|
|
47
|
+
warn: (msg: string) => logs.push(`WARN: ${msg}`),
|
|
48
|
+
error: (msg: string) => logs.push(`ERROR: ${msg}`),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Collect all output lines from the server.
|
|
54
|
+
*/
|
|
55
|
+
function collectOutputLines(): string[] {
|
|
56
|
+
const data = output.read();
|
|
57
|
+
if (!data) return [];
|
|
58
|
+
const text = typeof data === "string" ? data : data.toString("utf-8");
|
|
59
|
+
return text.split("\n").filter((l: string) => l.trim().length > 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Send a JSON message to the server (newline-delimited).
|
|
64
|
+
*/
|
|
65
|
+
function send(msg: unknown): void {
|
|
66
|
+
input.write(JSON.stringify(msg) + "\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Send a handshake request and read the response.
|
|
71
|
+
*/
|
|
72
|
+
async function handshake(sessionId = "test-session"): Promise<HandshakeAck> {
|
|
73
|
+
send({
|
|
74
|
+
type: "handshake_request",
|
|
75
|
+
protocolVersion: CES_PROTOCOL_VERSION,
|
|
76
|
+
sessionId,
|
|
77
|
+
});
|
|
78
|
+
// Give the server a tick to process
|
|
79
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
80
|
+
const lines = collectOutputLines();
|
|
81
|
+
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
82
|
+
return JSON.parse(lines[0]) as HandshakeAck;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Send an RPC request and read the response.
|
|
87
|
+
*/
|
|
88
|
+
async function rpc(
|
|
89
|
+
method: string,
|
|
90
|
+
payload: unknown,
|
|
91
|
+
id = "1",
|
|
92
|
+
): Promise<RpcEnvelope> {
|
|
93
|
+
send({
|
|
94
|
+
type: "rpc",
|
|
95
|
+
id,
|
|
96
|
+
kind: "request",
|
|
97
|
+
method,
|
|
98
|
+
payload,
|
|
99
|
+
timestamp: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
102
|
+
const lines = collectOutputLines();
|
|
103
|
+
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
104
|
+
return JSON.parse(lines[lines.length - 1]) as RpcEnvelope;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { server, input, output, send, collectOutputLines, handshake, rpc, logs };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// 1. Managed entrypoint never opens a generic localhost command API
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe("managed entrypoint transport isolation", () => {
|
|
115
|
+
test("managed-main.ts does not call Bun.serve for RPC (only health)", () => {
|
|
116
|
+
const src = readFileSync(
|
|
117
|
+
resolve(__dirname, "..", "managed-main.ts"),
|
|
118
|
+
"utf-8",
|
|
119
|
+
);
|
|
120
|
+
// Bun.serve calls in managed-main should only be in startHealthServer
|
|
121
|
+
const bunServeMatches = src.match(/Bun\.serve\(/g);
|
|
122
|
+
expect(bunServeMatches).not.toBeNull();
|
|
123
|
+
// There should be exactly 1 Bun.serve call — for health probes only
|
|
124
|
+
expect(bunServeMatches!.length).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("managed-main.ts uses createNetServer for Unix socket, not Bun.serve", () => {
|
|
128
|
+
const src = readFileSync(
|
|
129
|
+
resolve(__dirname, "..", "managed-main.ts"),
|
|
130
|
+
"utf-8",
|
|
131
|
+
);
|
|
132
|
+
// Uses node:net createServer for the bootstrap socket
|
|
133
|
+
expect(src).toMatch(/createNetServer/);
|
|
134
|
+
// The net server never listens on a TCP port (only a socket path)
|
|
135
|
+
expect(src).not.toMatch(/netServer\.listen\(\d+/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("managed-main.ts unlinks socket after accepting one connection", () => {
|
|
139
|
+
const src = readFileSync(
|
|
140
|
+
resolve(__dirname, "..", "managed-main.ts"),
|
|
141
|
+
"utf-8",
|
|
142
|
+
);
|
|
143
|
+
// Should unlink the socket path after connection
|
|
144
|
+
expect(src).toMatch(/unlinkSync\(socketPath\)/);
|
|
145
|
+
// Should close the net server after one connection
|
|
146
|
+
expect(src).toMatch(/netServer\.close\(\)/);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// 2. Health probes on dedicated port
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe("health probes", () => {
|
|
155
|
+
test("managed-main.ts serves /healthz and /readyz on a separate port", () => {
|
|
156
|
+
const src = readFileSync(
|
|
157
|
+
resolve(__dirname, "..", "managed-main.ts"),
|
|
158
|
+
"utf-8",
|
|
159
|
+
);
|
|
160
|
+
expect(src).toMatch(/\/healthz/);
|
|
161
|
+
expect(src).toMatch(/\/readyz/);
|
|
162
|
+
// Health server uses Bun.serve on a dedicated port, not the socket
|
|
163
|
+
expect(src).toMatch(/startHealthServer\(healthPort/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("getHealthPort defaults to 7841", () => {
|
|
167
|
+
// Save and clear env
|
|
168
|
+
const saved = process.env["CES_HEALTH_PORT"];
|
|
169
|
+
delete process.env["CES_HEALTH_PORT"];
|
|
170
|
+
try {
|
|
171
|
+
expect(getHealthPort()).toBe(7841);
|
|
172
|
+
} finally {
|
|
173
|
+
if (saved !== undefined) process.env["CES_HEALTH_PORT"] = saved;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("getHealthPort respects CES_HEALTH_PORT env var", () => {
|
|
178
|
+
const saved = process.env["CES_HEALTH_PORT"];
|
|
179
|
+
process.env["CES_HEALTH_PORT"] = "9999";
|
|
180
|
+
try {
|
|
181
|
+
expect(getHealthPort()).toBe(9999);
|
|
182
|
+
} finally {
|
|
183
|
+
if (saved !== undefined) {
|
|
184
|
+
process.env["CES_HEALTH_PORT"] = saved;
|
|
185
|
+
} else {
|
|
186
|
+
delete process.env["CES_HEALTH_PORT"];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// 3. Local stdio transport not inherited by subprocesses
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
describe("local entrypoint transport isolation", () => {
|
|
197
|
+
test("main.ts uses process.stdin/stdout, not TCP listeners", () => {
|
|
198
|
+
const src = readFileSync(resolve(__dirname, "..", "main.ts"), "utf-8");
|
|
199
|
+
// Uses stdin/stdout for transport
|
|
200
|
+
expect(src).toMatch(/process\.stdin/);
|
|
201
|
+
expect(src).toMatch(/process\.stdout/);
|
|
202
|
+
// Does not open any TCP or Unix socket listener
|
|
203
|
+
expect(src).not.toMatch(/Bun\.serve\(/);
|
|
204
|
+
expect(src).not.toMatch(/createServer\(/);
|
|
205
|
+
expect(src).not.toMatch(/\.listen\(/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("main.ts logs to stderr, not stdout (avoids polluting transport)", () => {
|
|
209
|
+
const src = readFileSync(resolve(__dirname, "..", "main.ts"), "utf-8");
|
|
210
|
+
// All log output goes to stderr
|
|
211
|
+
expect(src).toMatch(/process\.stderr\.write/);
|
|
212
|
+
// Should not use console.log (which writes to stdout)
|
|
213
|
+
expect(src).not.toMatch(/console\.log\(/);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// 4. CES RPC server handshake and dispatch
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe("CesRpcServer", () => {
|
|
222
|
+
test("completes handshake with correct protocol version", async () => {
|
|
223
|
+
const { server, handshake, input } = createTestServer();
|
|
224
|
+
const servePromise = server.serve();
|
|
225
|
+
|
|
226
|
+
const ack = await handshake();
|
|
227
|
+
expect(ack.type).toBe("handshake_ack");
|
|
228
|
+
expect(ack.accepted).toBe(true);
|
|
229
|
+
expect(ack.protocolVersion).toBe(CES_PROTOCOL_VERSION);
|
|
230
|
+
expect(ack.sessionId).toBe("test-session");
|
|
231
|
+
expect(server.isHandshakeComplete).toBe(true);
|
|
232
|
+
expect(server.currentSessionId).toBe("test-session");
|
|
233
|
+
|
|
234
|
+
server.close();
|
|
235
|
+
input.end();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("rejects handshake with wrong protocol version", async () => {
|
|
239
|
+
const { server, send, collectOutputLines, input } = createTestServer();
|
|
240
|
+
const servePromise = server.serve();
|
|
241
|
+
|
|
242
|
+
send({
|
|
243
|
+
type: "handshake_request",
|
|
244
|
+
protocolVersion: "99.99.99",
|
|
245
|
+
sessionId: "bad-session",
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
249
|
+
const lines = collectOutputLines();
|
|
250
|
+
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
251
|
+
const ack = JSON.parse(lines[0]) as HandshakeAck;
|
|
252
|
+
expect(ack.accepted).toBe(false);
|
|
253
|
+
expect(ack.reason).toMatch(/Unsupported protocol version/);
|
|
254
|
+
expect(server.isHandshakeComplete).toBe(false);
|
|
255
|
+
|
|
256
|
+
server.close();
|
|
257
|
+
input.end();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("rejects RPC before handshake", async () => {
|
|
261
|
+
const { server, send, collectOutputLines, input } = createTestServer();
|
|
262
|
+
const servePromise = server.serve();
|
|
263
|
+
|
|
264
|
+
send({
|
|
265
|
+
type: "rpc",
|
|
266
|
+
id: "1",
|
|
267
|
+
kind: "request",
|
|
268
|
+
method: "list_grants",
|
|
269
|
+
payload: {},
|
|
270
|
+
timestamp: new Date().toISOString(),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
274
|
+
const lines = collectOutputLines();
|
|
275
|
+
expect(lines.length).toBeGreaterThanOrEqual(1);
|
|
276
|
+
const resp = JSON.parse(lines[0]);
|
|
277
|
+
expect(resp.payload.success).toBe(false);
|
|
278
|
+
expect(resp.payload.error.code).toBe("HANDSHAKE_REQUIRED");
|
|
279
|
+
|
|
280
|
+
server.close();
|
|
281
|
+
input.end();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("dispatches RPC to registered handler", async () => {
|
|
285
|
+
const handlers: RpcHandlerRegistry = {
|
|
286
|
+
list_grants: async (req: unknown) => {
|
|
287
|
+
return { grants: [] };
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const { server, handshake, rpc, input } = createTestServer(handlers);
|
|
292
|
+
const servePromise = server.serve();
|
|
293
|
+
|
|
294
|
+
await handshake();
|
|
295
|
+
|
|
296
|
+
const resp = await rpc("list_grants", {});
|
|
297
|
+
expect(resp.kind).toBe("response");
|
|
298
|
+
expect(resp.method).toBe("list_grants");
|
|
299
|
+
expect((resp.payload as { grants: unknown[] }).grants).toEqual([]);
|
|
300
|
+
|
|
301
|
+
server.close();
|
|
302
|
+
input.end();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("returns METHOD_NOT_FOUND for unknown methods", async () => {
|
|
306
|
+
const { server, handshake, rpc, input } = createTestServer();
|
|
307
|
+
const servePromise = server.serve();
|
|
308
|
+
|
|
309
|
+
await handshake();
|
|
310
|
+
|
|
311
|
+
const resp = await rpc("nonexistent_method", {});
|
|
312
|
+
expect(resp.kind).toBe("response");
|
|
313
|
+
expect((resp.payload as { success: boolean; error: { code: string } }).error.code).toBe(
|
|
314
|
+
"METHOD_NOT_FOUND",
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
server.close();
|
|
318
|
+
input.end();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("returns HANDLER_ERROR when handler throws", async () => {
|
|
322
|
+
const handlers: RpcHandlerRegistry = {
|
|
323
|
+
fail_method: async () => {
|
|
324
|
+
throw new Error("Intentional test failure");
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const { server, handshake, rpc, input } = createTestServer(handlers);
|
|
329
|
+
const servePromise = server.serve();
|
|
330
|
+
|
|
331
|
+
await handshake();
|
|
332
|
+
|
|
333
|
+
const resp = await rpc("fail_method", {});
|
|
334
|
+
expect(resp.kind).toBe("response");
|
|
335
|
+
const payload = resp.payload as { success: boolean; error: { code: string; message: string } };
|
|
336
|
+
expect(payload.error.code).toBe("HANDLER_ERROR");
|
|
337
|
+
expect(payload.error.message).toMatch(/Intentional test failure/);
|
|
338
|
+
|
|
339
|
+
server.close();
|
|
340
|
+
input.end();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// 5. CES private data paths
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
describe("CES data paths", () => {
|
|
349
|
+
test("local mode data root includes 'protected/credential-executor'", () => {
|
|
350
|
+
const root = getCesDataRoot("local");
|
|
351
|
+
expect(root).toMatch(/protected[/\\]credential-executor$/);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("managed mode data root is /ces-data", () => {
|
|
355
|
+
expect(getCesDataRoot("managed")).toBe("/ces-data");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("local data root is under the Vellum root, not the workspace", () => {
|
|
359
|
+
const root = getCesDataRoot("local");
|
|
360
|
+
// Must be under .vellum/protected/, NOT .vellum/workspace/
|
|
361
|
+
expect(root).toMatch(/\.vellum[/\\]protected[/\\]/);
|
|
362
|
+
expect(root).not.toMatch(/workspace/);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("getBootstrapSocketPath defaults to /run/ces/ces.sock", () => {
|
|
366
|
+
const saved = process.env["CES_BOOTSTRAP_SOCKET"];
|
|
367
|
+
delete process.env["CES_BOOTSTRAP_SOCKET"];
|
|
368
|
+
try {
|
|
369
|
+
expect(getBootstrapSocketPath()).toBe("/run/ces/ces.sock");
|
|
370
|
+
} finally {
|
|
371
|
+
if (saved !== undefined) process.env["CES_BOOTSTRAP_SOCKET"] = saved;
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("getBootstrapSocketPath respects CES_BOOTSTRAP_SOCKET env var", () => {
|
|
376
|
+
const saved = process.env["CES_BOOTSTRAP_SOCKET"];
|
|
377
|
+
process.env["CES_BOOTSTRAP_SOCKET"] = "/tmp/test-ces.sock";
|
|
378
|
+
try {
|
|
379
|
+
expect(getBootstrapSocketPath()).toBe("/tmp/test-ces.sock");
|
|
380
|
+
} finally {
|
|
381
|
+
if (saved !== undefined) {
|
|
382
|
+
process.env["CES_BOOTSTRAP_SOCKET"] = saved;
|
|
383
|
+
} else {
|
|
384
|
+
delete process.env["CES_BOOTSTRAP_SOCKET"];
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CES audit record persistence.
|
|
3
|
+
*
|
|
4
|
+
* Persists token-free audit record summaries to `audit.jsonl` inside
|
|
5
|
+
* the CES-private data root. Each line is a self-contained JSON object
|
|
6
|
+
* conforming to the `AuditRecordSummary` schema from `@vellumai/ces-contracts`.
|
|
7
|
+
*
|
|
8
|
+
* Design principles:
|
|
9
|
+
* - **Append-only**: Records are appended one per line. The file is never
|
|
10
|
+
* rewritten or truncated during normal operation.
|
|
11
|
+
* - **Token-free**: Audit records must never contain raw secrets, auth
|
|
12
|
+
* tokens, raw headers, or raw response bodies. Only sanitized summaries
|
|
13
|
+
* (method, URL template, status code, credential handle, grant ID) are
|
|
14
|
+
* persisted.
|
|
15
|
+
* - **Fail-open for reads**: If the file is corrupt or missing, reads
|
|
16
|
+
* return an empty array rather than throwing. Writes still throw on I/O
|
|
17
|
+
* failure so callers know when persistence is broken.
|
|
18
|
+
* - **Bounded reads**: The `list` method supports limit and cursor-based
|
|
19
|
+
* pagination to avoid reading the entire log into memory.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
appendFileSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { dirname, join } from "node:path";
|
|
29
|
+
|
|
30
|
+
import type { AuditRecordSummary } from "@vellumai/ces-contracts";
|
|
31
|
+
|
|
32
|
+
import { getCesAuditDir } from "../paths.js";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const AUDIT_FILENAME = "audit.jsonl";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Store implementation
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export class AuditStore {
|
|
45
|
+
private readonly filePath: string;
|
|
46
|
+
|
|
47
|
+
constructor(auditDir?: string) {
|
|
48
|
+
const dir = auditDir ?? getCesAuditDir();
|
|
49
|
+
this.filePath = join(dir, AUDIT_FILENAME);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// -----------------------------------------------------------------------
|
|
53
|
+
// Public API
|
|
54
|
+
// -----------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ensure the parent directory exists. Safe to call multiple times.
|
|
58
|
+
*/
|
|
59
|
+
init(): void {
|
|
60
|
+
const dir = dirname(this.filePath);
|
|
61
|
+
if (!existsSync(dir)) {
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Append a token-free audit record summary to the log.
|
|
68
|
+
*
|
|
69
|
+
* Throws on I/O failure (callers should handle gracefully — audit
|
|
70
|
+
* persistence failure must not block the execution pipeline).
|
|
71
|
+
*/
|
|
72
|
+
append(record: AuditRecordSummary): void {
|
|
73
|
+
const dir = dirname(this.filePath);
|
|
74
|
+
if (!existsSync(dir)) {
|
|
75
|
+
mkdirSync(dir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const line = JSON.stringify(record) + "\n";
|
|
79
|
+
appendFileSync(this.filePath, line, { mode: 0o600 });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* List audit records with optional filtering and pagination.
|
|
84
|
+
*
|
|
85
|
+
* Records are returned in reverse-chronological order (newest first).
|
|
86
|
+
*
|
|
87
|
+
* @param options.sessionId - Filter by session ID.
|
|
88
|
+
* @param options.credentialHandle - Filter by credential handle.
|
|
89
|
+
* @param options.grantId - Filter by grant ID.
|
|
90
|
+
* @param options.limit - Maximum number of records to return (default: 50).
|
|
91
|
+
* @param options.cursor - Opaque cursor from a previous response to
|
|
92
|
+
* continue pagination. The cursor is the 0-based line offset encoded
|
|
93
|
+
* as a string.
|
|
94
|
+
*
|
|
95
|
+
* @returns An object with `records` and `nextCursor`. `nextCursor` is
|
|
96
|
+
* null when there are no more results.
|
|
97
|
+
*/
|
|
98
|
+
list(options?: {
|
|
99
|
+
sessionId?: string;
|
|
100
|
+
credentialHandle?: string;
|
|
101
|
+
grantId?: string;
|
|
102
|
+
limit?: number;
|
|
103
|
+
cursor?: string;
|
|
104
|
+
}): { records: AuditRecordSummary[]; nextCursor: string | null } {
|
|
105
|
+
const limit = options?.limit ?? 50;
|
|
106
|
+
|
|
107
|
+
const allRecords = this.readAll();
|
|
108
|
+
|
|
109
|
+
// Reverse for newest-first ordering
|
|
110
|
+
allRecords.reverse();
|
|
111
|
+
|
|
112
|
+
// Apply filters
|
|
113
|
+
let filtered = allRecords;
|
|
114
|
+
if (options?.sessionId) {
|
|
115
|
+
filtered = filtered.filter((r) => r.sessionId === options.sessionId);
|
|
116
|
+
}
|
|
117
|
+
if (options?.credentialHandle) {
|
|
118
|
+
filtered = filtered.filter(
|
|
119
|
+
(r) => r.credentialHandle === options.credentialHandle,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (options?.grantId) {
|
|
123
|
+
filtered = filtered.filter((r) => r.grantId === options.grantId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Apply cursor-based pagination
|
|
127
|
+
const offset = options?.cursor ? parseInt(options.cursor, 10) : 0;
|
|
128
|
+
const startIdx = isNaN(offset) ? 0 : offset;
|
|
129
|
+
|
|
130
|
+
const page = filtered.slice(startIdx, startIdx + limit);
|
|
131
|
+
const hasMore = startIdx + limit < filtered.length;
|
|
132
|
+
const nextCursor = hasMore ? String(startIdx + limit) : null;
|
|
133
|
+
|
|
134
|
+
return { records: page, nextCursor };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Return the total number of records in the log.
|
|
139
|
+
*
|
|
140
|
+
* Returns 0 if the file does not exist or is unreadable.
|
|
141
|
+
*/
|
|
142
|
+
count(): number {
|
|
143
|
+
return this.readAll().length;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -----------------------------------------------------------------------
|
|
147
|
+
// Internals
|
|
148
|
+
// -----------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read all records from the JSONL file, skipping malformed lines.
|
|
152
|
+
*
|
|
153
|
+
* Returns an empty array if the file is missing or unreadable.
|
|
154
|
+
*/
|
|
155
|
+
private readAll(): AuditRecordSummary[] {
|
|
156
|
+
if (!existsSync(this.filePath)) return [];
|
|
157
|
+
|
|
158
|
+
let raw: string;
|
|
159
|
+
try {
|
|
160
|
+
raw = readFileSync(this.filePath, "utf-8");
|
|
161
|
+
} catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const records: AuditRecordSummary[] = [];
|
|
166
|
+
const lines = raw.split("\n");
|
|
167
|
+
|
|
168
|
+
for (const line of lines) {
|
|
169
|
+
const trimmed = line.trim();
|
|
170
|
+
if (trimmed.length === 0) continue;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(trimmed) as AuditRecordSummary;
|
|
174
|
+
// Minimal validation — must have auditId and timestamp
|
|
175
|
+
if (
|
|
176
|
+
typeof parsed.auditId === "string" &&
|
|
177
|
+
typeof parsed.timestamp === "string"
|
|
178
|
+
) {
|
|
179
|
+
records.push(parsed);
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Skip malformed lines
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return records;
|
|
187
|
+
}
|
|
188
|
+
}
|