@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 +11 -2
- package/dist/index.d.ts +1 -1
- package/dist/log-admin.js +13 -3
- package/dist/portal.d.ts +12 -0
- package/dist/portal.js +94 -12
- package/package.json +1 -1
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
-
|
|
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
|
-
<
|
|
456
|
-
<
|
|
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
|
-
|
|
509
|
-
|
|
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; $("
|
|
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; $("
|
|
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.
|
|
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": {
|