@agent-custody/receipts 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -14,7 +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
+ import { PortalStore, servePortal } from "./portal.js";
18
18
  import { exportLog, formatExport } from "./log-export.js";
19
19
  import { CheckpointPublisher, fileResolver } from "./log-sink.js";
20
20
  import { createRequire } from "node:module";
@@ -407,7 +407,7 @@ async function main(argv) {
407
407
  const adminToken = process.env[values["admin-token-env"]];
408
408
  if (!adminToken)
409
409
  throw new Error(`log: environment variable ${values["admin-token-env"]} is not set`);
410
- admin = { tenancy, token: adminToken, ...(values["public-url"] ? { publicUrl: values["public-url"] } : {}), ...(values["checkpoints-url"] ? { checkpointsUrl: values["checkpoints-url"] } : {}) };
410
+ admin = { tenancy, token: adminToken, portal: new PortalStore(client), ...(values["public-url"] ? { publicUrl: values["public-url"] } : {}), ...(values["checkpoints-url"] ? { checkpointsUrl: values["checkpoints-url"] } : {}) };
411
411
  }
412
412
  where = `store=postgres default-log=${(await tenancy.tenant("default"))?.logId} ${token ? "environment token accepted for the default log; " : ""}tokens from the database`;
413
413
  }
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { type PostgresTenancy } from "./log-store.ts";
2
+ import { type PostgresTenancy, type Plan } from "./log-store.ts";
3
+ import type { PortalStore } from "./portal.ts";
3
4
  export interface AdminOptions {
4
5
  tenancy: PostgresTenancy;
5
6
  /** the admin token; every /admin route needs it as a bearer */
@@ -12,6 +13,8 @@ export interface AdminOptions {
12
13
  keyid?: string;
13
14
  /** key the failure throttle by X-Forwarded-For's first address; only behind a proxy you run */
14
15
  trustProxy?: boolean;
16
+ /** the portal's tables on the same database, read only, so the registrations list can say who signed up */
17
+ portal?: PortalStore;
15
18
  }
16
19
  /** The welcome sheet as text, the same one deploy/onboard-tenant.sh prints. */
17
20
  export declare function welcomeSheet(o: {
@@ -33,5 +36,32 @@ export declare function welcomeSheet(o: {
33
36
  * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
34
37
  * GET /admin/usage?month=YYYY-MM { month, tenants: [{ id, logId, appends, totalLeaves, liveTokens, disabled }] }
35
38
  * GET /admin/usage.csv?month=YYYY-MM the same as CSV, for an invoice
39
+ * GET /admin/registrations?month=YYYY-MM { month, rows: [{ tenant, logId, plan, email, registeredAt, billing, appends, quota, totalLeaves, liveTokens, disabled }], totals }
40
+ * GET /admin/registrations.csv?month=YYYY-MM the same as CSV
36
41
  */
42
+ /** One row per tenant: who registered it through the portal (null when it was onboarded by script), its plan and billing
43
+ * state, appends in the month asked for, and leaves in total; plus the totals across tenants. */
44
+ export declare function registrations(tenancy: PostgresTenancy, portal: PortalStore | undefined, month: string): Promise<{
45
+ month: string;
46
+ rows: RegistrationRow[];
47
+ totals: {
48
+ tenants: number;
49
+ registered: number;
50
+ appends: number;
51
+ totalLeaves: number;
52
+ };
53
+ }>;
54
+ export interface RegistrationRow {
55
+ tenant: string;
56
+ logId: string;
57
+ plan: Plan;
58
+ email: string | null;
59
+ registeredAt: string | null;
60
+ billing: string | null;
61
+ appends: number;
62
+ quota: number | null;
63
+ totalLeaves: number;
64
+ liveTokens: number;
65
+ disabled: boolean;
66
+ }
37
67
  export declare function adminRoutes(opts: AdminOptions): (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<boolean>;
package/dist/log-admin.js CHANGED
@@ -57,7 +57,23 @@ export function welcomeSheet(o) {
57
57
  * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
58
58
  * GET /admin/usage?month=YYYY-MM { month, tenants: [{ id, logId, appends, totalLeaves, liveTokens, disabled }] }
59
59
  * GET /admin/usage.csv?month=YYYY-MM the same as CSV, for an invoice
60
+ * GET /admin/registrations?month=YYYY-MM { month, rows: [{ tenant, logId, plan, email, registeredAt, billing, appends, quota, totalLeaves, liveTokens, disabled }], totals }
61
+ * GET /admin/registrations.csv?month=YYYY-MM the same as CSV
60
62
  */
63
+ /** One row per tenant: who registered it through the portal (null when it was onboarded by script), its plan and billing
64
+ * state, appends in the month asked for, and leaves in total; plus the totals across tenants. */
65
+ export async function registrations(tenancy, portal, month) {
66
+ const usage = await tenancy.usage(month);
67
+ const who = new Map();
68
+ for (const r of portal ? await portal.registrations() : [])
69
+ if (!who.has(r.tenantId))
70
+ who.set(r.tenantId, r);
71
+ const rows = usage.tenants.map((t) => {
72
+ const w = who.get(t.id);
73
+ return { tenant: t.id, logId: t.logId, plan: t.plan, email: w?.email ?? null, registeredAt: w?.registeredAt ?? null, billing: w?.billing ?? null, appends: t.appends, quota: t.quota, totalLeaves: t.totalLeaves, liveTokens: t.liveTokens, disabled: t.disabled };
74
+ });
75
+ return { month: usage.month, rows, totals: { tenants: rows.length, registered: rows.filter((r) => r.email).length, appends: rows.reduce((n, r) => n + r.appends, 0), totalLeaves: rows.reduce((n, r) => n + r.totalLeaves, 0) } };
76
+ }
61
77
  export function adminRoutes(opts) {
62
78
  // Five wrong tokens from one address, then one more a minute: enough to stop guessing, not enough to lock out a typo.
63
79
  const failures = new RateLimiter({ perSecond: 1 / 60, burst: 5 });
@@ -118,6 +134,16 @@ export function adminRoutes(opts) {
118
134
  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
135
  res.end(csv);
120
136
  }
137
+ else if (req.method === "GET" && parts.length === 2 && parts[1] === "registrations") {
138
+ json(200, await registrations(t, opts.portal, month));
139
+ }
140
+ else if (req.method === "GET" && parts.length === 2 && parts[1] === "registrations.csv") {
141
+ const r = await registrations(t, opts.portal, month);
142
+ const cell = (v) => (v === null ? "" : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
143
+ const csv = ["month,tenant,log_id,plan,email,registered_at,billing,appends,quota,total_leaves,live_tokens,disabled", ...r.rows.map((x) => [r.month, x.tenant, x.logId, x.plan, x.email, x.registeredAt, x.billing, x.appends, x.quota, x.totalLeaves, x.liveTokens, x.disabled].map(cell).join(","))].join("\n") + "\n";
144
+ res.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="agent-custody-registrations-${r.month}.csv"`, "cache-control": "no-store" });
145
+ res.end(csv);
146
+ }
121
147
  else if (req.method === "GET" && parts.length === 2 && parts[1] === "audit") {
122
148
  const tenant = url.searchParams.get("tenant");
123
149
  const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : 200;
@@ -200,11 +226,18 @@ const ADMIN_PAGE = `<!doctype html>
200
226
  .muted { color: var(--ink2); } .err { color: #b3261e; } .ok { color: var(--accent); }
201
227
  .tok { font-family: var(--mono); font-size: 1.05rem; word-break: break-all; user-select: all; }
202
228
  [hidden] { display: none !important; }
229
+ .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: .6rem; margin: 0 0 1rem; }
230
+ .stat { background: var(--panel); border: 1px solid var(--line); border-radius: 4px; padding: .6rem .8rem; }
231
+ .stat b { display: block; font-size: 1.4rem; font-variant-numeric: tabular-nums; } .stat span { color: var(--ink2); font-size: .8rem; }
203
232
  </style>
204
233
  <main>
205
234
  <h1>Log admin</h1>
206
235
  <p class="sub" id="where">Tenants and tokens on this log.</p>
207
236
  <section id="app">
237
+ <h2>Registrations</h2>
238
+ <div class="row"><label>month<input id="rmonth" type="month"></label><button class="quiet" id="loadRegs">Show</button><a id="rcsv" class="quiet" href="#" style="align-self:center">Download CSV</a></div>
239
+ <div class="stats"><div class="stat"><b id="sTenants">–</b><span>tenants</span></div><div class="stat"><b id="sRegistered">–</b><span>registered through the portal</span></div><div class="stat"><b id="sAppends">–</b><span>appends this month</span></div><div class="stat"><b id="sLeaves">–</b><span>leaves in total</span></div></div>
240
+ <table><thead><tr><th>email</th><th>tenant</th><th>plan</th><th>registered</th><th>billing</th><th>appends this month</th><th>leaves in total</th><th>live tokens</th></tr></thead><tbody id="regs"></tbody></table>
208
241
  <h2>Tenants</h2>
209
242
  <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>
210
243
  <h2>New tenant</h2>
@@ -263,6 +296,7 @@ const ADMIN_PAGE = `<!doctype html>
263
296
  try {
264
297
  const info = await api("GET", "/admin/info");
265
298
  $("where").textContent = (info.publicUrl || location.origin) + " · keyid " + (info.keyid ? info.keyid.slice(0, 12) : "?") + (info.checkpointsUrl ? " · checkpoints at " + info.checkpointsUrl : "");
299
+ await loadRegs();
266
300
  await loadTenants();
267
301
  await loadUsage();
268
302
  await loadAudit();
@@ -287,15 +321,23 @@ const ADMIN_PAGE = `<!doctype html>
287
321
  $("csv").href = "/admin/usage.csv?month=" + encodeURIComponent(month);
288
322
  $("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>";
289
323
  };
324
+ const loadRegs = async () => {
325
+ const month = $("rmonth").value || new Date().toISOString().slice(0, 7);
326
+ const r = await api("GET", "/admin/registrations?month=" + encodeURIComponent(month));
327
+ $("rcsv").href = "/admin/registrations.csv?month=" + encodeURIComponent(month);
328
+ $("sTenants").textContent = r.totals.tenants; $("sRegistered").textContent = r.totals.registered; $("sAppends").textContent = r.totals.appends; $("sLeaves").textContent = r.totals.totalLeaves;
329
+ $("regs").innerHTML = r.rows.map((x) => "<tr><td>" + (x.email ? esc(x.email) : "<span class=muted>onboarded by script</span>") + "</td><td><code>" + esc(x.tenant) + "</code>" + (x.disabled ? " <span class=muted>disabled</span>" : "") + "</td><td>" + esc(x.plan) + "</td><td>" + (x.registeredAt ? esc(x.registeredAt.slice(0, 10)) : "") + "</td><td>" + (x.billing ? esc(x.billing) : "<span class=muted>none</span>") + "</td><td>" + x.appends + (x.quota === null ? "" : " <span class=muted>/ " + x.quota + "</span>") + "</td><td>" + x.totalLeaves + "</td><td>" + x.liveTokens + "</td></tr>").join("") || "<tr><td colspan=8 class=muted>no tenants</td></tr>";
330
+ };
290
331
  const loadAudit = async () => {
291
332
  const a = await api("GET", "/admin/audit?limit=100");
292
333
  $("audit").innerHTML = a.entries.map((e) => "<tr><td>" + esc(e.at.replace("T", " ").slice(0, 19)) + "</td><td><code>" + esc(e.actor) + "</code></td><td>" + esc(e.action) + "</td><td><code>" + esc(e.tenantId || "") + "</code></td><td class=muted>" + esc(Object.entries(e.detail).map(([k, v]) => k + "=" + v).join(" ")) + "</td></tr>").join("") || "<tr><td colspan=5 class=muted>nothing yet</td></tr>";
293
334
  };
294
335
  $("loadUsage").onclick = () => loadUsage().catch((e) => say(e.message, "err"));
295
- $("month").value = new Date().toISOString().slice(0, 7);
336
+ $("loadRegs").onclick = () => loadRegs().catch((e) => say(e.message, "err"));
337
+ $("month").value = $("rmonth").value = new Date().toISOString().slice(0, 7);
296
338
  document.addEventListener("change", async (e) => {
297
339
  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(); }
340
+ 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 loadRegs(); await loadUsage(); await loadAudit(); } catch (err) { say(err.message, "err"); await loadTenants(); }
299
341
  });
300
342
  document.addEventListener("click", async (e) => {
301
343
  const b = e.target.closest("button"); if (!b) return;
package/dist/portal.d.ts CHANGED
@@ -57,6 +57,13 @@ export declare class PortalStore {
57
57
  subscriptionId: string | null;
58
58
  status: string;
59
59
  } | null>;
60
+ /** Every portal registration with its tenant and billing state, oldest first: what the operator's admin page lists. */
61
+ registrations(): Promise<{
62
+ tenantId: string;
63
+ email: string;
64
+ registeredAt: string;
65
+ billing: string | null;
66
+ }[]>;
60
67
  tenantBySubscription(subscriptionId: string): Promise<string | null>;
61
68
  }
62
69
  export declare function signSession(secret: string, userId: string, ttlMs?: number): string;
package/dist/portal.js CHANGED
@@ -95,6 +95,12 @@ export class PortalStore {
95
95
  const r = rows[0];
96
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
97
  }
98
+ /** Every portal registration with its tenant and billing state, oldest first: what the operator's admin page lists. */
99
+ async registrations() {
100
+ await this.init();
101
+ const rows = (await this.client.query(`SELECT m.tenant_id, u.email, u.created_at, b.status FROM ${this.p}members m JOIN ${this.p}users u ON u.id = m.user_id LEFT JOIN ${this.p}billing b ON b.tenant_id = m.tenant_id ORDER BY u.created_at, m.tenant_id`)).rows;
102
+ return rows.map((r) => ({ tenantId: String(r.tenant_id), email: String(r.email), registeredAt: new Date(r.created_at).toISOString(), billing: r.status ? String(r.status) : null }));
103
+ }
98
104
  async tenantBySubscription(subscriptionId) {
99
105
  await this.init();
100
106
  const rows = (await this.client.query(`SELECT tenant_id FROM ${this.p}billing WHERE subscription_id = $1`, [subscriptionId])).rows;
@@ -365,7 +371,8 @@ const PORTAL_PAGE = `<!doctype html>
365
371
  body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
366
372
  .top { display: flex; align-items: center; gap: 1rem; padding: .7rem 1.25rem; border-bottom: 1px solid var(--line); background: var(--panel); }
367
373
  .brand { font-weight: 700; letter-spacing: .04em; } .brand b { color: var(--accent); }
368
- .top .who { margin-left: auto; color: var(--ink2); font-size: .9rem; }
374
+ .top .links { margin-left: auto; display: flex; gap: .9rem; font-size: .9rem; } .top .links a { color: var(--accent); text-decoration: none; }
375
+ .top .who { color: var(--ink2); font-size: .9rem; margin-left: .5rem; }
369
376
  .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
377
  .layout { display: grid; grid-template-columns: 15rem 1fr; min-height: calc(100vh - 3.3rem); }
371
378
  nav { border-right: 1px solid var(--line); background: var(--panel); padding: 1rem 0; }
@@ -404,7 +411,7 @@ const PORTAL_PAGE = `<!doctype html>
404
411
  [hidden] { display: none !important; }
405
412
  @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
413
  </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>
414
+ <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="links"><a href="https://agent-custody.dev/guide/getting-started">Guide</a><a href="https://docs.agent-custody.dev/reference/">Docs</a><a href="https://agent-custody.dev/verify">Verify a receipt</a></span><span class="who" id="who"></span></div>
408
415
  <section id="auth" class="auth" hidden>
409
416
  <h1 id="authTitle">Sign in</h1>
410
417
  <form id="authForm">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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": {