@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/log-store.js
CHANGED
|
@@ -170,6 +170,8 @@ export class PostgresLog {
|
|
|
170
170
|
return this.cache.subproof(oldSize, 0, n, true).map((b) => b.toString("hex"));
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
+
export const PLANS = ["free", "team", "enterprise"];
|
|
174
|
+
export const PLAN_QUOTAS = { free: 10_000, team: 1_000_000, enterprise: null };
|
|
173
175
|
const sha256hex = (s) => createHash("sha256").update(s).digest("hex");
|
|
174
176
|
/** Tenants and their tokens, in Postgres. Tokens are stored hashed; a lookup hashes what the caller presented. */
|
|
175
177
|
export class PostgresTenancy {
|
|
@@ -178,10 +180,13 @@ export class PostgresTenancy {
|
|
|
178
180
|
logs = new Map();
|
|
179
181
|
tenantCache = new Map();
|
|
180
182
|
tokenCache = new Map();
|
|
183
|
+
quotaCache = new Map();
|
|
184
|
+
quotas;
|
|
181
185
|
ready = null;
|
|
182
186
|
constructor(client, opts = {}) {
|
|
183
187
|
this.client = client;
|
|
184
188
|
this.prefix = ident(opts.prefix ?? "log_", "prefix");
|
|
189
|
+
this.quotas = { ...PLAN_QUOTAS, ...(opts.quotas ?? {}) };
|
|
185
190
|
}
|
|
186
191
|
init() {
|
|
187
192
|
if (!this.ready) {
|
|
@@ -189,6 +194,7 @@ export class PostgresTenancy {
|
|
|
189
194
|
this.ready = (async () => {
|
|
190
195
|
await PostgresLog.ensureSchema(this.client, p);
|
|
191
196
|
await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}tenants (id TEXT PRIMARY KEY, log_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), disabled_at TIMESTAMPTZ)`);
|
|
197
|
+
await this.client.query(`ALTER TABLE ${p}tenants ADD COLUMN IF NOT EXISTS plan TEXT NOT NULL DEFAULT 'free'`);
|
|
192
198
|
await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}tokens (token_hash TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES ${p}tenants(id), label TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ)`);
|
|
193
199
|
await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}audit (id BIGSERIAL PRIMARY KEY, at TIMESTAMPTZ NOT NULL DEFAULT now(), actor TEXT NOT NULL, action TEXT NOT NULL, tenant_id TEXT, detail JSONB NOT NULL DEFAULT '{}')`);
|
|
194
200
|
})();
|
|
@@ -208,7 +214,7 @@ export class PostgresTenancy {
|
|
|
208
214
|
return rows.map((r) => ({ id: Number(r.id), at: new Date(r.at).toISOString(), actor: String(r.actor), action: r.action, tenantId: r.tenant_id === null || r.tenant_id === undefined ? null : String(r.tenant_id), detail: (typeof r.detail === "string" ? JSON.parse(r.detail) : r.detail) }));
|
|
209
215
|
}
|
|
210
216
|
row(r) {
|
|
211
|
-
return { id: String(r.id), logId: String(r.log_id), createdAt: new Date(r.created_at).toISOString(), disabledAt: r.disabled_at ? new Date(r.disabled_at).toISOString() : null };
|
|
217
|
+
return { id: String(r.id), logId: String(r.log_id), plan: PLANS.includes(String(r.plan)) ? String(r.plan) : "free", createdAt: new Date(r.created_at).toISOString(), disabledAt: r.disabled_at ? new Date(r.disabled_at).toISOString() : null };
|
|
212
218
|
}
|
|
213
219
|
/** The tenant, or null. Answers from a ten-second cache, so a disabled tenant is refused within that. */
|
|
214
220
|
async tenant(id) {
|
|
@@ -216,7 +222,7 @@ export class PostgresTenancy {
|
|
|
216
222
|
const hit = this.tenantCache.get(id);
|
|
217
223
|
if (hit && Date.now() - hit.at < 10_000)
|
|
218
224
|
return hit.tenant;
|
|
219
|
-
const rows = (await this.client.query(`SELECT id, log_id, created_at, disabled_at FROM ${this.prefix}tenants WHERE id = $1`, [id])).rows;
|
|
225
|
+
const rows = (await this.client.query(`SELECT id, log_id, plan, created_at, disabled_at FROM ${this.prefix}tenants WHERE id = $1`, [id])).rows;
|
|
220
226
|
const tenant = rows[0] ? this.row(rows[0]) : null;
|
|
221
227
|
this.tenantCache.set(id, { at: Date.now(), tenant });
|
|
222
228
|
return tenant;
|
|
@@ -251,11 +257,47 @@ export class PostgresTenancy {
|
|
|
251
257
|
if (!/^[A-Za-z0-9_.-]+$/.test(id))
|
|
252
258
|
throw new Error(`tenant id must be a plain identifier; got "${id}"`);
|
|
253
259
|
await this.init();
|
|
254
|
-
const rows = (await this.client.query(`INSERT INTO ${this.prefix}tenants (id, log_id) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET log_id = EXCLUDED.log_id RETURNING id, log_id, created_at, disabled_at`, [id, logId])).rows;
|
|
260
|
+
const rows = (await this.client.query(`INSERT INTO ${this.prefix}tenants (id, log_id) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET log_id = EXCLUDED.log_id RETURNING id, log_id, plan, created_at, disabled_at`, [id, logId])).rows;
|
|
255
261
|
this.tenantCache.delete(id);
|
|
256
262
|
await this.record(by, "tenant.add", id, { logId });
|
|
257
263
|
return this.row(rows[0]);
|
|
258
264
|
}
|
|
265
|
+
/** Moves a tenant to a plan; the quota applies from the next append. */
|
|
266
|
+
async setPlan(id, plan, by) {
|
|
267
|
+
if (!PLANS.includes(plan))
|
|
268
|
+
throw new Error(`unknown plan ${plan}; one of ${PLANS.join(", ")}`);
|
|
269
|
+
await this.init();
|
|
270
|
+
const rows = (await this.client.query(`UPDATE ${this.prefix}tenants SET plan = $2 WHERE id = $1 RETURNING id, log_id, plan, created_at, disabled_at`, [id, plan])).rows;
|
|
271
|
+
if (!rows[0])
|
|
272
|
+
throw new Error(`unknown tenant ${id}`);
|
|
273
|
+
this.tenantCache.delete(id);
|
|
274
|
+
await this.record(by, "tenant.plan", id, { plan });
|
|
275
|
+
return this.row(rows[0]);
|
|
276
|
+
}
|
|
277
|
+
/** The tenant's plan, appends this month, and the plan's quota. Cached ten seconds, so a burst may overshoot slightly. */
|
|
278
|
+
async quota(id) {
|
|
279
|
+
const t = await this.tenant(id);
|
|
280
|
+
if (!t)
|
|
281
|
+
throw new Error(`unknown tenant ${id}`);
|
|
282
|
+
const cached = this.quotaCache.get(id);
|
|
283
|
+
let used;
|
|
284
|
+
if (cached && Date.now() - cached.at < 10_000) {
|
|
285
|
+
used = cached.used;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
const start = `${new Date().toISOString().slice(0, 7)}-01T00:00:00Z`;
|
|
289
|
+
const rows = (await this.client.query(`SELECT COUNT(*) AS n FROM ${this.prefix}leaves WHERE tenant_id = $1 AND appended_at >= $2::timestamptz`, [id, start])).rows;
|
|
290
|
+
used = Number(rows[0]?.n ?? 0);
|
|
291
|
+
this.quotaCache.set(id, { at: Date.now(), used });
|
|
292
|
+
}
|
|
293
|
+
return { plan: t.plan, used, quota: this.quotas[t.plan] };
|
|
294
|
+
}
|
|
295
|
+
/** Called after an append lands, so the cached count stays honest between refreshes. */
|
|
296
|
+
noteAppend(id) {
|
|
297
|
+
const c = this.quotaCache.get(id);
|
|
298
|
+
if (c)
|
|
299
|
+
c.used += 1;
|
|
300
|
+
}
|
|
259
301
|
async disableTenant(id, by) {
|
|
260
302
|
await this.init();
|
|
261
303
|
await this.client.query(`UPDATE ${this.prefix}tenants SET disabled_at = now() WHERE id = $1 AND disabled_at IS NULL`, [id]);
|
|
@@ -264,7 +306,7 @@ export class PostgresTenancy {
|
|
|
264
306
|
}
|
|
265
307
|
async listTenants() {
|
|
266
308
|
await this.init();
|
|
267
|
-
return (await this.client.query(`SELECT id, log_id, created_at, disabled_at FROM ${this.prefix}tenants ORDER BY created_at`)).rows.map((r) => this.row(r));
|
|
309
|
+
return (await this.client.query(`SELECT id, log_id, plan, created_at, disabled_at FROM ${this.prefix}tenants ORDER BY created_at`)).rows.map((r) => this.row(r));
|
|
268
310
|
}
|
|
269
311
|
/** Mints a token for a tenant. The token is returned once and stored only as its hash. */
|
|
270
312
|
async addToken(tenantId, label, by) {
|
|
@@ -301,12 +343,12 @@ export class PostgresTenancy {
|
|
|
301
343
|
const [y, m] = month.split("-").map(Number);
|
|
302
344
|
const end = `${m === 12 ? y + 1 : y}-${String(m === 12 ? 1 : m + 1).padStart(2, "0")}-01T00:00:00Z`;
|
|
303
345
|
const p = this.prefix;
|
|
304
|
-
const rows = (await this.client.query(`SELECT t.id, t.log_id, t.disabled_at,
|
|
346
|
+
const rows = (await this.client.query(`SELECT t.id, t.log_id, t.plan, t.disabled_at,
|
|
305
347
|
(SELECT COUNT(*) FROM ${p}leaves l WHERE l.tenant_id = t.id AND l.appended_at >= $1::timestamptz AND l.appended_at < $2::timestamptz) AS appends,
|
|
306
348
|
(SELECT COUNT(*) FROM ${p}leaves l WHERE l.tenant_id = t.id) AS total,
|
|
307
349
|
(SELECT COUNT(*) FROM ${p}tokens k WHERE k.tenant_id = t.id AND k.revoked_at IS NULL) AS live
|
|
308
350
|
FROM ${p}tenants t ORDER BY t.created_at`, [start, end])).rows;
|
|
309
|
-
return { month, tenants: rows.map((r) => ({ id: String(r.id), logId: String(r.log_id), appends: Number(r.appends), totalLeaves: Number(r.total), liveTokens: Number(r.live), disabled: !!r.disabled_at })
|
|
351
|
+
return { month, tenants: rows.map((r) => { const plan = PLANS.includes(String(r.plan)) ? String(r.plan) : "free"; return { id: String(r.id), logId: String(r.log_id), plan, quota: this.quotas[plan], appends: Number(r.appends), totalLeaves: Number(r.total), liveTokens: Number(r.live), disabled: !!r.disabled_at }; }) };
|
|
310
352
|
}
|
|
311
353
|
async listTokens(tenantId) {
|
|
312
354
|
await this.init();
|
package/dist/portal.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { type PostgresLike, type PostgresTenancy } from "./log-store.ts";
|
|
3
|
+
export interface StripeOptions {
|
|
4
|
+
secretKey: string;
|
|
5
|
+
webhookSecret: string;
|
|
6
|
+
/** the Stripe price id of the team plan's monthly subscription */
|
|
7
|
+
priceTeam: string;
|
|
8
|
+
fetch?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
export interface PortalOptions {
|
|
11
|
+
tenancy: PostgresTenancy;
|
|
12
|
+
/** the Postgres client the tenancy uses; the portal's own tables live beside the log's */
|
|
13
|
+
client: PostgresLike;
|
|
14
|
+
/** signs session cookies; rotate to sign everyone out */
|
|
15
|
+
secret: string;
|
|
16
|
+
/** the log's public base URL, for the welcome sheet and the export command */
|
|
17
|
+
publicUrl: string;
|
|
18
|
+
checkpointsUrl?: string;
|
|
19
|
+
/** the log's current signing keyid, for the sheet */
|
|
20
|
+
keyid?: string;
|
|
21
|
+
/** the portal's own public URL, for Stripe's return addresses */
|
|
22
|
+
portalUrl?: string;
|
|
23
|
+
stripe?: StripeOptions;
|
|
24
|
+
/** key throttles by X-Forwarded-For; only behind a proxy you run. Also marks cookies Secure. */
|
|
25
|
+
trustProxy?: boolean;
|
|
26
|
+
/** table prefix; default portal_ */
|
|
27
|
+
prefix?: string;
|
|
28
|
+
log?: (message: string) => void;
|
|
29
|
+
}
|
|
30
|
+
export interface PortalUser {
|
|
31
|
+
id: string;
|
|
32
|
+
email: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
}
|
|
35
|
+
/** Users, memberships, and billing records, beside the log's tables. */
|
|
36
|
+
export declare class PortalStore {
|
|
37
|
+
private readonly client;
|
|
38
|
+
private readonly p;
|
|
39
|
+
private ready;
|
|
40
|
+
constructor(client: PostgresLike, prefix?: string);
|
|
41
|
+
private init;
|
|
42
|
+
static hashPassword(password: string): string;
|
|
43
|
+
static checkPassword(password: string, stored: string): boolean;
|
|
44
|
+
createUser(email: string, password: string): Promise<PortalUser>;
|
|
45
|
+
authenticate(email: string, password: string): Promise<PortalUser | null>;
|
|
46
|
+
user(id: string): Promise<PortalUser | null>;
|
|
47
|
+
addMember(userId: string, tenantId: string): Promise<void>;
|
|
48
|
+
/** the user's tenant; one per account today */
|
|
49
|
+
tenantOf(userId: string): Promise<string | null>;
|
|
50
|
+
setBilling(tenantId: string, b: {
|
|
51
|
+
customerId?: string | null;
|
|
52
|
+
subscriptionId?: string | null;
|
|
53
|
+
status: string;
|
|
54
|
+
}): Promise<void>;
|
|
55
|
+
billing(tenantId: string): Promise<{
|
|
56
|
+
customerId: string | null;
|
|
57
|
+
subscriptionId: string | null;
|
|
58
|
+
status: string;
|
|
59
|
+
} | null>;
|
|
60
|
+
tenantBySubscription(subscriptionId: string): Promise<string | null>;
|
|
61
|
+
}
|
|
62
|
+
export declare function signSession(secret: string, userId: string, ttlMs?: number): string;
|
|
63
|
+
export declare function readSession(secret: string, cookie: string | undefined): string | null;
|
|
64
|
+
export declare function stripeRequest(s: StripeOptions, path: string, body: Record<string, string>): Promise<Record<string, unknown>>;
|
|
65
|
+
/** Stripe-Signature: t=<unix>,v1=<hmac>; the mac is over `${t}.${rawBody}` with the endpoint secret. */
|
|
66
|
+
export declare function verifyStripeSignature(header: string | undefined, rawBody: string, secret: string, now?: number, toleranceMs?: number): boolean;
|
|
67
|
+
export interface RunningPortal {
|
|
68
|
+
url: string;
|
|
69
|
+
close(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
export declare function portalHandler(o: PortalOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
72
|
+
export declare function servePortal(o: PortalOptions, opts: {
|
|
73
|
+
port: number;
|
|
74
|
+
host?: string;
|
|
75
|
+
}): Promise<RunningPortal>;
|