@agent-custody/receipts 0.5.1 → 0.5.3

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/dist/cli.js CHANGED
@@ -42,6 +42,7 @@ 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 ... [--trust-proxy] behind a reverse proxy you run: key per-address limits by X-Forwarded-For
45
46
  log ... --db-env NAME --admin-token-env NAME [--public-url <https://log.example.com/>] [--checkpoints-url <https://checkpoints.example.com/>]
46
47
  the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
47
48
  the welcome sheet; the public URLs fill the sheet in
@@ -229,7 +230,7 @@ async function main(argv) {
229
230
  case "log": {
230
231
  const { values } = parseArgs({
231
232
  args: rest,
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" } },
233
+ 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" }, "trust-proxy": { type: "boolean", default: false } },
233
234
  });
234
235
  if (!values.key === !values["signer-url"])
235
236
  throw new Error("log needs exactly one of --key or --signer-url");
@@ -281,7 +282,7 @@ async function main(argv) {
281
282
  resolver = fileResolver(values.file, { ...(token ? { tokens: [token] } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}), ...(tenants ? { tenants } : {}) });
282
283
  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
  }
284
- const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}), ...(admin ? { admin } : {}) });
285
+ const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}), ...(admin ? { admin } : {}), trustProxy: values["trust-proxy"] });
285
286
  const publisher = checkpoints ? new CheckpointPublisher(resolver, signer, checkpoints, everyMs) : null;
286
287
  publisher?.start();
287
288
  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" : ""}`);
@@ -1,5 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import type { PostgresTenancy } from "./log-store.ts";
2
+ import { type PostgresTenancy } from "./log-store.ts";
3
3
  export interface AdminOptions {
4
4
  tenancy: PostgresTenancy;
5
5
  /** the admin token; every /admin route needs it as a bearer */
@@ -10,6 +10,8 @@ export interface AdminOptions {
10
10
  checkpointsUrl?: string;
11
11
  /** the current signing keyid, for the sheet */
12
12
  keyid?: string;
13
+ /** key the failure throttle by X-Forwarded-For's first address; only behind a proxy you run */
14
+ trustProxy?: boolean;
13
15
  }
14
16
  /** The welcome sheet as text, the same one deploy/onboard-tenant.sh prints. */
15
17
  export declare function welcomeSheet(o: {
package/dist/log-admin.js CHANGED
@@ -1,8 +1,12 @@
1
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.
2
+ // single page at /admin that drives it. It is for whoever runs the log, never for tenants. Everything under /admin,
3
+ // the page included, needs the admin token: the browser's own prompt supplies it as HTTP Basic (any user name,
4
+ // the token as the password) and an API client sends it as a bearer. Failed attempts from one address are
5
+ // throttled. A minted token is shown once, beside the welcome sheet the tenant gets. Nothing here touches
6
+ // receipts; the log holds hashes and the panel holds names.
5
7
  import { timingSafeEqual } from "node:crypto";
8
+ import { RateLimiter } from "./log-store.js";
9
+ import { clientAddress } from "./log-sink.js";
6
10
  const same = (a, b) => {
7
11
  const x = Buffer.from(a);
8
12
  const y = Buffer.from(b);
@@ -48,23 +52,39 @@ export function welcomeSheet(o) {
48
52
  * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
49
53
  */
50
54
  export function adminRoutes(opts) {
55
+ // Five wrong tokens from one address, then one more a minute: enough to stop guessing, not enough to lock out a typo.
56
+ const failures = new RateLimiter({ perSecond: 1 / 60, burst: 5 });
57
+ const presented = (req) => {
58
+ const h = req.headers.authorization ?? "";
59
+ if (h.startsWith("Bearer ") && h.length > 7)
60
+ return h.slice(7);
61
+ if (h.startsWith("Basic ") && h.length > 6) {
62
+ const pair = Buffer.from(h.slice(6), "base64").toString();
63
+ const at = pair.indexOf(":");
64
+ return at >= 0 ? pair.slice(at + 1) : pair;
65
+ }
66
+ return null;
67
+ };
51
68
  return async (req, res, url) => {
52
69
  if (url.pathname !== "/admin" && !url.pathname.startsWith("/admin/"))
53
70
  return false;
54
- const json = (status, body) => {
55
- res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
71
+ const json = (status, body, headers = {}) => {
72
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers });
56
73
  res.end(JSON.stringify(body));
57
74
  };
75
+ const addr = clientAddress(req, opts.trustProxy);
76
+ const given = presented(req);
77
+ if (given === null || !same(given, opts.token)) {
78
+ if (!failures.take(`admin:${addr}`))
79
+ return json(429, { error: "too many attempts; wait a minute" }, { "retry-after": "60" }), true;
80
+ // The challenge makes the browser ask; the same 401 tells an API client what is missing.
81
+ return json(401, { error: "admin token required" }, { "www-authenticate": 'Basic realm="agent-custody log admin", charset="UTF-8"' }), true;
82
+ }
58
83
  if (req.method === "GET" && url.pathname === "/admin") {
59
84
  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
85
  res.end(ADMIN_PAGE);
61
86
  return true;
62
87
  }
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
88
  const body = async () => {
69
89
  let text = "";
70
90
  for await (const chunk of req) {
@@ -120,7 +140,7 @@ export function adminRoutes(opts) {
120
140
  return true;
121
141
  };
122
142
  }
123
- /** The page. One file, no framework, no third-party requests; the admin token lives in sessionStorage for the tab. */
143
+ /** The page. One file, no framework, no third-party requests; the browser holds the admin credential it prompted for. */
124
144
  const ADMIN_PAGE = `<!doctype html>
125
145
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
126
146
  <title>agent-custody log admin</title>
@@ -149,12 +169,8 @@ const ADMIN_PAGE = `<!doctype html>
149
169
  </style>
150
170
  <main>
151
171
  <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>
172
+ <p class="sub" id="where">Tenants and tokens on this log.</p>
173
+ <section id="app">
158
174
  <h2>Tenants</h2>
159
175
  <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
176
  <h2>New tenant</h2>
@@ -184,10 +200,11 @@ const ADMIN_PAGE = `<!doctype html>
184
200
  <script>
185
201
  (() => {
186
202
  const $ = (id) => document.getElementById(id);
187
- let token = sessionStorage.getItem("agent-custody-admin") || "";
203
+ // The browser sends the credential it prompted for on every request under /admin; nothing is stored by this page.
188
204
  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 });
205
+ const r = await fetch(path, { method, headers: body ? { "content-type": "application/json" } : {}, body: body ? JSON.stringify(body) : undefined, credentials: "same-origin" });
190
206
  const j = await r.json().catch(() => ({}));
207
+ if (r.status === 401) throw new Error("the admin token was not accepted; reload the page and enter it again");
191
208
  if (!r.ok) throw new Error(j.error || r.statusText);
192
209
  return j;
193
210
  };
@@ -205,13 +222,9 @@ const ADMIN_PAGE = `<!doctype html>
205
222
  try {
206
223
  const info = await api("GET", "/admin/info");
207
224
  $("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
225
  await loadTenants();
211
- } catch (e) { $("loginErr").hidden = false; $("loginErr").textContent = e.message; }
226
+ } catch (e) { say(e.message, "err"); }
212
227
  };
213
- $("enter").onclick = () => { token = $("token").value.trim(); enter(); };
214
- $("token").onkeydown = (e) => { if (e.key === "Enter") $("enter").click(); };
215
228
  $("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
229
  $("mint").onclick = async () => {
217
230
  try {
@@ -229,7 +242,7 @@ const ADMIN_PAGE = `<!doctype html>
229
242
  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
243
  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
244
  });
232
- if (token) enter();
245
+ enter();
233
246
  })();
234
247
  </script>
235
248
  `;
@@ -65,7 +65,15 @@ export interface LogServerOptions {
65
65
  checkpoints?: CheckpointStore;
66
66
  /** the operator's admin API and page under /admin, behind its own token; only with a Postgres tenancy */
67
67
  admin?: AdminOptions;
68
+ /**
69
+ * Behind a reverse proxy every request arrives from the proxy's address, so per-address limits would be shared by
70
+ * everyone. With this on, the first address in X-Forwarded-For is the client. Only set it when a proxy you run
71
+ * is the only way to reach this server, since the header is otherwise the client's to forge.
72
+ */
73
+ trustProxy?: boolean;
68
74
  }
75
+ /** The address a limit is keyed by: the socket's, or the proxy's forwarded one when the proxy is trusted. */
76
+ export declare function clientAddress(req: IncomingMessage, trustProxy?: boolean): string;
69
77
  /** One log as the handler sees it, whatever stands behind it. */
70
78
  export interface ResolvedLog {
71
79
  backend: LogBackend;
package/dist/log-sink.js CHANGED
@@ -94,6 +94,16 @@ export function openLog(cfg, key) {
94
94
  throw new Error("config needs logFile or log.url");
95
95
  return fileLog(cfg.logFile, key);
96
96
  }
97
+ /** The address a limit is keyed by: the socket's, or the proxy's forwarded one when the proxy is trusted. */
98
+ export function clientAddress(req, trustProxy = false) {
99
+ if (trustProxy) {
100
+ const xff = req.headers["x-forwarded-for"];
101
+ const first = (Array.isArray(xff) ? xff[0] : xff)?.split(",")[0]?.trim();
102
+ if (first)
103
+ return first;
104
+ }
105
+ return req.socket.remoteAddress ?? "?";
106
+ }
97
107
  const tokenMatches = (tokens, token) => {
98
108
  if (tokens.length === 0)
99
109
  return true;
@@ -212,7 +222,7 @@ export class CheckpointPublisher {
212
222
  export function logHandler(source, keyOrSigner, opts = {}) {
213
223
  const resolver = typeof source === "string" ? fileResolver(source, opts) : source;
214
224
  const signer = "privateKey" in keyOrSigner ? localSigner(keyOrSigner) : keyOrSigner;
215
- const admin = opts.admin ? adminRoutes({ ...opts.admin, keyid: opts.admin.keyid ?? signer.keyid }) : null;
225
+ const admin = opts.admin ? adminRoutes({ ...opts.admin, keyid: opts.admin.keyid ?? signer.keyid, trustProxy: opts.trustProxy ?? opts.admin.trustProxy }) : null;
216
226
  const limiter = new RateLimiter(opts.rateLimit);
217
227
  const maxBody = opts.maxBodyBytes ?? 65_536;
218
228
  const bearer = (req) => {
@@ -254,7 +264,7 @@ export function logHandler(source, keyOrSigner, opts = {}) {
254
264
  const token = bearer(req);
255
265
  if (!(await which.authorize(token)))
256
266
  return json(401, { error: "unauthorized" });
257
- const limitKey = token ? createHash("sha256").update(token).digest("hex").slice(0, 16) : `addr:${req.socket.remoteAddress ?? "?"}`;
267
+ const limitKey = token ? createHash("sha256").update(token).digest("hex").slice(0, 16) : `addr:${clientAddress(req, opts.trustProxy)}`;
258
268
  if (!limiter.take(limitKey))
259
269
  return json(429, { error: "too many appends; retry shortly" }, { "retry-after": "1" });
260
270
  let body = "";
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>`. 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.
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. Everything under `/admin`, the page included, needs the admin token: the browser asks for it (any user name, the token as the password) and an API client sends it as a bearer; a handful of wrong attempts from one address are throttled for a minute. Nothing is stored by the page. Behind a reverse proxy, start the server with `--trust-proxy` so those per-address limits key on `X-Forwarded-For` instead of on the proxy's own address, and only there, since the header is otherwise the client's to forge. `--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.1",
3
+ "version": "0.5.3",
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": {