@agent-custody/receipts 0.4.0 → 0.5.0

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/log-sink.js CHANGED
@@ -6,12 +6,22 @@
6
6
  import { timingSafeEqual } from "node:crypto";
7
7
  import { createServer } from "node:http";
8
8
  import { dsseSign } from "./crypto.js";
9
+ import { createHash } from "node:crypto";
9
10
  import { leafHash, MerkleLog } from "./log.js";
11
+ import { fileBackend, RateLimiter } from "./log-store.js";
12
+ import { localSigner } from "./signer.js";
10
13
  import { TREEHEAD_TYPE } from "./receipt.js";
11
- function appendSigned(log, key, leaf, logId) {
12
- const e = "leaf" in leaf ? log.append(leaf.leaf) : log.appendHash(leaf.leafHash);
14
+ function signedHead(e, key, logId) {
13
15
  const head = { treeSize: e.treeSize, rootHash: e.rootHash, timestamp: new Date().toISOString(), ...(logId ? { log: logId } : {}) };
14
- return { inclusion: { leafIndex: e.leafIndex, treeSize: e.treeSize, hashes: e.hashes }, treeHead: dsseSign(TREEHEAD_TYPE, head, key) };
16
+ return dsseSign(TREEHEAD_TYPE, head, key);
17
+ }
18
+ async function signHead(e, signer, logId) {
19
+ const head = { treeSize: e.treeSize, rootHash: e.rootHash, timestamp: new Date().toISOString(), ...(logId ? { log: logId } : {}) };
20
+ return signer.sign(TREEHEAD_TYPE, head);
21
+ }
22
+ async function appendSigned(log, signer, leaf, logId) {
23
+ const e = "leaf" in leaf ? await log.append(leaf.leaf) : await log.appendHash(leaf.leafHash);
24
+ return { inclusion: { leafIndex: e.leafIndex, treeSize: e.treeSize, hashes: e.hashes }, treeHead: await signHead(e, signer, logId) };
15
25
  }
16
26
  /** A local JSONL Merkle log. Tree heads are signed with the given key, normally the issuer's own. */
17
27
  export function fileLog(file, key) {
@@ -20,29 +30,54 @@ export function fileLog(file, key) {
20
30
  kind: "file",
21
31
  where: file,
22
32
  async append(leaf) {
23
- return appendSigned(log, key, { leaf });
33
+ const e = log.append(leaf);
34
+ return { inclusion: { leafIndex: e.leafIndex, treeSize: e.treeSize, hashes: e.hashes }, treeHead: signedHead(e, key) };
24
35
  },
25
36
  };
26
37
  }
27
- /** A log reached over HTTP: POST <url>/append with {leaf}, expecting a LogAppend back. */
38
+ /**
39
+ * A log reached over HTTP: POST <url>/append with {leaf} or {leafHash}, expecting a LogAppend back. A 429 or a 5xx
40
+ * is retried a few times with backoff, honouring Retry-After; anything else, or the last failure, is the caller's.
41
+ */
28
42
  export function httpLog(url, opts = {}) {
29
43
  const f = opts.fetch ?? fetch;
30
44
  const base = url.endsWith("/") ? url : `${url}/`;
45
+ const attempts = opts.retries ?? 3;
31
46
  return {
32
47
  kind: "http",
33
48
  where: url,
34
49
  async append(leaf) {
35
- const res = await f(new URL("append", base), {
36
- method: "POST",
37
- headers: { "content-type": "application/json", ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) },
38
- body: JSON.stringify(opts.hashOnly ? { leafHash: leafHash(leaf).toString("hex") } : { leaf }),
39
- });
40
- if (!res.ok)
41
- throw new Error(`log ${url} refused the append: ${res.status} ${(await res.text()).slice(0, 200)}`);
42
- const body = (await res.json());
43
- if (!body.inclusion || !body.treeHead)
44
- throw new Error(`log ${url} returned a malformed append result`);
45
- return body;
50
+ let last = "";
51
+ for (let i = 0; i < attempts; i++) {
52
+ let res;
53
+ try {
54
+ res = await f(new URL("append", base), {
55
+ method: "POST",
56
+ headers: { "content-type": "application/json", ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) },
57
+ body: JSON.stringify(opts.hashOnly ? { leafHash: leafHash(leaf).toString("hex") } : { leaf }),
58
+ });
59
+ }
60
+ catch (e) {
61
+ last = `log ${url} unreachable: ${e instanceof Error ? e.message : String(e)}`;
62
+ if (i + 1 < attempts)
63
+ await new Promise((r) => setTimeout(r, 200 * 2 ** i));
64
+ continue;
65
+ }
66
+ if (res.ok) {
67
+ const body = (await res.json());
68
+ if (!body.inclusion || !body.treeHead)
69
+ throw new Error(`log ${url} returned a malformed append result`);
70
+ return body;
71
+ }
72
+ last = `log ${url} refused the append: ${res.status} ${(await res.text()).slice(0, 200)}`;
73
+ if (res.status !== 429 && res.status < 500)
74
+ break;
75
+ if (i + 1 < attempts) {
76
+ const after = Number(res.headers.get("retry-after"));
77
+ await new Promise((r) => setTimeout(r, Number.isFinite(after) && after > 0 ? Math.min(after, 5) * 1000 : 200 * 2 ** i));
78
+ }
79
+ }
80
+ throw new Error(last);
46
81
  },
47
82
  };
48
83
  }
@@ -58,6 +93,114 @@ export function openLog(cfg, key) {
58
93
  throw new Error("config needs logFile or log.url");
59
94
  return fileLog(cfg.logFile, key);
60
95
  }
96
+ const tokenMatches = (tokens, token) => {
97
+ if (tokens.length === 0)
98
+ return true;
99
+ if (!token)
100
+ return false;
101
+ const given = Buffer.from(token);
102
+ return tokens.some((t) => {
103
+ const want = Buffer.from(t);
104
+ return want.length === given.length && timingSafeEqual(want, given);
105
+ });
106
+ };
107
+ /** The reference server's logs: one file for the root paths and, optionally, a file per tenant from the options. */
108
+ export function fileResolver(file, opts = {}) {
109
+ const root = { backend: fileBackend(file), logId: opts.logId, authorize: async (t) => tokenMatches(opts.tokens ?? [], t) };
110
+ const tenants = new Map();
111
+ for (const [name, t] of Object.entries(opts.tenants ?? {}))
112
+ tenants.set(name, { backend: fileBackend(t.file), logId: t.logId ?? name, authorize: async (tok) => tokenMatches(t.tokens ?? [], tok) });
113
+ return {
114
+ async resolve(tenant) {
115
+ return tenant === null ? root : (tenants.get(tenant) ?? null);
116
+ },
117
+ async tenants() {
118
+ return [null, ...tenants.keys()];
119
+ },
120
+ };
121
+ }
122
+ /**
123
+ * Logs in Postgres: every tenant from the tenants table, each with its own log and tokens; the root paths serve the
124
+ * tenant named `defaultTenant`, which also accepts `staticTokens` so a server can keep its environment token.
125
+ */
126
+ export function postgresResolver(tenancy, opts = {}) {
127
+ const def = opts.defaultTenant ?? "default";
128
+ return {
129
+ async resolve(tenant) {
130
+ const id = tenant ?? def;
131
+ const t = await tenancy.tenant(id);
132
+ if (!t || t.disabledAt)
133
+ return null;
134
+ const backend = await tenancy.log(id);
135
+ return {
136
+ backend,
137
+ logId: t.logId,
138
+ authorize: async (tok) => (tenant === null && (opts.staticTokens?.length ?? 0) > 0 && tokenMatches(opts.staticTokens, tok)) || (await tenancy.authorize(id, tok)),
139
+ };
140
+ },
141
+ async tenants() {
142
+ return (await tenancy.listTenants()).filter((t) => !t.disabledAt).map((t) => (t.id === def ? null : t.id));
143
+ },
144
+ };
145
+ }
146
+ /**
147
+ * Publishes one checkpoint per log whose tree has grown since the last one: the current head, signed, into the
148
+ * checkpoint store. Call publishOnce on a timer, or start() to run it every `everyMs`.
149
+ */
150
+ export class CheckpointPublisher {
151
+ timer = null;
152
+ resolver;
153
+ signer;
154
+ store;
155
+ everyMs;
156
+ warn;
157
+ constructor(resolver, signer, store, everyMs = 300_000, warn = (m) => console.error(m)) {
158
+ this.resolver = resolver;
159
+ this.signer = signer;
160
+ this.store = store;
161
+ this.everyMs = everyMs;
162
+ this.warn = warn;
163
+ }
164
+ /** Publishes for every log that has grown; returns the checkpoints written. */
165
+ async publishOnce() {
166
+ const out = [];
167
+ for (const tenant of await this.resolver.tenants()) {
168
+ try {
169
+ const r = await this.resolver.resolve(tenant);
170
+ if (!r)
171
+ continue;
172
+ const name = tenant ?? "default";
173
+ const size = await r.backend.size();
174
+ if (size === 0)
175
+ continue; // an empty tree is not a checkpoint worth publishing
176
+ const last = await this.store.latest(name);
177
+ if (last && last.treeSize >= size)
178
+ continue;
179
+ const rootHash = await r.backend.root(size);
180
+ const envelope = await signHead({ treeSize: size, rootHash }, this.signer, r.logId);
181
+ const c = { tenant: name, logId: r.logId, treeSize: size, rootHash, signedAt: new Date().toISOString(), envelope };
182
+ await this.store.save(c);
183
+ out.push(c);
184
+ }
185
+ catch (e) {
186
+ this.warn(`agent-custody log: checkpoint for ${tenant ?? "default"} failed: ${e instanceof Error ? e.message : String(e)}`);
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+ start() {
192
+ if (this.timer)
193
+ return;
194
+ this.timer = setInterval(() => void this.publishOnce(), this.everyMs);
195
+ this.timer.unref?.();
196
+ void this.publishOnce();
197
+ }
198
+ stop() {
199
+ if (this.timer)
200
+ clearInterval(this.timer);
201
+ this.timer = null;
202
+ }
203
+ }
61
204
  /**
62
205
  * The reference log server as a node:http request handler.
63
206
  * POST /append {leaf} -> LogAppend, tree head signed with the log's key
@@ -65,80 +208,110 @@ export function openLog(cfg, key) {
65
208
  * GET /consistency?old=M&new=N -> {oldSize, newSize, hashes}, proof that the log at N extends the log at M
66
209
  * GET /head -> {treeHead}, the current tree head signed with the log's key
67
210
  */
68
- export function logHandler(file, key, opts = {}) {
69
- const root = { log: new MerkleLog(file), tokens: opts.tokens ?? [], logId: opts.logId };
70
- const tenants = new Map();
71
- for (const [name, t] of Object.entries(opts.tenants ?? {}))
72
- tenants.set(name, { log: new MerkleLog(t.file), tokens: t.tokens ?? [], logId: t.logId ?? name });
73
- const authorized = (req, tokens) => {
74
- if (tokens.length === 0)
75
- return true;
211
+ export function logHandler(source, keyOrSigner, opts = {}) {
212
+ const resolver = typeof source === "string" ? fileResolver(source, opts) : source;
213
+ const signer = "privateKey" in keyOrSigner ? localSigner(keyOrSigner) : keyOrSigner;
214
+ const limiter = new RateLimiter(opts.rateLimit);
215
+ const maxBody = opts.maxBodyBytes ?? 65_536;
216
+ const bearer = (req) => {
76
217
  const h = req.headers.authorization ?? "";
77
- const given = Buffer.from(h.startsWith("Bearer ") ? h.slice(7) : "");
78
- return tokens.some((t) => {
79
- const want = Buffer.from(t);
80
- return want.length === given.length && timingSafeEqual(want, given);
81
- });
218
+ return h.startsWith("Bearer ") && h.length > 7 ? h.slice(7) : null;
82
219
  };
83
220
  return async (req, res) => {
84
- const json = (status, body) => {
85
- res.writeHead(status, { "content-type": "application/json" });
221
+ const json = (status, body, headers = {}) => {
222
+ res.writeHead(status, { "content-type": "application/json", ...headers });
86
223
  res.end(JSON.stringify(body));
87
224
  };
88
225
  const url = new URL(req.url ?? "/", "http://localhost");
226
+ if (req.method === "GET" && url.pathname === "/.well-known/agent-custody-log.json") {
227
+ try {
228
+ const doc = await signer.keys();
229
+ const root = await resolver.resolve(null);
230
+ return json(200, { ...(root?.logId ? { log: root.logId } : {}), ...doc }, { "cache-control": "public, max-age=300" });
231
+ }
232
+ catch (e) {
233
+ return json(503, { error: `keys unavailable: ${e instanceof Error ? e.message : String(e)}` });
234
+ }
235
+ }
89
236
  // /t/<tenant>/<op> reaches that tenant's log; anything else is the default log.
90
- const m = /^\/t\/([A-Za-z0-9_.-]+)\/(append|root|consistency|head)$/.exec(url.pathname);
91
- const which = m ? tenants.get(m[1]) : root;
237
+ const m = /^\/t\/([A-Za-z0-9_.-]+)\/(append|root|consistency|head|checkpoints)$/.exec(url.pathname);
238
+ let which;
239
+ try {
240
+ which = await resolver.resolve(m ? m[1] : null);
241
+ }
242
+ catch (e) {
243
+ return json(503, { error: `log unavailable: ${e instanceof Error ? e.message : String(e)}` });
244
+ }
92
245
  if (!which)
93
246
  return json(404, { error: "unknown log" });
94
- const { log, tokens, logId } = which;
95
- if (req.method === "POST" && url.pathname.endsWith("/append")) {
96
- if (!authorized(req, tokens))
97
- return json(401, { error: "unauthorized" });
98
- let body = "";
99
- for await (const chunk of req)
100
- body += chunk;
101
- let parsed;
102
- try {
103
- parsed = JSON.parse(body);
247
+ const { backend: log, logId } = which;
248
+ try {
249
+ if (req.method === "POST" && url.pathname.endsWith("/append")) {
250
+ const token = bearer(req);
251
+ if (!(await which.authorize(token)))
252
+ return json(401, { error: "unauthorized" });
253
+ const limitKey = token ? createHash("sha256").update(token).digest("hex").slice(0, 16) : `addr:${req.socket.remoteAddress ?? "?"}`;
254
+ if (!limiter.take(limitKey))
255
+ return json(429, { error: "too many appends; retry shortly" }, { "retry-after": "1" });
256
+ let body = "";
257
+ for await (const chunk of req) {
258
+ body += chunk;
259
+ if (body.length > maxBody)
260
+ return json(413, { error: `append body larger than ${maxBody} bytes` });
261
+ }
262
+ let parsed;
263
+ try {
264
+ parsed = JSON.parse(body);
265
+ }
266
+ catch {
267
+ return json(400, { error: "body must be JSON {leaf} or {leafHash}" });
268
+ }
269
+ if (typeof parsed.leafHash === "string") {
270
+ if (!/^[0-9a-f]{64}$/.test(parsed.leafHash))
271
+ return json(400, { error: "leafHash must be 64 lowercase hex characters" });
272
+ return json(200, await appendSigned(log, signer, { leafHash: parsed.leafHash }, logId));
273
+ }
274
+ if (typeof parsed.leaf !== "string" || parsed.leaf.length === 0)
275
+ return json(400, { error: "leaf must be a non-empty string, or send leafHash" });
276
+ return json(200, await appendSigned(log, signer, { leaf: parsed.leaf }, logId));
104
277
  }
105
- catch {
106
- return json(400, { error: "body must be JSON {leaf} or {leafHash}" });
278
+ const current = await log.size();
279
+ if (req.method === "GET" && url.pathname.endsWith("/root")) {
280
+ const size = url.searchParams.has("size") ? Number(url.searchParams.get("size")) : current;
281
+ if (!Number.isInteger(size) || size < 0 || size > current)
282
+ return json(400, { error: `size must be an integer in 0..${current}` });
283
+ return json(200, { treeSize: size, rootHash: await log.root(size) });
107
284
  }
108
- if (typeof parsed.leafHash === "string") {
109
- if (!/^[0-9a-f]{64}$/.test(parsed.leafHash))
110
- return json(400, { error: "leafHash must be 64 lowercase hex characters" });
111
- return json(200, appendSigned(log, key, { leafHash: parsed.leafHash }, logId));
285
+ if (req.method === "GET" && url.pathname.endsWith("/consistency")) {
286
+ const oldSize = Number(url.searchParams.get("old"));
287
+ const newSize = url.searchParams.has("new") ? Number(url.searchParams.get("new")) : current;
288
+ if (![oldSize, newSize].every(Number.isInteger) || oldSize < 0 || oldSize > newSize || newSize > current)
289
+ return json(400, { error: `old and new must be integers with 0 <= old <= new <= ${current}` });
290
+ return json(200, { oldSize, newSize, hashes: await log.consistencyProof(oldSize, newSize) });
112
291
  }
113
- if (typeof parsed.leaf !== "string" || parsed.leaf.length === 0)
114
- return json(400, { error: "leaf must be a non-empty string, or send leafHash" });
115
- return json(200, appendSigned(log, key, { leaf: parsed.leaf }, logId));
116
- }
117
- if (req.method === "GET" && url.pathname.endsWith("/root")) {
118
- const size = url.searchParams.has("size") ? Number(url.searchParams.get("size")) : log.size;
119
- if (!Number.isInteger(size) || size < 0 || size > log.size)
120
- return json(400, { error: `size must be an integer in 0..${log.size}` });
121
- return json(200, { treeSize: size, rootHash: log.root(size) });
122
- }
123
- if (req.method === "GET" && url.pathname.endsWith("/consistency")) {
124
- const oldSize = Number(url.searchParams.get("old"));
125
- const newSize = url.searchParams.has("new") ? Number(url.searchParams.get("new")) : log.size;
126
- if (![oldSize, newSize].every(Number.isInteger) || oldSize < 0 || oldSize > newSize || newSize > log.size)
127
- return json(400, { error: `old and new must be integers with 0 <= old <= new <= ${log.size}` });
128
- return json(200, { oldSize, newSize, hashes: log.consistencyProof(oldSize, newSize) });
292
+ if (req.method === "GET" && url.pathname.endsWith("/head")) {
293
+ return json(200, { treeHead: await signHead({ treeSize: current, rootHash: await log.root(current) }, signer, logId) });
294
+ }
295
+ if (req.method === "GET" && url.pathname.endsWith("/checkpoints")) {
296
+ const since = url.searchParams.has("since") ? Number(url.searchParams.get("since")) : -1;
297
+ if (!Number.isInteger(since))
298
+ return json(400, { error: "since must be an integer tree size" });
299
+ const list = opts.checkpoints ? await opts.checkpoints.list(m ? m[1] : "default", since) : [];
300
+ return json(200, { checkpoints: list.map((c) => ({ treeSize: c.treeSize, rootHash: c.rootHash, signedAt: c.signedAt, treeHead: c.envelope })) });
301
+ }
302
+ return json(404, { error: "not found" });
129
303
  }
130
- if (req.method === "GET" && url.pathname.endsWith("/head")) {
131
- const head = { treeSize: log.size, rootHash: log.root(), timestamp: new Date().toISOString(), ...(logId ? { log: logId } : {}) };
132
- return json(200, { treeHead: dsseSign(TREEHEAD_TYPE, head, key) });
304
+ catch (e) {
305
+ return json(500, { error: e instanceof Error ? e.message : String(e) });
133
306
  }
134
- return json(404, { error: "not found" });
135
307
  };
136
308
  }
137
309
  /** Starts the reference log server. Port 0 picks a free port. */
138
- export function serveLog(file, key, opts) {
310
+ export function serveLog(source, keyOrSigner, opts) {
139
311
  const host = opts.host ?? "127.0.0.1";
312
+ const handler = logHandler(source, keyOrSigner, opts);
140
313
  const server = createServer((req, res) => {
141
- void logHandler(file, key, opts)(req, res);
314
+ void handler(req, res);
142
315
  });
143
316
  return new Promise((resolve) => {
144
317
  server.listen(opts.port, host, () => {
@@ -0,0 +1,122 @@
1
+ import { type InclusionProof } from "./log.ts";
2
+ export interface AppendResult extends InclusionProof {
3
+ rootHash: string;
4
+ }
5
+ /** One log, as the server sees it: everything a Merkle log answers, asynchronously, so a database can stand behind it. */
6
+ export interface LogBackend {
7
+ size(): Promise<number>;
8
+ append(leaf: string): Promise<AppendResult>;
9
+ appendHash(leafHashHex: string): Promise<AppendResult>;
10
+ root(size?: number): Promise<string>;
11
+ consistencyProof(oldSize: number, newSize?: number): Promise<string[]>;
12
+ }
13
+ /** The JSONL file log behind the asynchronous interface. */
14
+ export declare function fileBackend(file: string): LogBackend;
15
+ /**
16
+ * What the Postgres backend needs from a client: the query method of a `pg` Pool and of PGlite, and, for a pool,
17
+ * `connect` so a transaction runs on one connection. A store never closes a client it was given.
18
+ */
19
+ export interface PostgresLike {
20
+ query(text: string, values?: unknown[]): Promise<{
21
+ rows: unknown[];
22
+ }>;
23
+ connect?(): Promise<{
24
+ query(text: string, values?: unknown[]): Promise<{
25
+ rows: unknown[];
26
+ }>;
27
+ release(): void;
28
+ }>;
29
+ }
30
+ export interface PostgresLogOptions {
31
+ /** table name prefix; default "log_", giving log_leaves, log_tenants, log_tokens */
32
+ prefix?: string;
33
+ }
34
+ /** One tenant's log in Postgres. Leaves are hashes only; the table never holds a receipt. */
35
+ export declare class PostgresLog implements LogBackend {
36
+ readonly tenant: string;
37
+ private readonly client;
38
+ private readonly leaves;
39
+ private hashes;
40
+ private cache;
41
+ private ready;
42
+ private chain;
43
+ constructor(client: PostgresLike, tenant: string, opts?: PostgresLogOptions);
44
+ static ensureSchema(client: PostgresLike, prefix?: string): Promise<void>;
45
+ private init;
46
+ /** Replaces the in-memory tree with what the table holds. Called when the table has moved on without this instance. */
47
+ private reload;
48
+ private count;
49
+ private sync;
50
+ size(): Promise<number>;
51
+ private commit;
52
+ append(leaf: string): Promise<AppendResult>;
53
+ appendHash(leafHashHex: string): Promise<AppendResult>;
54
+ root(size?: number): Promise<string>;
55
+ consistencyProof(oldSize: number, newSize?: number): Promise<string[]>;
56
+ }
57
+ export interface Tenant {
58
+ id: string;
59
+ logId: string;
60
+ createdAt: string;
61
+ disabledAt: string | null;
62
+ }
63
+ export interface TokenRecord {
64
+ tenantId: string;
65
+ label: string;
66
+ /** hex sha256 of the token; the token itself is shown once, at creation */
67
+ tokenHash: string;
68
+ createdAt: string;
69
+ revokedAt: string | null;
70
+ }
71
+ /** Tenants and their tokens, in Postgres. Tokens are stored hashed; a lookup hashes what the caller presented. */
72
+ export declare class PostgresTenancy {
73
+ private readonly client;
74
+ private readonly prefix;
75
+ private readonly logs;
76
+ private readonly tenantCache;
77
+ private readonly tokenCache;
78
+ private ready;
79
+ constructor(client: PostgresLike, opts?: PostgresLogOptions);
80
+ private init;
81
+ private row;
82
+ /** The tenant, or null. Answers from a ten-second cache, so a disabled tenant is refused within that. */
83
+ tenant(id: string): Promise<Tenant | null>;
84
+ /** Whether the presented token is a live token of this tenant. Positive answers are cached for thirty seconds. */
85
+ authorize(tenantId: string, token: string | null): Promise<boolean>;
86
+ /** The tenant's log, one instance per tenant per process. */
87
+ log(tenantId: string): Promise<PostgresLog>;
88
+ addTenant(id: string, logId?: string): Promise<Tenant>;
89
+ disableTenant(id: string): Promise<void>;
90
+ listTenants(): Promise<Tenant[]>;
91
+ /** Mints a token for a tenant. The token is returned once and stored only as its hash. */
92
+ addToken(tenantId: string, label: string): Promise<{
93
+ token: string;
94
+ tokenHash: string;
95
+ }>;
96
+ /** Revokes the tokens of a tenant whose hash starts with the prefix; returns how many. */
97
+ revokeToken(tenantId: string, hashPrefix: string): Promise<number>;
98
+ listTokens(tenantId: string): Promise<TokenRecord[]>;
99
+ }
100
+ /**
101
+ * Copies a JSONL log file into a backend as hashes: leaf strings are hashed, {pruned} and {hash} lines are taken as
102
+ * they are. Skips the leaves the backend already has, so it can be re-run. Returns how many were added.
103
+ */
104
+ export declare function importLogFile(file: string, into: LogBackend): Promise<{
105
+ added: number;
106
+ total: number;
107
+ }>;
108
+ export interface RateLimitOptions {
109
+ /** sustained appends per second per key; default 50 */
110
+ perSecond?: number;
111
+ /** how many may arrive at once before the limit bites; default 100 */
112
+ burst?: number;
113
+ }
114
+ /** A token bucket per key, in memory: enough to keep one tenant from crowding out the rest on one instance. */
115
+ export declare class RateLimiter {
116
+ private readonly perSecond;
117
+ private readonly burst;
118
+ private readonly buckets;
119
+ constructor(opts?: RateLimitOptions);
120
+ /** Takes one unit for the key; false when the key must wait. */
121
+ take(key: string, now?: number): boolean;
122
+ }