@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.
package/README.md CHANGED
@@ -225,6 +225,9 @@ If a vendor tells you their receipts prove more than the first five rows, ask th
225
225
  src/config.ts gateway and SDK config schemas, path resolution
226
226
  src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
227
227
  src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
228
+ src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
229
+ src/checkpoints.ts signed heads published on a schedule, to files and to Postgres
230
+ src/log-store.ts the log server's backends: the file, and Postgres with tenants, hashed tokens, one writer per tenant, rate limits
228
231
  src/log-sink.ts where leaves go: the local file, or a remote log over HTTP; plus the reference log server
229
232
  src/policy.ts Cedar evaluation wrapper, fail-closed
230
233
  src/delegation.ts signed delegation grants
@@ -277,6 +280,8 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
277
280
  - Remote log: the issuer can append to a log run by someone else over HTTP, whose key then signs the tree heads, so a verifier learns the receipt was in a log the operator could not rewrite. Includes the reference log server, bearer-token auth, and a root endpoint for auditors.
278
281
  - Framework adapters, each tested against the real package with a scripted model and no network: OpenAI Agents SDK (`wrapTools` enforces, `observeRunner` records from lifecycle events), Vercel AI SDK (`wrapTools` over a real `generateText` loop), LangChain (`ReceiptCallbackHandler` records, `issuer.wrap` enforces).
279
282
 
283
+ - The signer, keys, and checkpoints: the key in its own process (`signer`, `--signer-url`), the key document at `/.well-known/agent-custody-log.json` fetched and pinned by `verify --log-url` and `audit --log-url`, and signed checkpoints per log published to a directory and a table for a verifier who was not watching. Phase 3 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
284
+ - The log over Postgres: `log --db-env`, leaves as hashes in one table keyed by tenant, one writer per tenant by advisory lock, tenants and hashed tokens in tables managed by `log-admin`, rate limits and a body cap, retries in the sink, and `import` for an existing file log. Phase 2 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
280
285
  - A log for someone else: `hashOnly` sends leaf hashes so the log never holds a receipt; the reference server runs several tenant logs at `/t/<tenant>/` with their own tokens and ids; tree heads name their log and the verifier checks it with `--log-id`. Phase 1 of the hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
281
286
  - OpenTelemetry export: with `otel` in either config, every receipt is also one span at the collector the team already runs, trace id equal to the receipt id, attributes for tool, agent, principal, status, decision, and log position; after the receipt, best effort, never on the evidence path.
282
287
  - The REST connector: a plain HTTP API described as tools in the gateway config, credentials from the environment, so an agent's direct API calls become receipted, policy-checked tool calls through the gateway.
@@ -284,7 +289,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
284
289
 
285
290
  **Next, in the order it pays off**
286
291
 
287
- 1. The hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6), phases 2 and 3: the Postgres store with one writer per tenant, tokens and rate limits, the signer as its own process, published checkpoints, and well-known keys fetched with `--log-url`. Phase 1 is done; it comes before everything below.
292
+ 1. The hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6): running at log.agent-custody.dev with keys published and checkpoints on a second host, taking its first tenants. What remains is the witness that countersigns checkpoints.
288
293
  2. Post-quantum signatures: ML-DSA beside Ed25519 in the same DSSE envelope, hybrid by default when a PQ key is present, in every signed artefact and in the browser verifier. [Issue #11](https://github.com/ch4r10t33r/agent-custody/issues/11).
289
294
  3. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
290
295
  4. Delegation chains for sub-agents.
@@ -0,0 +1,26 @@
1
+ import type { Envelope } from "./crypto.ts";
2
+ import type { PostgresLike } from "./log-store.ts";
3
+ export interface Checkpoint {
4
+ tenant: string;
5
+ logId: string | undefined;
6
+ treeSize: number;
7
+ rootHash: string;
8
+ signedAt: string;
9
+ envelope: Envelope;
10
+ }
11
+ export interface CheckpointStore {
12
+ save(c: Checkpoint): Promise<void>;
13
+ /** checkpoints of a tenant with treeSize > since, oldest first */
14
+ list(tenant: string, since?: number): Promise<Checkpoint[]>;
15
+ latest(tenant: string): Promise<Checkpoint | null>;
16
+ }
17
+ /** Files: <dir>/<tenant>/<treeSize>.json and <dir>/<tenant>/latest.json. Serve the directory read-only from the checkpoints host. */
18
+ export declare function dirCheckpoints(dir: string): CheckpointStore;
19
+ /** Rows in <prefix>heads, one per tenant and tree size. */
20
+ export declare function postgresCheckpoints(client: PostgresLike, prefix?: string): CheckpointStore;
21
+ /**
22
+ * Writes every checkpoint to each store: the directory the checkpoints host serves and the database the API lists
23
+ * from. `latest` is the store that is furthest behind, so a store that missed a write (a directory that was not yet
24
+ * writable, say) is caught up on the next publication; saves are idempotent in every store.
25
+ */
26
+ export declare function bothCheckpoints(...stores: CheckpointStore[]): CheckpointStore;
@@ -0,0 +1,95 @@
1
+ // Checkpoints: signed tree heads published on a schedule to a place the log's own API does not have to be up to
2
+ // serve. A verifier who kept a head can fetch a later checkpoint and the consistency proof between them, and
3
+ // learn that nothing was rewritten while nobody was watching. On disk they are plain files, one per tree size,
4
+ // meant to be served statically from a second host; in Postgres they are rows, so the API can list them too.
5
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ const safe = (s) => s.replace(/[^A-Za-z0-9_.-]/g, "_");
8
+ /** Files: <dir>/<tenant>/<treeSize>.json and <dir>/<tenant>/latest.json. Serve the directory read-only from the checkpoints host. */
9
+ export function dirCheckpoints(dir) {
10
+ const folder = (tenant) => join(dir, safe(tenant));
11
+ const read = (tenant, name) => {
12
+ try {
13
+ return JSON.parse(readFileSync(join(folder(tenant), name), "utf8"));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ };
19
+ return {
20
+ async save(c) {
21
+ mkdirSync(folder(c.tenant), { recursive: true });
22
+ const text = JSON.stringify(c, null, 2);
23
+ writeFileSync(join(folder(c.tenant), `${c.treeSize}.json`), text);
24
+ writeFileSync(join(folder(c.tenant), "latest.json"), text);
25
+ },
26
+ async list(tenant, since = -1) {
27
+ let names;
28
+ try {
29
+ names = readdirSync(folder(tenant));
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ return names
35
+ .filter((n) => /^\d+\.json$/.test(n))
36
+ .map((n) => Number(n.slice(0, -5)))
37
+ .filter((n) => n > since)
38
+ .sort((a, b) => a - b)
39
+ .map((n) => read(tenant, `${n}.json`))
40
+ .filter((c) => c !== null);
41
+ },
42
+ async latest(tenant) {
43
+ return read(tenant, "latest.json");
44
+ },
45
+ };
46
+ }
47
+ /** Rows in <prefix>heads, one per tenant and tree size. */
48
+ export function postgresCheckpoints(client, prefix = "log_") {
49
+ if (!/^[a-z_][a-z0-9_]*$/.test(prefix))
50
+ throw new Error(`prefix must be a plain lowercase identifier; got "${prefix}"`);
51
+ const table = `${prefix}heads`;
52
+ let ready = null;
53
+ const init = () => (ready ??= client.query(`CREATE TABLE IF NOT EXISTS ${table} (tenant_id TEXT NOT NULL, tree_size BIGINT NOT NULL, log_id TEXT, root_hash TEXT NOT NULL, signed_at TIMESTAMPTZ NOT NULL, envelope TEXT NOT NULL, PRIMARY KEY (tenant_id, tree_size))`).then(() => { }));
54
+ const row = (r) => ({ tenant: String(r.tenant_id), logId: r.log_id ? String(r.log_id) : undefined, treeSize: Number(r.tree_size), rootHash: String(r.root_hash), signedAt: new Date(r.signed_at).toISOString(), envelope: JSON.parse(String(r.envelope)) });
55
+ return {
56
+ async save(c) {
57
+ await init();
58
+ await client.query(`INSERT INTO ${table} (tenant_id, tree_size, log_id, root_hash, signed_at, envelope) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (tenant_id, tree_size) DO NOTHING`, [c.tenant, c.treeSize, c.logId ?? null, c.rootHash, c.signedAt, JSON.stringify(c.envelope)]);
59
+ },
60
+ async list(tenant, since = -1) {
61
+ await init();
62
+ return (await client.query(`SELECT tenant_id, tree_size, log_id, root_hash, signed_at, envelope FROM ${table} WHERE tenant_id = $1 AND tree_size > $2 ORDER BY tree_size`, [tenant, since])).rows.map(row);
63
+ },
64
+ async latest(tenant) {
65
+ await init();
66
+ const rows = (await client.query(`SELECT tenant_id, tree_size, log_id, root_hash, signed_at, envelope FROM ${table} WHERE tenant_id = $1 ORDER BY tree_size DESC LIMIT 1`, [tenant])).rows;
67
+ return rows[0] ? row(rows[0]) : null;
68
+ },
69
+ };
70
+ }
71
+ /**
72
+ * Writes every checkpoint to each store: the directory the checkpoints host serves and the database the API lists
73
+ * from. `latest` is the store that is furthest behind, so a store that missed a write (a directory that was not yet
74
+ * writable, say) is caught up on the next publication; saves are idempotent in every store.
75
+ */
76
+ export function bothCheckpoints(...stores) {
77
+ return {
78
+ async save(c) {
79
+ for (const s of stores)
80
+ await s.save(c);
81
+ },
82
+ list: (t, since) => stores[0].list(t, since),
83
+ async latest(t) {
84
+ let behind;
85
+ for (const s of stores) {
86
+ const l = await s.latest(t);
87
+ if (l === null)
88
+ return null;
89
+ if (behind === undefined || l.treeSize < behind.treeSize)
90
+ behind = l;
91
+ }
92
+ return behind ?? null;
93
+ },
94
+ };
95
+ }
package/dist/cli.js CHANGED
@@ -6,7 +6,12 @@ import { loadConfig, loadSdkConfig } from "./config.js";
6
6
  import { generateKeyPair, loadPrivateKey, loadPublicKey, writeKeyPair } from "./crypto.js";
7
7
  import { createDelegation } from "./delegation.js";
8
8
  import { createGateway, serveStdio } from "./gateway.js";
9
- import { serveLog } from "./log-sink.js";
9
+ import { postgresResolver, serveLog } from "./log-sink.js";
10
+ import { importLogFile, PostgresTenancy } from "./log-store.js";
11
+ import { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
12
+ import { connectSigner, fetchLogKeys, localSigner, serveSigner } from "./signer.js";
13
+ import { CheckpointPublisher, fileResolver } from "./log-sink.js";
14
+ import { createRequire } from "node:module";
10
15
  import { pruneLog } from "./retention.js";
11
16
  import { serveSidecar } from "./sidecar.js";
12
17
  import { MerkleLog } from "./log.js";
@@ -30,10 +35,43 @@ const USAGE = `agent-custody <command>
30
35
  prune --log <log.jsonl> --before <ISO instant> [--receipts <dir>]
31
36
  retention on the receipt log: replaces older leaves with their hashes, so proofs still verify and the content is gone
32
37
  log --file <log.jsonl> --key <log.key> [--port 8787] [--host 127.0.0.1] [--token-env <NAME>] reference log server
33
- verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub>] [--log-id <id>] [--upstream-key <pub>] [--stripe-secret-env NAME] [--github-secret-env NAME] [--log <log.jsonl>] [--json]
34
- audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) --issuer-key <pub> [--log-key <pub>] [--log-id <id>] [--json]
38
+ verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub> | --log-url <url>] [--log-id <id>] [--upstream-key <pub>] [--stripe-secret-env NAME] [--github-secret-env NAME] [--log <log.jsonl>] [--json]
39
+ log ... --db-env NAME the same server over Postgres: tenants and tokens from the database, one writer per tenant,
40
+ root paths serve the tenant "default" (created with --log-id). Needs the pg package.
41
+ log ... (--key <log.key> [--retired-key <pub>]... | --signer-url <url> [--signer-token-env NAME]) [--checkpoint-dir <dir>] [--checkpoint-every <seconds>]
42
+ sign with a key in this process, or through a signer process that holds it; publish a signed
43
+ checkpoint per log that has grown, every 300 s by default, to the directory (and, with a
44
+ database, to its heads table); serve the key document at /.well-known/agent-custody-log.json
45
+ log ... --db-env NAME --admin-token-env NAME [--public-url <https://log.example.com/>] [--checkpoints-url <https://checkpoints.example.com/>]
46
+ the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
47
+ the welcome sheet; the public URLs fill the sheet in
48
+ signer --key <log.key> --port 8790 [--host 127.0.0.1] [--token-env NAME] [--retired-key <pub>]...
49
+ the one process that holds the log's key: POST /sign, GET /keys
50
+ log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
51
+ log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
52
+ log-admin --db-env NAME import --file <log.jsonl> [--tenant default] copies a file log into the database as hashes
53
+ audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) [--issuer-key <pub>] [--log-key <pub>] [--log-id <id>] [--json]
54
+ with --log-url the log's published keys are fetched and pinned by keyid
35
55
  checks that the newer receipt's log extends the older one's: nothing between them was rewritten
36
56
  `;
57
+ /** A retired public key for the key document, from a .pub file; still listed so heads it signed keep verifying. */
58
+ function retiredKey(pubFile) {
59
+ return { key: loadPublicKey(pubFile), pem: readFileSync(pubFile, "utf8") };
60
+ }
61
+ /** A pg Pool from the URL in an environment variable. pg is an optional peer: it is loaded only here, and its absence says what to install. */
62
+ function openPostgres(envName) {
63
+ const url = process.env[envName];
64
+ if (!url)
65
+ throw new Error(`environment variable ${envName} is not set`);
66
+ let Pool;
67
+ try {
68
+ ({ Pool } = createRequire(import.meta.url)("pg"));
69
+ }
70
+ catch {
71
+ throw new Error("a Postgres log needs the pg package: npm install pg");
72
+ }
73
+ return new Pool({ connectionString: url });
74
+ }
37
75
  /** The tenants file for `log --tenants`: paths relative to the file, tokens from the environment, ids default to the tenant name. */
38
76
  function loadTenants(path) {
39
77
  const raw = JSON.parse(readFileSync(path, "utf8"));
@@ -118,6 +156,66 @@ async function main(argv) {
118
156
  await running.close();
119
157
  return 0;
120
158
  }
159
+ case "signer": {
160
+ const { values } = parseArgs({ args: rest, options: { key: { type: "string" }, port: { type: "string", default: "8790" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "retired-key": { type: "string", multiple: true } } });
161
+ if (!values.key)
162
+ throw new Error("signer needs --key");
163
+ const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
164
+ if (values["token-env"] && !token)
165
+ throw new Error(`signer: environment variable ${values["token-env"]} is not set`);
166
+ const kp = loadPrivateKey(values.key);
167
+ const running = await serveSigner(kp, { port: Number(values.port), host: values.host, ...(token ? { token } : {}), retired: (values["retired-key"] ?? []).map(retiredKey) });
168
+ console.error(`agent-custody signer: ${running.url} keyid=${kp.keyid} ${token ? "token required" : "open: bind this to a private network"}${values["retired-key"]?.length ? ` retired=${values["retired-key"].length}` : ""}`);
169
+ await new Promise((resolve) => process.once("SIGINT", resolve));
170
+ await running.close();
171
+ return 0;
172
+ }
173
+ case "log-admin": {
174
+ const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { "db-env": { type: "string" }, "log-id": { type: "string" }, label: { type: "string" }, file: { type: "string" }, tenant: { type: "string", default: "default" } } });
175
+ if (!values["db-env"])
176
+ throw new Error("log-admin needs --db-env NAME");
177
+ const tenancy = new PostgresTenancy(openPostgres(values["db-env"]));
178
+ const [what, verb, ...args] = positionals;
179
+ if (what === "tenant" && verb === "add" && args[0]) {
180
+ const t = await tenancy.addTenant(args[0], values["log-id"] ?? args[0]);
181
+ console.log(`tenant ${t.id} log=${t.logId} reached at /t/${t.id}/`);
182
+ }
183
+ else if (what === "tenant" && verb === "list") {
184
+ for (const t of await tenancy.listTenants())
185
+ console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
186
+ }
187
+ else if (what === "tenant" && verb === "disable" && args[0]) {
188
+ await tenancy.disableTenant(args[0]);
189
+ console.log(`tenant ${args[0]} disabled`);
190
+ }
191
+ else if (what === "token" && verb === "add" && args[0]) {
192
+ if (!values.label)
193
+ throw new Error("token add needs --label");
194
+ const { token, tokenHash } = await tenancy.addToken(args[0], values.label);
195
+ console.error(`token for ${args[0]} (${values.label}); shown once, stored as hash ${tokenHash.slice(0, 12)}…:`);
196
+ console.log(token);
197
+ }
198
+ else if (what === "token" && verb === "list" && args[0]) {
199
+ for (const t of await tenancy.listTokens(args[0]))
200
+ console.log(`${t.tokenHash.slice(0, 12)} ${t.label.padEnd(24)} created ${t.createdAt}${t.revokedAt ? ` REVOKED ${t.revokedAt}` : ""}`);
201
+ }
202
+ else if (what === "token" && verb === "revoke" && args[0] && args[1]) {
203
+ console.log(`revoked ${await tenancy.revokeToken(args[0], args[1])} token(s)`);
204
+ }
205
+ else if (what === "import") {
206
+ if (!values.file)
207
+ throw new Error("import needs --file <log.jsonl>");
208
+ if (!(await tenancy.tenant(values.tenant)))
209
+ throw new Error(`unknown tenant ${values.tenant}; add it first`);
210
+ const r = await importLogFile(values.file, await tenancy.log(values.tenant));
211
+ console.log(`imported ${r.added} leaf hash(es) into ${values.tenant}; the log now has ${r.total}`);
212
+ }
213
+ else {
214
+ console.error(USAGE);
215
+ return 2;
216
+ }
217
+ return 0;
218
+ }
121
219
  case "prune": {
122
220
  const { values } = parseArgs({ args: rest, options: { log: { type: "string" }, before: { type: "string" }, receipts: { type: "string" } } });
123
221
  if (!values.log || !values.before)
@@ -131,19 +229,64 @@ async function main(argv) {
131
229
  case "log": {
132
230
  const { values } = parseArgs({
133
231
  args: rest,
134
- options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "log-id": { type: "string" }, tenants: { type: "string" } },
232
+ options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "log-id": { type: "string" }, tenants: { type: "string" }, "db-env": { type: "string" }, "signer-url": { type: "string" }, "signer-token-env": { type: "string" }, "retired-key": { type: "string", multiple: true }, "checkpoint-dir": { type: "string" }, "checkpoint-every": { type: "string", default: "300" }, "admin-token-env": { type: "string" }, "public-url": { type: "string" }, "checkpoints-url": { type: "string" } },
135
233
  });
136
- if (!values.file || !values.key)
137
- throw new Error("log needs --file and --key");
234
+ if (!values.key === !values["signer-url"])
235
+ throw new Error("log needs exactly one of --key or --signer-url");
138
236
  const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
139
237
  if (values["token-env"] && !token)
140
238
  throw new Error(`log: environment variable ${values["token-env"]} is not set`);
141
- const key = loadPrivateKey(values.key);
142
- // --tenants names a JSON file { "<tenant>": { "file": "...", "tokenEnv": "NAME", "logId": "..." } }; each is reached at /t/<tenant>/.
143
- const tenants = values.tenants ? loadTenants(values.tenants) : undefined;
144
- const running = await serveLog(values.file, key, { port: Number(values.port), host: values.host, ...(token ? { tokens: [token] } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}), ...(tenants ? { tenants } : {}) });
145
- console.error(`agent-custody log: ${running.url} keyid=${key.keyid} file=${values.file}${values["log-id"] ? ` log=${values["log-id"]}` : ""} ${token ? "bearer token required" : "open, anyone may append"}${tenants ? ` tenants=${Object.keys(tenants).join(",")}` : ""}`);
239
+ // The signer: a key in this process, or the signer service that holds it.
240
+ let signer;
241
+ if (values.key)
242
+ signer = localSigner(loadPrivateKey(values.key), { retired: (values["retired-key"] ?? []).map(retiredKey) });
243
+ else {
244
+ const st = values["signer-token-env"] ? process.env[values["signer-token-env"]] : undefined;
245
+ if (values["signer-token-env"] && !st)
246
+ throw new Error(`log: environment variable ${values["signer-token-env"]} is not set`);
247
+ signer = await connectSigner(values["signer-url"], st ? { token: st } : {});
248
+ }
249
+ const everyMs = Number(values["checkpoint-every"]) * 1000;
250
+ if (!(everyMs > 0))
251
+ throw new Error("--checkpoint-every must be a positive number of seconds");
252
+ let resolver;
253
+ let checkpoints = values["checkpoint-dir"] ? dirCheckpoints(values["checkpoint-dir"]) : undefined;
254
+ let where;
255
+ let admin;
256
+ if (values["db-env"]) {
257
+ // Postgres: the file is not used; tenants, tokens, leaves, and checkpoints live in the database.
258
+ const client = openPostgres(values["db-env"]);
259
+ const tenancy = new PostgresTenancy(client);
260
+ const defaultId = values["log-id"] ?? "default";
261
+ if (!(await tenancy.tenant("default")))
262
+ await tenancy.addTenant("default", defaultId);
263
+ resolver = postgresResolver(tenancy, { defaultTenant: "default", ...(token ? { staticTokens: [token] } : {}) });
264
+ const table = postgresCheckpoints(client);
265
+ checkpoints = checkpoints ? bothCheckpoints(table, checkpoints) : table;
266
+ if (values["admin-token-env"]) {
267
+ const adminToken = process.env[values["admin-token-env"]];
268
+ if (!adminToken)
269
+ throw new Error(`log: environment variable ${values["admin-token-env"]} is not set`);
270
+ admin = { tenancy, token: adminToken, ...(values["public-url"] ? { publicUrl: values["public-url"] } : {}), ...(values["checkpoints-url"] ? { checkpointsUrl: values["checkpoints-url"] } : {}) };
271
+ }
272
+ where = `store=postgres default-log=${(await tenancy.tenant("default"))?.logId} ${token ? "environment token accepted for the default log; " : ""}tokens from the database`;
273
+ }
274
+ else {
275
+ if (values["admin-token-env"])
276
+ throw new Error("the admin page needs --db-env; tenants live in the database");
277
+ if (!values.file)
278
+ throw new Error("log needs --file, or --db-env");
279
+ // --tenants names a JSON file { "<tenant>": { "file": "...", "tokenEnv": "NAME", "logId": "..." } }; each is reached at /t/<tenant>/.
280
+ const tenants = values.tenants ? loadTenants(values.tenants) : undefined;
281
+ resolver = fileResolver(values.file, { ...(token ? { tokens: [token] } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}), ...(tenants ? { tenants } : {}) });
282
+ where = `file=${values.file}${values["log-id"] ? ` log=${values["log-id"]}` : ""} ${token ? "bearer token required" : "open, anyone may append"}${tenants ? ` tenants=${Object.keys(tenants).join(",")}` : ""}`;
283
+ }
284
+ const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}), ...(admin ? { admin } : {}) });
285
+ const publisher = checkpoints ? new CheckpointPublisher(resolver, signer, checkpoints, everyMs) : null;
286
+ publisher?.start();
287
+ console.error(`agent-custody log: ${running.url} keyid=${signer.keyid} ${values["signer-url"] ? `signer=${values["signer-url"]} ` : ""}${where}${checkpoints ? ` checkpoints every ${values["checkpoint-every"]}s${values["checkpoint-dir"] ? ` to ${values["checkpoint-dir"]}` : ""}` : ""}${admin ? " admin page at /admin" : ""}`);
146
288
  await new Promise((resolve) => process.once("SIGINT", resolve));
289
+ publisher?.stop();
147
290
  await running.close();
148
291
  return 0;
149
292
  }
@@ -156,6 +299,7 @@ async function main(argv) {
156
299
  "gateway-key": { type: "string", multiple: true },
157
300
  "principal-key": { type: "string", multiple: true },
158
301
  "log-key": { type: "string", multiple: true },
302
+ "log-url": { type: "string" },
159
303
  "log-id": { type: "string" },
160
304
  "upstream-key": { type: "string", multiple: true },
161
305
  "stripe-secret-env": { type: "string" },
@@ -169,10 +313,12 @@ async function main(argv) {
169
313
  if (!file || issuerKeyFiles.length === 0)
170
314
  throw new Error("verify needs <bundle> --issuer-key (alias --gateway-key)");
171
315
  const bundle = JSON.parse(readFileSync(file, "utf8"));
316
+ const fetchedLogKeys = values["log-url"] ? (await fetchLogKeys(values["log-url"])).keys : [];
317
+ const logKeys = [...(values["log-key"] ?? []).map(loadPublicKey), ...fetchedLogKeys];
172
318
  const result = verifyBundle(bundle, {
173
319
  issuerKeys: issuerKeyFiles.map(loadPublicKey),
174
320
  principalKeys: (values["principal-key"] ?? []).map(loadPublicKey),
175
- ...(values["log-key"] ? { logKeys: values["log-key"].map(loadPublicKey) } : {}),
321
+ ...(logKeys.length ? { logKeys } : {}),
176
322
  ...(values["log-id"] ? { logId: values["log-id"] } : {}),
177
323
  ...(values["upstream-key"] ? { upstreamKeys: values["upstream-key"].map(loadPublicKey) } : {}),
178
324
  ...(values["stripe-secret-env"] || values["github-secret-env"] ? { providerSecrets: { ...(values["stripe-secret-env"] ? { stripe: secretFrom(values["stripe-secret-env"]) } : {}), ...(values["github-secret-env"] ? { github: secretFrom(values["github-secret-env"]) } : {}) } } : {}),
@@ -196,10 +342,13 @@ async function main(argv) {
196
342
  },
197
343
  });
198
344
  const keyFiles = [...(values["issuer-key"] ?? []), ...(values["log-key"] ?? [])];
199
- if (!values.older || !values.newer || keyFiles.length === 0)
200
- throw new Error("audit needs --older, --newer, and at least one --issuer-key or --log-key");
345
+ if (!values.older || !values.newer)
346
+ throw new Error("audit needs --older and --newer");
201
347
  if (!values.log === !values["log-url"])
202
348
  throw new Error("audit needs exactly one of --log or --log-url");
349
+ const auditKeys = [...keyFiles.map(loadPublicKey), ...(values["log-url"] ? (await fetchLogKeys(values["log-url"])).keys : [])];
350
+ if (auditKeys.length === 0)
351
+ throw new Error("audit needs a key: --issuer-key, --log-key, or a --log-url that publishes its keys");
203
352
  const older = JSON.parse(readFileSync(values.older, "utf8")).treeHead;
204
353
  const newer = JSON.parse(readFileSync(values.newer, "utf8")).treeHead;
205
354
  const sizeOf = (env) => JSON.parse(Buffer.from(env.payload, "base64").toString()).treeSize;
@@ -213,7 +362,7 @@ async function main(argv) {
213
362
  throw new Error(`log refused the consistency query: ${res.status}`);
214
363
  proof = (await res.json()).hashes;
215
364
  }
216
- const result = auditExtends(older, newer, proof, keyFiles.map(loadPublicKey), values["log-id"]);
365
+ const result = auditExtends(older, newer, proof, auditKeys, values["log-id"]);
217
366
  if (values.json)
218
367
  console.log(JSON.stringify(result, null, 2));
219
368
  else {
package/dist/index.d.ts CHANGED
@@ -3,6 +3,14 @@ export type { AuthorizationBundle, AuthorizationPredicate, AuthorizationStatemen
3
3
  export type { GatewayOptions } from "./gateway.ts";
4
4
  export { buildRequest, restUpstream } from "./rest.ts";
5
5
  export { openExporter, otlpExporter, spanFor } from "./otel.ts";
6
+ export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.ts";
7
+ export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.ts";
8
+ export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
9
+ export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
10
+ export { adminRoutes, welcomeSheet } from "./log-admin.ts";
11
+ export type { AdminOptions } from "./log-admin.ts";
12
+ export type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
13
+ export type { AppendResult, LogBackend, PostgresLike, PostgresLogOptions, RateLimitOptions, Tenant, TokenRecord } from "./log-store.ts";
6
14
  export type { OtelConfig, OtlpOptions, ReceiptExporter } from "./otel.ts";
7
15
  export type { IssuerOptions } from "./issue.ts";
8
16
  export type { RestOptions, UpstreamClient } from "./rest.ts";
package/dist/index.js CHANGED
@@ -2,6 +2,10 @@
2
2
  export { AUTHORIZATION_PREDICATE_TYPE, buildAuthorizationStatement } from "./receipt.js";
3
3
  export { buildRequest, restUpstream } from "./rest.js";
4
4
  export { openExporter, otlpExporter, spanFor } from "./otel.js";
5
+ export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.js";
6
+ export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.js";
7
+ export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
8
+ export { adminRoutes, welcomeSheet } from "./log-admin.js";
5
9
  export * from "./config.js";
6
10
  export * from "./crypto.js";
7
11
  export * from "./delegation.js";
@@ -0,0 +1,33 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { PostgresTenancy } from "./log-store.ts";
3
+ export interface AdminOptions {
4
+ tenancy: PostgresTenancy;
5
+ /** the admin token; every /admin route needs it as a bearer */
6
+ token: string;
7
+ /** the log's public base URL, for the welcome sheet, e.g. https://log.example.com/ */
8
+ publicUrl?: string;
9
+ /** the checkpoints host, e.g. https://checkpoints.example.com/ */
10
+ checkpointsUrl?: string;
11
+ /** the current signing keyid, for the sheet */
12
+ keyid?: string;
13
+ }
14
+ /** The welcome sheet as text, the same one deploy/onboard-tenant.sh prints. */
15
+ export declare function welcomeSheet(o: {
16
+ tenant: string;
17
+ logId: string;
18
+ publicUrl: string;
19
+ checkpointsUrl?: string;
20
+ keyid?: string;
21
+ }): string;
22
+ /**
23
+ * Routes under /admin. Returns true when it handled the request.
24
+ * GET /admin the page
25
+ * GET /admin/info { publicUrl, checkpointsUrl, keyid }
26
+ * GET /admin/tenants [{ id, logId, createdAt, disabledAt, tokens }]
27
+ * POST /admin/tenants { id, logId? } the tenant
28
+ * POST /admin/tenants/:id/disable
29
+ * GET /admin/tenants/:id/tokens [{ label, tokenHash, createdAt, revokedAt }]
30
+ * POST /admin/tenants/:id/tokens { label } { token, tokenHash, welcome } token shown once
31
+ * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
32
+ */
33
+ export declare function adminRoutes(opts: AdminOptions): (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<boolean>;