@agent-custody/receipts 0.6.6 → 0.6.8

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";
@@ -36,7 +36,7 @@ export declare function welcomeSheet(o: {
36
36
  * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
37
37
  * GET /admin/usage?month=YYYY-MM { month, tenants: [{ id, logId, appends, totalLeaves, liveTokens, disabled }] }
38
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 }
39
+ * GET /admin/registrations?month=YYYY-MM { month, rows: [{ tenant, logId, plan, email, name, company, role, phone, telegram, registeredAt, billing, appends, quota, totalLeaves, liveTokens, disabled }], totals }
40
40
  * GET /admin/registrations.csv?month=YYYY-MM the same as CSV
41
41
  */
42
42
  /** One row per tenant: who registered it through the portal (null when it was onboarded by script), its plan and billing
@@ -56,6 +56,11 @@ export interface RegistrationRow {
56
56
  logId: string;
57
57
  plan: Plan;
58
58
  email: string | null;
59
+ name: string | null;
60
+ company: string | null;
61
+ role: string | null;
62
+ phone: string | null;
63
+ telegram: string | null;
59
64
  registeredAt: string | null;
60
65
  billing: string | null;
61
66
  appends: number;
package/dist/log-admin.js CHANGED
@@ -57,7 +57,7 @@ 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 }
60
+ * GET /admin/registrations?month=YYYY-MM { month, rows: [{ tenant, logId, plan, email, name, company, role, phone, telegram, registeredAt, billing, appends, quota, totalLeaves, liveTokens, disabled }], totals }
61
61
  * GET /admin/registrations.csv?month=YYYY-MM the same as CSV
62
62
  */
63
63
  /** One row per tenant: who registered it through the portal (null when it was onboarded by script), its plan and billing
@@ -70,7 +70,7 @@ export async function registrations(tenancy, portal, month) {
70
70
  who.set(r.tenantId, r);
71
71
  const rows = usage.tenants.map((t) => {
72
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 };
73
+ return { tenant: t.id, logId: t.logId, plan: t.plan, email: w?.email ?? null, name: w?.name ?? null, company: w?.company ?? null, role: w?.role ?? null, phone: w?.phone ?? null, telegram: w?.telegram ?? 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
74
  });
75
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
76
  }
@@ -140,7 +140,7 @@ export function adminRoutes(opts) {
140
140
  else if (req.method === "GET" && parts.length === 2 && parts[1] === "registrations.csv") {
141
141
  const r = await registrations(t, opts.portal, month);
142
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";
143
+ const csv = ["month,tenant,log_id,plan,email,name,company,role,phone,telegram,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.name, x.company, x.role, x.phone, x.telegram, x.registeredAt, x.billing, x.appends, x.quota, x.totalLeaves, x.liveTokens, x.disabled].map(cell).join(","))].join("\n") + "\n";
144
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
145
  res.end(csv);
146
146
  }
@@ -237,7 +237,7 @@ const ADMIN_PAGE = `<!doctype html>
237
237
  <h2>Registrations</h2>
238
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
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>
240
+ <table><thead><tr><th>who</th><th>reach</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>
241
241
  <h2>Tenants</h2>
242
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>
243
243
  <h2>New tenant</h2>
@@ -326,7 +326,9 @@ const ADMIN_PAGE = `<!doctype html>
326
326
  const r = await api("GET", "/admin/registrations?month=" + encodeURIComponent(month));
327
327
  $("rcsv").href = "/admin/registrations.csv?month=" + encodeURIComponent(month);
328
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>";
329
+ const who = (x) => x.email ? esc(x.name || "") + (x.company ? " <span class=muted>· " + esc(x.company) + (x.role ? ", " + esc(x.role) : "") + "</span>" : "") : "<span class=muted>onboarded by script</span>";
330
+ const reach = (x) => [x.email ? "<a href=\"mailto:" + esc(x.email) + "\">" + esc(x.email) + "</a>" : "", x.phone ? esc(x.phone) : "", x.telegram ? "telegram @" + esc(x.telegram) : ""].filter(Boolean).join("<br>");
331
+ $("regs").innerHTML = r.rows.map((x) => "<tr><td>" + who(x) + "</td><td>" + reach(x) + "</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=9 class=muted>no tenants</td></tr>";
330
332
  };
331
333
  const loadAudit = async () => {
332
334
  const a = await api("GET", "/admin/audit?limit=100");
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_ */
@@ -33,6 +45,14 @@ export interface PortalUser {
33
45
  createdAt: string;
34
46
  }
35
47
  /** Users, memberships, and billing records, beside the log's tables. */
48
+ /** What a registration says about the person and the organisation; every field optional in the store, the route decides what it requires. */
49
+ export interface Profile {
50
+ name?: string | null;
51
+ company?: string | null;
52
+ role?: string | null;
53
+ phone?: string | null;
54
+ telegram?: string | null;
55
+ }
36
56
  export declare class PortalStore {
37
57
  private readonly client;
38
58
  private readonly p;
@@ -41,7 +61,7 @@ export declare class PortalStore {
41
61
  private init;
42
62
  static hashPassword(password: string): string;
43
63
  static checkPassword(password: string, stored: string): boolean;
44
- createUser(email: string, password: string): Promise<PortalUser>;
64
+ createUser(email: string, password: string, profile?: Profile): Promise<PortalUser>;
45
65
  authenticate(email: string, password: string): Promise<PortalUser | null>;
46
66
  user(id: string): Promise<PortalUser | null>;
47
67
  addMember(userId: string, tenantId: string): Promise<void>;
@@ -58,12 +78,12 @@ export declare class PortalStore {
58
78
  status: string;
59
79
  } | null>;
60
80
  /** Every portal registration with its tenant and billing state, oldest first: what the operator's admin page lists. */
61
- registrations(): Promise<{
81
+ registrations(): Promise<({
62
82
  tenantId: string;
63
83
  email: string;
64
84
  registeredAt: string;
65
85
  billing: string | null;
66
- }[]>;
86
+ } & Profile)[]>;
67
87
  tenantBySubscription(subscriptionId: string): Promise<string | null>;
68
88
  }
69
89
  export declare function signSession(secret: string, userId: string, ttlMs?: number): string;
package/dist/portal.js CHANGED
@@ -19,7 +19,7 @@ const ident = (s) => {
19
19
  throw new Error(`prefix must be a plain identifier; got "${s}"`);
20
20
  return s;
21
21
  };
22
- /** Users, memberships, and billing records, beside the log's tables. */
22
+ const PROFILE_COLUMNS = ["name", "company", "role", "phone", "telegram"];
23
23
  export class PortalStore {
24
24
  client;
25
25
  p;
@@ -36,6 +36,9 @@ export class PortalStore {
36
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
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
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
+ // who the account belongs to: added after the first tenants registered, so the columns are optional
40
+ for (const c of PROFILE_COLUMNS)
41
+ await this.client.query(`ALTER TABLE ${p}users ADD COLUMN IF NOT EXISTS ${c} TEXT`);
39
42
  })();
40
43
  }
41
44
  return this.ready;
@@ -52,10 +55,10 @@ export class PortalStore {
52
55
  const got = scryptSync(password, Buffer.from(saltHex, "hex"), expected.length);
53
56
  return got.length === expected.length && timingSafeEqual(got, expected);
54
57
  }
55
- async createUser(email, password) {
58
+ async createUser(email, password, profile = {}) {
56
59
  await this.init();
57
60
  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;
61
+ const rows = (await this.client.query(`INSERT INTO ${this.p}users (id, email, password_hash, name, company, role, phone, telegram) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (email) DO NOTHING RETURNING id, email, created_at`, [id, email, PortalStore.hashPassword(password), profile.name ?? null, profile.company ?? null, profile.role ?? null, profile.phone ?? null, profile.telegram ?? null])).rows;
59
62
  if (!rows[0])
60
63
  throw new Error("an account with this email already exists");
61
64
  return { id, email, createdAt: new Date(rows[0].created_at).toISOString() };
@@ -98,8 +101,9 @@ export class PortalStore {
98
101
  /** Every portal registration with its tenant and billing state, oldest first: what the operator's admin page lists. */
99
102
  async registrations() {
100
103
  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 }));
104
+ const rows = (await this.client.query(`SELECT m.tenant_id, u.email, u.created_at, u.name, u.company, u.role, u.phone, u.telegram, 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;
105
+ const str = (v) => (v == null ? null : String(v));
106
+ return rows.map((r) => ({ tenantId: String(r.tenant_id), email: String(r.email), registeredAt: new Date(r.created_at).toISOString(), billing: str(r.status), name: str(r.name), company: str(r.company), role: str(r.role), phone: str(r.phone), telegram: str(r.telegram) }));
103
107
  }
104
108
  async tenantBySubscription(subscriptionId) {
105
109
  await this.init();
@@ -169,7 +173,46 @@ export function portalHandler(o) {
169
173
  const base = o.publicUrl.endsWith("/") ? o.publicUrl : `${o.publicUrl}/`;
170
174
  const cookieName = "custody_session";
171
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();
172
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
+ };
173
216
  return async (req, res) => {
174
217
  const url = new URL(req.url ?? "/", "http://localhost");
175
218
  const addr = clientAddress(req, o.trustProxy);
@@ -200,7 +243,7 @@ export function portalHandler(o) {
200
243
  return json(200, { ok: true, stripe: !!o.stripe });
201
244
  if (req.method === "GET" && url.pathname === "/") {
202
245
  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'" });
203
- return void res.end(PORTAL_PAGE);
246
+ return void res.end(PORTAL_PAGE.replace("__LOG_BASE__", base.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c])));
204
247
  }
205
248
  // ---- Stripe's webhook: the only caller that is not a browser with a session ----
206
249
  if (req.method === "POST" && url.pathname === "/stripe/webhook") {
@@ -234,8 +277,6 @@ export function portalHandler(o) {
234
277
  }
235
278
  // ---- registration and login ----
236
279
  if (req.method === "POST" && url.pathname === "/api/register") {
237
- if (!registrations.take(`reg:${addr}`))
238
- return json(429, { error: "too many registrations from this address; try again later" });
239
280
  const b = await jsonBody();
240
281
  const email = String(b.email ?? "").trim().toLowerCase();
241
282
  const password = String(b.password ?? "");
@@ -246,11 +287,24 @@ export function portalHandler(o) {
246
287
  return json(400, { error: "the password needs at least ten characters" });
247
288
  if (!TENANT_ID.test(tenant) || RESERVED.has(tenant))
248
289
  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" });
290
+ const text = (k, max) => String(b[k] ?? "").trim().slice(0, max);
291
+ const profile = { name: text("name", 120), company: text("company", 160), role: text("role", 120) || null, phone: text("phone", 40) || null, telegram: text("telegram", 40).replace(/^@/, "") || null };
292
+ if (!profile.name)
293
+ return json(400, { error: "your name is needed, so we know who to write to" });
294
+ if (!profile.company)
295
+ return json(400, { error: "the company or organisation the tenant is for is needed" });
296
+ if (profile.telegram && !/^[A-Za-z0-9_]{5,32}$/.test(profile.telegram))
297
+ return json(400, { error: "a Telegram username is five to thirty-two letters, digits, or underscores, with or without the @" });
298
+ if (profile.phone && !/^[+0-9 ()./-]{6,40}$/.test(profile.phone))
299
+ return json(400, { error: "a phone number is digits, with an optional + and spaces" });
249
300
  if (await o.tenancy.tenant(tenant))
250
301
  return json(409, { error: "that tenant id is taken" });
302
+ // throttled once the request is well formed: a mistyped form costs nothing, five real registrations from one address, then one every ten minutes
303
+ if (!registrations.take(`reg:${addr}`))
304
+ return json(429, { error: "too many registrations from this address; try again later" });
251
305
  let user;
252
306
  try {
253
- user = await store.createUser(email, password);
307
+ user = await store.createUser(email, password, profile);
254
308
  }
255
309
  catch (e) {
256
310
  return json(409, { error: e instanceof Error ? e.message : String(e) });
@@ -259,7 +313,9 @@ export function portalHandler(o) {
259
313
  await store.addMember(user.id, tenant);
260
314
  const minted = await o.tenancy.addToken(tenant, "first key", `portal:${email}`);
261
315
  log(`agent-custody portal: ${email} registered tenant ${tenant}`);
262
- 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)) });
316
+ void welcomeMail(email, profile.name, t.id, t.logId);
317
+ void notifyMail({ ...profile, email, tenant: t.id });
318
+ 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)) });
263
319
  }
264
320
  if (req.method === "POST" && url.pathname === "/api/login") {
265
321
  if (!loginFailures.take(`login:${addr}`))
@@ -305,7 +361,7 @@ export function portalHandler(o) {
305
361
  const keys = (await o.tenancy.listTokens(tenantId)).map((k) => ({ label: k.label, hash: k.tokenHash.slice(0, 12), createdAt: k.createdAt, revokedAt: k.revokedAt }));
306
362
  const audit = await o.tenancy.audit({ tenant: tenantId, limit: 50 });
307
363
  const billing = await store.billing(tenantId);
308
- 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 });
364
+ 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 });
309
365
  }
310
366
  if (req.method === "GET" && url.pathname === "/api/keys") {
311
367
  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 })) });
@@ -399,11 +455,19 @@ const PORTAL_PAGE = `<!doctype html>
399
455
  .bar i { display: block; width: 70%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; }
400
456
  .bar b { font-family: var(--mono); font-weight: 400; margin-top: .3rem; }
401
457
  label { display: grid; gap: .25rem; font-size: .85rem; color: var(--ink2); margin: 0 0 .8rem; }
458
+ label .opt { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; margin-left: .3rem; } label .hint { font-size: .8rem; line-height: 1.45; } label .hint code { font-family: var(--mono); font-size: .9em; }
402
459
  input, select { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--ink); }
403
460
  button { font: inherit; padding: .5rem .9rem; border-radius: 4px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
404
461
  button.quiet { background: transparent; color: var(--accent); }
405
462
  button.link { background: none; border: 0; padding: 0; color: var(--accent); text-decoration: underline; }
406
463
  .auth { max-width: 26rem; margin: 4rem auto; }
464
+ .auth.wide { max-width: 44rem; }
465
+ .step { display: grid; grid-template-columns: 2rem 1fr; gap: .2rem .8rem; padding: 1rem 0; border-top: 1px solid var(--line); }
466
+ .step .n { font: 700 .85rem/1.6 var(--mono); color: var(--accent); }
467
+ .step h3 { margin: 0 0 .3rem; font-size: 1rem; } .step p { margin: 0 0 .5rem; color: var(--ink2); font-size: .92rem; }
468
+ .snip { position: relative; margin: .4rem 0 .6rem; } .snip pre { margin: 0; padding-right: 4.5rem; font-family: var(--mono); font-size: .84rem; }
469
+ .snip button { position: absolute; top: .45rem; right: .45rem; padding: .2rem .6rem; font-size: .8rem; }
470
+ .addr th { text-align: left; font-weight: 600; padding-right: 1rem; white-space: nowrap; } .addr td { word-break: break-all; }
407
471
  .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; }
408
472
  .tok { font-family: var(--mono); word-break: break-all; padding: .6rem; background: var(--bg); border-radius: 4px; }
409
473
  .msg { min-height: 1.4rem; margin: .6rem 0; color: var(--ink2); } .msg.err { color: var(--bad); } .msg.ok { color: var(--ok); }
@@ -415,21 +479,30 @@ const PORTAL_PAGE = `<!doctype html>
415
479
  <section id="auth" class="auth" hidden>
416
480
  <h1 id="authTitle">Sign in</h1>
417
481
  <form id="authForm">
418
- <label>Email<input id="email" type="email" autocomplete="email" required></label>
482
+ <div id="regFields" hidden>
483
+ <label>Your name<input id="name" autocomplete="name" maxlength="120"></label>
484
+ <label>Company or organisation<input id="company" autocomplete="organization" maxlength="160"></label>
485
+ <label>Your role <span class="opt">optional</span><input id="role" autocomplete="organization-title" maxlength="120" placeholder="Head of Platform"></label>
486
+ </div>
487
+ <label>Work email<input id="email" type="email" autocomplete="email" required></label>
488
+ <div id="regFields2" hidden>
489
+ <label>Phone <span class="opt">optional</span><input id="phone" type="tel" autocomplete="tel" maxlength="40" placeholder="+44 20 …"></label>
490
+ <label>Telegram username <span class="opt">optional</span><input id="telegram" maxlength="40" placeholder="@yourname"><span class="hint">Your handle in Telegram, under Settings, if you would rather we reach you there than by email.</span></label>
491
+ </div>
419
492
  <label>Password<input id="password" type="password" autocomplete="current-password" minlength="10" required></label>
420
- <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>
493
+ <label id="tenantField" hidden>Tenant id<input id="tenant" placeholder="acme" pattern="[a-z0-9][a-z0-9-]{1,38}[a-z0-9]"><span class="hint">A short name for your organisation, filled in from the company name; change it if you like. It becomes the path of your log, which your gateway config and your auditors will use: <code id="tenantPreview">__LOG_BASE__t/&lt;tenant&gt;/</code></span></label>
421
494
  <button id="authGo" type="submit">Sign in</button>
422
495
  <p class="msg" id="authMsg"></p>
423
496
  </form>
424
497
  <p class="muted"><button class="link" id="authSwap" type="button">Create an account and a tenant instead</button></p>
425
498
  <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>
426
499
  </section>
427
- <section id="welcome" class="auth" hidden>
500
+ <section id="welcome" class="auth wide" hidden>
428
501
  <h1>Your tenant is ready</h1>
502
+ <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>
429
503
  <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>
430
- <h2>Your welcome sheet</h2>
431
- <pre id="firstSheet"></pre>
432
- <button id="toDash">Go to the dashboard</button>
504
+ <div id="firstSetup"></div>
505
+ <p><button id="toDash">Go to the dashboard</button></p>
433
506
  </section>
434
507
  <div class="layout" id="app" hidden>
435
508
  <nav>
@@ -437,6 +510,7 @@ const PORTAL_PAGE = `<!doctype html>
437
510
  <a href="#overview" data-view="overview">Overview</a>
438
511
  <a href="#usage" data-view="usage">Usage</a>
439
512
  <div class="group">Configure</div>
513
+ <a href="#setup" data-view="setup">Setup</a>
440
514
  <a href="#keys" data-view="keys">API keys <span class="n" id="nKeys"></span></a>
441
515
  <a href="#billing" data-view="billing">Billing</a>
442
516
  <a href="#export" data-view="export">Export</a>
@@ -480,8 +554,11 @@ const PORTAL_PAGE = `<!doctype html>
480
554
  <h1>Export</h1>
481
555
  <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>
482
556
  <pre id="exportCmd"></pre>
483
- <h2>Welcome sheet</h2>
484
- <pre id="sheet"></pre>
557
+ </div>
558
+ <div data-pane="setup" hidden>
559
+ <h1>Setup</h1>
560
+ <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>
561
+ <div id="setupPane"></div>
485
562
  </div>
486
563
  </main>
487
564
  </div>
@@ -504,17 +581,39 @@ const PORTAL_PAGE = `<!doctype html>
504
581
  for (const a of document.querySelectorAll("nav a[data-view]")) a.classList.toggle("on", a.dataset.view === name);
505
582
  location.hash = name;
506
583
  };
507
- $("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"; };
584
+ const LOG_BASE = "__LOG_BASE__";
585
+ const slug = (s) => s.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
586
+ let tenantEdited = false;
587
+ const previewTenant = () => { $("tenantPreview").textContent = LOG_BASE + "t/" + ($("tenant").value || "<tenant>") + "/"; };
588
+ $("company").oninput = () => { if (!tenantEdited) { $("tenant").value = slug($("company").value); previewTenant(); } };
589
+ $("tenant").oninput = () => { tenantEdited = $("tenant").value !== ""; previewTenant(); };
590
+ $("authSwap").onclick = () => { registering = !registering; $("authTitle").textContent = registering ? "Create your tenant" : "Sign in"; $("authGo").textContent = registering ? "Create tenant" : "Sign in"; for (const id of ["regFields", "regFields2", "tenantField"]) $(id).hidden = !registering; for (const id of ["name", "company", "tenant"]) $(id).required = registering; $("password").autocomplete = registering ? "new-password" : "current-password"; $("authSwap").textContent = registering ? "I already have an account" : "Create an account and a tenant instead"; previewTenant(); };
508
591
  $("authForm").onsubmit = async (e) => {
509
592
  e.preventDefault(); $("authMsg").className = "msg"; $("authMsg").textContent = "";
510
593
  try {
511
594
  if (registering) {
512
- const r = await api("POST", "/api/register", { email: $("email").value, password: $("password").value, tenant: $("tenant").value });
513
- $("firstToken").textContent = r.token; $("firstSheet").textContent = r.welcome; show("welcome");
595
+ 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 });
596
+ $("firstToken").textContent = r.token; renderSetup($("firstSetup"), r.setup, true); show("welcome");
514
597
  } else { await api("POST", "/api/login", { email: $("email").value, password: $("password").value }); await enter(); }
515
598
  } catch (err) { $("authMsg").className = "msg err"; $("authMsg").textContent = err.message; }
516
599
  };
517
600
  $("copyFirst").onclick = () => navigator.clipboard.writeText($("firstToken").textContent);
601
+ const snip = (text) => "<div class=snip><pre>" + esc(text) + "</pre><button type=button class=quiet data-copy>Copy</button></div>";
602
+ const step = (n, title, body) => "<div class=step><span class=n>" + n + "</span><div><h3>" + title + "</h3>" + body + "</div></div>";
603
+ const renderSetup = (el, s, atRegistration) => {
604
+ el.innerHTML =
605
+ 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)) +
606
+ 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)) +
607
+ 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>") +
608
+ 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)) +
609
+ 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)) +
610
+ "<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>" +
611
+ "<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>";
612
+ };
613
+ document.addEventListener("click", (e) => {
614
+ 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; }
615
+ const a = e.target.closest("a[data-view]"); if (a && a.closest("main")) { e.preventDefault(); view(a.dataset.view); }
616
+ });
518
617
  $("toDash").onclick = () => enter();
519
618
  $("signout").onclick = async (e) => { e.preventDefault(); await api("POST", "/api/logout", {}); location.hash = ""; show("auth"); };
520
619
  for (const a of document.querySelectorAll("nav a[data-view]")) a.onclick = (e) => { e.preventDefault(); view(a.dataset.view); };
@@ -536,7 +635,7 @@ const PORTAL_PAGE = `<!doctype html>
536
635
  $("billingPanel").innerHTML = o.plan === "free"
537
636
  ? "<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>")
538
637
  : "<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>" : "");
539
- $("exportCmd").textContent = o.exportCommand; $("sheet").textContent = o.welcome;
638
+ $("exportCmd").textContent = o.exportCommand; renderSetup($("setupPane"), o.setup, false);
540
639
  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; } };
541
640
  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; } };
542
641
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
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": {