@agent-custody/receipts 0.5.9 → 0.6.1

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/gateway.js CHANGED
@@ -57,24 +57,16 @@ function extractValue(result) {
57
57
  return text.text;
58
58
  }
59
59
  }
60
- export async function createGateway(cfg, options = {}) {
60
+ export async function createGatewayHost(cfg, options = {}) {
61
61
  const gatewayKey = loadPrivateKey(cfg.identity.keyFile);
62
62
  const trusted = cfg.trustedPrincipalKeys.map(loadPublicKey);
63
- const grantEnvelope = JSON.parse(readFileSync(cfg.grantFile, "utf8"));
64
- const grant = verifyDelegation(grantEnvelope, trusted);
65
- if (!grant.ok)
66
- throw new Error(`delegation grant rejected: ${grant.error}`);
67
- if (!delegationValidAt(grant.delegation, new Date().toISOString()))
68
- throw new Error("delegation grant is outside its validity window");
69
- const delegation = grant.delegation;
70
- const principalKeyid = grant.keyid;
71
63
  const policyText = readFileSync(cfg.policyFile, "utf8");
72
64
  const pDigest = policyDigest(policyText);
73
65
  const issuer = createIssuer(gatewayKey, cfg.receiptsDir, options.log ?? openLog(cfg, gatewayKey), { exporter: options.exporter ?? openExporter(cfg) });
74
66
  const precommit = new Set(cfg.precommit);
75
67
  const consequential = (tool) => precommit.has("*") || precommit.has(tool);
76
- // One gateway, one grant, one session, and as many upstreams as the agent's job needs. Each tool name belongs to
77
- // exactly one upstream, decided at startup, so a receipt's tool is unambiguous and consumed facts flow across them.
68
+ // One host, as many upstreams as the agents' jobs need. Each tool name belongs to exactly one upstream, decided at
69
+ // startup, so a receipt's tool is unambiguous and consumed facts flow across them.
78
70
  const upstreamConfigs = cfg.upstreams ? cfg.upstreams.map((u) => ({ name: u.name, cfg: u })) : [{ name: "upstream", cfg: cfg.upstream }];
79
71
  const upstreams = new Map();
80
72
  const owner = new Map();
@@ -128,124 +120,161 @@ export async function createGateway(cfg, options = {}) {
128
120
  }
129
121
  return facts;
130
122
  }
131
- /** Every fact id an upstream has declared it served, in order of first sight. One gateway process is one agent session. */
132
- const consumed = [];
133
- const noteServedFacts = (result) => {
134
- const ids = result._meta?.[FACTS_META_KEY];
135
- if (!Array.isArray(ids))
136
- return;
137
- for (const id of ids)
138
- if (typeof id === "string" && !consumed.includes(id))
139
- consumed.push(id);
140
- };
141
- async function handleCall(params) {
142
- const tool = params.name;
143
- const args = params.arguments ?? {};
144
- const receiptId = randomUUID();
145
- const timestamp = new Date().toISOString();
146
- const modelClaim = params._meta?.[MODEL_META_KEY];
147
- // What the agent had been shown before this call; recorded before this call's own result is seen.
148
- const consumedNow = [...consumed];
149
- const upstreamMeta = { [RECEIPT_META_KEY]: receiptId, [AGENT_META_KEY]: delegation.agent, [PRINCIPAL_META_KEY]: delegation.principal };
150
- let facts = {};
151
- let policy;
152
- let execution;
153
- let authorization;
154
- if (!delegation.scopes.includes(tool)) {
155
- policy = { decision: "deny", reasons: [], errors: [`tool "${tool}" is not in the delegation scopes`], policyDigest: pDigest };
156
- }
157
- else {
158
- try {
159
- facts = await gatherFacts(tool, args, upstreamMeta);
160
- const factValues = Object.fromEntries(Object.entries(facts).map(([k, f]) => [k, f.value]));
161
- policy = evaluate(policyText, {
162
- agentId: delegation.agent,
163
- tool,
164
- context: { args, facts: factValues, grant: { principal: delegation.principal, scopes: delegation.scopes } },
165
- });
123
+ function open(grantEnvelope) {
124
+ const grant = verifyDelegation(grantEnvelope, trusted);
125
+ if (!grant.ok)
126
+ throw new Error(`delegation grant rejected: ${grant.error}`);
127
+ if (!delegationValidAt(grant.delegation, new Date().toISOString()))
128
+ throw new Error("delegation grant is outside its validity window");
129
+ const delegation = grant.delegation;
130
+ const principalKeyid = grant.keyid;
131
+ /** Every fact id an upstream has declared it served to this session, in order of first sight. A session is one agent under one grant. */
132
+ const consumed = [];
133
+ const noteServedFacts = (result) => {
134
+ const ids = result._meta?.[FACTS_META_KEY];
135
+ if (!Array.isArray(ids))
136
+ return;
137
+ for (const id of ids)
138
+ if (typeof id === "string" && !consumed.includes(id))
139
+ consumed.push(id);
140
+ };
141
+ async function handleCall(params) {
142
+ const tool = params.name;
143
+ const args = params.arguments ?? {};
144
+ const receiptId = randomUUID();
145
+ const timestamp = new Date().toISOString();
146
+ const modelClaim = params._meta?.[MODEL_META_KEY];
147
+ // What the agent had been shown before this call; recorded before this call's own result is seen.
148
+ const consumedNow = [...consumed];
149
+ const upstreamMeta = { [RECEIPT_META_KEY]: receiptId, [AGENT_META_KEY]: delegation.agent, [PRINCIPAL_META_KEY]: delegation.principal };
150
+ let facts = {};
151
+ let policy;
152
+ let execution;
153
+ let authorization;
154
+ if (!delegation.scopes.includes(tool)) {
155
+ policy = { decision: "deny", reasons: [], errors: [`tool "${tool}" is not in the delegation scopes`], policyDigest: pDigest };
166
156
  }
167
- catch (e) {
168
- policy = { decision: "deny", reasons: [], errors: [String(e instanceof Error ? e.message : e)], policyDigest: pDigest };
157
+ else {
158
+ try {
159
+ facts = await gatherFacts(tool, args, upstreamMeta);
160
+ const factValues = Object.fromEntries(Object.entries(facts).map(([k, f]) => [k, f.value]));
161
+ policy = evaluate(policyText, {
162
+ agentId: delegation.agent,
163
+ tool,
164
+ context: { args, facts: factValues, grant: { principal: delegation.principal, scopes: delegation.scopes } },
165
+ });
166
+ }
167
+ catch (e) {
168
+ policy = { decision: "deny", reasons: [], errors: [String(e instanceof Error ? e.message : e)], policyDigest: pDigest };
169
+ }
169
170
  }
170
- }
171
- const head = {
172
- receiptId,
173
- timestamp,
174
- issuer: { kind: "gateway", keyid: issuer.keyid, version: GATEWAY_VERSION },
175
- principal: { id: delegation.principal, keyid: principalKeyid, provenance: "attested" },
176
- agent: { id: delegation.agent, provenance: "attested" },
177
- delegation: { envelope: grantEnvelope, provenance: "attested" },
178
- tool: { name: tool, provenance: "observed", ...(owner.has(tool) && upstreamConfigs.length > 1 ? { upstream: owner.get(tool) } : {}) },
179
- request: { args, argsDigest: digestOf(args), provenance: "claimed" },
180
- facts,
181
- consumed: { factIds: consumedNow, provenance: "observed" },
182
- };
183
- if (policy.decision === "allow" && consequential(tool)) {
184
- // A consequential call is committed to the log before it goes out, so that evidence of the side effect exists
185
- // before the side effect does. If the log will not take the authorization, the call is not forwarded.
186
- try {
187
- authorization = await issuer.authorize({ ...head, policy: { ...policy, provenance: "observed" } });
171
+ const head = {
172
+ receiptId,
173
+ timestamp,
174
+ issuer: { kind: "gateway", keyid: issuer.keyid, version: GATEWAY_VERSION },
175
+ principal: { id: delegation.principal, keyid: principalKeyid, provenance: "attested" },
176
+ agent: { id: delegation.agent, provenance: "attested" },
177
+ delegation: { envelope: grantEnvelope, provenance: "attested" },
178
+ tool: { name: tool, provenance: "observed", ...(owner.has(tool) && upstreamConfigs.length > 1 ? { upstream: owner.get(tool) } : {}) },
179
+ request: { args, argsDigest: digestOf(args), provenance: "claimed" },
180
+ facts,
181
+ consumed: { factIds: consumedNow, provenance: "observed" },
182
+ };
183
+ if (policy.decision === "allow" && consequential(tool)) {
184
+ // A consequential call is committed to the log before it goes out, so that evidence of the side effect exists
185
+ // before the side effect does. If the log will not take the authorization, the call is not forwarded.
186
+ try {
187
+ authorization = await issuer.authorize({ ...head, policy: { ...policy, provenance: "observed" } });
188
+ }
189
+ catch (e) {
190
+ execution = { status: "withheld", reason: `the log did not commit the authorization, so the call was not forwarded: ${String(e instanceof Error ? e.message : e)}`, provenance: "observed" };
191
+ }
188
192
  }
189
- catch (e) {
190
- execution = { status: "withheld", reason: `the log did not commit the authorization, so the call was not forwarded: ${String(e instanceof Error ? e.message : e)}`, provenance: "observed" };
193
+ if (execution) {
194
+ // withheld: nothing was forwarded
191
195
  }
192
- }
193
- if (execution) {
194
- // withheld: nothing was forwarded
195
- }
196
- else if (policy.decision === "allow") {
197
- try {
198
- // The upstream learns which receipt this call is, and who the grant says is calling. An upstream that keeps
199
- // state, such as the memory server, cites the receipt as the source of what it stores.
200
- const observed = Object.fromEntries(Object.entries(facts).map(([k, f]) => [k, f.value]));
201
- const result = await callUpstream(tool, args, { ...upstreamMeta, [OBSERVED_META_KEY]: observed });
202
- const evidence = upstreamEvidenceOf(result);
203
- execution = { status: result.isError ? "failed" : "executed", result, resultDigest: digestOf(result), provenance: "observed", ...(evidence ? { upstream: evidence } : {}) };
204
- noteServedFacts(result);
196
+ else if (policy.decision === "allow") {
197
+ try {
198
+ // The upstream learns which receipt this call is, and who the grant says is calling. An upstream that keeps
199
+ // state, such as the memory server, cites the receipt as the source of what it stores.
200
+ const observed = Object.fromEntries(Object.entries(facts).map(([k, f]) => [k, f.value]));
201
+ const result = await callUpstream(tool, args, { ...upstreamMeta, [OBSERVED_META_KEY]: observed });
202
+ const evidence = upstreamEvidenceOf(result);
203
+ execution = { status: result.isError ? "failed" : "executed", result, resultDigest: digestOf(result), provenance: "observed", ...(evidence ? { upstream: evidence } : {}) };
204
+ noteServedFacts(result);
205
+ }
206
+ catch (e) {
207
+ execution = { status: "error", error: String(e instanceof Error ? e.message : e), provenance: "observed" };
208
+ }
205
209
  }
206
- catch (e) {
207
- execution = { status: "error", error: String(e instanceof Error ? e.message : e), provenance: "observed" };
210
+ else {
211
+ execution = { status: "denied", reason: [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched", provenance: "observed" };
208
212
  }
209
- }
210
- else {
211
- execution = { status: "denied", reason: [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched", provenance: "observed" };
212
- }
213
- await issuer.issue({
214
- ...head,
215
- session: { id: null, toolUseId: null, provenance: "claimed" },
216
- model: { id: typeof modelClaim === "string" ? modelClaim : null, provenance: "claimed" },
217
- policy: { ...policy, provenance: "observed" },
218
- ...(authorization ? { authorization } : {}),
219
- execution,
220
- });
221
- const meta = { [RECEIPT_META_KEY]: receiptId };
222
- const refuse = (text) => ({ isError: true, content: [{ type: "text", text: `${text} (receipt ${receiptId})` }], _meta: meta });
223
- switch (execution.status) {
224
- case "denied":
225
- return refuse(`Denied by policy: ${execution.reason}`);
226
- case "error":
227
- return refuse(`Upstream error: ${execution.error}`);
228
- case "withheld":
229
- return refuse(`Not executed: ${execution.reason}`);
230
- default: {
231
- const result = execution.result;
232
- return { ...result, _meta: { ...result._meta, ...meta } };
213
+ await issuer.issue({
214
+ ...head,
215
+ session: { id: null, toolUseId: null, provenance: "claimed" },
216
+ model: { id: typeof modelClaim === "string" ? modelClaim : null, provenance: "claimed" },
217
+ policy: { ...policy, provenance: "observed" },
218
+ ...(authorization ? { authorization } : {}),
219
+ execution,
220
+ });
221
+ const meta = { [RECEIPT_META_KEY]: receiptId };
222
+ const refuse = (text) => ({ isError: true, content: [{ type: "text", text: `${text} (receipt ${receiptId})` }], _meta: meta });
223
+ switch (execution.status) {
224
+ case "denied":
225
+ return refuse(`Denied by policy: ${execution.reason}`);
226
+ case "error":
227
+ return refuse(`Upstream error: ${execution.error}`);
228
+ case "withheld":
229
+ return refuse(`Not executed: ${execution.reason}`);
230
+ default: {
231
+ const result = execution.result;
232
+ return { ...result, _meta: { ...result._meta, ...meta } };
233
+ }
233
234
  }
234
235
  }
236
+ return {
237
+ agentId: delegation.agent,
238
+ delegation,
239
+ async listTools() {
240
+ return advertised.filter((t) => delegation.scopes.includes(t.name));
241
+ },
242
+ handleCall,
243
+ async close() {
244
+ // a session holds nothing of its own beyond what it consumed; the host owns the upstreams
245
+ },
246
+ };
235
247
  }
236
248
  return {
237
- agentId: delegation.agent,
238
- delegation,
239
- async listTools() {
240
- return advertised.filter((t) => delegation.scopes.includes(t.name));
241
- },
242
- handleCall,
249
+ keyid: issuer.keyid,
250
+ open,
243
251
  async close() {
244
252
  for (const c of upstreams.values())
245
253
  await c.close();
246
254
  },
247
255
  };
248
256
  }
257
+ /** One gateway for the grant the config names: what `agent-custody gateway` serves over stdio. Closing it closes the host. */
258
+ export async function createGateway(cfg, options = {}) {
259
+ if (!cfg.grantFile)
260
+ throw new Error("config needs grantFile for a single-grant gateway; over HTTP each connection presents its own grant");
261
+ const host = await createGatewayHost(cfg, options);
262
+ let session;
263
+ try {
264
+ session = host.open(JSON.parse(readFileSync(cfg.grantFile, "utf8")));
265
+ }
266
+ catch (e) {
267
+ await host.close();
268
+ throw e;
269
+ }
270
+ return {
271
+ ...session,
272
+ async close() {
273
+ await session.close();
274
+ await host.close();
275
+ },
276
+ };
277
+ }
249
278
  /** Exposes the gateway as an MCP server over stdio. Everything diagnostic must go to stderr. */
250
279
  export async function serveStdio(gw) {
251
280
  const server = new Server({ name: "agent-custody-gateway", version: GATEWAY_VERSION }, { capabilities: { tools: {} } });
package/dist/index.d.ts CHANGED
@@ -4,10 +4,15 @@ export type { GatewayOptions } from "./gateway.ts";
4
4
  export { buildRequest, restUpstream } from "./rest.ts";
5
5
  export { openExporter, otlpExporter, spanFor } from "./otel.ts";
6
6
  export { hecEvent, splunkExporter } from "./splunk.ts";
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";
10
+ export type { HttpGatewayOptions, RunningHttpGateway } from "./gateway-http.ts";
7
11
  export { exportLog, formatExport } from "./log-export.ts";
8
12
  export type { ExportOptions, ExportResult } from "./log-export.ts";
9
13
  export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.ts";
10
- 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";
11
16
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.ts";
12
17
  export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
13
18
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
package/dist/index.js CHANGED
@@ -3,8 +3,11 @@ export { AUTHORIZATION_PREDICATE_TYPE, buildAuthorizationStatement } from "./rec
3
3
  export { buildRequest, restUpstream } from "./rest.js";
4
4
  export { openExporter, otlpExporter, spanFor } from "./otel.js";
5
5
  export { hecEvent, splunkExporter } from "./splunk.js";
6
+ export { GRANT_HEADER, grantHeader, parseGrantHeader, serveHttp } from "./gateway-http.js";
7
+ export { PortalStore, portalHandler, readSession, servePortal, signSession, stripeRequest, verifyStripeSignature } from "./portal.js";
6
8
  export { exportLog, formatExport } from "./log-export.js";
7
9
  export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.js";
10
+ export { PLAN_QUOTAS, PLANS } from "./log-store.js";
8
11
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.js";
9
12
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
10
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";
@@ -92,6 +92,10 @@ export interface ResolvedLog {
92
92
  usage?(month: string): Promise<TenantUsage>;
93
93
  /** administrative actions on this log, newest first, where the store keeps them */
94
94
  audit?(limit: number): Promise<AuditEntry[]>;
95
+ /** the plan's monthly allowance and what is used, where the store keeps plans */
96
+ quota?(): Promise<QuotaState>;
97
+ /** told after an append lands, so a cached quota count stays honest */
98
+ appended?(): void;
95
99
  }
96
100
  /** Turns the tenant in a path, or null for the root paths, into a log. */
97
101
  export interface LogResolver {
package/dist/log-sink.js CHANGED
@@ -155,6 +155,8 @@ export function postgresResolver(tenancy, opts = {}) {
155
155
  return { month, appends: row?.appends ?? 0, totalLeaves: row?.totalLeaves ?? 0, liveTokens: row?.liveTokens ?? 0 };
156
156
  },
157
157
  audit: (limit) => tenancy.audit({ tenant: id, limit }),
158
+ quota: () => tenancy.quota(id),
159
+ appended: () => tenancy.noteAppend(id),
158
160
  };
159
161
  },
160
162
  async tenants() {
@@ -289,6 +291,16 @@ export function logHandler(source, keyOrSigner, opts = {}) {
289
291
  const token = bearer(req);
290
292
  if (!(await which.authorize(token)))
291
293
  return json(401, { error: "unauthorized" });
294
+ if (which.quota) {
295
+ // The plan's monthly allowance. Over it, the append is refused with the numbers, and the gateway behind it
296
+ // withholds pre-committed calls: a tenant out of quota never acts without evidence.
297
+ const q = await which.quota();
298
+ if (q.quota !== null && q.used >= q.quota) {
299
+ const now = new Date();
300
+ const monthEnd = Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
301
+ 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))) });
302
+ }
303
+ }
292
304
  const limitKey = token ? createHash("sha256").update(token).digest("hex").slice(0, 16) : `addr:${clientAddress(req, opts.trustProxy)}`;
293
305
  if (!limiter.take(limitKey))
294
306
  return json(429, { error: "too many appends; retry shortly" }, { "retry-after": "1" });
@@ -308,11 +320,15 @@ export function logHandler(source, keyOrSigner, opts = {}) {
308
320
  if (typeof parsed.leafHash === "string") {
309
321
  if (!/^[0-9a-f]{64}$/.test(parsed.leafHash))
310
322
  return json(400, { error: "leafHash must be 64 lowercase hex characters" });
311
- return json(200, await appendSigned(log, signer, { leafHash: parsed.leafHash }, logId));
323
+ const r = await appendSigned(log, signer, { leafHash: parsed.leafHash }, logId);
324
+ which.appended?.();
325
+ return json(200, r);
312
326
  }
313
327
  if (typeof parsed.leaf !== "string" || parsed.leaf.length === 0)
314
328
  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));
329
+ const r = await appendSigned(log, signer, { leaf: parsed.leaf }, logId);
330
+ which.appended?.();
331
+ return json(200, r);
316
332
  }
317
333
  const current = await log.size();
318
334
  // A tenant's own data, with their token: every leaf hash, in pages, and their metering. The export command
@@ -334,7 +350,9 @@ export function logHandler(source, keyOrSigner, opts = {}) {
334
350
  const month = url.searchParams.get("month") ?? new Date().toISOString().slice(0, 7);
335
351
  if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))
336
352
  return json(400, { error: "month must be YYYY-MM" });
337
- return json(200, await which.usage(month), { "cache-control": "no-store" });
353
+ const u = await which.usage(month);
354
+ const q = which.quota ? await which.quota() : null;
355
+ return json(200, { ...u, ...(q ? { plan: q.plan, quota: q.quota } : {}) }, { "cache-control": "no-store" });
338
356
  }
339
357
  const since = url.searchParams.has("since") ? Number(url.searchParams.get("since")) : 0;
340
358
  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;