@agent-custody/receipts 0.5.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
@@ -289,7 +289,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
289
289
 
290
290
  **Next, in the order it pays off**
291
291
 
292
- 1. The hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6): phases 1 to 3 are done; what remains is running it for the first tenant, and then a witness that countersigns checkpoints.
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.
293
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).
294
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.
295
295
  4. Delegation chains for sub-agents.
@@ -18,5 +18,9 @@ export interface CheckpointStore {
18
18
  export declare function dirCheckpoints(dir: string): CheckpointStore;
19
19
  /** Rows in <prefix>heads, one per tenant and tree size. */
20
20
  export declare function postgresCheckpoints(client: PostgresLike, prefix?: string): CheckpointStore;
21
- /** Writes every checkpoint to each store: the directory the checkpoints host serves and the database the API lists from. */
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
+ */
22
26
  export declare function bothCheckpoints(...stores: CheckpointStore[]): CheckpointStore;
@@ -68,7 +68,11 @@ export function postgresCheckpoints(client, prefix = "log_") {
68
68
  },
69
69
  };
70
70
  }
71
- /** Writes every checkpoint to each store: the directory the checkpoints host serves and the database the API lists from. */
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
+ */
72
76
  export function bothCheckpoints(...stores) {
73
77
  return {
74
78
  async save(c) {
@@ -76,6 +80,16 @@ export function bothCheckpoints(...stores) {
76
80
  await s.save(c);
77
81
  },
78
82
  list: (t, since) => stores[0].list(t, since),
79
- latest: (t) => stores[0].latest(t),
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
+ },
80
94
  };
81
95
  }
package/dist/cli.js CHANGED
@@ -42,6 +42,9 @@ const USAGE = `agent-custody <command>
42
42
  sign with a key in this process, or through a signer process that holds it; publish a signed
43
43
  checkpoint per log that has grown, every 300 s by default, to the directory (and, with a
44
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
45
48
  signer --key <log.key> --port 8790 [--host 127.0.0.1] [--token-env NAME] [--retired-key <pub>]...
46
49
  the one process that holds the log's key: POST /sign, GET /keys
47
50
  log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
@@ -226,7 +229,7 @@ async function main(argv) {
226
229
  case "log": {
227
230
  const { values } = parseArgs({
228
231
  args: rest,
229
- 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" } },
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" } },
230
233
  });
231
234
  if (!values.key === !values["signer-url"])
232
235
  throw new Error("log needs exactly one of --key or --signer-url");
@@ -249,6 +252,7 @@ async function main(argv) {
249
252
  let resolver;
250
253
  let checkpoints = values["checkpoint-dir"] ? dirCheckpoints(values["checkpoint-dir"]) : undefined;
251
254
  let where;
255
+ let admin;
252
256
  if (values["db-env"]) {
253
257
  // Postgres: the file is not used; tenants, tokens, leaves, and checkpoints live in the database.
254
258
  const client = openPostgres(values["db-env"]);
@@ -259,9 +263,17 @@ async function main(argv) {
259
263
  resolver = postgresResolver(tenancy, { defaultTenant: "default", ...(token ? { staticTokens: [token] } : {}) });
260
264
  const table = postgresCheckpoints(client);
261
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
+ }
262
272
  where = `store=postgres default-log=${(await tenancy.tenant("default"))?.logId} ${token ? "environment token accepted for the default log; " : ""}tokens from the database`;
263
273
  }
264
274
  else {
275
+ if (values["admin-token-env"])
276
+ throw new Error("the admin page needs --db-env; tenants live in the database");
265
277
  if (!values.file)
266
278
  throw new Error("log needs --file, or --db-env");
267
279
  // --tenants names a JSON file { "<tenant>": { "file": "...", "tokenEnv": "NAME", "logId": "..." } }; each is reached at /t/<tenant>/.
@@ -269,10 +281,10 @@ async function main(argv) {
269
281
  resolver = fileResolver(values.file, { ...(token ? { tokens: [token] } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}), ...(tenants ? { tenants } : {}) });
270
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(",")}` : ""}`;
271
283
  }
272
- const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}) });
284
+ const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}), ...(admin ? { admin } : {}) });
273
285
  const publisher = checkpoints ? new CheckpointPublisher(resolver, signer, checkpoints, everyMs) : null;
274
286
  publisher?.start();
275
- 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"]}` : ""}` : ""}`);
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" : ""}`);
276
288
  await new Promise((resolve) => process.once("SIGINT", resolve));
277
289
  publisher?.stop();
278
290
  await running.close();
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter }
7
7
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.ts";
8
8
  export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
9
9
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
10
+ export { adminRoutes, welcomeSheet } from "./log-admin.ts";
11
+ export type { AdminOptions } from "./log-admin.ts";
10
12
  export type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
11
13
  export type { AppendResult, LogBackend, PostgresLike, PostgresLogOptions, RateLimitOptions, Tenant, TokenRecord } from "./log-store.ts";
12
14
  export type { OtelConfig, OtlpOptions, ReceiptExporter } from "./otel.ts";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { openExporter, otlpExporter, spanFor } from "./otel.js";
5
5
  export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.js";
6
6
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.js";
7
7
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
8
+ export { adminRoutes, welcomeSheet } from "./log-admin.js";
8
9
  export * from "./config.js";
9
10
  export * from "./crypto.js";
10
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>;
@@ -0,0 +1,235 @@
1
+ // The operator's admin surface for a hosted log: tenants and their tokens, over HTTP behind an admin token, and a
2
+ // single page at /admin that drives it. It is for whoever runs the log, never for tenants: every route needs the
3
+ // admin token, the page keeps that token in the browser session only, and a minted token is shown once, beside the
4
+ // welcome sheet the tenant gets. Nothing here touches receipts; the log holds hashes and the panel holds names.
5
+ import { timingSafeEqual } from "node:crypto";
6
+ const same = (a, b) => {
7
+ const x = Buffer.from(a);
8
+ const y = Buffer.from(b);
9
+ return x.length === y.length && timingSafeEqual(x, y);
10
+ };
11
+ /** The welcome sheet as text, the same one deploy/onboard-tenant.sh prints. */
12
+ export function welcomeSheet(o) {
13
+ const base = o.publicUrl.endsWith("/") ? o.publicUrl : `${o.publicUrl}/`;
14
+ const url = `${base}t/${o.tenant}/`;
15
+ const lines = [
16
+ `agent-custody log: welcome sheet for tenant "${o.tenant}"`,
17
+ "",
18
+ `Your log ${url}`,
19
+ `Your log id ${o.logId}`,
20
+ ...(o.checkpointsUrl ? [`Your checkpoints ${o.checkpointsUrl.replace(/\/?$/, "/")}${o.tenant}/latest.json`] : []),
21
+ `The log's keys ${base}.well-known/agent-custody-log.json${o.keyid ? ` (current keyid ${o.keyid})` : ""}`,
22
+ "",
23
+ "Your token was shown once when it was made; the log keeps only its hash. Lose it and ask for a new one.",
24
+ "",
25
+ "In your gateway or SDK config:",
26
+ ` "log": { "url": "${url}", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN", "hashOnly": true }`,
27
+ "hashOnly means this log never receives your receipts, only their hashes.",
28
+ "",
29
+ "For whoever verifies your receipts:",
30
+ ` npx agent-custody verify receipts/<id>.json --issuer-key <your gateway.pub> --principal-key <your principal.pub> --log-url ${url} --log-id ${o.logId}`,
31
+ ` npx agent-custody audit --older receipts/<earlier>.json --newer receipts/<later>.json --log-url ${url} --log-id ${o.logId}`,
32
+ "--log-url fetches this log's published keys and pins them; --log-id makes sure the tree heads are this log's.",
33
+ "",
34
+ "What this log does not do: hold receipt contents, forge a receipt (your gateway key signs those), or, today,",
35
+ "countersign with a second independent witness. The proof table: https://agent-custody.dev/receipts/#what-a-receipt-proves-and-what-it-does-not",
36
+ ];
37
+ return lines.join("\n");
38
+ }
39
+ /**
40
+ * Routes under /admin. Returns true when it handled the request.
41
+ * GET /admin the page
42
+ * GET /admin/info { publicUrl, checkpointsUrl, keyid }
43
+ * GET /admin/tenants [{ id, logId, createdAt, disabledAt, tokens }]
44
+ * POST /admin/tenants { id, logId? } the tenant
45
+ * POST /admin/tenants/:id/disable
46
+ * GET /admin/tenants/:id/tokens [{ label, tokenHash, createdAt, revokedAt }]
47
+ * POST /admin/tenants/:id/tokens { label } { token, tokenHash, welcome } token shown once
48
+ * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
49
+ */
50
+ export function adminRoutes(opts) {
51
+ return async (req, res, url) => {
52
+ if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/"))
53
+ return false;
54
+ const json = (status, body) => {
55
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
56
+ res.end(JSON.stringify(body));
57
+ };
58
+ if (req.method === "GET" && url.pathname === "/admin") {
59
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-frame-options": "DENY", "content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'" });
60
+ res.end(ADMIN_PAGE);
61
+ return true;
62
+ }
63
+ const h = req.headers.authorization ?? "";
64
+ if (!(h.startsWith("Bearer ") && h.length > 7 && same(h.slice(7), opts.token))) {
65
+ json(401, { error: "admin token required" });
66
+ return true;
67
+ }
68
+ const body = async () => {
69
+ let text = "";
70
+ for await (const chunk of req) {
71
+ text += chunk;
72
+ if (text.length > 16_384)
73
+ throw new Error("body too large");
74
+ }
75
+ return text ? JSON.parse(text) : {};
76
+ };
77
+ try {
78
+ const t = opts.tenancy;
79
+ const parts = url.pathname.split("/").filter(Boolean); // ["admin", ...]
80
+ if (req.method === "GET" && parts.length === 2 && parts[1] === "info") {
81
+ json(200, { publicUrl: opts.publicUrl ?? null, checkpointsUrl: opts.checkpointsUrl ?? null, keyid: opts.keyid ?? null });
82
+ }
83
+ else if (req.method === "GET" && parts.length === 2 && parts[1] === "tenants") {
84
+ const tenants = await t.listTenants();
85
+ json(200, await Promise.all(tenants.map(async (x) => ({ ...x, tokens: (await t.listTokens(x.id)).filter((k) => !k.revokedAt).length }))));
86
+ }
87
+ else if (req.method === "POST" && parts.length === 2 && parts[1] === "tenants") {
88
+ const b = await body();
89
+ if (typeof b.id !== "string" || !/^[A-Za-z0-9_.-]+$/.test(b.id))
90
+ return json(400, { error: "id must be a plain identifier" }), true;
91
+ json(200, await t.addTenant(b.id, typeof b.logId === "string" && b.logId ? b.logId : b.id));
92
+ }
93
+ else if (req.method === "POST" && parts.length === 4 && parts[1] === "tenants" && parts[3] === "disable") {
94
+ await t.disableTenant(parts[2]);
95
+ json(200, { disabled: parts[2] });
96
+ }
97
+ else if (req.method === "GET" && parts.length === 4 && parts[1] === "tenants" && parts[3] === "tokens") {
98
+ json(200, await t.listTokens(parts[2]));
99
+ }
100
+ else if (req.method === "POST" && parts.length === 4 && parts[1] === "tenants" && parts[3] === "tokens") {
101
+ const b = await body();
102
+ const label = typeof b.label === "string" && b.label.trim() ? b.label.trim() : "fleet";
103
+ const tenant = await t.tenant(parts[2]);
104
+ if (!tenant)
105
+ return json(404, { error: "unknown tenant" }), true;
106
+ const minted = await t.addToken(tenant.id, label);
107
+ const welcome = opts.publicUrl ? welcomeSheet({ tenant: tenant.id, logId: tenant.logId, publicUrl: opts.publicUrl, ...(opts.checkpointsUrl ? { checkpointsUrl: opts.checkpointsUrl } : {}), ...(opts.keyid ? { keyid: opts.keyid } : {}) }) : null;
108
+ json(200, { ...minted, welcome });
109
+ }
110
+ else if (req.method === "POST" && parts.length === 6 && parts[1] === "tenants" && parts[3] === "tokens" && parts[5] === "revoke") {
111
+ json(200, { revoked: await t.revokeToken(parts[2], parts[4]) });
112
+ }
113
+ else {
114
+ json(404, { error: "not found" });
115
+ }
116
+ }
117
+ catch (e) {
118
+ json(400, { error: e instanceof Error ? e.message : String(e) });
119
+ }
120
+ return true;
121
+ };
122
+ }
123
+ /** The page. One file, no framework, no third-party requests; the admin token lives in sessionStorage for the tab. */
124
+ const ADMIN_PAGE = `<!doctype html>
125
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
126
+ <title>agent-custody log admin</title>
127
+ <style>
128
+ :root { color-scheme: light dark; --ink: #1b2430; --ink2: #5b6b7a; --line: #d7dfe5; --bg: #fafbfc; --panel: #ffffff; --accent: #0f6e63; --warn: #8a5a00; --warnbg: #fbf1dc; --mono: ui-monospace, Menlo, monospace; }
129
+ @media (prefers-color-scheme: dark) { :root { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #4fc3b0; --warn: #e2b862; --warnbg: #2d2412; } }
130
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
131
+ main { max-width: 72rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
132
+ h1 { font-size: 1.4rem; margin: 0 0 .25rem; } h2 { font-size: 1.05rem; margin: 2rem 0 .75rem; }
133
+ .sub { color: var(--ink2); margin: 0 0 1.5rem; }
134
+ .row { display: flex; gap: .6rem; flex-wrap: wrap; align-items: end; }
135
+ label { display: grid; gap: .25rem; font-size: .85rem; color: var(--ink2); }
136
+ input { font: inherit; padding: .45rem .6rem; border: 1px solid var(--line); border-radius: 4px; background: var(--panel); color: var(--ink); min-width: 14rem; }
137
+ button { font: inherit; padding: .5rem .9rem; border: 1px solid var(--accent); border-radius: 4px; background: var(--accent); color: #fff; cursor: pointer; }
138
+ button.quiet { background: transparent; color: var(--accent); }
139
+ button:disabled { opacity: .5; cursor: default; }
140
+ table { border-collapse: collapse; width: 100%; font-size: .93rem; }
141
+ th, td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
142
+ th { font-size: .78rem; letter-spacing: .04em; text-transform: uppercase; color: var(--ink2); }
143
+ code, pre { font-family: var(--mono); font-size: .86em; }
144
+ pre { background: var(--panel); border: 1px solid var(--line); border-radius: 4px; padding: .9rem 1rem; overflow-x: auto; white-space: pre-wrap; }
145
+ .once { border-left: 3px solid var(--warn); background: var(--warnbg); padding: .8rem 1rem; border-radius: 0 4px 4px 0; margin: 1rem 0; }
146
+ .muted { color: var(--ink2); } .err { color: #b3261e; } .ok { color: var(--accent); }
147
+ .tok { font-family: var(--mono); font-size: 1.05rem; word-break: break-all; user-select: all; }
148
+ [hidden] { display: none !important; }
149
+ </style>
150
+ <main>
151
+ <h1>Log admin</h1>
152
+ <p class="sub" id="where">Tenants and tokens on this log. The admin token stays in this tab.</p>
153
+ <section id="login">
154
+ <div class="row"><label>Admin token<input id="token" type="password" autocomplete="off"></label><button id="enter">Enter</button></div>
155
+ <p class="err" id="loginErr" hidden></p>
156
+ </section>
157
+ <section id="app" hidden>
158
+ <h2>Tenants</h2>
159
+ <table><thead><tr><th>tenant</th><th>log id</th><th>live tokens</th><th>created</th><th></th></tr></thead><tbody id="tenants"></tbody></table>
160
+ <h2>New tenant</h2>
161
+ <div class="row">
162
+ <label>tenant id (in the URL)<input id="tid" placeholder="acme" autocomplete="off"></label>
163
+ <label>log id (on tree heads; default = tenant id)<input id="lid" placeholder="acme-eu" autocomplete="off"></label>
164
+ <button id="addTenant">Create</button>
165
+ </div>
166
+ <h2>New token</h2>
167
+ <div class="row">
168
+ <label>tenant<input id="ttid" placeholder="acme" autocomplete="off"></label>
169
+ <label>label (which fleet)<input id="label" placeholder="support fleet" autocomplete="off"></label>
170
+ <button id="mint">Mint token</button>
171
+ </div>
172
+ <div id="minted" class="once" hidden>
173
+ <p><b>Shown once.</b> Hand it over by a channel you trust; the log keeps only its hash.</p>
174
+ <p class="tok" id="tokval"></p>
175
+ <button class="quiet" id="copyTok">Copy token</button> <button class="quiet" id="copySheet">Copy welcome sheet</button>
176
+ <pre id="sheet"></pre>
177
+ </div>
178
+ <h2>Tokens of a tenant</h2>
179
+ <div class="row"><label>tenant<input id="ltid" placeholder="acme" autocomplete="off"></label><button class="quiet" id="listTokens">List</button></div>
180
+ <table><thead><tr><th>label</th><th>hash</th><th>created</th><th>state</th><th></th></tr></thead><tbody id="tokens"></tbody></table>
181
+ <p class="muted" id="msg"></p>
182
+ </section>
183
+ </main>
184
+ <script>
185
+ (() => {
186
+ const $ = (id) => document.getElementById(id);
187
+ let token = sessionStorage.getItem("agent-custody-admin") || "";
188
+ const api = async (method, path, body) => {
189
+ const r = await fetch(path, { method, headers: { authorization: "Bearer " + token, ...(body ? { "content-type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined });
190
+ const j = await r.json().catch(() => ({}));
191
+ if (!r.ok) throw new Error(j.error || r.statusText);
192
+ return j;
193
+ };
194
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
195
+ const say = (t, cls) => { $("msg").textContent = t; $("msg").className = cls || "muted"; };
196
+ const loadTenants = async () => {
197
+ const list = await api("GET", "/admin/tenants");
198
+ $("tenants").innerHTML = list.map((t) => "<tr><td><code>" + esc(t.id) + "</code></td><td><code>" + esc(t.logId) + "</code></td><td>" + t.tokens + "</td><td>" + esc(t.createdAt.slice(0, 10)) + "</td><td>" + (t.disabledAt ? "<span class=muted>disabled</span>" : "<button class=quiet data-disable=\\"" + esc(t.id) + "\\">Disable</button>") + "</td></tr>").join("") || "<tr><td colspan=5 class=muted>none yet</td></tr>";
199
+ };
200
+ const loadTokens = async (id) => {
201
+ const list = await api("GET", "/admin/tenants/" + encodeURIComponent(id) + "/tokens");
202
+ $("tokens").innerHTML = list.map((k) => "<tr><td>" + esc(k.label) + "</td><td><code>" + esc(k.tokenHash.slice(0, 12)) + "</code></td><td>" + esc(k.createdAt.slice(0, 10)) + "</td><td>" + (k.revokedAt ? "revoked " + esc(k.revokedAt.slice(0, 10)) : "<span class=ok>live</span>") + "</td><td>" + (k.revokedAt ? "" : "<button class=quiet data-revoke=\\"" + esc(id) + "|" + esc(k.tokenHash.slice(0, 12)) + "\\">Revoke</button>") + "</td></tr>").join("") || "<tr><td colspan=5 class=muted>no tokens</td></tr>";
203
+ };
204
+ const enter = async () => {
205
+ try {
206
+ const info = await api("GET", "/admin/info");
207
+ $("where").textContent = (info.publicUrl || location.origin) + " · keyid " + (info.keyid ? info.keyid.slice(0, 12) : "?") + (info.checkpointsUrl ? " · checkpoints at " + info.checkpointsUrl : "");
208
+ $("login").hidden = true; $("app").hidden = false;
209
+ sessionStorage.setItem("agent-custody-admin", token);
210
+ await loadTenants();
211
+ } catch (e) { $("loginErr").hidden = false; $("loginErr").textContent = e.message; }
212
+ };
213
+ $("enter").onclick = () => { token = $("token").value.trim(); enter(); };
214
+ $("token").onkeydown = (e) => { if (e.key === "Enter") $("enter").click(); };
215
+ $("addTenant").onclick = async () => { try { const t = await api("POST", "/admin/tenants", { id: $("tid").value.trim(), logId: $("lid").value.trim() }); say("tenant " + t.id + " created; reached at /t/" + t.id + "/", "ok"); $("ttid").value = t.id; await loadTenants(); } catch (e) { say(e.message, "err"); } };
216
+ $("mint").onclick = async () => {
217
+ try {
218
+ const r = await api("POST", "/admin/tenants/" + encodeURIComponent($("ttid").value.trim()) + "/tokens", { label: $("label").value.trim() });
219
+ $("tokval").textContent = r.token; $("sheet").textContent = r.welcome || "(set --public-url on the server for the welcome sheet)"; $("minted").hidden = false;
220
+ say("token minted for " + $("ttid").value.trim() + "; stored as hash " + r.tokenHash.slice(0, 12), "ok");
221
+ await loadTenants();
222
+ } catch (e) { say(e.message, "err"); }
223
+ };
224
+ $("copyTok").onclick = () => navigator.clipboard.writeText($("tokval").textContent).then(() => say("token copied", "ok"));
225
+ $("copySheet").onclick = () => navigator.clipboard.writeText($("sheet").textContent).then(() => say("welcome sheet copied", "ok"));
226
+ $("listTokens").onclick = () => loadTokens($("ltid").value.trim()).catch((e) => say(e.message, "err"));
227
+ document.addEventListener("click", async (e) => {
228
+ const b = e.target.closest("button"); if (!b) return;
229
+ if (b.dataset.disable && confirm("Disable tenant " + b.dataset.disable + "? Its paths answer 404 within ten seconds.")) { try { await api("POST", "/admin/tenants/" + encodeURIComponent(b.dataset.disable) + "/disable"); await loadTenants(); say("disabled " + b.dataset.disable, "ok"); } catch (err) { say(err.message, "err"); } }
230
+ if (b.dataset.revoke) { const [id, prefix] = b.dataset.revoke.split("|"); if (confirm("Revoke token " + prefix + " of " + id + "?")) { try { await api("POST", "/admin/tenants/" + encodeURIComponent(id) + "/tokens/" + prefix + "/revoke"); await loadTokens(id); await loadTenants(); say("revoked", "ok"); } catch (err) { say(err.message, "err"); } } }
231
+ });
232
+ if (token) enter();
233
+ })();
234
+ </script>
235
+ `;
@@ -4,6 +4,7 @@ import { type InclusionProof } from "./log.ts";
4
4
  import { type LogBackend, type PostgresTenancy, type RateLimitOptions } from "./log-store.ts";
5
5
  import { type Signer } from "./signer.ts";
6
6
  import type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
7
+ import { type AdminOptions } from "./log-admin.ts";
7
8
  export interface LogAppend {
8
9
  inclusion: InclusionProof;
9
10
  /** signed TreeHead; the signature's keyid says who runs the log */
@@ -62,6 +63,8 @@ export interface LogServerOptions {
62
63
  maxBodyBytes?: number;
63
64
  /** where published checkpoints go and are listed from; without one, /checkpoints answers with none */
64
65
  checkpoints?: CheckpointStore;
66
+ /** the operator's admin API and page under /admin, behind its own token; only with a Postgres tenancy */
67
+ admin?: AdminOptions;
65
68
  }
66
69
  /** One log as the handler sees it, whatever stands behind it. */
67
70
  export interface ResolvedLog {
package/dist/log-sink.js CHANGED
@@ -10,6 +10,7 @@ import { createHash } from "node:crypto";
10
10
  import { leafHash, MerkleLog } from "./log.js";
11
11
  import { fileBackend, RateLimiter } from "./log-store.js";
12
12
  import { localSigner } from "./signer.js";
13
+ import { adminRoutes } from "./log-admin.js";
13
14
  import { TREEHEAD_TYPE } from "./receipt.js";
14
15
  function signedHead(e, key, logId) {
15
16
  const head = { treeSize: e.treeSize, rootHash: e.rootHash, timestamp: new Date().toISOString(), ...(logId ? { log: logId } : {}) };
@@ -211,6 +212,7 @@ export class CheckpointPublisher {
211
212
  export function logHandler(source, keyOrSigner, opts = {}) {
212
213
  const resolver = typeof source === "string" ? fileResolver(source, opts) : source;
213
214
  const signer = "privateKey" in keyOrSigner ? localSigner(keyOrSigner) : keyOrSigner;
215
+ const admin = opts.admin ? adminRoutes({ ...opts.admin, keyid: opts.admin.keyid ?? signer.keyid }) : null;
214
216
  const limiter = new RateLimiter(opts.rateLimit);
215
217
  const maxBody = opts.maxBodyBytes ?? 65_536;
216
218
  const bearer = (req) => {
@@ -223,6 +225,8 @@ export function logHandler(source, keyOrSigner, opts = {}) {
223
225
  res.end(JSON.stringify(body));
224
226
  };
225
227
  const url = new URL(req.url ?? "/", "http://localhost");
228
+ if (admin && (await admin(req, res, url)))
229
+ return;
226
230
  if (req.method === "GET" && url.pathname === "/.well-known/agent-custody-log.json") {
227
231
  try {
228
232
  const doc = await signer.keys();
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. 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>`. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.5.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": {