@agent-custody/receipts 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,8 @@ Two producers, one receipt format, one verifier.
9
9
 
10
10
  Anyone holding the public keys can verify a receipt offline. The agent is not trusted. The layer around it is, and the receipt says exactly how far that trust extends, starting with who issued it.
11
11
 
12
- - [Tutorials](docs/tutorials.md): eighteen runnable examples, one per aspect of the code, all executed by the test suite
12
+ - [Reference](https://docs.agent-custody.dev/reference/): every function, endpoint, MCP tool, and command with its request and response
13
+ - [Tutorials](docs/tutorials.md): twenty runnable examples, one per aspect of the code, all executed by the test suite
13
14
  - [Usage guide](docs/usage.md): gateway setup, wiring into Claude Desktop, Claude Code, or your own agent loop
14
15
  - [The interceptor SDK](docs/sdk.md): Claude Code hooks, the Claude Agent SDK, adapters for the OpenAI Agents SDK, Vercel AI SDK and LangChain, and wrapping tool functions in anything else
15
16
  - [Writing policies](docs/policies.md): how a tool call becomes a Cedar request, with tested examples
@@ -228,6 +229,7 @@ src/config.ts gateway and SDK config schemas, path resolution
228
229
  src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
229
230
  src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
230
231
  src/log-check.ts the outside monitor: verifies the head, checkpoints, and witness of a running log
232
+ src/portal.ts the tenant portal: register, first key, usage against plan, keys, Stripe billing, export, on the log's Postgres
231
233
  src/log-export.ts a tenant's export of their own log, self-checked, as a log file the verifier reads
232
234
  src/witness.ts the witness: countersigns the log's checkpoints from another operator's machine, or refuses with an alarm
233
235
  src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
@@ -287,6 +289,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
287
289
  - Remote log: the issuer can append to a log run by someone else over HTTP, whose key then signs the tree heads, so a verifier learns the receipt was in a log the operator could not rewrite. Includes the reference log server, bearer-token auth, and a root endpoint for auditors.
288
290
  - Framework adapters, each tested against the real package with a scripted model and no network: OpenAI Agents SDK (`wrapTools` enforces, `observeRunner` records from lifecycle events), Vercel AI SDK (`wrapTools` over a real `generateText` loop), LangChain (`ReceiptCallbackHandler` records, `issuer.wrap` enforces).
289
291
 
292
+ - Plans and the tenant portal: every tenant is on a plan (free, ten thousand appends a month; team, a million; enterprise, no allowance) enforced at append with a clear 429; the portal at the operator's `PORTAL_HOST` lets a team register, get its tenant and first key, watch usage against the plan, mint and revoke keys, buy the team plan through Stripe, and copy the export command, with every action in the audit trail.
290
293
  - An audit trail of administrative actions: every tenant created or disabled and every token minted or revoked is recorded with who did it, from the admin page or the command line, shown on the page and carried in the tenant's export.
291
294
  - A tenant's export: `log-export` takes, with the tenant's own token, every leaf hash, the signed head, the published keys, the checkpoints, and their usage, checks that they add up, and writes a log copy the verifier reads offline; the evidence never depends on the operator staying in business.
292
295
  - Monitoring and metering: `log-check`, the outside probe that verifies the head, the checkpoints, and the witness and exits 1 on trouble, run every ten minutes by the `monitor` workflow; `GET /health`; and usage per tenant per month on the admin page and as CSV.
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { connectSigner, fetchLogKeys, localSigner, serveSigner } from "./signer.
14
14
  import { fetchWitnessKeys, Witness } from "./witness.js";
15
15
  import { checkLog, formatLogCheck } from "./log-check.js";
16
16
  import { serveHttp } from "./gateway-http.js";
17
+ import { servePortal } from "./portal.js";
17
18
  import { exportLog, formatExport } from "./log-export.js";
18
19
  import { CheckpointPublisher, fileResolver } from "./log-sink.js";
19
20
  import { createRequire } from "node:module";
@@ -53,7 +54,8 @@ const USAGE = `agent-custody <command>
53
54
  sign with a key in this process, or through a signer process that holds it; publish a signed
54
55
  checkpoint per log that has grown, every 300 s by default, to the directory (and, with a
55
56
  database, to its heads table); serve the key document at /.well-known/agent-custody-log.json
56
- log ... [--trust-proxy] behind a reverse proxy you run: key per-address limits by X-Forwarded-For
57
+ log ... [--trust-proxy] behind a reverse proxy you run: key per-address limits by the address the proxy appended
58
+ log ... [--hash-only] accept only leaf hashes on append; a log run for other people must never receive a receipt
57
59
  log ... --db-env NAME --admin-token-env NAME [--public-url <https://log.example.com/>] [--checkpoints-url <https://checkpoints.example.com/>]
58
60
  the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
59
61
  the welcome sheet; the public URLs fill the sheet in
@@ -70,9 +72,12 @@ const USAGE = `agent-custody <command>
70
72
  into <dir>; refuses and writes an alarm otherwise. Serve <dir> from a host of your own.
71
73
  signer --key <log.key> --port 8790 [--host 127.0.0.1] [--token-env NAME] [--retired-key <pub>]...
72
74
  the one process that holds the log's key: POST /sign, GET /keys
73
- log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
75
+ log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id> | tenant plan <id> <free|team|enterprise>
74
76
  log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
75
77
  log-admin --db-env NAME audit [--tenant <id>] who did what to tenants and tokens, newest first
78
+ portal --db-env NAME --secret-env NAME --public-url <log url> [--checkpoints-url <url>] [--portal-url <url>] [--port 8792] [--host 127.0.0.1]
79
+ [--stripe-key-env NAME --stripe-webhook-env NAME --stripe-price-team <price id>] [--trust-proxy]
80
+ the tenant portal: register, first key, usage against plan, keys, billing, export
76
81
  log-admin --db-env NAME import --file <log.jsonl> [--tenant default] copies a file log into the database as hashes
77
82
  audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) [--issuer-key <pub>] [--log-key <pub>] [--log-id <id>] [--witness-key <pub> | --witness-url <url>] [--json]
78
83
  with --log-url the log's published keys are fetched and pinned by keyid; with a witness key or
@@ -261,6 +266,38 @@ async function main(argv) {
261
266
  await running.close();
262
267
  return 0;
263
268
  }
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 } } });
271
+ if (!values["db-env"] || !values["secret-env"] || !values["public-url"])
272
+ throw new Error("portal needs --db-env, --secret-env, and --public-url");
273
+ const secret = process.env[values["secret-env"]];
274
+ if (!secret || secret.length < 32)
275
+ throw new Error(`environment variable ${values["secret-env"]} must hold a secret of at least 32 characters`);
276
+ let stripe;
277
+ if (values["stripe-key-env"] || values["stripe-webhook-env"] || values["stripe-price-team"]) {
278
+ if (!values["stripe-key-env"] || !values["stripe-webhook-env"] || !values["stripe-price-team"])
279
+ throw new Error("billing needs all three of --stripe-key-env, --stripe-webhook-env, --stripe-price-team");
280
+ const secretKey = process.env[values["stripe-key-env"]];
281
+ const webhookSecret = process.env[values["stripe-webhook-env"]];
282
+ if (!secretKey || !webhookSecret)
283
+ throw new Error("the Stripe key and webhook secret variables must both be set");
284
+ stripe = { secretKey, webhookSecret, priceTeam: values["stripe-price-team"] };
285
+ }
286
+ const client = openPostgres(values["db-env"]);
287
+ const tenancy = new PostgresTenancy(client);
288
+ let keyid;
289
+ try {
290
+ keyid = (await fetchLogKeys(values["public-url"])).keys[0]?.keyid;
291
+ }
292
+ catch {
293
+ // the log may not be reachable from here at start; the sheet then omits the keyid
294
+ }
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 });
296
+ console.error(`agent-custody portal: ${running.url} log=${values["public-url"]} billing=${stripe ? "stripe" : "off"}${values["trust-proxy"] ? " trust-proxy" : ""}`);
297
+ await new Promise((resolve) => process.once("SIGINT", resolve));
298
+ await running.close();
299
+ return 0;
300
+ }
264
301
  case "log-admin": {
265
302
  const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { "db-env": { type: "string" }, "log-id": { type: "string" }, label: { type: "string" }, file: { type: "string" }, tenant: { type: "string", default: "default" } } });
266
303
  if (!values["db-env"])
@@ -274,7 +311,11 @@ async function main(argv) {
274
311
  }
275
312
  else if (what === "tenant" && verb === "list") {
276
313
  for (const t of await tenancy.listTenants())
277
- console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
314
+ console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} plan=${t.plan.padEnd(10)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
315
+ }
316
+ else if (what === "tenant" && verb === "plan" && args[0] && args[1]) {
317
+ const t = await tenancy.setPlan(args[0], args[1], actor);
318
+ console.log(`tenant ${t.id} on plan ${t.plan}`);
278
319
  }
279
320
  else if (what === "tenant" && verb === "disable" && args[0]) {
280
321
  await tenancy.disableTenant(args[0], actor);
@@ -325,7 +366,7 @@ async function main(argv) {
325
366
  case "log": {
326
367
  const { values } = parseArgs({
327
368
  args: rest,
328
- options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "log-id": { type: "string" }, tenants: { type: "string" }, "db-env": { type: "string" }, "signer-url": { type: "string" }, "signer-token-env": { type: "string" }, "retired-key": { type: "string", multiple: true }, "checkpoint-dir": { type: "string" }, "checkpoint-every": { type: "string", default: "300" }, "checkpoint-heartbeat": { type: "string", default: "21600" }, "admin-token-env": { type: "string" }, "public-url": { type: "string" }, "checkpoints-url": { type: "string" }, "trust-proxy": { type: "boolean", default: false } },
369
+ options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "log-id": { type: "string" }, tenants: { type: "string" }, "db-env": { type: "string" }, "signer-url": { type: "string" }, "signer-token-env": { type: "string" }, "retired-key": { type: "string", multiple: true }, "checkpoint-dir": { type: "string" }, "checkpoint-every": { type: "string", default: "300" }, "checkpoint-heartbeat": { type: "string", default: "21600" }, "admin-token-env": { type: "string" }, "public-url": { type: "string" }, "checkpoints-url": { type: "string" }, "trust-proxy": { type: "boolean", default: false }, "hash-only": { type: "boolean", default: false } },
329
370
  });
330
371
  if (!values.key === !values["signer-url"])
331
372
  throw new Error("log needs exactly one of --key or --signer-url");
@@ -380,7 +421,7 @@ async function main(argv) {
380
421
  resolver = fileResolver(values.file, { ...(token ? { tokens: [token] } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}), ...(tenants ? { tenants } : {}) });
381
422
  where = `file=${values.file}${values["log-id"] ? ` log=${values["log-id"]}` : ""} ${token ? "bearer token required" : "open, anyone may append"}${tenants ? ` tenants=${Object.keys(tenants).join(",")}` : ""}`;
382
423
  }
383
- const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}), ...(admin ? { admin } : {}), trustProxy: values["trust-proxy"] });
424
+ const running = await serveLog(resolver, signer, { port: Number(values.port), host: values.host, ...(checkpoints ? { checkpoints } : {}), ...(admin ? { admin } : {}), trustProxy: values["trust-proxy"], hashOnly: values["hash-only"] });
384
425
  const publisher = checkpoints ? new CheckpointPublisher(resolver, signer, checkpoints, everyMs, undefined, heartbeatMs) : null;
385
426
  publisher?.start();
386
427
  console.error(`agent-custody log: ${running.url} keyid=${signer.keyid} ${values["signer-url"] ? `signer=${values["signer-url"]} ` : ""}${where}${checkpoints ? ` checkpoints every ${values["checkpoint-every"]}s${values["checkpoint-dir"] ? ` to ${values["checkpoint-dir"]}` : ""}` : ""}${admin ? " admin page at /admin" : ""}`);
@@ -30,6 +30,8 @@ export interface SubDelegation {
30
30
  /** the sub-agent's own public key, so it may delegate further */
31
31
  agentKey?: string;
32
32
  }
33
+ /** True when the envelope's payload, verified or not, carries a parent: what decides whether the chain check is reported. */
34
+ export declare function delegationHasParent(env: Envelope): boolean;
33
35
  /** The parent's payload as written, without verifying it; the verifier does that. */
34
36
  export declare function decodeDelegation(env: Envelope): Delegation | null;
35
37
  /**
@@ -16,8 +16,8 @@ export const DelegationSchema = z.object({
16
16
  principal: z.string().min(1),
17
17
  agent: z.string().min(1),
18
18
  scopes: z.array(z.string().min(1)).min(1),
19
- issuedAt: z.iso.datetime(),
20
- expiresAt: z.iso.datetime(),
19
+ issuedAt: z.iso.datetime({ offset: true }),
20
+ expiresAt: z.iso.datetime({ offset: true }),
21
21
  /** the agent's own public key, SPKI PEM; with it the agent may delegate to a sub-agent */
22
22
  agentKey: z.string().min(1).optional(),
23
23
  /** the grant this one was delegated from; the chain ends at a grant signed by a trusted principal */
@@ -26,6 +26,16 @@ export const DelegationSchema = z.object({
26
26
  export function createDelegation(principalKey, d) {
27
27
  return dsseSign(DELEGATION_TYPE, DelegationSchema.parse(d), principalKey);
28
28
  }
29
+ /** True when the envelope's payload, verified or not, carries a parent: what decides whether the chain check is reported. */
30
+ export function delegationHasParent(env) {
31
+ try {
32
+ const raw = JSON.parse(Buffer.from(env.payload, "base64").toString("utf8"));
33
+ return !!raw && typeof raw === "object" && raw.parent !== undefined;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
29
39
  /** The parent's payload as written, without verifying it; the verifier does that. */
30
40
  export function decodeDelegation(env) {
31
41
  try {
package/dist/gateway.d.ts CHANGED
@@ -26,6 +26,12 @@ export interface Gateway {
26
26
  handleCall(params: CallParams): Promise<CallToolResult>;
27
27
  close(): Promise<void>;
28
28
  }
29
+ /**
30
+ * The environment a stdio upstream is started with: what a child process needs to run, plus what the config names.
31
+ * Never the gateway's own environment, which holds the tokens other upstreams and the log are given. An explicit
32
+ * `env` in the config is added to this base, not to the gateway's variables.
33
+ */
34
+ export declare function upstreamEnv(extra?: Record<string, string>): Record<string, string>;
29
35
  export interface GatewayOptions {
30
36
  /** the log to append to, in place of the one the config names; for embedding and tests */
31
37
  log?: LogSink;
package/dist/gateway.js CHANGED
@@ -57,6 +57,20 @@ function extractValue(result) {
57
57
  return text.text;
58
58
  }
59
59
  }
60
+ /**
61
+ * The environment a stdio upstream is started with: what a child process needs to run, plus what the config names.
62
+ * Never the gateway's own environment, which holds the tokens other upstreams and the log are given. An explicit
63
+ * `env` in the config is added to this base, not to the gateway's variables.
64
+ */
65
+ export function upstreamEnv(extra) {
66
+ const base = {};
67
+ for (const k of ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL", "SystemRoot", "SYSTEMROOT", "USERPROFILE", "APPDATA", "NODE_OPTIONS"]) {
68
+ const v = process.env[k];
69
+ if (v !== undefined)
70
+ base[k] = v;
71
+ }
72
+ return { ...base, ...(extra ?? {}) };
73
+ }
60
74
  export async function createGatewayHost(cfg, options = {}) {
61
75
  const gatewayKey = loadPrivateKey(cfg.identity.keyFile);
62
76
  const trusted = cfg.trustedPrincipalKeys.map(loadPublicKey);
@@ -65,6 +79,13 @@ export async function createGatewayHost(cfg, options = {}) {
65
79
  const issuer = createIssuer(gatewayKey, cfg.receiptsDir, options.log ?? openLog(cfg, gatewayKey), { exporter: options.exporter ?? openExporter(cfg) });
66
80
  const precommit = new Set(cfg.precommit);
67
81
  const consequential = (tool) => precommit.has("*") || precommit.has(tool);
82
+ // A fact lookup runs before the policy can decide, because the policy decides on it. It is therefore a read the
83
+ // gateway makes on the agent's request, never a consequential call: a fact tool named in precommit is a config error.
84
+ // "*" covers the tools the agent calls; a lookup is the gateway's own read, so only an explicit name makes it consequential.
85
+ for (const f of cfg.facts) {
86
+ if (precommit.has(f.tool))
87
+ throw new Error(`fact "${f.name}" uses tool ${f.tool}, which precommit names as consequential; a fact lookup runs before the policy decides and must be a read`);
88
+ }
68
89
  // One host, as many upstreams as the agents' jobs need. Each tool name belongs to exactly one upstream, decided at
69
90
  // startup, so a receipt's tool is unambiguous and consumed facts flow across them.
70
91
  const upstreamConfigs = cfg.upstreams ? cfg.upstreams.map((u) => ({ name: u.name, cfg: u })) : [{ name: "upstream", cfg: cfg.upstream }];
@@ -85,7 +106,7 @@ export async function createGatewayHost(cfg, options = {}) {
85
106
  }
86
107
  else {
87
108
  client = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
88
- await client.connect(new StdioClientTransport({ command: u.command, args: u.args, env: u.env, stderr: "inherit" }));
109
+ await client.connect(new StdioClientTransport({ command: u.command, args: u.args, env: upstreamEnv(u.env), stderr: "inherit" }));
89
110
  }
90
111
  upstreams.set(name, client);
91
112
  const { tools } = await client.listTools();
package/dist/index.d.ts CHANGED
@@ -5,11 +5,14 @@ export { buildRequest, restUpstream } from "./rest.ts";
5
5
  export { openExporter, otlpExporter, spanFor } from "./otel.ts";
6
6
  export { hecEvent, splunkExporter } from "./splunk.ts";
7
7
  export { GRANT_HEADER, grantHeader, parseGrantHeader, serveHttp } from "./gateway-http.ts";
8
+ export { PortalStore, portalHandler, readSession, servePortal, signSession, stripeRequest, verifyStripeSignature } from "./portal.ts";
9
+ export type { PortalOptions, PortalUser, RunningPortal, StripeOptions } from "./portal.ts";
8
10
  export type { HttpGatewayOptions, RunningHttpGateway } from "./gateway-http.ts";
9
11
  export { exportLog, formatExport } from "./log-export.ts";
10
12
  export type { ExportOptions, ExportResult } from "./log-export.ts";
11
13
  export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.ts";
12
- export type { AuditEntry } from "./log-store.ts";
14
+ export type { AuditEntry, Plan, QuotaState } from "./log-store.ts";
15
+ export { PLAN_QUOTAS, PLANS } from "./log-store.ts";
13
16
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.ts";
14
17
  export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
15
18
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
package/dist/index.js CHANGED
@@ -4,8 +4,10 @@ export { buildRequest, restUpstream } from "./rest.js";
4
4
  export { openExporter, otlpExporter, spanFor } from "./otel.js";
5
5
  export { hecEvent, splunkExporter } from "./splunk.js";
6
6
  export { GRANT_HEADER, grantHeader, parseGrantHeader, serveHttp } from "./gateway-http.js";
7
+ export { PortalStore, portalHandler, readSession, servePortal, signSession, stripeRequest, verifyStripeSignature } from "./portal.js";
7
8
  export { exportLog, formatExport } from "./log-export.js";
8
9
  export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.js";
10
+ export { PLAN_QUOTAS, PLANS } from "./log-store.js";
9
11
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.js";
10
12
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
11
13
  export { adminRoutes, welcomeSheet } from "./log-admin.js";
package/dist/log-admin.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // throttled. A minted token is shown once, beside the welcome sheet the tenant gets. Nothing here touches
6
6
  // receipts; the log holds hashes and the panel holds names.
7
7
  import { timingSafeEqual } from "node:crypto";
8
- import { RateLimiter } from "./log-store.js";
8
+ import { RateLimiter, PLANS } from "./log-store.js";
9
9
  import { clientAddress } from "./log-sink.js";
10
10
  const same = (a, b) => {
11
11
  const x = Buffer.from(a);
@@ -114,7 +114,7 @@ export function adminRoutes(opts) {
114
114
  }
115
115
  else if (req.method === "GET" && parts.length === 2 && parts[1] === "usage.csv") {
116
116
  const u = await t.usage(month);
117
- const csv = ["month,tenant,log_id,appends,total_leaves,live_tokens,disabled", ...u.tenants.map((x) => [u.month, x.id, x.logId, x.appends, x.totalLeaves, x.liveTokens, x.disabled].join(","))].join("\n") + "\n";
117
+ const csv = ["month,tenant,log_id,plan,quota,appends,total_leaves,live_tokens,disabled", ...u.tenants.map((x) => [u.month, x.id, x.logId, x.plan, x.quota ?? "", x.appends, x.totalLeaves, x.liveTokens, x.disabled].join(","))].join("\n") + "\n";
118
118
  res.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="agent-custody-usage-${u.month}.csv"`, "cache-control": "no-store" });
119
119
  res.end(csv);
120
120
  }
@@ -138,6 +138,12 @@ export function adminRoutes(opts) {
138
138
  return json(400, { error: "id must be a plain identifier" }), true;
139
139
  json(200, await t.addTenant(b.id, typeof b.logId === "string" && b.logId ? b.logId : b.id, actor));
140
140
  }
141
+ else if (req.method === "POST" && parts.length === 4 && parts[1] === "tenants" && parts[3] === "plan") {
142
+ const b = await body();
143
+ if (typeof b.plan !== "string" || !PLANS.includes(b.plan))
144
+ return json(400, { error: `plan must be one of ${PLANS.join(", ")}` }), true;
145
+ json(200, await t.setPlan(parts[2], b.plan, actor));
146
+ }
141
147
  else if (req.method === "POST" && parts.length === 4 && parts[1] === "tenants" && parts[3] === "disable") {
142
148
  await t.disableTenant(parts[2], actor);
143
149
  json(200, { disabled: parts[2] });
@@ -200,7 +206,7 @@ const ADMIN_PAGE = `<!doctype html>
200
206
  <p class="sub" id="where">Tenants and tokens on this log.</p>
201
207
  <section id="app">
202
208
  <h2>Tenants</h2>
203
- <table><thead><tr><th>tenant</th><th>log id</th><th>live tokens</th><th>created</th><th></th></tr></thead><tbody id="tenants"></tbody></table>
209
+ <table><thead><tr><th>tenant</th><th>log id</th><th>plan</th><th>live tokens</th><th>created</th><th></th></tr></thead><tbody id="tenants"></tbody></table>
204
210
  <h2>New tenant</h2>
205
211
  <div class="row">
206
212
  <label>tenant id (in the URL)<input id="tid" placeholder="acme" autocomplete="off"></label>
@@ -221,7 +227,7 @@ const ADMIN_PAGE = `<!doctype html>
221
227
  </div>
222
228
  <h2>Usage</h2>
223
229
  <div class="row"><label>month<input id="month" type="month"></label><button class="quiet" id="loadUsage">Show</button><a id="csv" class="quiet" href="#" style="align-self:center">Download CSV</a></div>
224
- <table><thead><tr><th>tenant</th><th>log id</th><th>appends this month</th><th>leaves in total</th><th>live tokens</th></tr></thead><tbody id="usage"></tbody></table>
230
+ <table><thead><tr><th>tenant</th><th>log id</th><th>plan</th><th>appends this month</th><th>quota</th><th>leaves in total</th><th>live tokens</th></tr></thead><tbody id="usage"></tbody></table>
225
231
  <h2>Tokens of a tenant</h2>
226
232
  <div class="row"><label>tenant<input id="ltid" placeholder="acme" autocomplete="off"></label><button class="quiet" id="listTokens">List</button></div>
227
233
  <table><thead><tr><th>label</th><th>hash</th><th>created</th><th>state</th><th></th></tr></thead><tbody id="tokens"></tbody></table>
@@ -246,7 +252,8 @@ const ADMIN_PAGE = `<!doctype html>
246
252
  const say = (t, cls) => { $("msg").textContent = t; $("msg").className = cls || "muted"; };
247
253
  const loadTenants = async () => {
248
254
  const list = await api("GET", "/admin/tenants");
249
- $("tenants").innerHTML = list.map((t) => "<tr><td><code>" + esc(t.id) + "</code></td><td><code>" + esc(t.logId) + "</code></td><td>" + t.tokens + "</td><td>" + esc(t.createdAt.slice(0, 10)) + "</td><td>" + (t.disabledAt ? "<span class=muted>disabled</span>" : "<button class=quiet data-disable=\\"" + esc(t.id) + "\\">Disable</button>") + "</td></tr>").join("") || "<tr><td colspan=5 class=muted>none yet</td></tr>";
255
+ const planPick = (t) => "<select data-plan=\\"" + esc(t.id) + "\\">" + ["free", "team", "enterprise"].map((p) => "<option" + (p === t.plan ? " selected" : "") + ">" + p + "</option>").join("") + "</select>";
256
+ $("tenants").innerHTML = list.map((t) => "<tr><td><code>" + esc(t.id) + "</code></td><td><code>" + esc(t.logId) + "</code></td><td>" + planPick(t) + "</td><td>" + t.tokens + "</td><td>" + esc(t.createdAt.slice(0, 10)) + "</td><td>" + (t.disabledAt ? "<span class=muted>disabled</span>" : "<button class=quiet data-disable=\\"" + esc(t.id) + "\\">Disable</button>") + "</td></tr>").join("") || "<tr><td colspan=6 class=muted>none yet</td></tr>";
250
257
  };
251
258
  const loadTokens = async (id) => {
252
259
  const list = await api("GET", "/admin/tenants/" + encodeURIComponent(id) + "/tokens");
@@ -278,7 +285,7 @@ const ADMIN_PAGE = `<!doctype html>
278
285
  const month = $("month").value || new Date().toISOString().slice(0, 7);
279
286
  const u = await api("GET", "/admin/usage?month=" + encodeURIComponent(month));
280
287
  $("csv").href = "/admin/usage.csv?month=" + encodeURIComponent(month);
281
- $("usage").innerHTML = u.tenants.map((t) => "<tr><td><code>" + esc(t.id) + "</code>" + (t.disabled ? " <span class=muted>disabled</span>" : "") + "</td><td><code>" + esc(t.logId) + "</code></td><td>" + t.appends + "</td><td>" + t.totalLeaves + "</td><td>" + t.liveTokens + "</td></tr>").join("") || "<tr><td colspan=5 class=muted>no tenants</td></tr>";
288
+ $("usage").innerHTML = u.tenants.map((t) => "<tr><td><code>" + esc(t.id) + "</code>" + (t.disabled ? " <span class=muted>disabled</span>" : "") + "</td><td><code>" + esc(t.logId) + "</code></td><td>" + esc(t.plan) + "</td><td>" + t.appends + "</td><td>" + (t.quota === null ? "none" : t.quota) + "</td><td>" + t.totalLeaves + "</td><td>" + t.liveTokens + "</td></tr>").join("") || "<tr><td colspan=7 class=muted>no tenants</td></tr>";
282
289
  };
283
290
  const loadAudit = async () => {
284
291
  const a = await api("GET", "/admin/audit?limit=100");
@@ -286,6 +293,10 @@ const ADMIN_PAGE = `<!doctype html>
286
293
  };
287
294
  $("loadUsage").onclick = () => loadUsage().catch((e) => say(e.message, "err"));
288
295
  $("month").value = new Date().toISOString().slice(0, 7);
296
+ document.addEventListener("change", async (e) => {
297
+ const s = e.target.closest("select[data-plan]"); if (!s) return;
298
+ try { await api("POST", "/admin/tenants/" + encodeURIComponent(s.dataset.plan) + "/plan", { plan: s.value }); say("plan of " + s.dataset.plan + " set to " + s.value, "ok"); await loadUsage(); await loadAudit(); } catch (err) { say(err.message, "err"); await loadTenants(); }
299
+ });
289
300
  document.addEventListener("click", async (e) => {
290
301
  const b = e.target.closest("button"); if (!b) return;
291
302
  if (b.dataset.disable && confirm("Disable tenant " + b.dataset.disable + "? Its paths answer 404 within ten seconds.")) { try { await api("POST", "/admin/tenants/" + encodeURIComponent(b.dataset.disable) + "/disable"); await loadTenants(); await loadAudit(); say("disabled " + b.dataset.disable, "ok"); } catch (err) { say(err.message, "err"); } }
@@ -1,7 +1,7 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import { type Envelope, type KeyPair } from "./crypto.ts";
3
3
  import { type InclusionProof } from "./log.ts";
4
- import { type AuditEntry, type LogBackend, type PostgresTenancy, type RateLimitOptions } from "./log-store.ts";
4
+ import { type AuditEntry, type QuotaState, type LogBackend, type PostgresTenancy, type RateLimitOptions } from "./log-store.ts";
5
5
  import { type Signer } from "./signer.ts";
6
6
  import type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
7
7
  import { type AdminOptions } from "./log-admin.ts";
@@ -62,6 +62,8 @@ export interface LogServerOptions {
62
62
  }>;
63
63
  /** appends per token (or per address without one); default 50 a second, burst 100 */
64
64
  rateLimit?: RateLimitOptions;
65
+ /** accept only { leafHash } on append: a log run for other people must never receive a receipt */
66
+ hashOnly?: boolean;
65
67
  /** largest append body accepted, in bytes; default 65536 */
66
68
  maxBodyBytes?: number;
67
69
  /** where published checkpoints go and are listed from; without one, /checkpoints answers with none */
@@ -76,6 +78,10 @@ export interface LogServerOptions {
76
78
  trustProxy?: boolean;
77
79
  }
78
80
  /** The address a limit is keyed by: the socket's, or the proxy's forwarded one when the proxy is trusted. */
81
+ /**
82
+ * The address a per-client limit keys on. Behind a proxy you run, the proxy appends the real client to
83
+ * X-Forwarded-For, so the rightmost entry is the one it wrote; everything left of it is whatever the client sent.
84
+ */
79
85
  export declare function clientAddress(req: IncomingMessage, trustProxy?: boolean): string;
80
86
  /** One log as the handler sees it, whatever stands behind it. */
81
87
  export interface TenantUsage {
@@ -92,6 +98,10 @@ export interface ResolvedLog {
92
98
  usage?(month: string): Promise<TenantUsage>;
93
99
  /** administrative actions on this log, newest first, where the store keeps them */
94
100
  audit?(limit: number): Promise<AuditEntry[]>;
101
+ /** the plan's monthly allowance and what is used, where the store keeps plans */
102
+ quota?(): Promise<QuotaState>;
103
+ /** told after an append lands, so a cached quota count stays honest */
104
+ appended?(): void;
95
105
  }
96
106
  /** Turns the tenant in a path, or null for the root paths, into a log. */
97
107
  export interface LogResolver {
package/dist/log-sink.js CHANGED
@@ -97,12 +97,17 @@ export function openLog(cfg, key) {
97
97
  return fileLog(cfg.logFile, key);
98
98
  }
99
99
  /** The address a limit is keyed by: the socket's, or the proxy's forwarded one when the proxy is trusted. */
100
+ /**
101
+ * The address a per-client limit keys on. Behind a proxy you run, the proxy appends the real client to
102
+ * X-Forwarded-For, so the rightmost entry is the one it wrote; everything left of it is whatever the client sent.
103
+ */
100
104
  export function clientAddress(req, trustProxy = false) {
101
105
  if (trustProxy) {
102
106
  const xff = req.headers["x-forwarded-for"];
103
- const first = (Array.isArray(xff) ? xff[0] : xff)?.split(",")[0]?.trim();
104
- if (first)
105
- return first;
107
+ const entries = (Array.isArray(xff) ? xff.join(",") : xff ?? "").split(",").map((s) => s.trim()).filter(Boolean);
108
+ const last = entries[entries.length - 1];
109
+ if (last)
110
+ return last;
106
111
  }
107
112
  return req.socket.remoteAddress ?? "?";
108
113
  }
@@ -155,6 +160,8 @@ export function postgresResolver(tenancy, opts = {}) {
155
160
  return { month, appends: row?.appends ?? 0, totalLeaves: row?.totalLeaves ?? 0, liveTokens: row?.liveTokens ?? 0 };
156
161
  },
157
162
  audit: (limit) => tenancy.audit({ tenant: id, limit }),
163
+ quota: () => tenancy.quota(id),
164
+ appended: () => tenancy.noteAppend(id),
158
165
  };
159
166
  },
160
167
  async tenants() {
@@ -289,6 +296,16 @@ export function logHandler(source, keyOrSigner, opts = {}) {
289
296
  const token = bearer(req);
290
297
  if (!(await which.authorize(token)))
291
298
  return json(401, { error: "unauthorized" });
299
+ if (which.quota) {
300
+ // The plan's monthly allowance. Over it, the append is refused with the numbers, and the gateway behind it
301
+ // withholds pre-committed calls: a tenant out of quota never acts without evidence.
302
+ const q = await which.quota();
303
+ if (q.quota !== null && q.used >= q.quota) {
304
+ const now = new Date();
305
+ const monthEnd = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
306
+ return json(429, { error: `monthly quota reached: ${q.used} of ${q.quota} appends on the ${q.plan} plan; it resets at the start of next month, or move to a larger plan` }, { "retry-after": String(Math.max(1, Math.ceil((monthEnd - now.getTime()) / 1000))) });
307
+ }
308
+ }
292
309
  const limitKey = token ? createHash("sha256").update(token).digest("hex").slice(0, 16) : `addr:${clientAddress(req, opts.trustProxy)}`;
293
310
  if (!limiter.take(limitKey))
294
311
  return json(429, { error: "too many appends; retry shortly" }, { "retry-after": "1" });
@@ -308,11 +325,17 @@ export function logHandler(source, keyOrSigner, opts = {}) {
308
325
  if (typeof parsed.leafHash === "string") {
309
326
  if (!/^[0-9a-f]{64}$/.test(parsed.leafHash))
310
327
  return json(400, { error: "leafHash must be 64 lowercase hex characters" });
311
- return json(200, await appendSigned(log, signer, { leafHash: parsed.leafHash }, logId));
328
+ const r = await appendSigned(log, signer, { leafHash: parsed.leafHash }, logId);
329
+ which.appended?.();
330
+ return json(200, r);
312
331
  }
332
+ if (opts.hashOnly)
333
+ return json(400, { error: "this log accepts leaf hashes only; set \"hashOnly\": true in your log config so the receipt never leaves your machine" });
313
334
  if (typeof parsed.leaf !== "string" || parsed.leaf.length === 0)
314
335
  return json(400, { error: "leaf must be a non-empty string, or send leafHash" });
315
- return json(200, await appendSigned(log, signer, { leaf: parsed.leaf }, logId));
336
+ const r = await appendSigned(log, signer, { leaf: parsed.leaf }, logId);
337
+ which.appended?.();
338
+ return json(200, r);
316
339
  }
317
340
  const current = await log.size();
318
341
  // A tenant's own data, with their token: every leaf hash, in pages, and their metering. The export command
@@ -334,7 +357,9 @@ export function logHandler(source, keyOrSigner, opts = {}) {
334
357
  const month = url.searchParams.get("month") ?? new Date().toISOString().slice(0, 7);
335
358
  if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))
336
359
  return json(400, { error: "month must be YYYY-MM" });
337
- return json(200, await which.usage(month), { "cache-control": "no-store" });
360
+ const u = await which.usage(month);
361
+ const q = which.quota ? await which.quota() : null;
362
+ return json(200, { ...u, ...(q ? { plan: q.plan, quota: q.quota } : {}) }, { "cache-control": "no-store" });
338
363
  }
339
364
  const since = url.searchParams.has("since") ? Number(url.searchParams.get("since")) : 0;
340
365
  const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : 10_000;
@@ -57,12 +57,24 @@ export declare class PostgresLog implements LogBackend {
57
57
  root(size?: number): Promise<string>;
58
58
  consistencyProof(oldSize: number, newSize?: number): Promise<string[]>;
59
59
  }
60
+ /** A tenant's plan decides its monthly append quota; enterprise has none. The names are what the pricing page sells. */
61
+ export type Plan = "free" | "team" | "enterprise";
62
+ export declare const PLANS: readonly Plan[];
63
+ export declare const PLAN_QUOTAS: Readonly<Record<Plan, number | null>>;
60
64
  export interface Tenant {
61
65
  id: string;
62
66
  logId: string;
67
+ plan: Plan;
63
68
  createdAt: string;
64
69
  disabledAt: string | null;
65
70
  }
71
+ export interface QuotaState {
72
+ plan: Plan;
73
+ /** appends so far this calendar month, UTC */
74
+ used: number;
75
+ /** the plan's monthly allowance, or null for none */
76
+ quota: number | null;
77
+ }
66
78
  export interface TokenRecord {
67
79
  tenantId: string;
68
80
  label: string;
@@ -76,7 +88,7 @@ export interface AuditEntry {
76
88
  id: number;
77
89
  at: string;
78
90
  actor: string;
79
- action: "tenant.add" | "tenant.disable" | "token.add" | "token.revoke";
91
+ action: "tenant.add" | "tenant.disable" | "tenant.plan" | "token.add" | "token.revoke";
80
92
  tenantId: string | null;
81
93
  detail: Record<string, unknown>;
82
94
  }
@@ -87,8 +99,12 @@ export declare class PostgresTenancy {
87
99
  private readonly logs;
88
100
  private readonly tenantCache;
89
101
  private readonly tokenCache;
102
+ private readonly quotaCache;
103
+ private readonly quotas;
90
104
  private ready;
91
- constructor(client: PostgresLike, opts?: PostgresLogOptions);
105
+ constructor(client: PostgresLike, opts?: PostgresLogOptions & {
106
+ quotas?: Partial<Record<Plan, number | null>>;
107
+ });
92
108
  private init;
93
109
  private record;
94
110
  /** Administrative actions, newest first; for one tenant when given. What the admin page shows and a tenant's export carries. */
@@ -105,6 +121,12 @@ export declare class PostgresTenancy {
105
121
  log(tenantId: string): Promise<PostgresLog>;
106
122
  /** Creates a tenant, or renames its log id. `by` names who did it in the audit trail. */
107
123
  addTenant(id: string, logId?: string, by?: string): Promise<Tenant>;
124
+ /** Moves a tenant to a plan; the quota applies from the next append. */
125
+ setPlan(id: string, plan: Plan, by?: string): Promise<Tenant>;
126
+ /** The tenant's plan, appends this month, and the plan's quota. Cached ten seconds, so a burst may overshoot slightly. */
127
+ quota(id: string): Promise<QuotaState>;
128
+ /** Called after an append lands, so the cached count stays honest between refreshes. */
129
+ noteAppend(id: string): void;
108
130
  disableTenant(id: string, by?: string): Promise<void>;
109
131
  listTenants(): Promise<Tenant[]>;
110
132
  /** Mints a token for a tenant. The token is returned once and stored only as its hash. */
@@ -123,6 +145,8 @@ export declare class PostgresTenancy {
123
145
  tenants: {
124
146
  id: string;
125
147
  logId: string;
148
+ plan: Plan;
149
+ quota: number | null;
126
150
  appends: number;
127
151
  totalLeaves: number;
128
152
  liveTokens: number;