@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/README.md +8 -5
- package/dist/cli.js +81 -8
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -2
- package/dist/crypto.d.ts +2 -0
- package/dist/crypto.js +4 -0
- package/dist/delegation.d.ts +34 -1
- package/dist/delegation.js +89 -5
- package/dist/gateway-http.d.ts +30 -0
- package/dist/gateway-http.js +139 -0
- package/dist/gateway.d.ts +14 -0
- package/dist/gateway.js +142 -113
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3 -0
- package/dist/log-admin.js +17 -6
- package/dist/log-sink.d.ts +5 -1
- package/dist/log-sink.js +21 -3
- package/dist/log-store.d.ts +26 -2
- package/dist/log-store.js +48 -6
- package/dist/portal.d.ts +75 -0
- package/dist/portal.js +547 -0
- package/dist/verify.js +6 -1
- package/docs/tutorials.md +1 -0
- package/docs/usage.md +7 -1
- package/docs/verification.md +2 -1
- package/package.json +2 -2
- package/vectors/audit.json +27 -27
- package/vectors/canonical.json +5 -5
- package/vectors/receipts.json +318 -216
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
|
|
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
|
|
77
|
-
//
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (!
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
193
|
+
if (execution) {
|
|
194
|
+
// withheld: nothing was forwarded
|
|
191
195
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
execution = { status: "
|
|
210
|
+
else {
|
|
211
|
+
execution = { status: "denied", reason: [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched", provenance: "observed" };
|
|
208
212
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
execution
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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=
|
|
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"); } }
|
package/dist/log-sink.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/log-store.d.ts
CHANGED
|
@@ -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;
|