@agent-custody/receipts 0.4.0 → 0.5.1

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.
@@ -0,0 +1,312 @@
1
+ // Where a log server keeps its logs. The file backend is the reference server as it was: one JSONL file per log,
2
+ // read into memory. The Postgres backend is a log run for other people: leaves in one table keyed by tenant, one
3
+ // writer per tenant enforced by an advisory lock so a second instance is safe, tenants and their tokens in tables of
4
+ // their own, and rate limits per token. The subtree cache stays in memory on every instance and resyncs from the
5
+ // table whenever the table has moved on without it.
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import { readFileSync } from "node:fs";
8
+ import { leafHash, MerkleLog, SubtreeCache } from "./log.js";
9
+ /** The JSONL file log behind the asynchronous interface. */
10
+ export function fileBackend(file) {
11
+ const log = new MerkleLog(file);
12
+ return {
13
+ async size() {
14
+ return log.size;
15
+ },
16
+ async append(leaf) {
17
+ return log.append(leaf);
18
+ },
19
+ async appendHash(hex) {
20
+ return log.appendHash(hex);
21
+ },
22
+ async root(size) {
23
+ return log.root(size);
24
+ },
25
+ async consistencyProof(oldSize, newSize) {
26
+ return log.consistencyProof(oldSize, newSize);
27
+ },
28
+ };
29
+ }
30
+ const ident = (s, what) => {
31
+ if (!/^[a-z_][a-z0-9_]*$/.test(s))
32
+ throw new Error(`${what} must be a plain lowercase identifier; got "${s}"`);
33
+ return s;
34
+ };
35
+ /**
36
+ * A client without `connect` is one connection (PGlite, a single pg Client), so its transactions must not interleave:
37
+ * they are queued per client. A pool hands out a connection per transaction and the advisory lock does the rest.
38
+ */
39
+ const queues = new WeakMap();
40
+ function queued(client, fn) {
41
+ const prev = queues.get(client) ?? Promise.resolve();
42
+ const next = prev.then(fn, fn);
43
+ queues.set(client, next.catch(() => { }));
44
+ return next;
45
+ }
46
+ /** Runs `fn` inside one transaction on one connection, whichever kind of client this is. */
47
+ async function transaction(client, fn) {
48
+ if (!client.connect)
49
+ return queued(client, () => singleConnectionTransaction(client, fn));
50
+ {
51
+ const c = await client.connect();
52
+ try {
53
+ await c.query("BEGIN");
54
+ const out = await fn((t, v) => c.query(t, v));
55
+ await c.query("COMMIT");
56
+ return out;
57
+ }
58
+ catch (e) {
59
+ await c.query("ROLLBACK").catch(() => { });
60
+ throw e;
61
+ }
62
+ finally {
63
+ c.release();
64
+ }
65
+ }
66
+ }
67
+ async function singleConnectionTransaction(client, fn) {
68
+ await client.query("BEGIN");
69
+ try {
70
+ const out = await fn((t, v) => client.query(t, v));
71
+ await client.query("COMMIT");
72
+ return out;
73
+ }
74
+ catch (e) {
75
+ await client.query("ROLLBACK").catch(() => { });
76
+ throw e;
77
+ }
78
+ }
79
+ /** One tenant's log in Postgres. Leaves are hashes only; the table never holds a receipt. */
80
+ export class PostgresLog {
81
+ tenant;
82
+ client;
83
+ leaves;
84
+ hashes = [];
85
+ cache = new SubtreeCache(this.hashes);
86
+ ready = null;
87
+ chain = Promise.resolve();
88
+ constructor(client, tenant, opts = {}) {
89
+ this.client = client;
90
+ this.tenant = tenant;
91
+ this.leaves = `${ident(opts.prefix ?? "log_", "prefix")}leaves`;
92
+ }
93
+ static async ensureSchema(client, prefix = "log_") {
94
+ const p = ident(prefix, "prefix");
95
+ await client.query(`CREATE TABLE IF NOT EXISTS ${p}leaves (tenant_id TEXT NOT NULL, seq BIGINT NOT NULL, leaf_hash BYTEA NOT NULL, appended_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, seq))`);
96
+ }
97
+ init() {
98
+ if (!this.ready)
99
+ this.ready = PostgresLog.ensureSchema(this.client, this.leaves.slice(0, -"leaves".length)).then(() => this.reload(this.client.query.bind(this.client)));
100
+ return this.ready;
101
+ }
102
+ /** Replaces the in-memory tree with what the table holds. Called when the table has moved on without this instance. */
103
+ async reload(q) {
104
+ const rows = (await q(`SELECT leaf_hash FROM ${this.leaves} WHERE tenant_id = $1 ORDER BY seq`, [this.tenant])).rows;
105
+ this.hashes = rows.map((r) => (typeof r.leaf_hash === "string" ? Buffer.from(r.leaf_hash.replace(/^\\x/, ""), "hex") : Buffer.from(r.leaf_hash)));
106
+ this.cache = new SubtreeCache(this.hashes);
107
+ }
108
+ async count(q) {
109
+ return Number((await q(`SELECT COALESCE(MAX(seq) + 1, 0) AS n FROM ${this.leaves} WHERE tenant_id = $1`, [this.tenant])).rows[0].n);
110
+ }
111
+ async sync() {
112
+ await this.init();
113
+ const n = await this.count(this.client.query.bind(this.client));
114
+ if (n !== this.hashes.length)
115
+ await this.reload(this.client.query.bind(this.client));
116
+ }
117
+ async size() {
118
+ await this.sync();
119
+ return this.hashes.length;
120
+ }
121
+ commit(hash) {
122
+ const run = async () => {
123
+ await this.init();
124
+ // One writer per tenant: the advisory lock serialises appends across instances, and the count check inside the
125
+ // lock catches a tree that another instance has extended, so the in-memory cache is rebuilt before this leaf.
126
+ await transaction(this.client, async (q) => {
127
+ await q("SELECT pg_advisory_xact_lock(hashtext($1))", [this.tenant]);
128
+ const n = await this.count(q);
129
+ if (n !== this.hashes.length)
130
+ await this.reload(q);
131
+ await q(`INSERT INTO ${this.leaves} (tenant_id, seq, leaf_hash) VALUES ($1, $2, $3)`, [this.tenant, this.hashes.length, hash]);
132
+ });
133
+ this.hashes.push(hash);
134
+ const treeSize = this.hashes.length;
135
+ return { leafIndex: treeSize - 1, treeSize, hashes: this.cache.path(treeSize - 1, 0, treeSize).map((b) => b.toString("hex")), rootHash: this.cache.mth(0, treeSize).toString("hex") };
136
+ };
137
+ const next = this.chain.then(run, run);
138
+ this.chain = next.catch(() => { });
139
+ return next;
140
+ }
141
+ async append(leaf) {
142
+ return this.commit(leafHash(leaf));
143
+ }
144
+ async appendHash(leafHashHex) {
145
+ if (!/^[0-9a-f]{64}$/.test(leafHashHex))
146
+ throw new Error("leafHash must be 64 lowercase hex characters");
147
+ return this.commit(Buffer.from(leafHashHex, "hex"));
148
+ }
149
+ async root(size) {
150
+ await this.sync();
151
+ const n = size ?? this.hashes.length;
152
+ if (n < 0 || n > this.hashes.length)
153
+ throw new Error("size out of range");
154
+ return this.cache.mth(0, n).toString("hex");
155
+ }
156
+ async consistencyProof(oldSize, newSize) {
157
+ await this.sync();
158
+ const n = newSize ?? this.hashes.length;
159
+ if (oldSize < 0 || oldSize > n || n > this.hashes.length)
160
+ throw new Error("sizes out of range");
161
+ if (oldSize === 0 || oldSize === n)
162
+ return [];
163
+ return this.cache.subproof(oldSize, 0, n, true).map((b) => b.toString("hex"));
164
+ }
165
+ }
166
+ const sha256hex = (s) => createHash("sha256").update(s).digest("hex");
167
+ /** Tenants and their tokens, in Postgres. Tokens are stored hashed; a lookup hashes what the caller presented. */
168
+ export class PostgresTenancy {
169
+ client;
170
+ prefix;
171
+ logs = new Map();
172
+ tenantCache = new Map();
173
+ tokenCache = new Map();
174
+ ready = null;
175
+ constructor(client, opts = {}) {
176
+ this.client = client;
177
+ this.prefix = ident(opts.prefix ?? "log_", "prefix");
178
+ }
179
+ init() {
180
+ if (!this.ready) {
181
+ const p = this.prefix;
182
+ this.ready = (async () => {
183
+ await PostgresLog.ensureSchema(this.client, p);
184
+ await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}tenants (id TEXT PRIMARY KEY, log_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), disabled_at TIMESTAMPTZ)`);
185
+ await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}tokens (token_hash TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES ${p}tenants(id), label TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ)`);
186
+ })();
187
+ }
188
+ return this.ready;
189
+ }
190
+ row(r) {
191
+ return { id: String(r.id), logId: String(r.log_id), createdAt: new Date(r.created_at).toISOString(), disabledAt: r.disabled_at ? new Date(r.disabled_at).toISOString() : null };
192
+ }
193
+ /** The tenant, or null. Answers from a ten-second cache, so a disabled tenant is refused within that. */
194
+ async tenant(id) {
195
+ await this.init();
196
+ const hit = this.tenantCache.get(id);
197
+ if (hit && Date.now() - hit.at < 10_000)
198
+ return hit.tenant;
199
+ const rows = (await this.client.query(`SELECT id, log_id, created_at, disabled_at FROM ${this.prefix}tenants WHERE id = $1`, [id])).rows;
200
+ const tenant = rows[0] ? this.row(rows[0]) : null;
201
+ this.tenantCache.set(id, { at: Date.now(), tenant });
202
+ return tenant;
203
+ }
204
+ /** Whether the presented token is a live token of this tenant. Positive answers are cached for thirty seconds. */
205
+ async authorize(tenantId, token) {
206
+ if (!token)
207
+ return false;
208
+ await this.init();
209
+ const hash = sha256hex(token);
210
+ const key = `${tenantId}:${hash}`;
211
+ const at = this.tokenCache.get(key);
212
+ if (at && Date.now() - at < 30_000)
213
+ return true;
214
+ const rows = (await this.client.query(`SELECT 1 FROM ${this.prefix}tokens WHERE token_hash = $1 AND tenant_id = $2 AND revoked_at IS NULL`, [hash, tenantId])).rows;
215
+ if (rows.length === 0)
216
+ return false;
217
+ this.tokenCache.set(key, Date.now());
218
+ return true;
219
+ }
220
+ /** The tenant's log, one instance per tenant per process. */
221
+ async log(tenantId) {
222
+ let l = this.logs.get(tenantId);
223
+ if (!l) {
224
+ l = new PostgresLog(this.client, tenantId, { prefix: this.prefix });
225
+ this.logs.set(tenantId, l);
226
+ }
227
+ return l;
228
+ }
229
+ async addTenant(id, logId = id) {
230
+ if (!/^[A-Za-z0-9_.-]+$/.test(id))
231
+ throw new Error(`tenant id must be a plain identifier; got "${id}"`);
232
+ await this.init();
233
+ const rows = (await this.client.query(`INSERT INTO ${this.prefix}tenants (id, log_id) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET log_id = EXCLUDED.log_id RETURNING id, log_id, created_at, disabled_at`, [id, logId])).rows;
234
+ this.tenantCache.delete(id);
235
+ return this.row(rows[0]);
236
+ }
237
+ async disableTenant(id) {
238
+ await this.init();
239
+ await this.client.query(`UPDATE ${this.prefix}tenants SET disabled_at = now() WHERE id = $1 AND disabled_at IS NULL`, [id]);
240
+ this.tenantCache.delete(id);
241
+ }
242
+ async listTenants() {
243
+ await this.init();
244
+ return (await this.client.query(`SELECT id, log_id, created_at, disabled_at FROM ${this.prefix}tenants ORDER BY created_at`)).rows.map((r) => this.row(r));
245
+ }
246
+ /** Mints a token for a tenant. The token is returned once and stored only as its hash. */
247
+ async addToken(tenantId, label) {
248
+ await this.init();
249
+ if (!(await this.tenant(tenantId)))
250
+ throw new Error(`unknown tenant ${tenantId}`);
251
+ const token = randomBytes(32).toString("hex");
252
+ const tokenHash = sha256hex(token);
253
+ await this.client.query(`INSERT INTO ${this.prefix}tokens (token_hash, tenant_id, label) VALUES ($1, $2, $3)`, [tokenHash, tenantId, label]);
254
+ return { token, tokenHash };
255
+ }
256
+ /** Revokes the tokens of a tenant whose hash starts with the prefix; returns how many. */
257
+ async revokeToken(tenantId, hashPrefix) {
258
+ await this.init();
259
+ if (hashPrefix.length < 8)
260
+ throw new Error("give at least eight characters of the token hash");
261
+ const rows = (await this.client.query(`UPDATE ${this.prefix}tokens SET revoked_at = now() WHERE tenant_id = $1 AND token_hash LIKE $2 AND revoked_at IS NULL RETURNING token_hash`, [tenantId, `${hashPrefix}%`])).rows;
262
+ for (const r of rows)
263
+ this.tokenCache.delete(`${tenantId}:${r.token_hash}`);
264
+ return rows.length;
265
+ }
266
+ async listTokens(tenantId) {
267
+ await this.init();
268
+ return (await this.client.query(`SELECT tenant_id, label, token_hash, created_at, revoked_at FROM ${this.prefix}tokens WHERE tenant_id = $1 ORDER BY created_at`, [tenantId])).rows.map((r) => ({ tenantId: String(r.tenant_id), label: String(r.label), tokenHash: String(r.token_hash), createdAt: new Date(r.created_at).toISOString(), revokedAt: r.revoked_at ? new Date(r.revoked_at).toISOString() : null }));
269
+ }
270
+ }
271
+ /**
272
+ * Copies a JSONL log file into a backend as hashes: leaf strings are hashed, {pruned} and {hash} lines are taken as
273
+ * they are. Skips the leaves the backend already has, so it can be re-run. Returns how many were added.
274
+ */
275
+ export async function importLogFile(file, into) {
276
+ const lines = readFileSync(file, "utf8").split("\n").filter((l) => l.trim());
277
+ const have = await into.size();
278
+ let added = 0;
279
+ for (let i = have; i < lines.length; i++) {
280
+ const parsed = JSON.parse(lines[i]);
281
+ await into.appendHash(typeof parsed === "string" ? leafHash(parsed).toString("hex") : (parsed.pruned ?? parsed.hash));
282
+ added++;
283
+ }
284
+ return { added, total: lines.length };
285
+ }
286
+ /** A token bucket per key, in memory: enough to keep one tenant from crowding out the rest on one instance. */
287
+ export class RateLimiter {
288
+ perSecond;
289
+ burst;
290
+ buckets = new Map();
291
+ constructor(opts = {}) {
292
+ this.perSecond = opts.perSecond ?? 50;
293
+ this.burst = opts.burst ?? 100;
294
+ }
295
+ /** Takes one unit for the key; false when the key must wait. */
296
+ take(key, now = Date.now()) {
297
+ const b = this.buckets.get(key) ?? { tokens: this.burst, at: now };
298
+ b.tokens = Math.min(this.burst, b.tokens + ((now - b.at) / 1000) * this.perSecond);
299
+ b.at = now;
300
+ if (b.tokens < 1) {
301
+ this.buckets.set(key, b);
302
+ return false;
303
+ }
304
+ b.tokens -= 1;
305
+ this.buckets.set(key, b);
306
+ if (this.buckets.size > 10_000)
307
+ for (const [k, v] of this.buckets)
308
+ if (now - v.at > 60_000)
309
+ this.buckets.delete(k);
310
+ return true;
311
+ }
312
+ }
package/dist/log.d.ts CHANGED
@@ -4,6 +4,19 @@ export interface InclusionProof {
4
4
  hashes: string[];
5
5
  }
6
6
  export declare function leafHash(data: string): Buffer;
7
+ /**
8
+ * Subtree hashes over a growing list of leaves. A subtree over an aligned, complete, power-of-two range never changes
9
+ * once its leaves exist, so those are cached; everything else is recomputed from at most log(n) cached parts. That
10
+ * makes appends, roots, and proofs O(log n) instead of O(n), which is what keeps a long session's receipts cheap.
11
+ */
12
+ export declare class SubtreeCache {
13
+ private readonly perfect;
14
+ private readonly leaves;
15
+ constructor(leaves: Buffer[]);
16
+ mth(lo: number, hi: number): Buffer;
17
+ path(m: number, lo: number, hi: number): Buffer[];
18
+ subproof(m: number, lo: number, hi: number, b: boolean): Buffer[];
19
+ }
7
20
  /** Proof that the tree of size newSize extends the tree of size oldSize. Empty when oldSize is 0 or equal to newSize. */
8
21
  export declare function consistencyProof(leafHashes: Buffer[], oldSize: number, newSize?: number): string[];
9
22
  /** RFC 9162 section 2.1.4.2. Pure: needs only the two sizes, the two roots, and the proof. */
package/dist/log.js CHANGED
@@ -25,7 +25,7 @@ function split(n) {
25
25
  * once its leaves exist, so those are cached; everything else is recomputed from at most log(n) cached parts. That
26
26
  * makes appends, roots, and proofs O(log n) instead of O(n), which is what keeps a long session's receipts cheap.
27
27
  */
28
- class SubtreeCache {
28
+ export class SubtreeCache {
29
29
  perfect = new Map();
30
30
  leaves;
31
31
  constructor(leaves) {
@@ -0,0 +1,64 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { type Envelope, type KeyPair, type PublicKeyRef } from "./crypto.ts";
3
+ /** What a log publishes about its keys. Verifiers pin by keyid; retired keys stay listed so old heads keep verifying. */
4
+ export interface KeyDocument {
5
+ /** the id of the log served at the root paths, when it has one */
6
+ log?: string;
7
+ keys: {
8
+ keyid: string;
9
+ alg: "ed25519";
10
+ publicKeyPem: string;
11
+ validFrom: string;
12
+ validTo?: string;
13
+ }[];
14
+ }
15
+ export interface Signer {
16
+ readonly keyid: string;
17
+ sign(payloadType: string, payload: unknown): Promise<Envelope>;
18
+ keys(): Promise<KeyDocument>;
19
+ }
20
+ export interface RetiredKey {
21
+ key: PublicKeyRef;
22
+ pem: string;
23
+ validFrom?: string;
24
+ validTo?: string;
25
+ }
26
+ /** A key held in this process. `retired` keys are listed in the document, never used to sign. */
27
+ export declare function localSigner(kp: KeyPair, opts?: {
28
+ retired?: RetiredKey[];
29
+ validFrom?: string;
30
+ }): Signer;
31
+ export interface RemoteSignerOptions {
32
+ /** the shared secret the signer requires */
33
+ token?: string;
34
+ fetch?: typeof fetch;
35
+ }
36
+ /** The signer over HTTP. Fetches the key document once to learn the keyid, then signs through POST /sign. */
37
+ export declare function connectSigner(url: string, opts?: RemoteSignerOptions): Promise<Signer>;
38
+ export interface SignerServerOptions {
39
+ /** the shared secret; without one, anyone who can reach the port may sign, so bind to loopback or a private network */
40
+ token?: string;
41
+ retired?: RetiredKey[];
42
+ validFrom?: string;
43
+ }
44
+ /**
45
+ * The signer as a node:http handler.
46
+ * POST /sign {payloadType, payload} -> DSSE envelope (token required when one is configured)
47
+ * GET /keys -> KeyDocument
48
+ * GET /health -> {keyid}
49
+ */
50
+ export declare function signerHandler(kp: KeyPair, opts?: SignerServerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
51
+ export interface RunningSigner {
52
+ url: string;
53
+ keyid: string;
54
+ close(): Promise<void>;
55
+ }
56
+ export declare function serveSigner(kp: KeyPair, opts: SignerServerOptions & {
57
+ port: number;
58
+ host?: string;
59
+ }): Promise<RunningSigner>;
60
+ /** For verifiers: the keys a log publishes, fetched from its origin and returned as key references pinned by keyid. */
61
+ export declare function fetchLogKeys(logUrl: string, f?: typeof fetch): Promise<{
62
+ doc: KeyDocument;
63
+ keys: PublicKeyRef[];
64
+ }>;
package/dist/signer.js ADDED
@@ -0,0 +1,136 @@
1
+ // The signer: the one process that holds the log's key. The log server asks it to sign tree heads over a private
2
+ // HTTP call with a shared secret, so the key never sits in the process that faces the internet, and the same key
3
+ // document it serves is what the log publishes at /.well-known/agent-custody-log.json for verifiers to pin.
4
+ // A local signer wraps a key in-process for the reference server and for tests; both look the same to the handler.
5
+ import { timingSafeEqual } from "node:crypto";
6
+ import { createServer } from "node:http";
7
+ import { dsseSign, publicKeyFromPem } from "./crypto.js";
8
+ const pemOf = (k) => k.publicKey.export({ type: "spki", format: "pem" });
9
+ /** A key held in this process. `retired` keys are listed in the document, never used to sign. */
10
+ export function localSigner(kp, opts = {}) {
11
+ const validFrom = opts.validFrom ?? new Date().toISOString();
12
+ const doc = {
13
+ keys: [
14
+ { keyid: kp.keyid, alg: "ed25519", publicKeyPem: pemOf(kp), validFrom },
15
+ ...(opts.retired ?? []).map((r) => ({ keyid: r.key.keyid, alg: "ed25519", publicKeyPem: r.pem, validFrom: r.validFrom ?? "1970-01-01T00:00:00.000Z", ...(r.validTo ? { validTo: r.validTo } : {}) })),
16
+ ],
17
+ };
18
+ return {
19
+ keyid: kp.keyid,
20
+ async sign(payloadType, payload) {
21
+ return dsseSign(payloadType, payload, kp);
22
+ },
23
+ async keys() {
24
+ return structuredClone(doc);
25
+ },
26
+ };
27
+ }
28
+ /** The signer over HTTP. Fetches the key document once to learn the keyid, then signs through POST /sign. */
29
+ export async function connectSigner(url, opts = {}) {
30
+ const f = opts.fetch ?? fetch;
31
+ const base = url.endsWith("/") ? url : `${url}/`;
32
+ const headers = { "content-type": "application/json", ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) };
33
+ const res = await f(new URL("keys", base), { headers });
34
+ if (!res.ok)
35
+ throw new Error(`signer ${url} refused the key request: ${res.status}`);
36
+ const doc = (await res.json());
37
+ const current = doc.keys[0];
38
+ if (!current)
39
+ throw new Error(`signer ${url} lists no key`);
40
+ return {
41
+ keyid: current.keyid,
42
+ async sign(payloadType, payload) {
43
+ const r = await f(new URL("sign", base), { method: "POST", headers, body: JSON.stringify({ payloadType, payload }), signal: AbortSignal.timeout(5000) });
44
+ if (!r.ok)
45
+ throw new Error(`signer ${url} refused to sign: ${r.status} ${(await r.text()).slice(0, 200)}`);
46
+ const env = (await r.json());
47
+ if (typeof env.payload !== "string" || !Array.isArray(env.signatures))
48
+ throw new Error(`signer ${url} returned a malformed envelope`);
49
+ return env;
50
+ },
51
+ async keys() {
52
+ const k = await f(new URL("keys", base), { headers });
53
+ if (!k.ok)
54
+ throw new Error(`signer ${url} refused the key request: ${k.status}`);
55
+ return (await k.json());
56
+ },
57
+ };
58
+ }
59
+ /**
60
+ * The signer as a node:http handler.
61
+ * POST /sign {payloadType, payload} -> DSSE envelope (token required when one is configured)
62
+ * GET /keys -> KeyDocument
63
+ * GET /health -> {keyid}
64
+ */
65
+ export function signerHandler(kp, opts = {}) {
66
+ const signer = localSigner(kp, opts);
67
+ const authorized = (req) => {
68
+ if (!opts.token)
69
+ return true;
70
+ const h = req.headers.authorization ?? "";
71
+ const given = Buffer.from(h.startsWith("Bearer ") ? h.slice(7) : "");
72
+ const want = Buffer.from(opts.token);
73
+ return want.length === given.length && timingSafeEqual(want, given);
74
+ };
75
+ return async (req, res) => {
76
+ const json = (status, body) => {
77
+ res.writeHead(status, { "content-type": "application/json" });
78
+ res.end(JSON.stringify(body));
79
+ };
80
+ const url = new URL(req.url ?? "/", "http://localhost");
81
+ if (req.method === "GET" && url.pathname === "/health")
82
+ return json(200, { keyid: kp.keyid });
83
+ if (req.method === "GET" && url.pathname === "/keys")
84
+ return json(200, await signer.keys());
85
+ if (req.method === "POST" && url.pathname === "/sign") {
86
+ if (!authorized(req))
87
+ return json(401, { error: "unauthorized" });
88
+ let body = "";
89
+ for await (const chunk of req) {
90
+ body += chunk;
91
+ if (body.length > 65_536)
92
+ return json(413, { error: "payload too large" });
93
+ }
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(body);
97
+ }
98
+ catch {
99
+ return json(400, { error: "body must be JSON {payloadType, payload}" });
100
+ }
101
+ if (typeof parsed.payloadType !== "string" || parsed.payload === undefined)
102
+ return json(400, { error: "body must be JSON {payloadType, payload}" });
103
+ return json(200, await signer.sign(parsed.payloadType, parsed.payload));
104
+ }
105
+ return json(404, { error: "not found" });
106
+ };
107
+ }
108
+ export function serveSigner(kp, opts) {
109
+ const host = opts.host ?? "127.0.0.1";
110
+ const handler = signerHandler(kp, opts);
111
+ const server = createServer((req, res) => {
112
+ void handler(req, res);
113
+ });
114
+ return new Promise((resolve) => {
115
+ server.listen(opts.port, host, () => {
116
+ const { port } = server.address();
117
+ resolve({ url: `http://${host}:${port}/`, keyid: kp.keyid, close: () => new Promise((r) => server.close(() => r())) });
118
+ });
119
+ });
120
+ }
121
+ /** For verifiers: the keys a log publishes, fetched from its origin and returned as key references pinned by keyid. */
122
+ export async function fetchLogKeys(logUrl, f = fetch) {
123
+ const res = await f(new URL("/.well-known/agent-custody-log.json", logUrl));
124
+ if (!res.ok)
125
+ throw new Error(`log ${logUrl} serves no key document: ${res.status}`);
126
+ const doc = (await res.json());
127
+ if (!Array.isArray(doc.keys) || doc.keys.length === 0)
128
+ throw new Error(`log ${logUrl} lists no keys`);
129
+ const keys = doc.keys.map((k) => {
130
+ const ref = publicKeyFromPem(k.publicKeyPem);
131
+ if (ref.keyid !== k.keyid)
132
+ throw new Error(`log ${logUrl} lists key ${k.keyid.slice(0, 12)} whose pem has keyid ${ref.keyid.slice(0, 12)}`);
133
+ return ref;
134
+ });
135
+ return { doc, keys };
136
+ }
package/docs/usage.md CHANGED
@@ -105,7 +105,7 @@ An upstream need not be an MCP server. A plain HTTP API is described as tools:
105
105
  "log": { "url": "https://log.example.com/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN" }
106
106
  ```
107
107
 
108
- Exactly one of the two. The bearer token comes from the named environment variable, never from the file, and a missing variable fails at startup. Add `"hashOnly": true` for any log run by someone else: the gateway then sends only the leaf hash, sha256 of the receipt envelope with the RFC 6962 prefix, so the log commits to the receipt without ever holding it, and the receipts with their arguments and results stay in `receiptsDir`. The verifier does not change; it hashes the envelope itself. A log that serves several tenants is reached at `<url>/t/<tenant>/`, and each of its tree heads names its log, which a verifier checks with `--log-id`. With a remote log the tree head in each receipt is signed by the log's key, and a verifier must be given that key with `--log-key`. If the log refuses a leaf, the receipt is not issued and the call returns an error to the agent. For an ordinary call the upstream action has already happened by then, and the error says so; a receipt that was never logged must not be handed out. For a tool named in `precommit` the order is reversed, below, and the action never happens. The reference log server is `node src/cli.ts log --file log.jsonl --key keys/log.key --port 8787 --token-env AGENT_CUSTODY_LOG_TOKEN [--log-id <id>] [--tenants tenants.json]`. It serves `POST /append` with `{leaf}` or `{leafHash}` (token required when one is configured), `GET /root?size=N`, `GET /consistency?old=M&new=N`, and `GET /head`; [verification.md](verification.md) says what each proves. `--log-id` writes that id into every tree head. `--tenants` names a JSON file, `{ "acme": { "file": "acme.jsonl", "tokenEnv": "ACME_TOKEN", "logId": "acme-eu" } }`, and each tenant is its own log at `/t/acme/…` with its own token and id; the default log stays at the root paths. [deploy/](../../deploy/README.md) runs the server as a container.
108
+ Exactly one of the two. The bearer token comes from the named environment variable, never from the file, and a missing variable fails at startup. Add `"hashOnly": true` for any log run by someone else: the gateway then sends only the leaf hash, sha256 of the receipt envelope with the RFC 6962 prefix, so the log commits to the receipt without ever holding it, and the receipts with their arguments and results stay in `receiptsDir`. The verifier does not change; it hashes the envelope itself. A log that serves several tenants is reached at `<url>/t/<tenant>/`, and each of its tree heads names its log, which a verifier checks with `--log-id`. With a remote log the tree head in each receipt is signed by the log's key, and a verifier must be given that key with `--log-key`. If the log refuses a leaf, the receipt is not issued and the call returns an error to the agent. For an ordinary call the upstream action has already happened by then, and the error says so; a receipt that was never logged must not be handed out. For a tool named in `precommit` the order is reversed, below, and the action never happens. The reference log server is `node src/cli.ts log --file log.jsonl --key keys/log.key --port 8787 --token-env AGENT_CUSTODY_LOG_TOKEN [--log-id <id>] [--tenants tenants.json]`. It serves `POST /append` with `{leaf}` or `{leafHash}` (token required when one is configured), `GET /root?size=N`, `GET /consistency?old=M&new=N`, and `GET /head`; [verification.md](verification.md) says what each proves. `--log-id` writes that id into every tree head. `--tenants` names a JSON file, `{ "acme": { "file": "acme.jsonl", "tokenEnv": "ACME_TOKEN", "logId": "acme-eu" } }`, and each tenant is its own log at `/t/acme/…` with its own token and id; the default log stays at the root paths. With `--db-env DATABASE_URL` the server keeps its logs in Postgres instead of files, and needs the `pg` package beside it: leaves as hashes in one table keyed by tenant, one writer per tenant enforced with an advisory lock so a second instance is safe, tenants and their tokens in tables of their own with tokens stored only as hashes, and rate limits per token (50 appends a second, burst 100, a 64 KB body cap; a refused append answers 429 with `retry-after`, and the gateway's sink retries a few times). Tenants are managed with `log-admin --db-env DATABASE_URL`: `tenant add <id> [--log-id <id>]`, `token add <tenant> --label <text>` (the token is printed once), `token revoke <tenant> <hash-prefix>`, `tenant disable <id>`, and `import --file log.jsonl [--tenant default]` to bring an existing file log in as hashes. The root paths serve the tenant `default`, created on first start with `--log-id`, and `--token-env` still works for it. The key that signs tree heads can live in its own process: `agent-custody signer --key keys/log.key --port 8790 --token-env SIGNER_TOKEN` holds it and answers `POST /sign` with the shared secret and `GET /keys` to anyone; the log server then runs with `--signer-url http://signer:8790/ --signer-token-env SIGNER_TOKEN` instead of `--key`, and the process that faces the internet never holds the key. Either way the log serves its keys at `/.well-known/agent-custody-log.json`, current key first and retired keys (`--retired-key old.pub`) after it, so verifiers fetch and pin them with `verify --log-url` and `audit --log-url` rather than receiving a key file from the operator. With `--checkpoint-dir <dir>` the server publishes a signed checkpoint, every `--checkpoint-every` seconds (default 300), for each log whose tree has grown, as `<dir>/<tenant>/<treeSize>.json` and `latest.json`, and with a database also as rows; `GET /checkpoints?since=<size>` and `GET /t/<tenant>/checkpoints` list them. Serve the directory read-only from a second host, so the record of what the log signed does not depend on the log's API being up; a verifier who kept an earlier head audits against a later checkpoint with `audit --older <bundle> --newer <checkpoint> --log-url <url>`. With `--admin-token-env ADMIN_TOKEN` (Postgres only) the server also serves the operator's page at `/admin` and its API under `/admin/`: list and create tenants, mint a token that is shown once beside the tenant's welcome sheet, revoke tokens, disable tenants. Every route needs the admin token as a bearer; the page keeps it in the browser session. `--public-url` and `--checkpoints-url` fill the sheet in. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers.
109
109
 
110
110
  `otel`, optional in both the gateway and SDK configs, sends every receipt to the collector you already run as one span over OTLP/HTTP, after the receipt is issued: `"otel": { "url": "http://localhost:4318", "headersEnv": { "x-api-key": "OTEL_KEY" }, "serviceName": "support-agents" }`. The span's trace id is the receipt id, its attributes carry the tool, agent, principal, execution status, policy decision, and log position, and its status is an error only when the upstream failed or errored, since a denial is the policy working. Export is best effort: a collector that is down or refuses costs a line on stderr, never a receipt. Tutorial 18 shows it against a stand-in collector.
111
111
 
@@ -161,7 +161,9 @@ An inclusion proof says a receipt was in the log at one moment. It does not say
161
161
 
162
162
  ```bash
163
163
  node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log log.jsonl --issuer-key keys/gateway.pub
164
- node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log-url https://log.example.com/t/acme/ --log-key keys/log.pub --log-id acme
164
+ node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log-url https://log.example.com/t/acme/ --log-id acme
165
+
166
+ `--log-url` also fetches the log's published keys from `/.well-known/agent-custody-log.json` and pins them by keyid, so no key file changes hands; `--log-key` still works for a key you were handed. The same flag on `verify` does the same for a receipt.
165
167
  ```
166
168
 
167
169
  Both tree heads must be signed by a trusted key. With `--log` the proof is computed from a copy of the log; with `--log-url` it is fetched from the log's `GET /consistency?old=M&new=N`. Exit code 0 means the newer log extends the older one. A failure means either history was rewritten between the two heads or the proof belongs to other tree heads; example 14 shows a rewritten log failing this way while every individual receipt still verifies.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Chain of custody for AI agents: signed, independently verifiable receipts for tool calls. MCP gateway + Cedar policy + Merkle transparency log",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -72,12 +72,14 @@
72
72
  "ai": "^7.0.92",
73
73
  "tsx": "^4.23.13",
74
74
  "typescript": "^7.0.2",
75
- "vitest": "^5.0.0"
75
+ "vitest": "^5.0.0",
76
+ "@electric-sql/pglite": "0.5.8"
76
77
  },
77
78
  "peerDependencies": {
78
79
  "@langchain/core": ">=1.0.0",
79
80
  "@openai/agents": ">=0.1.0",
80
- "ai": ">=5.0.0"
81
+ "ai": ">=5.0.0",
82
+ "pg": ">=8"
81
83
  },
82
84
  "peerDependenciesMeta": {
83
85
  "@langchain/core": {
@@ -88,6 +90,9 @@
88
90
  },
89
91
  "ai": {
90
92
  "optional": true
93
+ },
94
+ "pg": {
95
+ "optional": true
91
96
  }
92
97
  }
93
98
  }