@agent-custody/receipts 0.6.7 → 0.6.9

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
@@ -267,9 +267,18 @@ async function main(argv) {
267
267
  return 0;
268
268
  }
269
269
  case "portal": {
270
- 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
+ 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" }, "mail-key-env": { type: "string" }, "mail-from": { type: "string" }, "mail-notify": { type: "string" }, "trust-proxy": { type: "boolean", default: false } } });
271
271
  if (!values["db-env"] || !values["secret-env"] || !values["public-url"])
272
272
  throw new Error("portal needs --db-env, --secret-env, and --public-url");
273
+ let mail;
274
+ if (values["mail-key-env"] || values["mail-from"]) {
275
+ if (!values["mail-key-env"] || !values["mail-from"])
276
+ throw new Error("mail needs both --mail-key-env and --mail-from");
277
+ const apiKey = process.env[values["mail-key-env"]];
278
+ if (!apiKey)
279
+ throw new Error(`portal: environment variable ${values["mail-key-env"]} is not set`);
280
+ mail = { apiKey, from: values["mail-from"], ...(values["mail-notify"] ? { notify: values["mail-notify"] } : {}) };
281
+ }
273
282
  const secret = process.env[values["secret-env"]];
274
283
  if (!secret || secret.length < 32)
275
284
  throw new Error(`environment variable ${values["secret-env"]} must hold a secret of at least 32 characters`);
@@ -292,7 +301,7 @@ async function main(argv) {
292
301
  catch {
293
302
  // the log may not be reachable from here at start; the sheet then omits the keyid
294
303
  }
295
- 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 });
304
+ 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 } : {}), ...(mail ? { mail } : {}), trustProxy: values["trust-proxy"] }, { port: Number(values.port), host: values.host });
296
305
  console.error(`agent-custody portal: ${running.url} log=${values["public-url"]} billing=${stripe ? "stripe" : "off"}${values["trust-proxy"] ? " trust-proxy" : ""}`);
297
306
  await new Promise((resolve) => process.once("SIGINT", resolve));
298
307
  await running.close();
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ 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
8
  export { PortalStore, portalHandler, readSession, servePortal, signSession, stripeRequest, verifyStripeSignature } from "./portal.ts";
9
- export type { PortalOptions, PortalUser, RunningPortal, StripeOptions } from "./portal.ts";
9
+ export type { MailOptions, PortalOptions, PortalUser, Profile, RunningPortal, StripeOptions } from "./portal.ts";
10
10
  export type { HttpGatewayOptions, RunningHttpGateway } from "./gateway-http.ts";
11
11
  export { exportLog, formatExport } from "./log-export.ts";
12
12
  export type { ExportOptions, ExportResult } from "./log-export.ts";
package/dist/log-admin.js CHANGED
@@ -204,9 +204,12 @@ export function adminRoutes(opts) {
204
204
  const ADMIN_PAGE = `<!doctype html>
205
205
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
206
206
  <title>agent-custody log admin</title>
207
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
208
+ <script>(() => { try { const t = localStorage.getItem("agent-custody-theme"); if (t === "dark" || t === "light") document.documentElement.dataset.theme = t; } catch {} })();</script>
207
209
  <style>
208
210
  :root { color-scheme: light dark; --ink: #1b2430; --ink2: #5b6b7a; --line: #d7dfe5; --bg: #fafbfc; --panel: #ffffff; --accent: #0f6e63; --warn: #8a5a00; --warnbg: #fbf1dc; --mono: ui-monospace, Menlo, monospace; }
209
- @media (prefers-color-scheme: dark) { :root { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #4fc3b0; --warn: #e2b862; --warnbg: #2d2412; } }
211
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #4fc3b0; --warn: #e2b862; --warnbg: #2d2412; } }
212
+ :root[data-theme="dark"] { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #4fc3b0; --warn: #e2b862; --warnbg: #2d2412; }
210
213
  body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
211
214
  main { max-width: 72rem; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
212
215
  h1 { font-size: 1.4rem; margin: 0 0 .25rem; } h2 { font-size: 1.05rem; margin: 2rem 0 .75rem; }
@@ -225,14 +228,16 @@ const ADMIN_PAGE = `<!doctype html>
225
228
  .once { border-left: 3px solid var(--warn); background: var(--warnbg); padding: .8rem 1rem; border-radius: 0 4px 4px 0; margin: 1rem 0; }
226
229
  .muted { color: var(--ink2); } .err { color: #b3261e; } .ok { color: var(--accent); }
227
230
  .tok { font-family: var(--mono); font-size: 1.05rem; word-break: break-all; user-select: all; }
231
+ .head { display: flex; justify-content: space-between; align-items: start; gap: 1rem; }
232
+ button.theme { background: transparent; color: var(--ink2); border: 1px solid var(--line); padding: .25rem .6rem; font-size: .8rem; border-radius: 4px; } button.theme:hover { color: var(--ink); }
228
233
  [hidden] { display: none !important; }
229
234
  .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: .6rem; margin: 0 0 1rem; }
230
235
  .stat { background: var(--panel); border: 1px solid var(--line); border-radius: 4px; padding: .6rem .8rem; }
231
236
  .stat b { display: block; font-size: 1.4rem; font-variant-numeric: tabular-nums; } .stat span { color: var(--ink2); font-size: .8rem; }
232
237
  </style>
233
238
  <main>
234
- <h1>Log admin</h1>
235
- <p class="sub" id="where">Tenants and tokens on this log.</p>
239
+ <div class="head"><div><h1>Log admin</h1>
240
+ <p class="sub" id="where">Tenants and tokens on this log.</p></div><button class="theme" id="themeToggle" type="button">Dark mode</button></div>
236
241
  <section id="app">
237
242
  <h2>Registrations</h2>
238
243
  <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>
@@ -283,6 +288,11 @@ const ADMIN_PAGE = `<!doctype html>
283
288
  };
284
289
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
285
290
  const say = (t, cls) => { $("msg").textContent = t; $("msg").className = cls || "muted"; };
291
+ // Light or dark by choice, kept in this browser; unset, the page follows the system. The button says where it is going.
292
+ const themeNow = () => document.documentElement.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
293
+ const themeLabel = () => { $("themeToggle").textContent = themeNow() === "dark" ? "Light mode" : "Dark mode"; };
294
+ $("themeToggle").onclick = () => { const next = themeNow() === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = next; try { localStorage.setItem("agent-custody-theme", next); } catch {} themeLabel(); };
295
+ themeLabel();
286
296
  const loadTenants = async () => {
287
297
  const list = await api("GET", "/admin/tenants");
288
298
  const planPick = (t) => "<select data-plan=\\"" + esc(t.id) + "\\">" + ["free", "team", "enterprise"].map((p) => "<option" + (p === t.plan ? " selected" : "") + ">" + p + "</option>").join("") + "</select>";
package/dist/portal.d.ts CHANGED
@@ -7,6 +7,17 @@ export interface StripeOptions {
7
7
  priceTeam: string;
8
8
  fetch?: typeof fetch;
9
9
  }
10
+ /** Outbound mail over an HTTP API in Resend's shape (`POST /emails` with a bearer key). One message at registration; never a secret. */
11
+ export interface MailOptions {
12
+ apiKey: string;
13
+ /** the sender, an address on a domain the provider has verified */
14
+ from: string;
15
+ /** an operator address that gets a note per registration, with the contact details */
16
+ notify?: string;
17
+ /** the endpoint; Resend's by default */
18
+ url?: string;
19
+ fetch?: typeof fetch;
20
+ }
10
21
  export interface PortalOptions {
11
22
  tenancy: PostgresTenancy;
12
23
  /** the Postgres client the tenancy uses; the portal's own tables live beside the log's */
@@ -21,6 +32,7 @@ export interface PortalOptions {
21
32
  /** the portal's own public URL, for Stripe's return addresses */
22
33
  portalUrl?: string;
23
34
  stripe?: StripeOptions;
35
+ mail?: MailOptions;
24
36
  /** key throttles by X-Forwarded-For; only behind a proxy you run. Also marks cookies Secure. */
25
37
  trustProxy?: boolean;
26
38
  /** table prefix; default portal_ */
package/dist/portal.js CHANGED
@@ -173,7 +173,46 @@ export function portalHandler(o) {
173
173
  const base = o.publicUrl.endsWith("/") ? o.publicUrl : `${o.publicUrl}/`;
174
174
  const cookieName = "custody_session";
175
175
  const sheet = (tenant, logId) => welcomeSheet({ tenant, logId, publicUrl: base, ...(o.checkpointsUrl ? { checkpointsUrl: o.checkpointsUrl } : {}), ...(o.keyid ? { keyid: o.keyid } : {}) });
176
+ const portalBase = (o.portalUrl ?? "http://localhost/").replace(/\/?$/, "/");
177
+ // Mail is best effort and off the request path: a provider outage is logged, never a failed registration.
178
+ const send = async (m) => {
179
+ if (!o.mail)
180
+ return;
181
+ const f = o.mail.fetch ?? fetch;
182
+ try {
183
+ const r = await f(o.mail.url ?? "https://api.resend.com/emails", { method: "POST", headers: { authorization: `Bearer ${o.mail.apiKey}`, "content-type": "application/json" }, body: JSON.stringify({ from: o.mail.from, to: [m.to], subject: m.subject, text: m.text }), signal: AbortSignal.timeout(10_000) });
184
+ if (!r.ok)
185
+ log(`agent-custody portal: mail to ${m.to} refused: ${r.status} ${(await r.text()).slice(0, 200)}`);
186
+ }
187
+ catch (e) {
188
+ log(`agent-custody portal: mail to ${m.to} failed: ${e instanceof Error ? e.message : String(e)}`);
189
+ }
190
+ };
191
+ const welcomeMail = (to, name, tenant, logId) => send({ to, subject: `Your agent-custody log "${tenant}" is ready`, text: [
192
+ `Hello ${name},`, "",
193
+ `Your tenant "${tenant}" is live on the hosted log. Your API key was shown once when you registered and is not in this email; if it is gone, mint another under API keys at ${portalBase}.`, "",
194
+ "Everything below is under Setup in the dashboard whenever you need it.", "",
195
+ sheet(tenant, logId), "",
196
+ `Dashboard: ${portalBase}`, "Getting started: https://agent-custody.dev/guide/getting-started", "Questions: reply to this email.",
197
+ ].join("\n") });
198
+ const notifyMail = (p) => o.mail?.notify ? send({ to: o.mail.notify, subject: `New registration: ${p.company} (${p.tenant})`, text: [`${p.name}${p.role ? `, ${p.role}` : ""} at ${p.company} registered tenant "${p.tenant}".`, "", `email ${p.email}`, `phone ${p.phone ?? "-"}`, `telegram ${p.telegram ? `@${p.telegram}` : "-"}`, "", "The Registrations section of the admin page has the same, with their usage."].join("\n") }) : Promise.resolve();
176
199
  const exportCommand = (tenant) => `npx @agent-custody/receipts log-export --log-url ${base} --tenant ${tenant} --token-env AGENT_CUSTODY_LOG_TOKEN --out custody-export/`;
200
+ // The welcome sheet as data: what the page renders as numbered steps, at registration and again under Setup.
201
+ const setupFor = (tenant, logId) => {
202
+ const url = `${base}t/${tenant}/`;
203
+ return {
204
+ log: url,
205
+ logId,
206
+ checkpoints: o.checkpointsUrl ? `${o.checkpointsUrl.replace(/\/?$/, "/")}${tenant}/latest.json` : null,
207
+ keys: `${base}.well-known/agent-custody-log.json`,
208
+ keyid: o.keyid ?? null,
209
+ env: "export AGENT_CUSTODY_LOG_TOKEN=<the key shown at registration>",
210
+ config: `"log": { "url": "${url}", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN", "hashOnly": true }`,
211
+ verify: `npx @agent-custody/receipts verify receipts/<id>.json --issuer-key <your gateway.pub> --principal-key <your principal.pub> --log-url ${url} --log-id ${logId}`,
212
+ audit: `npx @agent-custody/receipts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log-url ${url} --log-id ${logId}`,
213
+ export: exportCommand(tenant),
214
+ };
215
+ };
177
216
  return async (req, res) => {
178
217
  const url = new URL(req.url ?? "/", "http://localhost");
179
218
  const addr = clientAddress(req, o.trustProxy);
@@ -202,6 +241,10 @@ export function portalHandler(o) {
202
241
  try {
203
242
  if (req.method === "GET" && url.pathname === "/health")
204
243
  return json(200, { ok: true, stripe: !!o.stripe });
244
+ if (req.method === "GET" && url.pathname === "/favicon.svg") {
245
+ res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" });
246
+ return void res.end(FAVICON);
247
+ }
205
248
  if (req.method === "GET" && url.pathname === "/") {
206
249
  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'" });
207
250
  return void res.end(PORTAL_PAGE.replace("__LOG_BASE__", base.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c])));
@@ -274,7 +317,9 @@ export function portalHandler(o) {
274
317
  await store.addMember(user.id, tenant);
275
318
  const minted = await o.tenancy.addToken(tenant, "first key", `portal:${email}`);
276
319
  log(`agent-custody portal: ${email} registered tenant ${tenant}`);
277
- 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)) });
320
+ void welcomeMail(email, profile.name, t.id, t.logId);
321
+ void notifyMail({ ...profile, email, tenant: t.id });
322
+ 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), setup: setupFor(t.id, t.logId), exportCommand: exportCommand(t.id) }, { "set-cookie": setCookie(signSession(o.secret, user.id)) });
278
323
  }
279
324
  if (req.method === "POST" && url.pathname === "/api/login") {
280
325
  if (!loginFailures.take(`login:${addr}`))
@@ -320,7 +365,7 @@ export function portalHandler(o) {
320
365
  const keys = (await o.tenancy.listTokens(tenantId)).map((k) => ({ label: k.label, hash: k.tokenHash.slice(0, 12), createdAt: k.createdAt, revokedAt: k.revokedAt }));
321
366
  const audit = await o.tenancy.audit({ tenant: tenantId, limit: 50 });
322
367
  const billing = await store.billing(tenantId);
323
- 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 });
368
+ 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), setup: setupFor(tenantId, tenant.logId), stripe: !!o.stripe });
324
369
  }
325
370
  if (req.method === "GET" && url.pathname === "/api/keys") {
326
371
  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 })) });
@@ -376,18 +421,23 @@ export function servePortal(o, opts) {
376
421
  });
377
422
  }
378
423
  // ---- the page: one file, no framework, no outside requests ----
424
+ const FAVICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#b45309"/><circle cx="32" cy="32" r="19" fill="none" stroke="#fff" stroke-width="4.5"/><path d="M22 33.5l7 6.5 13-15" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
379
425
  const PORTAL_PAGE = `<!doctype html>
380
426
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
381
427
  <title>agent-custody</title>
428
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
429
+ <script>(() => { try { const t = localStorage.getItem("agent-custody-theme"); if (t === "dark" || t === "light") document.documentElement.dataset.theme = t; } catch {} })();</script>
382
430
  <style>
383
431
  :root { color-scheme: light dark; --ink: #1b2430; --ink2: #5b6b7a; --line: #d7dfe5; --bg: #f5f7f9; --panel: #ffffff; --accent: #b45309; --accent-bg: #fbf1dc; --warn: #8a5a00; --bad: #b3261e; --ok: #1f7a4d; --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
384
- @media (prefers-color-scheme: dark) { :root { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #f59e0b; --accent-bg: #2d2412; --warn: #e2b862; --bad: #ff8a80; --ok: #6fd39a; } }
432
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #f59e0b; --accent-bg: #2d2412; --warn: #e2b862; --bad: #ff8a80; --ok: #6fd39a; } }
433
+ :root[data-theme="dark"] { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #f59e0b; --accent-bg: #2d2412; --warn: #e2b862; --bad: #ff8a80; --ok: #6fd39a; }
385
434
  * { box-sizing: border-box; }
386
435
  body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
387
436
  .top { display: flex; align-items: center; gap: 1rem; padding: .7rem 1.25rem; border-bottom: 1px solid var(--line); background: var(--panel); }
388
437
  .brand { font-weight: 700; letter-spacing: .04em; display: inline-flex; align-items: center; gap: .5rem; color: var(--ink); text-decoration: none; } .brand svg { width: 20px; height: 20px; display: block; } .brand rect { fill: var(--accent); }
389
438
  .top .links { margin-left: auto; display: flex; gap: .9rem; font-size: .9rem; } .top .links a { color: var(--accent); text-decoration: none; }
390
439
  .top .who { color: var(--ink2); font-size: .9rem; margin-left: .5rem; }
440
+ button.theme { background: transparent; color: var(--ink2); border: 1px solid var(--line); padding: .25rem .6rem; font-size: .8rem; border-radius: 4px; } button.theme:hover { color: var(--ink); }
391
441
  .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); }
392
442
  .layout { display: grid; grid-template-columns: 15rem 1fr; min-height: calc(100vh - 3.3rem); }
393
443
  nav { border-right: 1px solid var(--line); background: var(--panel); padding: 1rem 0; }
@@ -420,6 +470,13 @@ const PORTAL_PAGE = `<!doctype html>
420
470
  button.quiet { background: transparent; color: var(--accent); }
421
471
  button.link { background: none; border: 0; padding: 0; color: var(--accent); text-decoration: underline; }
422
472
  .auth { max-width: 26rem; margin: 4rem auto; }
473
+ .auth.wide { max-width: 44rem; }
474
+ .step { display: grid; grid-template-columns: 2rem 1fr; gap: .2rem .8rem; padding: 1rem 0; border-top: 1px solid var(--line); }
475
+ .step .n { font: 700 .85rem/1.6 var(--mono); color: var(--accent); }
476
+ .step h3 { margin: 0 0 .3rem; font-size: 1rem; } .step p { margin: 0 0 .5rem; color: var(--ink2); font-size: .92rem; }
477
+ .snip { position: relative; margin: .4rem 0 .6rem; } .snip pre { margin: 0; padding-right: 4.5rem; font-family: var(--mono); font-size: .84rem; }
478
+ .snip button { position: absolute; top: .45rem; right: .45rem; padding: .2rem .6rem; font-size: .8rem; }
479
+ .addr th { text-align: left; font-weight: 600; padding-right: 1rem; white-space: nowrap; } .addr td { word-break: break-all; }
423
480
  .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; }
424
481
  .tok { font-family: var(--mono); word-break: break-all; padding: .6rem; background: var(--bg); border-radius: 4px; }
425
482
  .msg { min-height: 1.4rem; margin: .6rem 0; color: var(--ink2); } .msg.err { color: var(--bad); } .msg.ok { color: var(--ok); }
@@ -427,7 +484,7 @@ const PORTAL_PAGE = `<!doctype html>
427
484
  [hidden] { display: none !important; }
428
485
  @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); } }
429
486
  </style>
430
- <div class="top"><a class="brand" href="https://agent-custody.dev/"><svg viewBox="0 0 64 64" aria-hidden="true"><rect width="64" height="64" rx="14"/><circle cx="32" cy="32" r="19" fill="none" stroke="#fff" stroke-width="4.5"/><path d="M22 33.5l7 6.5 13-15" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>agent-custody</a><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>
487
+ <div class="top"><a class="brand" href="https://agent-custody.dev/"><svg viewBox="0 0 64 64" aria-hidden="true"><rect width="64" height="64" rx="14"/><circle cx="32" cy="32" r="19" fill="none" stroke="#fff" stroke-width="4.5"/><path d="M22 33.5l7 6.5 13-15" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>agent-custody</a><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><button class="theme" id="themeToggle" type="button">Dark mode</button></div>
431
488
  <section id="auth" class="auth" hidden>
432
489
  <h1 id="authTitle">Sign in</h1>
433
490
  <form id="authForm">
@@ -449,12 +506,12 @@ const PORTAL_PAGE = `<!doctype html>
449
506
  <p class="muted"><button class="link" id="authSwap" type="button">Create an account and a tenant instead</button></p>
450
507
  <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>
451
508
  </section>
452
- <section id="welcome" class="auth" hidden>
509
+ <section id="welcome" class="auth wide" hidden>
453
510
  <h1>Your tenant is ready</h1>
511
+ <p class="muted">Three steps to your first receipt in this log. Everything here is under <b>Setup</b> in the dashboard whenever you need it again.</p>
454
512
  <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>
455
- <h2>Your welcome sheet</h2>
456
- <pre id="firstSheet"></pre>
457
- <button id="toDash">Go to the dashboard</button>
513
+ <div id="firstSetup"></div>
514
+ <p><button id="toDash">Go to the dashboard</button></p>
458
515
  </section>
459
516
  <div class="layout" id="app" hidden>
460
517
  <nav>
@@ -462,6 +519,7 @@ const PORTAL_PAGE = `<!doctype html>
462
519
  <a href="#overview" data-view="overview">Overview</a>
463
520
  <a href="#usage" data-view="usage">Usage</a>
464
521
  <div class="group">Configure</div>
522
+ <a href="#setup" data-view="setup">Setup</a>
465
523
  <a href="#keys" data-view="keys">API keys <span class="n" id="nKeys"></span></a>
466
524
  <a href="#billing" data-view="billing">Billing</a>
467
525
  <a href="#export" data-view="export">Export</a>
@@ -505,8 +563,11 @@ const PORTAL_PAGE = `<!doctype html>
505
563
  <h1>Export</h1>
506
564
  <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>
507
565
  <pre id="exportCmd"></pre>
508
- <h2>Welcome sheet</h2>
509
- <pre id="sheet"></pre>
566
+ </div>
567
+ <div data-pane="setup" hidden>
568
+ <h1>Setup</h1>
569
+ <p class="muted">How to connect a gateway or SDK to your log, what to hand your auditors, and how to take your data. The same sheet you saw at registration.</p>
570
+ <div id="setupPane"></div>
510
571
  </div>
511
572
  </main>
512
573
  </div>
@@ -541,11 +602,32 @@ const PORTAL_PAGE = `<!doctype html>
541
602
  try {
542
603
  if (registering) {
543
604
  const r = await api("POST", "/api/register", { email: $("email").value, password: $("password").value, tenant: $("tenant").value, name: $("name").value, company: $("company").value, role: $("role").value, phone: $("phone").value, telegram: $("telegram").value });
544
- $("firstToken").textContent = r.token; $("firstSheet").textContent = r.welcome; show("welcome");
605
+ $("firstToken").textContent = r.token; renderSetup($("firstSetup"), r.setup, true); show("welcome");
545
606
  } else { await api("POST", "/api/login", { email: $("email").value, password: $("password").value }); await enter(); }
546
607
  } catch (err) { $("authMsg").className = "msg err"; $("authMsg").textContent = err.message; }
547
608
  };
548
609
  $("copyFirst").onclick = () => navigator.clipboard.writeText($("firstToken").textContent);
610
+ // Light or dark by choice, kept in this browser; unset, the page follows the system. The button says where it is going.
611
+ const themeNow = () => document.documentElement.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
612
+ const themeLabel = () => { $("themeToggle").textContent = themeNow() === "dark" ? "Light mode" : "Dark mode"; };
613
+ $("themeToggle").onclick = () => { const next = themeNow() === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = next; try { localStorage.setItem("agent-custody-theme", next); } catch {} themeLabel(); };
614
+ themeLabel();
615
+ const snip = (text) => "<div class=snip><pre>" + esc(text) + "</pre><button type=button class=quiet data-copy>Copy</button></div>";
616
+ const step = (n, title, body) => "<div class=step><span class=n>" + n + "</span><div><h3>" + title + "</h3>" + body + "</div></div>";
617
+ const renderSetup = (el, s, atRegistration) => {
618
+ el.innerHTML =
619
+ step(1, "Keep the key where your gateway runs", (atRegistration ? "<p>The key above is shown once; we keep only its hash. Put it in the environment of the machine that runs your gateway or SDK:</p>" : "<p>Your key was shown once at registration. If it is gone, mint another under <a href=\"#keys\" data-view=\"keys\">API keys</a>. It lives in the environment of the machine that runs your gateway or SDK:</p>") + snip(s.env)) +
620
+ step(2, "Point your gateway or SDK at your log", "<p>Add this to <code>gateway.json</code> or <code>sdk.json</code>. <code>hashOnly</code> means this log receives the hash of each receipt and never the receipt.</p>" + snip(s.config)) +
621
+ step(3, "Send the first receipt", "<p>Run your agent through the gateway once. The <a href=\"#overview\" data-view=\"overview\">Overview</a> shows the append within seconds, and the first signed checkpoint follows within minutes. New to the gateway? <a href=\"https://agent-custody.dev/guide/getting-started\">Getting started</a> takes ten minutes.</p>") +
622
+ step(4, "Hand this to whoever verifies your receipts", "<p>Both commands fetch this log's published keys and pin them; <code>--log-id</code> makes sure the tree heads are this log's.</p>" + snip(s.verify) + snip(s.audit)) +
623
+ step(5, "Take your data, any time", "<p>Every leaf hash, the signed head, the keys, the checkpoints, your usage, and the actions taken on your tenant, checked against each other and written as a log copy the verifier reads offline.</p>" + snip(s.export)) +
624
+ "<h2>Your log's addresses</h2><div class=panel><table class=addr><tbody>" + [["Your log", s.log], ["Log id on tree heads", s.logId], ["Your checkpoints", s.checkpoints || "published after your first append"], ["The log's keys", s.keys + (s.keyid ? " (current keyid " + s.keyid.slice(0, 12) + "…)" : "")]].map(([k, v]) => "<tr><th>" + esc(k) + "</th><td class=mono>" + esc(v) + "</td></tr>").join("") + "</tbody></table></div>" +
625
+ "<h2>What this log does not do</h2><p class=muted>Hold receipt contents; forge a receipt, since your gateway key signs those; or, today, countersign with a second independent witness. <a href=\"https://agent-custody.dev/receipts/#what-a-receipt-proves-and-what-it-does-not\">What a receipt proves and what it does not.</a></p>";
626
+ };
627
+ document.addEventListener("click", (e) => {
628
+ const b = e.target.closest("button[data-copy]"); if (b) { navigator.clipboard.writeText(b.previousElementSibling.textContent).then(() => { b.textContent = "Copied"; setTimeout(() => { b.textContent = "Copy"; }, 1500); }); return; }
629
+ const a = e.target.closest("a[data-view]"); if (a && a.closest("main")) { e.preventDefault(); view(a.dataset.view); }
630
+ });
549
631
  $("toDash").onclick = () => enter();
550
632
  $("signout").onclick = async (e) => { e.preventDefault(); await api("POST", "/api/logout", {}); location.hash = ""; show("auth"); };
551
633
  for (const a of document.querySelectorAll("nav a[data-view]")) a.onclick = (e) => { e.preventDefault(); view(a.dataset.view); };
@@ -567,7 +649,7 @@ const PORTAL_PAGE = `<!doctype html>
567
649
  $("billingPanel").innerHTML = o.plan === "free"
568
650
  ? "<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>")
569
651
  : "<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>" : "");
570
- $("exportCmd").textContent = o.exportCommand; $("sheet").textContent = o.welcome;
652
+ $("exportCmd").textContent = o.exportCommand; renderSetup($("setupPane"), o.setup, false);
571
653
  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; } };
572
654
  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; } };
573
655
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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": {