@fusionkit/plane 0.1.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.
Files changed (52) hide show
  1. package/dist/auth.d.ts +18 -0
  2. package/dist/auth.js +46 -0
  3. package/dist/claim-token-service.d.ts +23 -0
  4. package/dist/claim-token-service.js +54 -0
  5. package/dist/contract-service.d.ts +14 -0
  6. package/dist/contract-service.js +39 -0
  7. package/dist/domain-errors.d.ts +13 -0
  8. package/dist/domain-errors.js +31 -0
  9. package/dist/idp.d.ts +26 -0
  10. package/dist/idp.js +24 -0
  11. package/dist/index.d.ts +35 -0
  12. package/dist/index.js +21 -0
  13. package/dist/keys.d.ts +60 -0
  14. package/dist/keys.js +132 -0
  15. package/dist/logging.d.ts +21 -0
  16. package/dist/logging.js +42 -0
  17. package/dist/plane.d.ts +167 -0
  18. package/dist/plane.js +606 -0
  19. package/dist/policy.d.ts +23 -0
  20. package/dist/policy.js +92 -0
  21. package/dist/ratelimit.d.ts +40 -0
  22. package/dist/ratelimit.js +94 -0
  23. package/dist/receipt-service.d.ts +16 -0
  24. package/dist/receipt-service.js +17 -0
  25. package/dist/retention.d.ts +33 -0
  26. package/dist/retention.js +123 -0
  27. package/dist/run-lifecycle.d.ts +2 -0
  28. package/dist/run-lifecycle.js +19 -0
  29. package/dist/secrets.d.ts +25 -0
  30. package/dist/secrets.js +73 -0
  31. package/dist/server.d.ts +38 -0
  32. package/dist/server.js +418 -0
  33. package/dist/sqlite-store.d.ts +53 -0
  34. package/dist/sqlite-store.js +401 -0
  35. package/dist/store.d.ts +107 -0
  36. package/dist/store.js +9 -0
  37. package/dist/test/api.test.d.ts +1 -0
  38. package/dist/test/api.test.js +179 -0
  39. package/dist/test/hardening.test.d.ts +1 -0
  40. package/dist/test/hardening.test.js +259 -0
  41. package/dist/test/policy.test.d.ts +1 -0
  42. package/dist/test/policy.test.js +78 -0
  43. package/dist/test/server-hardening.test.d.ts +1 -0
  44. package/dist/test/server-hardening.test.js +192 -0
  45. package/dist/test/ui-parity.test.d.ts +1 -0
  46. package/dist/test/ui-parity.test.js +28 -0
  47. package/dist/validation.d.ts +326 -0
  48. package/dist/validation.js +178 -0
  49. package/package.json +34 -0
  50. package/ui/app.css +276 -0
  51. package/ui/app.js +483 -0
  52. package/ui/index.html +65 -0
package/dist/server.js ADDED
@@ -0,0 +1,418 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ // Served on raw node:http deliberately: the API surface is small and fully
4
+ // enumerated below, auth/validation/rate-limiting are explicit functions
5
+ // rather than middleware, and TLS termination is the fronting proxy's job
6
+ // in deployment (docker-compose/K8s).
7
+ import { createServer } from "node:http";
8
+ import { fileURLToPath } from "node:url";
9
+ import { PolicyDeniedError } from "@fusionkit/protocol";
10
+ import { isPlaneDomainError } from "./domain-errors.js";
11
+ import { DEFAULT_RATE_LIMIT, RateLimiter } from "./ratelimit.js";
12
+ import { approveBodySchema, cancelBodySchema, claimBodySchema, completeBodySchema, createRunBodySchema, enrollBodySchema, eventsBodySchema, issuePrincipalBodySchema, parseBody, ValidationError } from "./validation.js";
13
+ /** Default request body cap (workspace bundles can be large). */
14
+ export const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
15
+ /**
16
+ * Liveness payload. The shape (`ok` + `service`) is part of the public API
17
+ * surface; deployment healthchecks and the docs reference it, so it is a
18
+ * named constant rather than scattered literals.
19
+ */
20
+ const HEALTH_RESPONSE = { ok: true, service: "warrant-plane" };
21
+ const UI_FILES = {
22
+ "/ui": { file: "index.html", type: "text/html; charset=utf-8" },
23
+ "/ui/": { file: "index.html", type: "text/html; charset=utf-8" },
24
+ "/ui/index.html": { file: "index.html", type: "text/html; charset=utf-8" },
25
+ "/ui/app.css": { file: "app.css", type: "text/css; charset=utf-8" },
26
+ "/ui/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" }
27
+ };
28
+ // UI assets are read from disk once and served from memory thereafter; the
29
+ // bundle is three small files, so caching them avoids per-request blocking
30
+ // filesystem reads.
31
+ const uiAssetCache = new Map();
32
+ function uiAsset(file) {
33
+ const cached = uiAssetCache.get(file);
34
+ if (cached)
35
+ return cached;
36
+ const body = readFileSync(fileURLToPath(new URL(`../ui/${file}`, import.meta.url)));
37
+ uiAssetCache.set(file, body);
38
+ return body;
39
+ }
40
+ function readBody(req, maxBodyBytes) {
41
+ return new Promise((resolve, reject) => {
42
+ const chunks = [];
43
+ let total = 0;
44
+ req.on("data", (chunk) => {
45
+ total += chunk.length;
46
+ if (total > maxBodyBytes) {
47
+ reject(new RequestError(413, "body too large"));
48
+ req.destroy();
49
+ return;
50
+ }
51
+ chunks.push(chunk);
52
+ });
53
+ req.on("end", () => resolve(Buffer.concat(chunks)));
54
+ req.on("error", reject);
55
+ });
56
+ }
57
+ async function readJson(req, maxBodyBytes) {
58
+ const raw = (await readBody(req, maxBodyBytes)).toString("utf8");
59
+ try {
60
+ return JSON.parse(raw);
61
+ }
62
+ catch {
63
+ throw new RequestError(400, "request body is not valid JSON");
64
+ }
65
+ }
66
+ function sendJson(res, status, body) {
67
+ const payload = JSON.stringify(body);
68
+ res.writeHead(status, {
69
+ "content-type": "application/json",
70
+ "content-length": Buffer.byteLength(payload)
71
+ });
72
+ res.end(payload);
73
+ }
74
+ function bearerToken(req) {
75
+ const header = req.headers.authorization;
76
+ if (!header || !header.startsWith("Bearer "))
77
+ return undefined;
78
+ return header.slice("Bearer ".length);
79
+ }
80
+ /**
81
+ * Client identity for rate limiting and auth-failure backoff. By default the
82
+ * socket address is used; behind a trusted reverse proxy, enable
83
+ * `trustProxy` so limits key on the originating client from X-Forwarded-For
84
+ * rather than the proxy itself. Only enable it when a proxy you control
85
+ * sets the header, since clients can spoof it otherwise.
86
+ */
87
+ function clientIp(req, trustProxy) {
88
+ if (trustProxy) {
89
+ const forwarded = req.headers["x-forwarded-for"];
90
+ const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)
91
+ ?.split(",")[0]
92
+ ?.trim();
93
+ if (first)
94
+ return first;
95
+ }
96
+ return req.socket.remoteAddress ?? "unknown";
97
+ }
98
+ /** A request-handling error carrying an HTTP status. */
99
+ class RequestError extends Error {
100
+ status;
101
+ constructor(status, message) {
102
+ super(message);
103
+ this.status = status;
104
+ this.name = "RequestError";
105
+ }
106
+ }
107
+ /**
108
+ * Control-plane HTTP API plus the control panel UI. Every mutating and
109
+ * data-returning route is authenticated against a principal and gated by
110
+ * capability; bodies are schema-validated; requests are rate-limited per
111
+ * principal/IP with auth-failure backoff; everything is logged with a
112
+ * request id.
113
+ */
114
+ export function startPlaneServer(plane, options) {
115
+ const resolved = typeof options === "number" ? { port: options } : options;
116
+ const { port, host = "127.0.0.1" } = resolved;
117
+ const limiter = new RateLimiter(resolved.rateLimit ?? DEFAULT_RATE_LIMIT);
118
+ const context = {
119
+ plane,
120
+ limiter,
121
+ maxBodyBytes: resolved.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
122
+ trustProxy: resolved.trustProxy ?? false
123
+ };
124
+ const server = createServer((req, res) => {
125
+ const requestId = randomUUID();
126
+ handle(context, req, res, requestId).catch((error) => {
127
+ if (error instanceof PolicyDeniedError) {
128
+ sendJson(res, 403, { error: error.message, code: error.code, reasons: error.reasons });
129
+ return;
130
+ }
131
+ if (error instanceof ValidationError) {
132
+ sendJson(res, 400, { error: error.message, issues: error.issues });
133
+ return;
134
+ }
135
+ if (error instanceof RequestError) {
136
+ sendJson(res, error.status, { error: error.message });
137
+ return;
138
+ }
139
+ if (isPlaneDomainError(error)) {
140
+ plane.log.warn({ requestId, err: error.message }, "request rejected");
141
+ sendJson(res, error.status, { error: error.message, code: error.code });
142
+ return;
143
+ }
144
+ plane.log.error({
145
+ requestId,
146
+ err: error instanceof Error ? error.message : String(error)
147
+ }, "request failed");
148
+ sendJson(res, 500, { error: "internal server error" });
149
+ });
150
+ });
151
+ server.keepAliveTimeout = resolved.keepAliveTimeoutMs ?? 0;
152
+ return new Promise((resolve) => {
153
+ server.listen(port, host, () => {
154
+ const address = server.address();
155
+ const boundPort = typeof address === "object" && address !== null ? address.port : port;
156
+ plane.log.info({ host, port: boundPort }, "plane listening");
157
+ resolve({ server, port: boundPort, host });
158
+ });
159
+ });
160
+ }
161
+ /** Authenticate + authorize, recording rate-limit/auth-failure state. */
162
+ function requirePrincipal(ctx, req, capability) {
163
+ const { plane, limiter } = ctx;
164
+ const token = bearerToken(req);
165
+ const ip = clientIp(req, ctx.trustProxy);
166
+ if (limiter.isLockedOut(ip)) {
167
+ throw new RequestError(429, "too many authentication failures; backing off");
168
+ }
169
+ const principal = plane.authorize(token, capability);
170
+ if (!principal) {
171
+ limiter.recordAuthFailure(ip);
172
+ // Distinguish "no/invalid token" from "valid token, wrong role".
173
+ if (plane.authenticate(token)) {
174
+ throw new RequestError(403, "forbidden: principal lacks the required role");
175
+ }
176
+ throw new RequestError(401, "unauthorized");
177
+ }
178
+ limiter.recordAuthSuccess(ip);
179
+ if (!limiter.allow(principal.principalId)) {
180
+ throw new RequestError(429, "rate limit exceeded");
181
+ }
182
+ return principal;
183
+ }
184
+ async function handle(ctx, req, res, requestId) {
185
+ const { plane, limiter } = ctx;
186
+ // The base is only needed so WHATWG URL can parse the path + query of a
187
+ // server-side request URL; it never appears in any response.
188
+ const url = new URL(req.url ?? "/", "http://localhost");
189
+ const method = req.method ?? "GET";
190
+ const path = url.pathname;
191
+ plane.log.debug({ requestId, method, path }, "request");
192
+ // ---- Public routes ----
193
+ if (method === "GET" && path === "/") {
194
+ res.writeHead(302, { location: "/ui/" });
195
+ res.end();
196
+ return;
197
+ }
198
+ const uiEntry = UI_FILES[path];
199
+ if (method === "GET" && uiEntry) {
200
+ const body = uiAsset(uiEntry.file);
201
+ res.writeHead(200, {
202
+ "content-type": uiEntry.type,
203
+ "content-length": body.length,
204
+ "cache-control": "no-store"
205
+ });
206
+ res.end(body);
207
+ return;
208
+ }
209
+ if (method === "GET" && path === "/v1/health") {
210
+ sendJson(res, 200, HEALTH_RESPONSE);
211
+ return;
212
+ }
213
+ if (method === "GET" && path === "/v1/ready") {
214
+ const ready = plane.ready();
215
+ sendJson(res, ready ? 200 : 503, { ready });
216
+ return;
217
+ }
218
+ if (method === "GET" && path === "/v1/metrics") {
219
+ requirePrincipal(ctx, req, "policy:read");
220
+ sendJson(res, 200, { metrics: plane.metrics.snapshot() });
221
+ return;
222
+ }
223
+ // Runner enrollment authenticates with an enroll token (principal or
224
+ // single-use), not a control-plane principal.
225
+ if (method === "POST" && path === "/v1/runners/enroll") {
226
+ const ip = clientIp(req, ctx.trustProxy);
227
+ if (limiter.isLockedOut(ip)) {
228
+ throw new RequestError(429, "too many authentication failures; backing off");
229
+ }
230
+ const body = parseBody(enrollBodySchema, await readJson(req, ctx.maxBodyBytes));
231
+ try {
232
+ const result = plane.enrollRunner(body);
233
+ limiter.recordAuthSuccess(ip);
234
+ sendJson(res, 200, result);
235
+ }
236
+ catch (error) {
237
+ limiter.recordAuthFailure(ip);
238
+ throw error;
239
+ }
240
+ return;
241
+ }
242
+ // Runner claim/event/completion authenticate with runner tokens + signed
243
+ // claim tokens (verified inside the plane), not principals.
244
+ if (method === "POST" && path === "/v1/claims") {
245
+ const body = parseBody(claimBodySchema, await readJson(req, ctx.maxBodyBytes));
246
+ const claim = plane.claim(body);
247
+ sendJson(res, 200, claim ?? { empty: true });
248
+ return;
249
+ }
250
+ // ---- Principal-authenticated routes ----
251
+ if (method === "GET" && path === "/v1/runners") {
252
+ requirePrincipal(ctx, req, "runners:read");
253
+ sendJson(res, 200, { runners: plane.listRunners() });
254
+ return;
255
+ }
256
+ if (method === "GET" && path === "/v1/policy") {
257
+ requirePrincipal(ctx, req, "policy:read");
258
+ sendJson(res, 200, plane.policySnapshot);
259
+ return;
260
+ }
261
+ if (method === "GET" && path === "/v1/principals") {
262
+ requirePrincipal(ctx, req, "principals:manage");
263
+ sendJson(res, 200, { principals: plane.listPrincipals() });
264
+ return;
265
+ }
266
+ if (method === "POST" && path === "/v1/principals") {
267
+ requirePrincipal(ctx, req, "principals:manage");
268
+ const body = parseBody(issuePrincipalBodySchema, await readJson(req, ctx.maxBodyBytes));
269
+ sendJson(res, 200, plane.issuePrincipal(body.name, body.role));
270
+ return;
271
+ }
272
+ if (method === "POST" && path === "/v1/enroll-tokens") {
273
+ requirePrincipal(ctx, req, "principals:manage");
274
+ const issued = plane.issueEnrollToken();
275
+ sendJson(res, 200, issued);
276
+ return;
277
+ }
278
+ if (method === "POST" && path === "/v1/blobs") {
279
+ // Writers are either a blobs:write principal (CLI/SDK) or a runner
280
+ // holding a valid plane-signed claim token (artifact uploads).
281
+ const token = bearerToken(req);
282
+ const principal = plane.authorize(token, "blobs:write");
283
+ if (!principal && !(token && plane.verifyClaimTokenSignature(token))) {
284
+ throw new RequestError(401, "unauthorized");
285
+ }
286
+ const content = await readBody(req, ctx.maxBodyBytes);
287
+ sendJson(res, 200, { hash: plane.blobs.putBlob(content) });
288
+ return;
289
+ }
290
+ // Blobs are content-addressed by sha256; knowing the hash (which only
291
+ // appears in capability-gated receipts/events or the issued contract) is
292
+ // itself the read capability, so reads are not separately gated.
293
+ // Deliberate hash-as-capability design: a sha256 is unguessable, and the
294
+ // hashes only appear inside capability-gated responses (contracts,
295
+ // receipts, events). The structured logger redacts token/secret carriers
296
+ // and never logs blob hashes from request paths at info level.
297
+ const blobMatch = path.match(/^\/v1\/blobs\/([0-9a-f]{64})$/);
298
+ if (method === "GET" && blobMatch && blobMatch[1]) {
299
+ const blob = plane.blobs.getBlob(blobMatch[1]);
300
+ if (!blob) {
301
+ sendJson(res, 404, { error: "blob not found" });
302
+ return;
303
+ }
304
+ res.writeHead(200, {
305
+ "content-type": "application/octet-stream",
306
+ "content-length": blob.length
307
+ });
308
+ res.end(blob);
309
+ return;
310
+ }
311
+ if (method === "GET" && path === "/v1/runs") {
312
+ requirePrincipal(ctx, req, "runs:read");
313
+ sendJson(res, 200, { runs: plane.listRuns() });
314
+ return;
315
+ }
316
+ if (method === "POST" && path === "/v1/runs") {
317
+ requirePrincipal(ctx, req, "runs:create");
318
+ const body = parseBody(createRunBodySchema, await readJson(req, ctx.maxBodyBytes));
319
+ if (body.dryRun) {
320
+ sendJson(res, 200, plane.dryRun(body.request));
321
+ return;
322
+ }
323
+ const record = plane.requestRun(body.request);
324
+ sendJson(res, 200, {
325
+ runId: record.id,
326
+ status: record.status,
327
+ consentRequirements: record.consentRequirements
328
+ });
329
+ return;
330
+ }
331
+ // The segment charset is a superset of every id the plane issues
332
+ // (`run_<uuid>`); unknown ids simply 404 from the store lookup below.
333
+ const runMatch = path.match(/^\/v1\/runs\/([A-Za-z0-9_-]+)(\/.*)?$/);
334
+ if (runMatch && runMatch[1]) {
335
+ const runId = runMatch[1];
336
+ const sub = runMatch[2] ?? "";
337
+ if (method === "POST" && sub === "/approve") {
338
+ const principal = requirePrincipal(ctx, req, "runs:approve");
339
+ const body = parseBody(approveBodySchema, await readJson(req, ctx.maxBodyBytes));
340
+ let actor = body.actor ?? {
341
+ kind: "human",
342
+ id: principal.name
343
+ };
344
+ let verified;
345
+ if (body.idpToken) {
346
+ verified = await plane.verifyIdpToken(body.idpToken);
347
+ actor = { kind: "human", id: verified.idpSubject };
348
+ }
349
+ const record = plane.approve(runId, actor, verified);
350
+ sendJson(res, 200, { runId: record.id, status: record.status });
351
+ return;
352
+ }
353
+ if (method === "POST" && sub === "/cancel") {
354
+ const principal = requirePrincipal(ctx, req, "runs:cancel");
355
+ const body = parseBody(cancelBodySchema, await readJson(req, ctx.maxBodyBytes));
356
+ const actor = body.actor ?? { kind: "human", id: principal.name };
357
+ const record = plane.cancel(runId, actor);
358
+ sendJson(res, 200, { runId: record.id, status: record.status });
359
+ return;
360
+ }
361
+ if (method === "POST" && sub === "/events") {
362
+ const body = parseBody(eventsBodySchema, await readJson(req, ctx.maxBodyBytes));
363
+ // Events are hash-chained and verified inside appendRunnerEvents; the
364
+ // chain verification is the authoritative structural gate (a malformed
365
+ // event cannot have a valid chain hash), so the cast is safe here.
366
+ plane.appendRunnerEvents(runId, body.claimToken, body.events);
367
+ sendJson(res, 200, { ok: true });
368
+ return;
369
+ }
370
+ if (method === "POST" && sub === "/complete") {
371
+ const body = parseBody(completeBodySchema, await readJson(req, ctx.maxBodyBytes));
372
+ // The receipt is verified inside complete(): contract-hash binding and
373
+ // the runner's ed25519 signature over the canonical payload. A
374
+ // malformed receipt cannot carry a valid signature, so the signature
375
+ // check is the authoritative structural gate.
376
+ const countersigned = plane.complete(runId, body.claimToken, body.receipt);
377
+ sendJson(res, 200, { receipt: countersigned });
378
+ return;
379
+ }
380
+ if (method === "GET" && sub === "/bundle") {
381
+ requirePrincipal(ctx, req, "runs:read");
382
+ const bundle = plane.getBundle(runId);
383
+ if (!bundle) {
384
+ sendJson(res, 404, { error: "bundle not available" });
385
+ return;
386
+ }
387
+ sendJson(res, 200, bundle);
388
+ return;
389
+ }
390
+ if (method === "GET" && sub === "") {
391
+ requirePrincipal(ctx, req, "runs:read");
392
+ const record = plane.getRun(runId);
393
+ if (!record) {
394
+ sendJson(res, 404, { error: "run not found" });
395
+ return;
396
+ }
397
+ sendJson(res, 200, {
398
+ runId: record.id,
399
+ status: record.status,
400
+ createdAt: record.createdAt,
401
+ updatedAt: record.updatedAt,
402
+ consentRequirements: record.consentRequirements,
403
+ failureMessage: record.failureMessage,
404
+ events: plane.getEvents(runId)
405
+ });
406
+ return;
407
+ }
408
+ }
409
+ if (method === "GET" && path === "/v1/export") {
410
+ requirePrincipal(ctx, req, "export:read");
411
+ const since = url.searchParams.get("since") ?? undefined;
412
+ const jsonl = plane.exportJsonl(since);
413
+ res.writeHead(200, { "content-type": "application/x-ndjson" });
414
+ res.end(jsonl);
415
+ return;
416
+ }
417
+ sendJson(res, 404, { error: `no route for ${method} ${path}` });
418
+ }
@@ -0,0 +1,53 @@
1
+ import type { ChainedEvent, Receipt, RunStatus } from "@fusionkit/protocol";
2
+ import type { EnrollTokenRecord, PlaneStore, PrincipalRecord, RunRecord, RunnerRecord } from "./store.js";
3
+ /**
4
+ * node:sqlite-backed control-plane store. Single-file database with WAL
5
+ * journaling and immediate-transaction claims. Synchronous by design
6
+ * (DatabaseSync), which on a single plane process is atomic by virtue of
7
+ * the event loop, and across processes is serialized by SQLite's writer
8
+ * lock — so the claim compare-and-set and the nonce ledger hold either way.
9
+ */
10
+ export type SqliteStoreOptions = {
11
+ /** How long a writer waits on the SQLite lock before erroring. */
12
+ busyTimeoutMs?: number;
13
+ /** Journal mode; WAL is the right default for a long-lived server. */
14
+ journalMode?: "WAL" | "DELETE";
15
+ };
16
+ export declare class SqliteStore implements PlaneStore {
17
+ private readonly db;
18
+ constructor(dbPath: string, options?: SqliteStoreOptions);
19
+ private migrate;
20
+ close(): void;
21
+ saveRun(record: RunRecord): void;
22
+ getRun(runId: string): RunRecord | undefined;
23
+ listRuns(): RunRecord[];
24
+ claimNextRun(pool: string, runnerId: string, now: string): RunRecord | undefined;
25
+ appendEvents(runId: string, events: ChainedEvent[]): void;
26
+ getEvents(runId: string): ChainedEvent[];
27
+ exportEvents(sinceMs: number): {
28
+ runId: string;
29
+ event: ChainedEvent;
30
+ }[];
31
+ saveReceipt(runId: string, receipt: Receipt): void;
32
+ getReceipt(runId: string): Receipt | undefined;
33
+ putBlob(content: Buffer): string;
34
+ getBlob(hash: string): Buffer | undefined;
35
+ saveRunner(record: RunnerRecord): void;
36
+ private runnerFromRow;
37
+ getRunnerByTokenHash(tokenHash: string): RunnerRecord | undefined;
38
+ getRunnerById(runnerId: string): RunnerRecord | undefined;
39
+ listRunners(): RunnerRecord[];
40
+ savePrincipal(record: PrincipalRecord): void;
41
+ private principalFromRow;
42
+ getPrincipalByTokenHash(tokenHash: string): PrincipalRecord | undefined;
43
+ getPrincipalByName(name: string): PrincipalRecord | undefined;
44
+ listPrincipals(): PrincipalRecord[];
45
+ revokePrincipal(principalId: string, now: string): boolean;
46
+ saveEnrollToken(record: EnrollTokenRecord): void;
47
+ consumeEnrollToken(tokenHash: string, now: string): EnrollTokenRecord | undefined;
48
+ recordClaimNonce(nonce: string, expiresAtMs: number): boolean;
49
+ pruneClaimNonces(nowMs: number): number;
50
+ deleteRunsUpdatedBefore(cutoffMs: number, terminalStatuses: RunStatus[]): string[];
51
+ deleteBlobsExcept(keep: Set<string>): number;
52
+ countBlobs(): number;
53
+ }