@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 +4 -1
- package/dist/cli.js +46 -5
- package/dist/delegation.d.ts +2 -0
- package/dist/delegation.js +12 -2
- package/dist/gateway.d.ts +6 -0
- package/dist/gateway.js +22 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -0
- package/dist/log-admin.js +17 -6
- package/dist/log-sink.d.ts +11 -1
- package/dist/log-sink.js +31 -6
- package/dist/log-store.d.ts +26 -2
- package/dist/log-store.js +48 -6
- package/dist/policy.d.ts +7 -0
- package/dist/policy.js +29 -0
- package/dist/portal.d.ts +75 -0
- package/dist/portal.js +547 -0
- package/dist/verify.js +2 -2
- package/docs/threat-model.md +5 -2
- package/docs/usage.md +2 -2
- package/package.json +1 -1
- package/vectors/audit.json +27 -27
- package/vectors/canonical.json +5 -5
- package/vectors/receipts.json +283 -234
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
|
-
- [
|
|
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
|
|
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" : ""}`);
|
package/dist/delegation.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/delegation.js
CHANGED
|
@@ -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
|
-
|
|
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";
|
|
@@ -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
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
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;
|