@agent-custody/receipts 0.6.0 → 0.6.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
@@ -228,6 +228,7 @@ src/config.ts gateway and SDK config schemas, path resolution
228
228
  src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
229
229
  src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
230
230
  src/log-check.ts the outside monitor: verifies the head, checkpoints, and witness of a running log
231
+ src/portal.ts the tenant portal: register, first key, usage against plan, keys, Stripe billing, export, on the log's Postgres
231
232
  src/log-export.ts a tenant's export of their own log, self-checked, as a log file the verifier reads
232
233
  src/witness.ts the witness: countersigns the log's checkpoints from another operator's machine, or refuses with an alarm
233
234
  src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
@@ -287,6 +288,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
287
288
  - 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.
288
289
  - 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).
289
290
 
291
+ - Plans and the tenant portal: every tenant is on a plan (free, ten thousand appends a month; team, a million; enterprise, no allowance) enforced at append with a clear 429; the portal at the operator's `PORTAL_HOST` lets a team register, get its tenant and first key, watch usage against the plan, mint and revoke keys, buy the team plan through Stripe, and copy the export command, with every action in the audit trail.
290
292
  - An audit trail of administrative actions: every tenant created or disabled and every token minted or revoked is recorded with who did it, from the admin page or the command line, shown on the page and carried in the tenant's export.
291
293
  - A tenant's export: `log-export` takes, with the tenant's own token, every leaf hash, the signed head, the published keys, the checkpoints, and their usage, checks that they add up, and writes a log copy the verifier reads offline; the evidence never depends on the operator staying in business.
292
294
  - Monitoring and metering: `log-check`, the outside probe that verifies the head, the checkpoints, and the witness and exits 1 on trouble, run every ten minutes by the `monitor` workflow; `GET /health`; and usage per tenant per month on the admin page and as CSV.
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { connectSigner, fetchLogKeys, localSigner, serveSigner } from "./signer.
14
14
  import { fetchWitnessKeys, Witness } from "./witness.js";
15
15
  import { checkLog, formatLogCheck } from "./log-check.js";
16
16
  import { serveHttp } from "./gateway-http.js";
17
+ import { servePortal } from "./portal.js";
17
18
  import { exportLog, formatExport } from "./log-export.js";
18
19
  import { CheckpointPublisher, fileResolver } from "./log-sink.js";
19
20
  import { createRequire } from "node:module";
@@ -70,9 +71,12 @@ const USAGE = `agent-custody <command>
70
71
  into <dir>; refuses and writes an alarm otherwise. Serve <dir> from a host of your own.
71
72
  signer --key <log.key> --port 8790 [--host 127.0.0.1] [--token-env NAME] [--retired-key <pub>]...
72
73
  the one process that holds the log's key: POST /sign, GET /keys
73
- log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
74
+ log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id> | tenant plan <id> <free|team|enterprise>
74
75
  log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
75
- log-admin --db-env NAME audit [--tenant <id>] who did what to tenants and tokens, newest first
76
+ log-admin --db-env NAME audit [--tenant <id>]
77
+ portal --db-env NAME --secret-env NAME --public-url <log url> [--checkpoints-url <url>] [--portal-url <url>] [--port 8792] [--host 127.0.0.1]
78
+ [--stripe-key-env NAME --stripe-webhook-env NAME --stripe-price-team <price id>] [--trust-proxy]
79
+ the tenant portal: register, first key, usage against plan, keys, billing, export who did what to tenants and tokens, newest first
76
80
  log-admin --db-env NAME import --file <log.jsonl> [--tenant default] copies a file log into the database as hashes
77
81
  audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) [--issuer-key <pub>] [--log-key <pub>] [--log-id <id>] [--witness-key <pub> | --witness-url <url>] [--json]
78
82
  with --log-url the log's published keys are fetched and pinned by keyid; with a witness key or
@@ -261,6 +265,38 @@ async function main(argv) {
261
265
  await running.close();
262
266
  return 0;
263
267
  }
268
+ case "portal": {
269
+ const { values } = parseArgs({ args: rest, options: { "db-env": { type: "string" }, "secret-env": { type: "string" }, "public-url": { type: "string" }, "checkpoints-url": { type: "string" }, "portal-url": { type: "string" }, port: { type: "string", default: "8792" }, host: { type: "string", default: "127.0.0.1" }, "stripe-key-env": { type: "string" }, "stripe-webhook-env": { type: "string" }, "stripe-price-team": { type: "string" }, "trust-proxy": { type: "boolean", default: false } } });
270
+ if (!values["db-env"] || !values["secret-env"] || !values["public-url"])
271
+ throw new Error("portal needs --db-env, --secret-env, and --public-url");
272
+ const secret = process.env[values["secret-env"]];
273
+ if (!secret || secret.length < 32)
274
+ throw new Error(`environment variable ${values["secret-env"]} must hold a secret of at least 32 characters`);
275
+ let stripe;
276
+ if (values["stripe-key-env"] || values["stripe-webhook-env"] || values["stripe-price-team"]) {
277
+ if (!values["stripe-key-env"] || !values["stripe-webhook-env"] || !values["stripe-price-team"])
278
+ throw new Error("billing needs all three of --stripe-key-env, --stripe-webhook-env, --stripe-price-team");
279
+ const secretKey = process.env[values["stripe-key-env"]];
280
+ const webhookSecret = process.env[values["stripe-webhook-env"]];
281
+ if (!secretKey || !webhookSecret)
282
+ throw new Error("the Stripe key and webhook secret variables must both be set");
283
+ stripe = { secretKey, webhookSecret, priceTeam: values["stripe-price-team"] };
284
+ }
285
+ const client = openPostgres(values["db-env"]);
286
+ const tenancy = new PostgresTenancy(client);
287
+ let keyid;
288
+ try {
289
+ keyid = (await fetchLogKeys(values["public-url"])).keys[0]?.keyid;
290
+ }
291
+ catch {
292
+ // the log may not be reachable from here at start; the sheet then omits the keyid
293
+ }
294
+ const running = await servePortal({ tenancy, client, secret, publicUrl: values["public-url"], ...(values["checkpoints-url"] ? { checkpointsUrl: values["checkpoints-url"] } : {}), ...(values["portal-url"] ? { portalUrl: values["portal-url"] } : {}), ...(keyid ? { keyid } : {}), ...(stripe ? { stripe } : {}), trustProxy: values["trust-proxy"] }, { port: Number(values.port), host: values.host });
295
+ console.error(`agent-custody portal: ${running.url} log=${values["public-url"]} billing=${stripe ? "stripe" : "off"}${values["trust-proxy"] ? " trust-proxy" : ""}`);
296
+ await new Promise((resolve) => process.once("SIGINT", resolve));
297
+ await running.close();
298
+ return 0;
299
+ }
264
300
  case "log-admin": {
265
301
  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" } } });
266
302
  if (!values["db-env"])
@@ -274,7 +310,11 @@ async function main(argv) {
274
310
  }
275
311
  else if (what === "tenant" && verb === "list") {
276
312
  for (const t of await tenancy.listTenants())
277
- console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
313
+ console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} plan=${t.plan.padEnd(10)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
314
+ }
315
+ else if (what === "tenant" && verb === "plan" && args[0] && args[1]) {
316
+ const t = await tenancy.setPlan(args[0], args[1], actor);
317
+ console.log(`tenant ${t.id} on plan ${t.plan}`);
278
318
  }
279
319
  else if (what === "tenant" && verb === "disable" && args[0]) {
280
320
  await tenancy.disableTenant(args[0], actor);
package/dist/index.d.ts CHANGED
@@ -5,11 +5,14 @@ export { buildRequest, restUpstream } from "./rest.ts";
5
5
  export { openExporter, otlpExporter, spanFor } from "./otel.ts";
6
6
  export { hecEvent, splunkExporter } from "./splunk.ts";
7
7
  export { GRANT_HEADER, grantHeader, parseGrantHeader, serveHttp } from "./gateway-http.ts";
8
+ export { PortalStore, portalHandler, readSession, servePortal, signSession, stripeRequest, verifyStripeSignature } from "./portal.ts";
9
+ export type { PortalOptions, PortalUser, RunningPortal, StripeOptions } from "./portal.ts";
8
10
  export type { HttpGatewayOptions, RunningHttpGateway } from "./gateway-http.ts";
9
11
  export { exportLog, formatExport } from "./log-export.ts";
10
12
  export type { ExportOptions, ExportResult } from "./log-export.ts";
11
13
  export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.ts";
12
- export type { AuditEntry } from "./log-store.ts";
14
+ export type { AuditEntry, Plan, QuotaState } from "./log-store.ts";
15
+ export { PLAN_QUOTAS, PLANS } from "./log-store.ts";
13
16
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.ts";
14
17
  export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
15
18
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
package/dist/index.js CHANGED
@@ -4,8 +4,10 @@ export { buildRequest, restUpstream } from "./rest.js";
4
4
  export { openExporter, otlpExporter, spanFor } from "./otel.js";
5
5
  export { hecEvent, splunkExporter } from "./splunk.js";
6
6
  export { GRANT_HEADER, grantHeader, parseGrantHeader, serveHttp } from "./gateway-http.js";
7
+ export { PortalStore, portalHandler, readSession, servePortal, signSession, stripeRequest, verifyStripeSignature } from "./portal.js";
7
8
  export { exportLog, formatExport } from "./log-export.js";
8
9
  export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.js";
10
+ export { PLAN_QUOTAS, PLANS } from "./log-store.js";
9
11
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.js";
10
12
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
11
13
  export { adminRoutes, welcomeSheet } from "./log-admin.js";
package/dist/log-admin.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // throttled. A minted token is shown once, beside the welcome sheet the tenant gets. Nothing here touches
6
6
  // receipts; the log holds hashes and the panel holds names.
7
7
  import { timingSafeEqual } from "node:crypto";
8
- import { RateLimiter } from "./log-store.js";
8
+ import { RateLimiter, PLANS } from "./log-store.js";
9
9
  import { clientAddress } from "./log-sink.js";
10
10
  const same = (a, b) => {
11
11
  const x = Buffer.from(a);
@@ -114,7 +114,7 @@ export function adminRoutes(opts) {
114
114
  }
115
115
  else if (req.method === "GET" && parts.length === 2 && parts[1] === "usage.csv") {
116
116
  const u = await t.usage(month);
117
- const csv = ["month,tenant,log_id,appends,total_leaves,live_tokens,disabled", ...u.tenants.map((x) => [u.month, x.id, x.logId, x.appends, x.totalLeaves, x.liveTokens, x.disabled].join(","))].join("\n") + "\n";
117
+ const csv = ["month,tenant,log_id,plan,quota,appends,total_leaves,live_tokens,disabled", ...u.tenants.map((x) => [u.month, x.id, x.logId, x.plan, x.quota ?? "", x.appends, x.totalLeaves, x.liveTokens, x.disabled].join(","))].join("\n") + "\n";
118
118
  res.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="agent-custody-usage-${u.month}.csv"`, "cache-control": "no-store" });
119
119
  res.end(csv);
120
120
  }
@@ -138,6 +138,12 @@ export function adminRoutes(opts) {
138
138
  return json(400, { error: "id must be a plain identifier" }), true;
139
139
  json(200, await t.addTenant(b.id, typeof b.logId === "string" && b.logId ? b.logId : b.id, actor));
140
140
  }
141
+ else if (req.method === "POST" && parts.length === 4 && parts[1] === "tenants" && parts[3] === "plan") {
142
+ const b = await body();
143
+ if (typeof b.plan !== "string" || !PLANS.includes(b.plan))
144
+ return json(400, { error: `plan must be one of ${PLANS.join(", ")}` }), true;
145
+ json(200, await t.setPlan(parts[2], b.plan, actor));
146
+ }
141
147
  else if (req.method === "POST" && parts.length === 4 && parts[1] === "tenants" && parts[3] === "disable") {
142
148
  await t.disableTenant(parts[2], actor);
143
149
  json(200, { disabled: parts[2] });
@@ -200,7 +206,7 @@ const ADMIN_PAGE = `<!doctype html>
200
206
  <p class="sub" id="where">Tenants and tokens on this log.</p>
201
207
  <section id="app">
202
208
  <h2>Tenants</h2>
203
- <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>
209
+ <table><thead><tr><th>tenant</th><th>log id</th><th>plan</th><th>live tokens</th><th>created</th><th></th></tr></thead><tbody id="tenants"></tbody></table>
204
210
  <h2>New tenant</h2>
205
211
  <div class="row">
206
212
  <label>tenant id (in the URL)<input id="tid" placeholder="acme" autocomplete="off"></label>
@@ -221,7 +227,7 @@ const ADMIN_PAGE = `<!doctype html>
221
227
  </div>
222
228
  <h2>Usage</h2>
223
229
  <div class="row"><label>month<input id="month" type="month"></label><button class="quiet" id="loadUsage">Show</button><a id="csv" class="quiet" href="#" style="align-self:center">Download CSV</a></div>
224
- <table><thead><tr><th>tenant</th><th>log id</th><th>appends this month</th><th>leaves in total</th><th>live tokens</th></tr></thead><tbody id="usage"></tbody></table>
230
+ <table><thead><tr><th>tenant</th><th>log id</th><th>plan</th><th>appends this month</th><th>quota</th><th>leaves in total</th><th>live tokens</th></tr></thead><tbody id="usage"></tbody></table>
225
231
  <h2>Tokens of a tenant</h2>
226
232
  <div class="row"><label>tenant<input id="ltid" placeholder="acme" autocomplete="off"></label><button class="quiet" id="listTokens">List</button></div>
227
233
  <table><thead><tr><th>label</th><th>hash</th><th>created</th><th>state</th><th></th></tr></thead><tbody id="tokens"></tbody></table>
@@ -246,7 +252,8 @@ const ADMIN_PAGE = `<!doctype html>
246
252
  const say = (t, cls) => { $("msg").textContent = t; $("msg").className = cls || "muted"; };
247
253
  const loadTenants = async () => {
248
254
  const list = await api("GET", "/admin/tenants");
249
- $("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>";
255
+ const planPick = (t) => "<select data-plan=\\"" + esc(t.id) + "\\">" + ["free", "team", "enterprise"].map((p) => "<option" + (p === t.plan ? " selected" : "") + ">" + p + "</option>").join("") + "</select>";
256
+ $("tenants").innerHTML = list.map((t) => "<tr><td><code>" + esc(t.id) + "</code></td><td><code>" + esc(t.logId) + "</code></td><td>" + planPick(t) + "</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=6 class=muted>none yet</td></tr>";
250
257
  };
251
258
  const loadTokens = async (id) => {
252
259
  const list = await api("GET", "/admin/tenants/" + encodeURIComponent(id) + "/tokens");
@@ -278,7 +285,7 @@ const ADMIN_PAGE = `<!doctype html>
278
285
  const month = $("month").value || new Date().toISOString().slice(0, 7);
279
286
  const u = await api("GET", "/admin/usage?month=" + encodeURIComponent(month));
280
287
  $("csv").href = "/admin/usage.csv?month=" + encodeURIComponent(month);
281
- $("usage").innerHTML = u.tenants.map((t) => "<tr><td><code>" + esc(t.id) + "</code>" + (t.disabled ? " <span class=muted>disabled</span>" : "") + "</td><td><code>" + esc(t.logId) + "</code></td><td>" + t.appends + "</td><td>" + t.totalLeaves + "</td><td>" + t.liveTokens + "</td></tr>").join("") || "<tr><td colspan=5 class=muted>no tenants</td></tr>";
288
+ $("usage").innerHTML = u.tenants.map((t) => "<tr><td><code>" + esc(t.id) + "</code>" + (t.disabled ? " <span class=muted>disabled</span>" : "") + "</td><td><code>" + esc(t.logId) + "</code></td><td>" + esc(t.plan) + "</td><td>" + t.appends + "</td><td>" + (t.quota === null ? "none" : t.quota) + "</td><td>" + t.totalLeaves + "</td><td>" + t.liveTokens + "</td></tr>").join("") || "<tr><td colspan=7 class=muted>no tenants</td></tr>";
282
289
  };
283
290
  const loadAudit = async () => {
284
291
  const a = await api("GET", "/admin/audit?limit=100");
@@ -286,6 +293,10 @@ const ADMIN_PAGE = `<!doctype html>
286
293
  };
287
294
  $("loadUsage").onclick = () => loadUsage().catch((e) => say(e.message, "err"));
288
295
  $("month").value = new Date().toISOString().slice(0, 7);
296
+ document.addEventListener("change", async (e) => {
297
+ const s = e.target.closest("select[data-plan]"); if (!s) return;
298
+ try { await api("POST", "/admin/tenants/" + encodeURIComponent(s.dataset.plan) + "/plan", { plan: s.value }); say("plan of " + s.dataset.plan + " set to " + s.value, "ok"); await loadUsage(); await loadAudit(); } catch (err) { say(err.message, "err"); await loadTenants(); }
299
+ });
289
300
  document.addEventListener("click", async (e) => {
290
301
  const b = e.target.closest("button"); if (!b) return;
291
302
  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(); await loadAudit(); say("disabled " + b.dataset.disable, "ok"); } catch (err) { say(err.message, "err"); } }
@@ -1,7 +1,7 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import { type Envelope, type KeyPair } from "./crypto.ts";
3
3
  import { type InclusionProof } from "./log.ts";
4
- import { type AuditEntry, type LogBackend, type PostgresTenancy, type RateLimitOptions } from "./log-store.ts";
4
+ import { type AuditEntry, type QuotaState, 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
7
  import { type AdminOptions } from "./log-admin.ts";
@@ -92,6 +92,10 @@ export interface ResolvedLog {
92
92
  usage?(month: string): Promise<TenantUsage>;
93
93
  /** administrative actions on this log, newest first, where the store keeps them */
94
94
  audit?(limit: number): Promise<AuditEntry[]>;
95
+ /** the plan's monthly allowance and what is used, where the store keeps plans */
96
+ quota?(): Promise<QuotaState>;
97
+ /** told after an append lands, so a cached quota count stays honest */
98
+ appended?(): void;
95
99
  }
96
100
  /** Turns the tenant in a path, or null for the root paths, into a log. */
97
101
  export interface LogResolver {
package/dist/log-sink.js CHANGED
@@ -155,6 +155,8 @@ export function postgresResolver(tenancy, opts = {}) {
155
155
  return { month, appends: row?.appends ?? 0, totalLeaves: row?.totalLeaves ?? 0, liveTokens: row?.liveTokens ?? 0 };
156
156
  },
157
157
  audit: (limit) => tenancy.audit({ tenant: id, limit }),
158
+ quota: () => tenancy.quota(id),
159
+ appended: () => tenancy.noteAppend(id),
158
160
  };
159
161
  },
160
162
  async tenants() {
@@ -289,6 +291,16 @@ export function logHandler(source, keyOrSigner, opts = {}) {
289
291
  const token = bearer(req);
290
292
  if (!(await which.authorize(token)))
291
293
  return json(401, { error: "unauthorized" });
294
+ if (which.quota) {
295
+ // The plan's monthly allowance. Over it, the append is refused with the numbers, and the gateway behind it
296
+ // withholds pre-committed calls: a tenant out of quota never acts without evidence.
297
+ const q = await which.quota();
298
+ if (q.quota !== null && q.used >= q.quota) {
299
+ const now = new Date();
300
+ const monthEnd = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
301
+ return json(429, { error: `monthly quota reached: ${q.used} of ${q.quota} appends on the ${q.plan} plan; it resets at the start of next month, or move to a larger plan` }, { "retry-after": String(Math.max(1, Math.ceil((monthEnd - now.getTime()) / 1000))) });
302
+ }
303
+ }
292
304
  const limitKey = token ? createHash("sha256").update(token).digest("hex").slice(0, 16) : `addr:${clientAddress(req, opts.trustProxy)}`;
293
305
  if (!limiter.take(limitKey))
294
306
  return json(429, { error: "too many appends; retry shortly" }, { "retry-after": "1" });
@@ -308,11 +320,15 @@ export function logHandler(source, keyOrSigner, opts = {}) {
308
320
  if (typeof parsed.leafHash === "string") {
309
321
  if (!/^[0-9a-f]{64}$/.test(parsed.leafHash))
310
322
  return json(400, { error: "leafHash must be 64 lowercase hex characters" });
311
- return json(200, await appendSigned(log, signer, { leafHash: parsed.leafHash }, logId));
323
+ const r = await appendSigned(log, signer, { leafHash: parsed.leafHash }, logId);
324
+ which.appended?.();
325
+ return json(200, r);
312
326
  }
313
327
  if (typeof parsed.leaf !== "string" || parsed.leaf.length === 0)
314
328
  return json(400, { error: "leaf must be a non-empty string, or send leafHash" });
315
- return json(200, await appendSigned(log, signer, { leaf: parsed.leaf }, logId));
329
+ const r = await appendSigned(log, signer, { leaf: parsed.leaf }, logId);
330
+ which.appended?.();
331
+ return json(200, r);
316
332
  }
317
333
  const current = await log.size();
318
334
  // A tenant's own data, with their token: every leaf hash, in pages, and their metering. The export command
@@ -334,7 +350,9 @@ export function logHandler(source, keyOrSigner, opts = {}) {
334
350
  const month = url.searchParams.get("month") ?? new Date().toISOString().slice(0, 7);
335
351
  if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))
336
352
  return json(400, { error: "month must be YYYY-MM" });
337
- return json(200, await which.usage(month), { "cache-control": "no-store" });
353
+ const u = await which.usage(month);
354
+ const q = which.quota ? await which.quota() : null;
355
+ return json(200, { ...u, ...(q ? { plan: q.plan, quota: q.quota } : {}) }, { "cache-control": "no-store" });
338
356
  }
339
357
  const since = url.searchParams.has("since") ? Number(url.searchParams.get("since")) : 0;
340
358
  const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : 10_000;
@@ -57,12 +57,24 @@ export declare class PostgresLog implements LogBackend {
57
57
  root(size?: number): Promise<string>;
58
58
  consistencyProof(oldSize: number, newSize?: number): Promise<string[]>;
59
59
  }
60
+ /** A tenant's plan decides its monthly append quota; enterprise has none. The names are what the pricing page sells. */
61
+ export type Plan = "free" | "team" | "enterprise";
62
+ export declare const PLANS: readonly Plan[];
63
+ export declare const PLAN_QUOTAS: Readonly<Record<Plan, number | null>>;
60
64
  export interface Tenant {
61
65
  id: string;
62
66
  logId: string;
67
+ plan: Plan;
63
68
  createdAt: string;
64
69
  disabledAt: string | null;
65
70
  }
71
+ export interface QuotaState {
72
+ plan: Plan;
73
+ /** appends so far this calendar month, UTC */
74
+ used: number;
75
+ /** the plan's monthly allowance, or null for none */
76
+ quota: number | null;
77
+ }
66
78
  export interface TokenRecord {
67
79
  tenantId: string;
68
80
  label: string;
@@ -76,7 +88,7 @@ export interface AuditEntry {
76
88
  id: number;
77
89
  at: string;
78
90
  actor: string;
79
- action: "tenant.add" | "tenant.disable" | "token.add" | "token.revoke";
91
+ action: "tenant.add" | "tenant.disable" | "tenant.plan" | "token.add" | "token.revoke";
80
92
  tenantId: string | null;
81
93
  detail: Record<string, unknown>;
82
94
  }
@@ -87,8 +99,12 @@ export declare class PostgresTenancy {
87
99
  private readonly logs;
88
100
  private readonly tenantCache;
89
101
  private readonly tokenCache;
102
+ private readonly quotaCache;
103
+ private readonly quotas;
90
104
  private ready;
91
- constructor(client: PostgresLike, opts?: PostgresLogOptions);
105
+ constructor(client: PostgresLike, opts?: PostgresLogOptions & {
106
+ quotas?: Partial<Record<Plan, number | null>>;
107
+ });
92
108
  private init;
93
109
  private record;
94
110
  /** Administrative actions, newest first; for one tenant when given. What the admin page shows and a tenant's export carries. */
@@ -105,6 +121,12 @@ export declare class PostgresTenancy {
105
121
  log(tenantId: string): Promise<PostgresLog>;
106
122
  /** Creates a tenant, or renames its log id. `by` names who did it in the audit trail. */
107
123
  addTenant(id: string, logId?: string, by?: string): Promise<Tenant>;
124
+ /** Moves a tenant to a plan; the quota applies from the next append. */
125
+ setPlan(id: string, plan: Plan, by?: string): Promise<Tenant>;
126
+ /** The tenant's plan, appends this month, and the plan's quota. Cached ten seconds, so a burst may overshoot slightly. */
127
+ quota(id: string): Promise<QuotaState>;
128
+ /** Called after an append lands, so the cached count stays honest between refreshes. */
129
+ noteAppend(id: string): void;
108
130
  disableTenant(id: string, by?: string): Promise<void>;
109
131
  listTenants(): Promise<Tenant[]>;
110
132
  /** Mints a token for a tenant. The token is returned once and stored only as its hash. */
@@ -123,6 +145,8 @@ export declare class PostgresTenancy {
123
145
  tenants: {
124
146
  id: string;
125
147
  logId: string;
148
+ plan: Plan;
149
+ quota: number | null;
126
150
  appends: number;
127
151
  totalLeaves: number;
128
152
  liveTokens: number;
package/dist/log-store.js CHANGED
@@ -170,6 +170,8 @@ export class PostgresLog {
170
170
  return this.cache.subproof(oldSize, 0, n, true).map((b) => b.toString("hex"));
171
171
  }
172
172
  }
173
+ export const PLANS = ["free", "team", "enterprise"];
174
+ export const PLAN_QUOTAS = { free: 10_000, team: 1_000_000, enterprise: null };
173
175
  const sha256hex = (s) => createHash("sha256").update(s).digest("hex");
174
176
  /** Tenants and their tokens, in Postgres. Tokens are stored hashed; a lookup hashes what the caller presented. */
175
177
  export class PostgresTenancy {
@@ -178,10 +180,13 @@ export class PostgresTenancy {
178
180
  logs = new Map();
179
181
  tenantCache = new Map();
180
182
  tokenCache = new Map();
183
+ quotaCache = new Map();
184
+ quotas;
181
185
  ready = null;
182
186
  constructor(client, opts = {}) {
183
187
  this.client = client;
184
188
  this.prefix = ident(opts.prefix ?? "log_", "prefix");
189
+ this.quotas = { ...PLAN_QUOTAS, ...(opts.quotas ?? {}) };
185
190
  }
186
191
  init() {
187
192
  if (!this.ready) {
@@ -189,6 +194,7 @@ export class PostgresTenancy {
189
194
  this.ready = (async () => {
190
195
  await PostgresLog.ensureSchema(this.client, p);
191
196
  await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}tenants (id TEXT PRIMARY KEY, log_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), disabled_at TIMESTAMPTZ)`);
197
+ await this.client.query(`ALTER TABLE ${p}tenants ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`);
192
198
  await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}tokens (token_hash TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES ${p}tenants(id), label TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ)`);
193
199
  await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}audit (id BIGSERIAL PRIMARY KEY, at TIMESTAMPTZ NOT NULL DEFAULT now(), actor TEXT NOT NULL, action TEXT NOT NULL, tenant_id TEXT, detail JSONB NOT NULL DEFAULT '{}')`);
194
200
  })();
@@ -208,7 +214,7 @@ export class PostgresTenancy {
208
214
  return rows.map((r) => ({ id: Number(r.id), at: new Date(r.at).toISOString(), actor: String(r.actor), action: r.action, tenantId: r.tenant_id === null || r.tenant_id === undefined ? null : String(r.tenant_id), detail: (typeof r.detail === "string" ? JSON.parse(r.detail) : r.detail) }));
209
215
  }
210
216
  row(r) {
211
- return { id: String(r.id), logId: String(r.log_id), createdAt: new Date(r.created_at).toISOString(), disabledAt: r.disabled_at ? new Date(r.disabled_at).toISOString() : null };
217
+ return { id: String(r.id), logId: String(r.log_id), plan: PLANS.includes(String(r.plan)) ? String(r.plan) : "free", createdAt: new Date(r.created_at).toISOString(), disabledAt: r.disabled_at ? new Date(r.disabled_at).toISOString() : null };
212
218
  }
213
219
  /** The tenant, or null. Answers from a ten-second cache, so a disabled tenant is refused within that. */
214
220
  async tenant(id) {
@@ -216,7 +222,7 @@ export class PostgresTenancy {
216
222
  const hit = this.tenantCache.get(id);
217
223
  if (hit && Date.now() - hit.at < 10_000)
218
224
  return hit.tenant;
219
- const rows = (await this.client.query(`SELECT id, log_id, created_at, disabled_at FROM ${this.prefix}tenants WHERE id = $1`, [id])).rows;
225
+ const rows = (await this.client.query(`SELECT id, log_id, plan, created_at, disabled_at FROM ${this.prefix}tenants WHERE id = $1`, [id])).rows;
220
226
  const tenant = rows[0] ? this.row(rows[0]) : null;
221
227
  this.tenantCache.set(id, { at: Date.now(), tenant });
222
228
  return tenant;
@@ -251,11 +257,47 @@ export class PostgresTenancy {
251
257
  if (!/^[A-Za-z0-9_.-]+$/.test(id))
252
258
  throw new Error(`tenant id must be a plain identifier; got "${id}"`);
253
259
  await this.init();
254
- const rows = (await this.client.query(`INSERT INTO ${this.prefix}tenants (id, log_id) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET log_id = EXCLUDED.log_id RETURNING id, log_id, created_at, disabled_at`, [id, logId])).rows;
260
+ const rows = (await this.client.query(`INSERT INTO ${this.prefix}tenants (id, log_id) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET log_id = EXCLUDED.log_id RETURNING id, log_id, plan, created_at, disabled_at`, [id, logId])).rows;
255
261
  this.tenantCache.delete(id);
256
262
  await this.record(by, "tenant.add", id, { logId });
257
263
  return this.row(rows[0]);
258
264
  }
265
+ /** Moves a tenant to a plan; the quota applies from the next append. */
266
+ async setPlan(id, plan, by) {
267
+ if (!PLANS.includes(plan))
268
+ throw new Error(`unknown plan ${plan}; one of ${PLANS.join(", ")}`);
269
+ await this.init();
270
+ const rows = (await this.client.query(`UPDATE ${this.prefix}tenants SET plan = $2 WHERE id = $1 RETURNING id, log_id, plan, created_at, disabled_at`, [id, plan])).rows;
271
+ if (!rows[0])
272
+ throw new Error(`unknown tenant ${id}`);
273
+ this.tenantCache.delete(id);
274
+ await this.record(by, "tenant.plan", id, { plan });
275
+ return this.row(rows[0]);
276
+ }
277
+ /** The tenant's plan, appends this month, and the plan's quota. Cached ten seconds, so a burst may overshoot slightly. */
278
+ async quota(id) {
279
+ const t = await this.tenant(id);
280
+ if (!t)
281
+ throw new Error(`unknown tenant ${id}`);
282
+ const cached = this.quotaCache.get(id);
283
+ let used;
284
+ if (cached && Date.now() - cached.at < 10_000) {
285
+ used = cached.used;
286
+ }
287
+ else {
288
+ const start = `${new Date().toISOString().slice(0, 7)}-01T00:00:00Z`;
289
+ const rows = (await this.client.query(`SELECT COUNT(*) AS n FROM ${this.prefix}leaves WHERE tenant_id = $1 AND appended_at >= $2::timestamptz`, [id, start])).rows;
290
+ used = Number(rows[0]?.n ?? 0);
291
+ this.quotaCache.set(id, { at: Date.now(), used });
292
+ }
293
+ return { plan: t.plan, used, quota: this.quotas[t.plan] };
294
+ }
295
+ /** Called after an append lands, so the cached count stays honest between refreshes. */
296
+ noteAppend(id) {
297
+ const c = this.quotaCache.get(id);
298
+ if (c)
299
+ c.used += 1;
300
+ }
259
301
  async disableTenant(id, by) {
260
302
  await this.init();
261
303
  await this.client.query(`UPDATE ${this.prefix}tenants SET disabled_at = now() WHERE id = $1 AND disabled_at IS NULL`, [id]);
@@ -264,7 +306,7 @@ export class PostgresTenancy {
264
306
  }
265
307
  async listTenants() {
266
308
  await this.init();
267
- return (await this.client.query(`SELECT id, log_id, created_at, disabled_at FROM ${this.prefix}tenants ORDER BY created_at`)).rows.map((r) => this.row(r));
309
+ return (await this.client.query(`SELECT id, log_id, plan, created_at, disabled_at FROM ${this.prefix}tenants ORDER BY created_at`)).rows.map((r) => this.row(r));
268
310
  }
269
311
  /** Mints a token for a tenant. The token is returned once and stored only as its hash. */
270
312
  async addToken(tenantId, label, by) {
@@ -301,12 +343,12 @@ export class PostgresTenancy {
301
343
  const [y, m] = month.split("-").map(Number);
302
344
  const end = `${m === 12 ? y + 1 : y}-${String(m === 12 ? 1 : m + 1).padStart(2, "0")}-01T00:00:00Z`;
303
345
  const p = this.prefix;
304
- const rows = (await this.client.query(`SELECT t.id, t.log_id, t.disabled_at,
346
+ const rows = (await this.client.query(`SELECT t.id, t.log_id, t.plan, t.disabled_at,
305
347
  (SELECT COUNT(*) FROM ${p}leaves l WHERE l.tenant_id = t.id AND l.appended_at >= $1::timestamptz AND l.appended_at < $2::timestamptz) AS appends,
306
348
  (SELECT COUNT(*) FROM ${p}leaves l WHERE l.tenant_id = t.id) AS total,
307
349
  (SELECT COUNT(*) FROM ${p}tokens k WHERE k.tenant_id = t.id AND k.revoked_at IS NULL) AS live
308
350
  FROM ${p}tenants t ORDER BY t.created_at`, [start, end])).rows;
309
- return { month, tenants: rows.map((r) => ({ id: String(r.id), logId: String(r.log_id), appends: Number(r.appends), totalLeaves: Number(r.total), liveTokens: Number(r.live), disabled: !!r.disabled_at })) };
351
+ return { month, tenants: rows.map((r) => { const plan = PLANS.includes(String(r.plan)) ? String(r.plan) : "free"; return { id: String(r.id), logId: String(r.log_id), plan, quota: this.quotas[plan], appends: Number(r.appends), totalLeaves: Number(r.total), liveTokens: Number(r.live), disabled: !!r.disabled_at }; }) };
310
352
  }
311
353
  async listTokens(tenantId) {
312
354
  await this.init();
@@ -0,0 +1,75 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { type PostgresLike, type PostgresTenancy } from "./log-store.ts";
3
+ export interface StripeOptions {
4
+ secretKey: string;
5
+ webhookSecret: string;
6
+ /** the Stripe price id of the team plan's monthly subscription */
7
+ priceTeam: string;
8
+ fetch?: typeof fetch;
9
+ }
10
+ export interface PortalOptions {
11
+ tenancy: PostgresTenancy;
12
+ /** the Postgres client the tenancy uses; the portal's own tables live beside the log's */
13
+ client: PostgresLike;
14
+ /** signs session cookies; rotate to sign everyone out */
15
+ secret: string;
16
+ /** the log's public base URL, for the welcome sheet and the export command */
17
+ publicUrl: string;
18
+ checkpointsUrl?: string;
19
+ /** the log's current signing keyid, for the sheet */
20
+ keyid?: string;
21
+ /** the portal's own public URL, for Stripe's return addresses */
22
+ portalUrl?: string;
23
+ stripe?: StripeOptions;
24
+ /** key throttles by X-Forwarded-For; only behind a proxy you run. Also marks cookies Secure. */
25
+ trustProxy?: boolean;
26
+ /** table prefix; default portal_ */
27
+ prefix?: string;
28
+ log?: (message: string) => void;
29
+ }
30
+ export interface PortalUser {
31
+ id: string;
32
+ email: string;
33
+ createdAt: string;
34
+ }
35
+ /** Users, memberships, and billing records, beside the log's tables. */
36
+ export declare class PortalStore {
37
+ private readonly client;
38
+ private readonly p;
39
+ private ready;
40
+ constructor(client: PostgresLike, prefix?: string);
41
+ private init;
42
+ static hashPassword(password: string): string;
43
+ static checkPassword(password: string, stored: string): boolean;
44
+ createUser(email: string, password: string): Promise<PortalUser>;
45
+ authenticate(email: string, password: string): Promise<PortalUser | null>;
46
+ user(id: string): Promise<PortalUser | null>;
47
+ addMember(userId: string, tenantId: string): Promise<void>;
48
+ /** the user's tenant; one per account today */
49
+ tenantOf(userId: string): Promise<string | null>;
50
+ setBilling(tenantId: string, b: {
51
+ customerId?: string | null;
52
+ subscriptionId?: string | null;
53
+ status: string;
54
+ }): Promise<void>;
55
+ billing(tenantId: string): Promise<{
56
+ customerId: string | null;
57
+ subscriptionId: string | null;
58
+ status: string;
59
+ } | null>;
60
+ tenantBySubscription(subscriptionId: string): Promise<string | null>;
61
+ }
62
+ export declare function signSession(secret: string, userId: string, ttlMs?: number): string;
63
+ export declare function readSession(secret: string, cookie: string | undefined): string | null;
64
+ export declare function stripeRequest(s: StripeOptions, path: string, body: Record<string, string>): Promise<Record<string, unknown>>;
65
+ /** Stripe-Signature: t=<unix>,v1=<hmac>; the mac is over `${t}.${rawBody}` with the endpoint secret. */
66
+ export declare function verifyStripeSignature(header: string | undefined, rawBody: string, secret: string, now?: number, toleranceMs?: number): boolean;
67
+ export interface RunningPortal {
68
+ url: string;
69
+ close(): Promise<void>;
70
+ }
71
+ export declare function portalHandler(o: PortalOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
72
+ export declare function servePortal(o: PortalOptions, opts: {
73
+ port: number;
74
+ host?: string;
75
+ }): Promise<RunningPortal>;
package/dist/portal.js ADDED
@@ -0,0 +1,547 @@
1
+ // The tenant portal: where a team registers, gets its tenant on the hosted log and its first API key, watches its
2
+ // usage against its plan, mints and revokes keys, and pays. One process beside the log, on the same Postgres, with
3
+ // its own tables under `portal_`. Everything a tenant can do here they could also do by asking the operator; the
4
+ // portal removes the asking. It holds no receipts and never sees a receipt: the numbers it shows are the log's
5
+ // counts, the hashes it lists are the same hashes the export carries.
6
+ //
7
+ // Sessions are a signed cookie, passwords are scrypt, the page is one inline file with no framework and a strict
8
+ // content-security policy, and every API write requires a JSON body, which with a SameSite=Strict cookie is what
9
+ // keeps a cross-site page from acting as the user. Billing is Stripe Checkout for the team plan; the webhook moves
10
+ // the tenant's plan, and nothing about a card ever passes through here.
11
+ import { createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
12
+ import { createServer } from "node:http";
13
+ import { postgresCheckpoints } from "./checkpoints.js";
14
+ import { welcomeSheet } from "./log-admin.js";
15
+ import { clientAddress } from "./log-sink.js";
16
+ import { RateLimiter } from "./log-store.js";
17
+ const ident = (s) => {
18
+ if (!/^[a-z_][a-z0-9_]*$/.test(s))
19
+ throw new Error(`prefix must be a plain identifier; got "${s}"`);
20
+ return s;
21
+ };
22
+ /** Users, memberships, and billing records, beside the log's tables. */
23
+ export class PortalStore {
24
+ client;
25
+ p;
26
+ ready = null;
27
+ // no parameter properties: the CLI runs on plain Node type stripping
28
+ constructor(client, prefix = "portal_") {
29
+ this.client = client;
30
+ this.p = ident(prefix);
31
+ }
32
+ init() {
33
+ if (!this.ready) {
34
+ const p = this.p;
35
+ this.ready = (async () => {
36
+ await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`);
37
+ await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}members (user_id TEXT NOT NULL REFERENCES ${p}users(id), tenant_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'owner', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (user_id, tenant_id))`);
38
+ await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}billing (tenant_id TEXT PRIMARY KEY, customer_id TEXT, subscription_id TEXT, status TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`);
39
+ })();
40
+ }
41
+ return this.ready;
42
+ }
43
+ static hashPassword(password) {
44
+ const salt = randomBytes(16);
45
+ return `scrypt$${salt.toString("hex")}$${scryptSync(password, salt, 64).toString("hex")}`;
46
+ }
47
+ static checkPassword(password, stored) {
48
+ const [alg, saltHex, hashHex] = stored.split("$");
49
+ if (alg !== "scrypt" || !saltHex || !hashHex)
50
+ return false;
51
+ const expected = Buffer.from(hashHex, "hex");
52
+ const got = scryptSync(password, Buffer.from(saltHex, "hex"), expected.length);
53
+ return got.length === expected.length && timingSafeEqual(got, expected);
54
+ }
55
+ async createUser(email, password) {
56
+ await this.init();
57
+ const id = randomUUID();
58
+ const rows = (await this.client.query(`INSERT INTO ${this.p}users (id, email, password_hash) VALUES ($1, $2, $3) ON CONFLICT (email) DO NOTHING RETURNING id, email, created_at`, [id, email, PortalStore.hashPassword(password)])).rows;
59
+ if (!rows[0])
60
+ throw new Error("an account with this email already exists");
61
+ return { id, email, createdAt: new Date(rows[0].created_at).toISOString() };
62
+ }
63
+ async authenticate(email, password) {
64
+ await this.init();
65
+ const rows = (await this.client.query(`SELECT id, email, password_hash, created_at FROM ${this.p}users WHERE email = $1`, [email])).rows;
66
+ const r = rows[0];
67
+ if (!r || !PortalStore.checkPassword(password, String(r.password_hash)))
68
+ return null;
69
+ return { id: String(r.id), email: String(r.email), createdAt: new Date(r.created_at).toISOString() };
70
+ }
71
+ async user(id) {
72
+ await this.init();
73
+ const rows = (await this.client.query(`SELECT id, email, created_at FROM ${this.p}users WHERE id = $1`, [id])).rows;
74
+ const r = rows[0];
75
+ return r ? { id: String(r.id), email: String(r.email), createdAt: new Date(r.created_at).toISOString() } : null;
76
+ }
77
+ async addMember(userId, tenantId) {
78
+ await this.init();
79
+ await this.client.query(`INSERT INTO ${this.p}members (user_id, tenant_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [userId, tenantId]);
80
+ }
81
+ /** the user's tenant; one per account today */
82
+ async tenantOf(userId) {
83
+ await this.init();
84
+ const rows = (await this.client.query(`SELECT tenant_id FROM ${this.p}members WHERE user_id = $1 ORDER BY created_at LIMIT 1`, [userId])).rows;
85
+ return rows[0]?.tenant_id ?? null;
86
+ }
87
+ async setBilling(tenantId, b) {
88
+ await this.init();
89
+ await this.client.query(`INSERT INTO ${this.p}billing (tenant_id, customer_id, subscription_id, status, updated_at) VALUES ($1, $2, $3, $4, now())
90
+ ON CONFLICT (tenant_id) DO UPDATE SET customer_id = COALESCE(EXCLUDED.customer_id, ${this.p}billing.customer_id), subscription_id = COALESCE(EXCLUDED.subscription_id, ${this.p}billing.subscription_id), status = EXCLUDED.status, updated_at = now()`, [tenantId, b.customerId ?? null, b.subscriptionId ?? null, b.status]);
91
+ }
92
+ async billing(tenantId) {
93
+ await this.init();
94
+ const rows = (await this.client.query(`SELECT customer_id, subscription_id, status FROM ${this.p}billing WHERE tenant_id = $1`, [tenantId])).rows;
95
+ const r = rows[0];
96
+ return r ? { customerId: r.customer_id ? String(r.customer_id) : null, subscriptionId: r.subscription_id ? String(r.subscription_id) : null, status: String(r.status) } : null;
97
+ }
98
+ async tenantBySubscription(subscriptionId) {
99
+ await this.init();
100
+ const rows = (await this.client.query(`SELECT tenant_id FROM ${this.p}billing WHERE subscription_id = $1`, [subscriptionId])).rows;
101
+ return rows[0]?.tenant_id ?? null;
102
+ }
103
+ }
104
+ // ---- sessions ----
105
+ const b64u = (b) => b.toString("base64url");
106
+ export function signSession(secret, userId, ttlMs = 14 * 86_400_000) {
107
+ const body = b64u(Buffer.from(JSON.stringify({ u: userId, e: Date.now() + ttlMs })));
108
+ return `${body}.${createHmac("sha256", secret).update(body).digest("base64url")}`;
109
+ }
110
+ export function readSession(secret, cookie) {
111
+ if (!cookie)
112
+ return null;
113
+ const [body, mac] = cookie.split(".");
114
+ if (!body || !mac)
115
+ return null;
116
+ const expected = createHmac("sha256", secret).update(body).digest("base64url");
117
+ if (expected.length !== mac.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(mac)))
118
+ return null;
119
+ try {
120
+ const { u, e } = JSON.parse(Buffer.from(body, "base64url").toString());
121
+ return typeof u === "string" && typeof e === "number" && e > Date.now() ? u : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ // ---- Stripe, over its REST API; no SDK ----
128
+ const form = (o) => new URLSearchParams(o).toString();
129
+ export async function stripeRequest(s, path, body) {
130
+ const f = s.fetch ?? fetch;
131
+ const res = await f(`https://api.stripe.com/v1/${path}`, { method: "POST", headers: { authorization: `Bearer ${s.secretKey}`, "content-type": "application/x-www-form-urlencoded" }, body: form(body), signal: AbortSignal.timeout(15_000) });
132
+ const json = (await res.json());
133
+ if (!res.ok)
134
+ throw new Error(`stripe ${path}: ${json.error?.message ?? res.status}`);
135
+ return json;
136
+ }
137
+ /** Stripe-Signature: t=<unix>,v1=<hmac>; the mac is over `${t}.${rawBody}` with the endpoint secret. */
138
+ export function verifyStripeSignature(header, rawBody, secret, now = Date.now(), toleranceMs = 5 * 60_000) {
139
+ if (!header)
140
+ return false;
141
+ const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
142
+ const t = Number(parts.t);
143
+ if (!Number.isFinite(t) || Math.abs(now - t * 1000) > toleranceMs)
144
+ return false;
145
+ const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
146
+ return header
147
+ .split(",")
148
+ .filter((kv) => kv.startsWith("v1="))
149
+ .some((kv) => {
150
+ const sig = kv.slice(3);
151
+ return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
152
+ });
153
+ }
154
+ const TENANT_ID = /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/;
155
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
156
+ const RESERVED = new Set(["default", "admin", "api", "www", "log", "checkpoints", "app", "portal", "stripe", "health", "t"]);
157
+ export function portalHandler(o) {
158
+ const store = new PortalStore(o.client, o.prefix);
159
+ const heads = postgresCheckpoints(o.client);
160
+ const log = o.log ?? ((m) => console.error(m));
161
+ const loginFailures = new RateLimiter({ perSecond: 1 / 30, burst: 8 });
162
+ const registrations = new RateLimiter({ perSecond: 1 / 600, burst: 5 });
163
+ const base = o.publicUrl.endsWith("/") ? o.publicUrl : `${o.publicUrl}/`;
164
+ const cookieName = "custody_session";
165
+ const sheet = (tenant, logId) => welcomeSheet({ tenant, logId, publicUrl: base, ...(o.checkpointsUrl ? { checkpointsUrl: o.checkpointsUrl } : {}), ...(o.keyid ? { keyid: o.keyid } : {}) });
166
+ const exportCommand = (tenant) => `npx @agent-custody/receipts log-export --log-url ${base} --tenant ${tenant} --token-env AGENT_CUSTODY_LOG_TOKEN --out custody-export/`;
167
+ return async (req, res) => {
168
+ const url = new URL(req.url ?? "/", "http://localhost");
169
+ const addr = clientAddress(req, o.trustProxy);
170
+ const json = (status, body, headers = {}) => {
171
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers });
172
+ res.end(JSON.stringify(body));
173
+ };
174
+ const setCookie = (value) => `${cookieName}=${value ?? ""}; Path=/; HttpOnly; SameSite=Strict${o.trustProxy ? "; Secure" : ""}; Max-Age=${value ? 14 * 86_400 : 0}`;
175
+ const cookies = Object.fromEntries((req.headers.cookie ?? "").split(";").map((c) => c.trim().split("=")).filter(([k]) => k));
176
+ const userId = readSession(o.secret, cookies[cookieName]);
177
+ const rawBody = async () => {
178
+ let text = "";
179
+ for await (const chunk of req) {
180
+ text += chunk;
181
+ if (text.length > 65_536)
182
+ throw new Error("body too large");
183
+ }
184
+ return text;
185
+ };
186
+ const jsonBody = async () => {
187
+ if (!(req.headers["content-type"] ?? "").startsWith("application/json"))
188
+ throw new Error("expected a JSON body");
189
+ const t = await rawBody();
190
+ return t ? JSON.parse(t) : {};
191
+ };
192
+ try {
193
+ if (req.method === "GET" && url.pathname === "/health")
194
+ return json(200, { ok: true, stripe: !!o.stripe });
195
+ if (req.method === "GET" && url.pathname === "/") {
196
+ 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'; form-action 'self'; base-uri 'none'" });
197
+ return void res.end(PORTAL_PAGE);
198
+ }
199
+ // ---- Stripe's webhook: the only caller that is not a browser with a session ----
200
+ if (req.method === "POST" && url.pathname === "/stripe/webhook") {
201
+ if (!o.stripe)
202
+ return json(503, { error: "billing is not configured" });
203
+ const raw = await rawBody();
204
+ if (!verifyStripeSignature(req.headers["stripe-signature"], raw, o.stripe.webhookSecret))
205
+ return json(400, { error: "bad signature" });
206
+ const event = JSON.parse(raw);
207
+ const obj = event.data.object;
208
+ if (event.type === "checkout.session.completed") {
209
+ const tenant = String(obj.client_reference_id ?? "");
210
+ if (tenant && (await o.tenancy.tenant(tenant))) {
211
+ await store.setBilling(tenant, { customerId: obj.customer ? String(obj.customer) : null, subscriptionId: obj.subscription ? String(obj.subscription) : null, status: "active" });
212
+ await o.tenancy.setPlan(tenant, "team", "stripe:checkout");
213
+ log(`agent-custody portal: tenant ${tenant} moved to team by checkout ${String(obj.id ?? "")}`);
214
+ }
215
+ }
216
+ else if (event.type === "customer.subscription.deleted" || event.type === "customer.subscription.updated") {
217
+ const tenant = await store.tenantBySubscription(String(obj.id ?? ""));
218
+ if (tenant) {
219
+ const status = event.type === "customer.subscription.deleted" ? "canceled" : String(obj.status ?? "unknown");
220
+ await store.setBilling(tenant, { status });
221
+ const plan = status === "active" || status === "trialing" || status === "past_due" ? "team" : "free";
222
+ const current = (await o.tenancy.tenant(tenant))?.plan;
223
+ if (current !== "enterprise" && current !== plan)
224
+ await o.tenancy.setPlan(tenant, plan, `stripe:${event.type}`);
225
+ }
226
+ }
227
+ return json(200, { received: true });
228
+ }
229
+ // ---- registration and login ----
230
+ if (req.method === "POST" && url.pathname === "/api/register") {
231
+ if (!registrations.take(`reg:${addr}`))
232
+ return json(429, { error: "too many registrations from this address; try again later" });
233
+ const b = await jsonBody();
234
+ const email = String(b.email ?? "").trim().toLowerCase();
235
+ const password = String(b.password ?? "");
236
+ const tenant = String(b.tenant ?? "").trim().toLowerCase();
237
+ if (!EMAIL.test(email))
238
+ return json(400, { error: "a valid email address is needed" });
239
+ if (password.length < 10)
240
+ return json(400, { error: "the password needs at least ten characters" });
241
+ if (!TENANT_ID.test(tenant) || RESERVED.has(tenant))
242
+ return json(400, { error: "the tenant id is the name in your log's URL: three to forty lowercase letters, digits, or hyphens, and not a reserved word" });
243
+ if (await o.tenancy.tenant(tenant))
244
+ return json(409, { error: "that tenant id is taken" });
245
+ let user;
246
+ try {
247
+ user = await store.createUser(email, password);
248
+ }
249
+ catch (e) {
250
+ return json(409, { error: e instanceof Error ? e.message : String(e) });
251
+ }
252
+ const t = await o.tenancy.addTenant(tenant, tenant, `portal:${email}`);
253
+ await store.addMember(user.id, tenant);
254
+ const minted = await o.tenancy.addToken(tenant, "first key", `portal:${email}`);
255
+ log(`agent-custody portal: ${email} registered tenant ${tenant}`);
256
+ return json(200, { tenant: t.id, logId: t.logId, plan: t.plan, token: minted.token, tokenHash: minted.tokenHash.slice(0, 12), welcome: sheet(t.id, t.logId), exportCommand: exportCommand(t.id) }, { "set-cookie": setCookie(signSession(o.secret, user.id)) });
257
+ }
258
+ if (req.method === "POST" && url.pathname === "/api/login") {
259
+ if (!loginFailures.take(`login:${addr}`))
260
+ return json(429, { error: "too many attempts; wait a minute" });
261
+ const b = await jsonBody();
262
+ const user = await store.authenticate(String(b.email ?? "").trim().toLowerCase(), String(b.password ?? ""));
263
+ if (!user)
264
+ return json(401, { error: "email or password not recognised" });
265
+ return json(200, { email: user.email }, { "set-cookie": setCookie(signSession(o.secret, user.id)) });
266
+ }
267
+ if (req.method === "POST" && url.pathname === "/api/logout")
268
+ return json(200, { ok: true }, { "set-cookie": setCookie(null) });
269
+ // ---- everything below needs a session ----
270
+ if (!url.pathname.startsWith("/api/"))
271
+ return json(404, { error: "not found" });
272
+ const user = userId ? await store.user(userId) : null;
273
+ if (!user)
274
+ return json(401, { error: "sign in first" });
275
+ const tenantId = await store.tenantOf(user.id);
276
+ if (!tenantId)
277
+ return json(409, { error: "this account has no tenant" });
278
+ const tenant = await o.tenancy.tenant(tenantId);
279
+ if (!tenant)
280
+ return json(409, { error: "the tenant no longer exists" });
281
+ if (req.method !== "GET" && !(req.headers["content-type"] ?? "").startsWith("application/json"))
282
+ return json(415, { error: "expected a JSON body" });
283
+ if (req.method === "GET" && url.pathname === "/api/me") {
284
+ const q = await o.tenancy.quota(tenantId);
285
+ return json(200, { email: user.email, tenant: tenantId, logId: tenant.logId, plan: q.plan, used: q.used, quota: q.quota, disabled: !!tenant.disabledAt, billing: !!o.stripe });
286
+ }
287
+ if (req.method === "GET" && url.pathname === "/api/overview") {
288
+ const q = await o.tenancy.quota(tenantId);
289
+ const backend = await o.tenancy.log(tenantId);
290
+ const size = await backend.size();
291
+ const latest = await heads.latest(tenantId);
292
+ const months = [];
293
+ for (let i = 5; i >= 0; i--) {
294
+ const d = new Date();
295
+ const month = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - i, 1)).toISOString().slice(0, 7);
296
+ const u = await o.tenancy.usage(month);
297
+ months.push({ month, appends: u.tenants.find((t) => t.id === tenantId)?.appends ?? 0 });
298
+ }
299
+ const keys = (await o.tenancy.listTokens(tenantId)).map((k) => ({ label: k.label, hash: k.tokenHash.slice(0, 12), createdAt: k.createdAt, revokedAt: k.revokedAt }));
300
+ const audit = await o.tenancy.audit({ tenant: tenantId, limit: 50 });
301
+ const billing = await store.billing(tenantId);
302
+ return json(200, { tenant: tenantId, logId: tenant.logId, plan: q.plan, used: q.used, quota: q.quota, treeSize: size, rootHash: size ? await backend.root(size) : null, latestCheckpoint: latest ? { treeSize: latest.treeSize, signedAt: latest.signedAt } : null, months, keys, audit, billing: billing ? { status: billing.status } : null, urls: { log: `${base}t/${tenantId}/`, keys: `${base}.well-known/agent-custody-log.json`, checkpoints: o.checkpointsUrl ? `${o.checkpointsUrl.replace(/\/?$/, "/")}${tenantId}/latest.json` : null }, exportCommand: exportCommand(tenantId), welcome: sheet(tenantId, tenant.logId), stripe: !!o.stripe });
303
+ }
304
+ if (req.method === "GET" && url.pathname === "/api/keys") {
305
+ return json(200, { keys: (await o.tenancy.listTokens(tenantId)).map((k) => ({ label: k.label, hash: k.tokenHash.slice(0, 12), createdAt: k.createdAt, revokedAt: k.revokedAt })) });
306
+ }
307
+ if (req.method === "POST" && url.pathname === "/api/keys") {
308
+ const b = await jsonBody();
309
+ const label = String(b.label ?? "").trim().slice(0, 64) || "key";
310
+ const minted = await o.tenancy.addToken(tenantId, label, `portal:${user.email}`);
311
+ return json(200, { token: minted.token, tokenHash: minted.tokenHash.slice(0, 12), label });
312
+ }
313
+ const revoke = /^\/api\/keys\/([0-9a-f]{8,64})\/revoke$/.exec(url.pathname);
314
+ if (req.method === "POST" && revoke) {
315
+ await jsonBody();
316
+ return json(200, { revoked: await o.tenancy.revokeToken(tenantId, revoke[1], `portal:${user.email}`) });
317
+ }
318
+ if (req.method === "POST" && url.pathname === "/api/checkout") {
319
+ if (!o.stripe)
320
+ return json(503, { error: "billing is not configured on this portal yet; email us and we move the plan by hand" });
321
+ await jsonBody();
322
+ if (tenant.plan !== "free")
323
+ return json(409, { error: `this tenant is already on the ${tenant.plan} plan` });
324
+ const portalUrl = (o.portalUrl ?? "http://localhost/").replace(/\/?$/, "/");
325
+ const session = await stripeRequest(o.stripe, "checkout/sessions", { mode: "subscription", "line_items[0][price]": o.stripe.priceTeam, "line_items[0][quantity]": "1", client_reference_id: tenantId, customer_email: user.email, success_url: `${portalUrl}?upgraded=1`, cancel_url: `${portalUrl}?cancelled=1`, "metadata[tenant]": tenantId });
326
+ return json(200, { url: String(session.url) });
327
+ }
328
+ if (req.method === "POST" && url.pathname === "/api/billing-portal") {
329
+ if (!o.stripe)
330
+ return json(503, { error: "billing is not configured" });
331
+ await jsonBody();
332
+ const b = await store.billing(tenantId);
333
+ if (!b?.customerId)
334
+ return json(409, { error: "no billing record for this tenant" });
335
+ const session = await stripeRequest(o.stripe, "billing_portal/sessions", { customer: b.customerId, return_url: (o.portalUrl ?? "http://localhost/").replace(/\/?$/, "/") });
336
+ return json(200, { url: String(session.url) });
337
+ }
338
+ return json(404, { error: "not found" });
339
+ }
340
+ catch (e) {
341
+ if (!res.headersSent)
342
+ json(500, { error: e instanceof Error ? e.message : String(e) });
343
+ }
344
+ };
345
+ }
346
+ export function servePortal(o, opts) {
347
+ const host = opts.host ?? "127.0.0.1";
348
+ const handler = portalHandler(o);
349
+ const server = createServer((req, res) => void handler(req, res));
350
+ return new Promise((resolve) => {
351
+ server.listen(opts.port, host, () => {
352
+ const { port } = server.address();
353
+ resolve({ url: `http://${host}:${port}/`, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }) });
354
+ });
355
+ });
356
+ }
357
+ // ---- the page: one file, no framework, no outside requests ----
358
+ const PORTAL_PAGE = `<!doctype html>
359
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
360
+ <title>agent-custody</title>
361
+ <style>
362
+ :root { color-scheme: light dark; --ink: #1b2430; --ink2: #5b6b7a; --line: #d7dfe5; --bg: #f5f7f9; --panel: #ffffff; --accent: #0f6e63; --accent-bg: #e8f3f1; --warn: #b3731a; --bad: #b3261e; --ok: #1f7a4d; --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
363
+ @media (prefers-color-scheme: dark) { :root { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #4fc3b0; --accent-bg: #16302b; --warn: #e2b862; --bad: #ff8a80; --ok: #6fd39a; } }
364
+ * { box-sizing: border-box; }
365
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
366
+ .top { display: flex; align-items: center; gap: 1rem; padding: .7rem 1.25rem; border-bottom: 1px solid var(--line); background: var(--panel); }
367
+ .brand { font-weight: 700; letter-spacing: .04em; } .brand b { color: var(--accent); }
368
+ .top .who { margin-left: auto; color: var(--ink2); font-size: .9rem; }
369
+ .pill { display: inline-block; padding: .05rem .5rem; border-radius: 999px; font: 600 .72rem/1.6 var(--mono); letter-spacing: .06em; text-transform: uppercase; background: var(--accent-bg); color: var(--accent); }
370
+ .layout { display: grid; grid-template-columns: 15rem 1fr; min-height: calc(100vh - 3.3rem); }
371
+ nav { border-right: 1px solid var(--line); background: var(--panel); padding: 1rem 0; }
372
+ nav .group { font: 600 .68rem/1.4 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--accent); padding: 1rem 1.25rem .35rem; }
373
+ nav a { display: flex; justify-content: space-between; padding: .45rem 1.25rem; color: var(--ink); text-decoration: none; border-left: 3px solid transparent; }
374
+ nav a.on { border-left-color: var(--accent); background: var(--accent-bg); }
375
+ nav a span.n { color: var(--ink2); font-family: var(--mono); font-size: .8rem; }
376
+ main { padding: 1.5rem; max-width: 72rem; }
377
+ h1 { font-size: 1.25rem; margin: 0 0 1rem; } h2 { font: 600 .72rem/1.4 var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink2); margin: 1.5rem 0 .6rem; }
378
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: .9rem; }
379
+ .card { background: var(--panel); border: 1px solid var(--line); border-top: 3px solid var(--accent); border-radius: 6px; padding: 1rem 1.1rem; }
380
+ .card.warn { border-top-color: var(--warn); } .card.bad { border-top-color: var(--bad); } .card.ok { border-top-color: var(--ok); }
381
+ .card .k { font: 600 .68rem/1.4 var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink2); }
382
+ .card .v { font-size: 1.9rem; font-weight: 700; margin: .2rem 0 0; font-variant-numeric: tabular-nums; }
383
+ .card .s { color: var(--ink2); font-size: .88rem; }
384
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 1rem 1.1rem; margin-top: .9rem; }
385
+ table { border-collapse: collapse; width: 100%; font-size: .92rem; }
386
+ th, td { text-align: left; padding: .45rem .5rem .45rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
387
+ th { font: 600 .68rem/1.4 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--ink2); }
388
+ code, pre, .mono { font-family: var(--mono); font-size: .86em; }
389
+ pre { background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: .8rem .9rem; overflow-x: auto; white-space: pre-wrap; }
390
+ .bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: .6rem; align-items: end; height: 9rem; }
391
+ .bar { display: flex; flex-direction: column; justify-content: flex-end; align-items: center; height: 100%; font-size: .75rem; color: var(--ink2); }
392
+ .bar i { display: block; width: 70%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; }
393
+ .bar b { font-family: var(--mono); font-weight: 400; margin-top: .3rem; }
394
+ label { display: grid; gap: .25rem; font-size: .85rem; color: var(--ink2); margin: 0 0 .8rem; }
395
+ input, select { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--ink); }
396
+ button { font: inherit; padding: .5rem .9rem; border-radius: 4px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
397
+ button.quiet { background: transparent; color: var(--accent); }
398
+ button.link { background: none; border: 0; padding: 0; color: var(--accent); text-decoration: underline; }
399
+ .auth { max-width: 26rem; margin: 4rem auto; }
400
+ .once { border: 1px solid var(--warn); background: color-mix(in srgb, var(--warn) 10%, var(--panel)); border-radius: 6px; padding: 1rem 1.1rem; margin: 1rem 0; }
401
+ .tok { font-family: var(--mono); word-break: break-all; padding: .6rem; background: var(--bg); border-radius: 4px; }
402
+ .msg { min-height: 1.4rem; margin: .6rem 0; color: var(--ink2); } .msg.err { color: var(--bad); } .msg.ok { color: var(--ok); }
403
+ .muted { color: var(--ink2); }
404
+ [hidden] { display: none !important; }
405
+ @media (max-width: 48rem) { .layout { grid-template-columns: 1fr; } nav { display: flex; flex-wrap: wrap; padding: .3rem; border-right: 0; border-bottom: 1px solid var(--line); } nav .group { display: none; } nav a { border-left: 0; border-bottom: 3px solid transparent; } nav a.on { border-bottom-color: var(--accent); } }
406
+ </style>
407
+ <div class="top"><span class="brand"><b>◆</b> agent-custody</span><span id="tenantTag" class="pill" hidden></span><span id="planTag" class="pill" hidden></span><span class="who" id="who"></span></div>
408
+ <section id="auth" class="auth" hidden>
409
+ <h1 id="authTitle">Sign in</h1>
410
+ <form id="authForm">
411
+ <label>Email<input id="email" type="email" autocomplete="email" required></label>
412
+ <label>Password<input id="password" type="password" autocomplete="current-password" minlength="10" required></label>
413
+ <label id="tenantField" hidden>Tenant id, the name in your log's URL<input id="tenant" placeholder="acme" pattern="[a-z0-9][a-z0-9-]{1,38}[a-z0-9]"></label>
414
+ <button id="authGo" type="submit">Sign in</button>
415
+ <p class="msg" id="authMsg"></p>
416
+ </form>
417
+ <p class="muted"><button class="link" id="authSwap" type="button">Create an account and a tenant instead</button></p>
418
+ <p class="muted">The free plan is ten thousand appends a month, no card. Your gateway sends only hashes; nothing you log here can be read by us.</p>
419
+ </section>
420
+ <section id="welcome" class="auth" hidden>
421
+ <h1>Your tenant is ready</h1>
422
+ <div class="once"><p><b>Your first API key, shown once.</b> Put it in the environment your gateway reads as <code>AGENT_CUSTODY_LOG_TOKEN</code>. We keep only its hash.</p><p class="tok" id="firstToken"></p><button class="quiet" id="copyFirst">Copy key</button></div>
423
+ <h2>Your welcome sheet</h2>
424
+ <pre id="firstSheet"></pre>
425
+ <button id="toDash">Go to the dashboard</button>
426
+ </section>
427
+ <div class="layout" id="app" hidden>
428
+ <nav>
429
+ <div class="group">Monitor</div>
430
+ <a href="#overview" data-view="overview">Overview</a>
431
+ <a href="#usage" data-view="usage">Usage</a>
432
+ <div class="group">Configure</div>
433
+ <a href="#keys" data-view="keys">API keys <span class="n" id="nKeys"></span></a>
434
+ <a href="#billing" data-view="billing">Billing</a>
435
+ <a href="#export" data-view="export">Export</a>
436
+ <div class="group">Account</div>
437
+ <a href="#" id="signout">Sign out</a>
438
+ </nav>
439
+ <main>
440
+ <div data-pane="overview">
441
+ <h1>Overview</h1>
442
+ <div class="cards">
443
+ <div class="card" id="cUsed"><div class="k">Appends this month</div><div class="v" id="vUsed">–</div><div class="s" id="sUsed"></div></div>
444
+ <div class="card" id="cSize"><div class="k">Receipts in your log</div><div class="v" id="vSize">–</div><div class="s">leaf hashes, all time</div></div>
445
+ <div class="card" id="cKeys"><div class="k">Live keys</div><div class="v" id="vKeys">–</div><div class="s" id="sKeys"></div></div>
446
+ <div class="card" id="cCp"><div class="k">Latest checkpoint</div><div class="v" id="vCp">–</div><div class="s" id="sCp"></div></div>
447
+ </div>
448
+ <h2>Your log</h2>
449
+ <div class="panel"><table><tbody id="urls"></tbody></table></div>
450
+ <h2>Recent activity</h2>
451
+ <div class="panel"><table><thead><tr><th>when</th><th>who</th><th>action</th><th>detail</th></tr></thead><tbody id="audit"></tbody></table></div>
452
+ </div>
453
+ <div data-pane="usage" hidden>
454
+ <h1>Usage</h1>
455
+ <div class="panel"><div class="bars" id="bars"></div></div>
456
+ <p class="muted" id="usageNote"></p>
457
+ </div>
458
+ <div data-pane="keys" hidden>
459
+ <h1>API keys</h1>
460
+ <p class="muted">A key is a bearer token your gateway presents on append. It is shown once when minted; we keep only its hash. Mint a second key before revoking the first to rotate without a gap.</p>
461
+ <div class="panel"><form id="mintForm" style="display:flex;gap:.6rem;align-items:end;flex-wrap:wrap"><label style="margin:0">Label<input id="label" placeholder="support fleet"></label><button type="submit">Mint key</button></form>
462
+ <div id="minted" class="once" hidden><p><b>Shown once.</b></p><p class="tok" id="mintedTok"></p><button class="quiet" id="copyMinted">Copy key</button></div>
463
+ <table style="margin-top:1rem"><thead><tr><th>label</th><th>hash</th><th>created</th><th>state</th><th></th></tr></thead><tbody id="keys"></tbody></table></div>
464
+ <p class="msg" id="keysMsg"></p>
465
+ </div>
466
+ <div data-pane="billing" hidden>
467
+ <h1>Billing</h1>
468
+ <div class="panel" id="billingPanel"></div>
469
+ <p class="msg" id="billingMsg"></p>
470
+ </div>
471
+ <div data-pane="export" hidden>
472
+ <h1>Export</h1>
473
+ <p class="muted">Everything the log holds about you, any time, with your key: every leaf hash as a log file the verifier reads offline, the signed head, the published keys, the checkpoints, your usage, and the actions taken on your tenant. It checks itself before writing.</p>
474
+ <pre id="exportCmd"></pre>
475
+ <h2>Welcome sheet</h2>
476
+ <pre id="sheet"></pre>
477
+ </div>
478
+ </main>
479
+ </div>
480
+ <script>
481
+ (() => {
482
+ const $ = (id) => document.getElementById(id);
483
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
484
+ const api = async (method, path, body) => {
485
+ const r = await fetch(path, { method, headers: body !== undefined ? { "content-type": "application/json" } : {}, body: body !== undefined ? JSON.stringify(body) : undefined, credentials: "same-origin" });
486
+ const j = await r.json().catch(() => ({}));
487
+ if (!r.ok) { const e = new Error(j.error || r.statusText); e.status = r.status; throw e; }
488
+ return j;
489
+ };
490
+ const fmt = (n) => Number(n).toLocaleString();
491
+ const ago = (iso) => { const m = Math.round((Date.now() - Date.parse(iso)) / 60000); return m < 60 ? m + " min ago" : m < 1440 ? Math.round(m / 60) + " h ago" : Math.round(m / 1440) + " d ago"; };
492
+ let registering = false;
493
+ const show = (id) => { for (const s of ["auth", "welcome", "app"]) $(s).hidden = s !== id; };
494
+ const view = (name) => {
495
+ for (const p of document.querySelectorAll("[data-pane]")) p.hidden = p.dataset.pane !== name;
496
+ for (const a of document.querySelectorAll("nav a[data-view]")) a.classList.toggle("on", a.dataset.view === name);
497
+ location.hash = name;
498
+ };
499
+ $("authSwap").onclick = () => { registering = !registering; $("authTitle").textContent = registering ? "Create your tenant" : "Sign in"; $("authGo").textContent = registering ? "Create tenant" : "Sign in"; $("tenantField").hidden = !registering; $("tenant").required = registering; $("password").autocomplete = registering ? "new-password" : "current-password"; $("authSwap").textContent = registering ? "I already have an account" : "Create an account and a tenant instead"; };
500
+ $("authForm").onsubmit = async (e) => {
501
+ e.preventDefault(); $("authMsg").className = "msg"; $("authMsg").textContent = "";
502
+ try {
503
+ if (registering) {
504
+ const r = await api("POST", "/api/register", { email: $("email").value, password: $("password").value, tenant: $("tenant").value });
505
+ $("firstToken").textContent = r.token; $("firstSheet").textContent = r.welcome; show("welcome");
506
+ } else { await api("POST", "/api/login", { email: $("email").value, password: $("password").value }); await enter(); }
507
+ } catch (err) { $("authMsg").className = "msg err"; $("authMsg").textContent = err.message; }
508
+ };
509
+ $("copyFirst").onclick = () => navigator.clipboard.writeText($("firstToken").textContent);
510
+ $("toDash").onclick = () => enter();
511
+ $("signout").onclick = async (e) => { e.preventDefault(); await api("POST", "/api/logout", {}); location.hash = ""; show("auth"); };
512
+ for (const a of document.querySelectorAll("nav a[data-view]")) a.onclick = (e) => { e.preventDefault(); view(a.dataset.view); };
513
+ const render = (o) => {
514
+ $("tenantTag").textContent = o.tenant; $("tenantTag").hidden = false; $("planTag").textContent = o.plan + " plan"; $("planTag").hidden = false;
515
+ const pct = o.quota ? o.used / o.quota : 0;
516
+ $("cUsed").className = "card " + (o.quota === null ? "ok" : pct >= 1 ? "bad" : pct >= .8 ? "warn" : "ok");
517
+ $("vUsed").textContent = fmt(o.used); $("sUsed").textContent = o.quota === null ? "no allowance on " + o.plan : "of " + fmt(o.quota) + " on " + o.plan + (pct >= 1 ? ": appends are refused until next month" : "");
518
+ $("vSize").textContent = fmt(o.treeSize);
519
+ const live = o.keys.filter((k) => !k.revokedAt).length; $("vKeys").textContent = live; $("sKeys").textContent = o.keys.length - live + " revoked"; $("nKeys").textContent = live;
520
+ $("cCp").className = "card " + (o.latestCheckpoint ? (Date.now() - Date.parse(o.latestCheckpoint.signedAt) < 7 * 3600e3 ? "ok" : "warn") : "warn");
521
+ $("vCp").textContent = o.latestCheckpoint ? "size " + fmt(o.latestCheckpoint.treeSize) : "none yet"; $("sCp").textContent = o.latestCheckpoint ? "signed " + ago(o.latestCheckpoint.signedAt) : "published after your first append";
522
+ $("urls").innerHTML = [["Append here", o.urls.log], ["Log id on tree heads", o.logId], ["The log's keys", o.urls.keys], ["Your checkpoints", o.urls.checkpoints || "(not published)"], ["Current root", o.rootHash || "(empty)"]].map(([k, v]) => "<tr><th>" + esc(k) + "</th><td class=mono>" + esc(v) + "</td></tr>").join("");
523
+ $("audit").innerHTML = o.audit.map((e) => "<tr><td>" + esc(e.at.replace("T", " ").slice(0, 16)) + "</td><td class=mono>" + esc(e.actor) + "</td><td>" + esc(e.action) + "</td><td class=muted>" + esc(Object.entries(e.detail).map(([k, v]) => k + "=" + v).join(" ")) + "</td></tr>").join("") || "<tr><td colspan=4 class=muted>nothing yet</td></tr>";
524
+ const max = Math.max(1, ...o.months.map((m) => m.appends));
525
+ $("bars").innerHTML = o.months.map((m) => "<div class=bar><span class=mono>" + fmt(m.appends) + "</span><i style=\\"height:" + Math.max(2, Math.round(100 * m.appends / max)) + "%\\"></i><b>" + esc(m.month.slice(2)) + "</b></div>").join("");
526
+ $("usageNote").textContent = "Appends per calendar month, UTC. Your plan allows " + (o.quota === null ? "any number" : fmt(o.quota)) + " a month; the count resets on the first.";
527
+ $("keys").innerHTML = o.keys.map((k) => "<tr><td>" + esc(k.label) + "</td><td class=mono>" + esc(k.hash) + "</td><td>" + esc(k.createdAt.slice(0, 10)) + "</td><td>" + (k.revokedAt ? "revoked " + esc(k.revokedAt.slice(0, 10)) : "live") + "</td><td>" + (k.revokedAt ? "" : "<button class=quiet data-revoke=\\"" + esc(k.hash) + "\\">Revoke</button>") + "</td></tr>").join("");
528
+ $("billingPanel").innerHTML = o.plan === "free"
529
+ ? "<p>You are on the <b>free</b> plan: ten thousand appends a month, no card.</p><p>The <b>team</b> plan is <b>$50 a month</b>: a million appends, email support within two working days, the same export and audit trail. No availability commitment yet, and the design fails closed: when the log is unreachable your gateway withholds pre-committed calls.</p>" + (o.stripe ? "<button id=upgrade>Upgrade to team, $50/month</button>" : "<p class=muted>Card payments are not switched on for this portal yet; email us and we move the plan by hand.</p>")
530
+ : "<p>You are on the <b>" + esc(o.plan) + "</b> plan" + (o.billing ? " (subscription " + esc(o.billing.status) + ")" : "") + ".</p>" + (o.stripe && o.billing ? "<button class=quiet id=manage>Manage billing</button>" : "");
531
+ $("exportCmd").textContent = o.exportCommand; $("sheet").textContent = o.welcome;
532
+ const up = $("upgrade"); if (up) up.onclick = async () => { try { const r = await api("POST", "/api/checkout", {}); location.href = r.url; } catch (err) { $("billingMsg").className = "msg err"; $("billingMsg").textContent = err.message; } };
533
+ const mg = $("manage"); if (mg) mg.onclick = async () => { try { const r = await api("POST", "/api/billing-portal", {}); location.href = r.url; } catch (err) { $("billingMsg").className = "msg err"; $("billingMsg").textContent = err.message; } };
534
+ };
535
+ const load = async () => render(await api("GET", "/api/overview"));
536
+ const enter = async () => {
537
+ try { const me = await api("GET", "/api/me"); $("who").textContent = me.email; show("app"); await load(); view((location.hash || "#overview").slice(1) || "overview"); }
538
+ catch (err) { if (err.status === 401) show("auth"); else { show("app"); $("who").textContent = err.message; } }
539
+ };
540
+ $("mintForm").onsubmit = async (e) => { e.preventDefault(); try { const r = await api("POST", "/api/keys", { label: $("label").value }); $("mintedTok").textContent = r.token; $("minted").hidden = false; $("keysMsg").className = "msg ok"; $("keysMsg").textContent = "minted " + r.label + ", stored as hash " + r.tokenHash; await load(); } catch (err) { $("keysMsg").className = "msg err"; $("keysMsg").textContent = err.message; } };
541
+ $("copyMinted").onclick = () => navigator.clipboard.writeText($("mintedTok").textContent);
542
+ document.addEventListener("click", async (e) => { const b = e.target.closest("button[data-revoke]"); if (!b) return; if (!confirm("Revoke key " + b.dataset.revoke + "? A gateway using it stops appending at once.")) return; try { await api("POST", "/api/keys/" + b.dataset.revoke + "/revoke", {}); $("keysMsg").className = "msg ok"; $("keysMsg").textContent = "revoked"; await load(); } catch (err) { $("keysMsg").className = "msg err"; $("keysMsg").textContent = err.message; } });
543
+ if (new URLSearchParams(location.search).get("upgraded")) history.replaceState(null, "", "/#billing");
544
+ enter();
545
+ })();
546
+ </script>
547
+ `;
package/docs/usage.md CHANGED
@@ -107,7 +107,7 @@ An upstream need not be an MCP server. A plain HTTP API is described as tools:
107
107
  "log": { "url": "https://log.example.com/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN" }
108
108
  ```
109
109
 
110
- 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`. An append that gets no answer within `timeoutMs` (default 10000) counts as unreachable and is retried like a server error, so a log that accepts connections and never answers cannot hold a call forever. 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, and every `--checkpoint-heartbeat` seconds (default 21600, six hours) for a log that has not, so a quiet log's latest checkpoint is never more than six hours old and the monitor can tell quiet from stalled, 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. Every change made there or with `log-admin` is recorded: who (the name entered at the browser prompt and the address, `bearer` for an API client, or the user and host for the command line), what (`tenant.add`, `tenant.disable`, `token.add`, `token.revoke`), which tenant, and the detail, never the token itself; the page shows it under Activity, `GET /admin/audit?tenant=&limit=` and `log-admin audit` list it, and a tenant reads their own rows at `GET /t/<name>/audit` with their token. Nothing else 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. The witness closes the last gap: `agent-custody witness --key witness.key --log-url <url> --checkpoints-url <url> --out <dir> [--tenant <name>]...` runs on a machine the log's operator does not control, fetches each watched log's latest checkpoint, verifies it against the log's published keys, proves with the log's consistency proof that it extends the last head the witness signed, and countersigns it into `<dir>/<tenant>/<size>.json` and `latest.json`; a checkpoint that does not extend, or a second history at the same size, gets `ALARM.json` instead. Its key document is `<dir>/.well-known/agent-custody-witness.json`. Serve `<dir>` from the witness's own host; verifiers add `--witness-url` (or `--witness-key`) to `audit`, and the newer head must then carry the witness's signature. Two more things an operator needs. `agent-custody log-check --log-url <url> --checkpoints-url <url> [--witness-url <url>] [--tenant <name>]... [--max-lag <seconds>]` is the outside monitor: it verifies the head against the published keys, that the latest checkpoint verifies and keeps up with the head, that the head extends the checkpoint, and, with a witness, that the witness has countersigned, keeps up, and has raised no alarm; it exits 1 on any failure, so cron or a scheduled workflow on a machine that is not the log's turns it into an alert. `GET /health` on the server is the liveness check for a load balancer. And `GET /admin/usage?month=YYYY-MM`, on the admin page and as `/admin/usage.csv`, is the metering: appends per tenant for the month, leaves in total, live tokens, the numbers any invoice rests on. A tenant needs none of that to leave with their evidence: `agent-custody log-export --log-url <url> --tenant <name> --token-env AGENT_CUSTODY_LOG_TOKEN --out <dir>` fetches, with their own token, every leaf hash (`GET /t/<name>/leaves?since=&limit=`, pages of up to ten thousand), the signed head, the published keys, the signed checkpoints, their own usage (`GET /t/<name>/usage?month=`), and the administrative actions on their tenant (`GET /t/<name>/audit`, into `audit.json`), checks that the head and every checkpoint verify against the keys and that the leaves fetched hash to their roots, and writes `log.jsonl` in the format `verify --log` and `audit --log` read, so the export verifies receipts with no server at all. It exits 1 and says what did not add up if anything does not. Both routes answer only to that tenant's token. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers, and [deploy/witness/](../../deploy/witness/) the witness.
110
+ 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`. An append that gets no answer within `timeoutMs` (default 10000) counts as unreachable and is retried like a server error, so a log that accepts connections and never answers cannot hold a call forever. 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>`, `tenant plan <id> <free|team|enterprise>`, and `import --file log.jsonl [--tenant default]` to bring an existing file log in as hashes. Every tenant is on a plan, `free` unless moved: free allows ten thousand appends a calendar month, team a million, enterprise has no allowance. An append past the allowance is refused with 429, the numbers, and a `retry-after` that reaches the start of next month; the gateway behind it then withholds pre-committed calls, so a tenant out of quota never acts without evidence. The tenant's own `GET /t/<name>/usage` reports the plan and quota beside the month's appends. The tenant portal, `agent-custody portal --db-env DATABASE_URL --secret-env PORTAL_SECRET --public-url <log url> [--checkpoints-url <url>] [--portal-url <url>] [--stripe-key-env NAME --stripe-webhook-env NAME --stripe-price-team <price id>] [--trust-proxy]`, is the self-serve front of the same tables: a team registers with an email, a password (scrypt), and a tenant id and gets the tenant and its first key shown once with the welcome sheet; the dashboard shows appends against the plan, the tree size and root, the latest checkpoint, the log's URLs, keys, and the audit rows; keys are minted and revoked there, recorded as `portal:<email>`; the export command is on the page; and with the three Stripe variables the team plan is bought through Stripe Checkout, the signed webhook moving the plan (`stripe:<event>` in the audit trail) and the customer portal handling cancellation. Sessions are a signed cookie, `SameSite=Strict`, and every write needs a JSON body. The compose file runs it as the `portal` service at `PORTAL_HOST`. 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, and every `--checkpoint-heartbeat` seconds (default 21600, six hours) for a log that has not, so a quiet log's latest checkpoint is never more than six hours old and the monitor can tell quiet from stalled, 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. Every change made there or with `log-admin` is recorded: who (the name entered at the browser prompt and the address, `bearer` for an API client, or the user and host for the command line), what (`tenant.add`, `tenant.disable`, `token.add`, `token.revoke`), which tenant, and the detail, never the token itself; the page shows it under Activity, `GET /admin/audit?tenant=&limit=` and `log-admin audit` list it, and a tenant reads their own rows at `GET /t/<name>/audit` with their token. Nothing else 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. The witness closes the last gap: `agent-custody witness --key witness.key --log-url <url> --checkpoints-url <url> --out <dir> [--tenant <name>]...` runs on a machine the log's operator does not control, fetches each watched log's latest checkpoint, verifies it against the log's published keys, proves with the log's consistency proof that it extends the last head the witness signed, and countersigns it into `<dir>/<tenant>/<size>.json` and `latest.json`; a checkpoint that does not extend, or a second history at the same size, gets `ALARM.json` instead. Its key document is `<dir>/.well-known/agent-custody-witness.json`. Serve `<dir>` from the witness's own host; verifiers add `--witness-url` (or `--witness-key`) to `audit`, and the newer head must then carry the witness's signature. Two more things an operator needs. `agent-custody log-check --log-url <url> --checkpoints-url <url> [--witness-url <url>] [--tenant <name>]... [--max-lag <seconds>]` is the outside monitor: it verifies the head against the published keys, that the latest checkpoint verifies and keeps up with the head, that the head extends the checkpoint, and, with a witness, that the witness has countersigned, keeps up, and has raised no alarm; it exits 1 on any failure, so cron or a scheduled workflow on a machine that is not the log's turns it into an alert. `GET /health` on the server is the liveness check for a load balancer. And `GET /admin/usage?month=YYYY-MM`, on the admin page and as `/admin/usage.csv`, is the metering: appends per tenant for the month, leaves in total, live tokens, the numbers any invoice rests on. A tenant needs none of that to leave with their evidence: `agent-custody log-export --log-url <url> --tenant <name> --token-env AGENT_CUSTODY_LOG_TOKEN --out <dir>` fetches, with their own token, every leaf hash (`GET /t/<name>/leaves?since=&limit=`, pages of up to ten thousand), the signed head, the published keys, the signed checkpoints, their own usage (`GET /t/<name>/usage?month=`), and the administrative actions on their tenant (`GET /t/<name>/audit`, into `audit.json`), checks that the head and every checkpoint verify against the keys and that the leaves fetched hash to their roots, and writes `log.jsonl` in the format `verify --log` and `audit --log` read, so the export verifies receipts with no server at all. It exits 1 and says what did not add up if anything does not. Both routes answer only to that tenant's token. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers, and [deploy/witness/](../../deploy/witness/) the witness.
111
111
 
112
112
  `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.
113
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.6.0",
3
+ "version": "0.6.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": {