@agent-custody/receipts 0.3.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/README.md +7 -1
- package/dist/checkpoints.d.ts +22 -0
- package/dist/checkpoints.js +81 -0
- package/dist/cli.js +170 -13
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/log-sink.d.ts +71 -3
- package/dist/log-sink.js +248 -62
- package/dist/log-store.d.ts +122 -0
- package/dist/log-store.js +312 -0
- package/dist/log.d.ts +21 -0
- package/dist/log.js +18 -4
- package/dist/receipt.d.ts +2 -0
- package/dist/signer.d.ts +64 -0
- package/dist/signer.js +136 -0
- package/dist/verify.d.ts +3 -1
- package/dist/verify.js +7 -1
- package/docs/usage.md +1 -1
- package/docs/verification.md +4 -1
- package/package.json +8 -3
- package/vectors/audit.json +27 -27
- package/vectors/canonical.json +5 -5
- package/vectors/receipts.json +312 -200
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 {
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
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
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
function signedHead(e, key, logId) {
|
|
15
|
+
const head = { treeSize: e.treeSize, rootHash: e.rootHash, timestamp: new Date().toISOString(), ...(logId ? { log: logId } : {}) };
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
}
|
|
@@ -52,12 +87,120 @@ export function openLog(cfg, key) {
|
|
|
52
87
|
const token = cfg.log.tokenEnv ? process.env[cfg.log.tokenEnv] : undefined;
|
|
53
88
|
if (cfg.log.tokenEnv && !token)
|
|
54
89
|
throw new Error(`log token: environment variable ${cfg.log.tokenEnv} is not set`);
|
|
55
|
-
return httpLog(cfg.log.url, token === undefined ? {} : { token });
|
|
90
|
+
return httpLog(cfg.log.url, { ...(token === undefined ? {} : { token }), ...(cfg.log.hashOnly ? { hashOnly: true } : {}) });
|
|
56
91
|
}
|
|
57
92
|
if (!cfg.logFile)
|
|
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,67 +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(
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
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) => {
|
|
74
217
|
const h = req.headers.authorization ?? "";
|
|
75
|
-
|
|
76
|
-
return tokens.some((t) => {
|
|
77
|
-
const want = Buffer.from(t);
|
|
78
|
-
return want.length === given.length && timingSafeEqual(want, given);
|
|
79
|
-
});
|
|
218
|
+
return h.startsWith("Bearer ") && h.length > 7 ? h.slice(7) : null;
|
|
80
219
|
};
|
|
81
220
|
return async (req, res) => {
|
|
82
|
-
const json = (status, body) => {
|
|
83
|
-
res.writeHead(status, { "content-type": "application/json" });
|
|
221
|
+
const json = (status, body, headers = {}) => {
|
|
222
|
+
res.writeHead(status, { "content-type": "application/json", ...headers });
|
|
84
223
|
res.end(JSON.stringify(body));
|
|
85
224
|
};
|
|
86
225
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
87
|
-
if (req.method === "
|
|
88
|
-
if (!authorized(req))
|
|
89
|
-
return json(401, { error: "unauthorized" });
|
|
90
|
-
let body = "";
|
|
91
|
-
for await (const chunk of req)
|
|
92
|
-
body += chunk;
|
|
93
|
-
let leaf;
|
|
226
|
+
if (req.method === "GET" && url.pathname === "/.well-known/agent-custody-log.json") {
|
|
94
227
|
try {
|
|
95
|
-
|
|
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" });
|
|
96
231
|
}
|
|
97
|
-
catch {
|
|
98
|
-
return json(
|
|
232
|
+
catch (e) {
|
|
233
|
+
return json(503, { error: `keys unavailable: ${e instanceof Error ? e.message : String(e)}` });
|
|
99
234
|
}
|
|
100
|
-
if (typeof leaf !== "string" || leaf.length === 0)
|
|
101
|
-
return json(400, { error: "leaf must be a non-empty string" });
|
|
102
|
-
return json(200, appendSigned(log, key, leaf));
|
|
103
235
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
236
|
+
// /t/<tenant>/<op> reaches that tenant's log; anything else is the default log.
|
|
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);
|
|
109
241
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
242
|
+
catch (e) {
|
|
243
|
+
return json(503, { error: `log unavailable: ${e instanceof Error ? e.message : String(e)}` });
|
|
244
|
+
}
|
|
245
|
+
if (!which)
|
|
246
|
+
return json(404, { error: "unknown log" });
|
|
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));
|
|
277
|
+
}
|
|
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) });
|
|
284
|
+
}
|
|
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) });
|
|
291
|
+
}
|
|
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" });
|
|
116
303
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return json(200, { treeHead: dsseSign(TREEHEAD_TYPE, head, key) });
|
|
304
|
+
catch (e) {
|
|
305
|
+
return json(500, { error: e instanceof Error ? e.message : String(e) });
|
|
120
306
|
}
|
|
121
|
-
return json(404, { error: "not found" });
|
|
122
307
|
};
|
|
123
308
|
}
|
|
124
309
|
/** Starts the reference log server. Port 0 picks a free port. */
|
|
125
|
-
export function serveLog(
|
|
310
|
+
export function serveLog(source, keyOrSigner, opts) {
|
|
126
311
|
const host = opts.host ?? "127.0.0.1";
|
|
312
|
+
const handler = logHandler(source, keyOrSigner, opts);
|
|
127
313
|
const server = createServer((req, res) => {
|
|
128
|
-
void
|
|
314
|
+
void handler(req, res);
|
|
129
315
|
});
|
|
130
316
|
return new Promise((resolve) => {
|
|
131
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
|
+
}
|