@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/portal.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// The tenant portal: where a team registers, gets its tenant on the hosted log and its first API key, watches its
|
|
2
|
+
// usage against its plan, mints and revokes keys, and pays. One process beside the log, on the same Postgres, with
|
|
3
|
+
// its own tables under `portal_`. Everything a tenant can do here they could also do by asking the operator; the
|
|
4
|
+
// portal removes the asking. It holds no receipts and never sees a receipt: the numbers it shows are the log's
|
|
5
|
+
// counts, the hashes it lists are the same hashes the export carries.
|
|
6
|
+
//
|
|
7
|
+
// Sessions are a signed cookie, passwords are scrypt, the page is one inline file with no framework and a strict
|
|
8
|
+
// content-security policy, and every API write requires a JSON body, which with a SameSite=Strict cookie is what
|
|
9
|
+
// keeps a cross-site page from acting as the user. Billing is Stripe Checkout for the team plan; the webhook moves
|
|
10
|
+
// the tenant's plan, and nothing about a card ever passes through here.
|
|
11
|
+
import { createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { postgresCheckpoints } from "./checkpoints.js";
|
|
14
|
+
import { welcomeSheet } from "./log-admin.js";
|
|
15
|
+
import { clientAddress } from "./log-sink.js";
|
|
16
|
+
import { RateLimiter } from "./log-store.js";
|
|
17
|
+
const ident = (s) => {
|
|
18
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(s))
|
|
19
|
+
throw new Error(`prefix must be a plain identifier; got "${s}"`);
|
|
20
|
+
return s;
|
|
21
|
+
};
|
|
22
|
+
/** Users, memberships, and billing records, beside the log's tables. */
|
|
23
|
+
export class PortalStore {
|
|
24
|
+
client;
|
|
25
|
+
p;
|
|
26
|
+
ready = null;
|
|
27
|
+
// no parameter properties: the CLI runs on plain Node type stripping
|
|
28
|
+
constructor(client, prefix = "portal_") {
|
|
29
|
+
this.client = client;
|
|
30
|
+
this.p = ident(prefix);
|
|
31
|
+
}
|
|
32
|
+
init() {
|
|
33
|
+
if (!this.ready) {
|
|
34
|
+
const p = this.p;
|
|
35
|
+
this.ready = (async () => {
|
|
36
|
+
await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())`);
|
|
37
|
+
await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}members (user_id TEXT NOT NULL REFERENCES ${p}users(id), tenant_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'owner', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (user_id, tenant_id))`);
|
|
38
|
+
await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}billing (tenant_id TEXT PRIMARY KEY, customer_id TEXT, subscription_id TEXT, status TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`);
|
|
39
|
+
})();
|
|
40
|
+
}
|
|
41
|
+
return this.ready;
|
|
42
|
+
}
|
|
43
|
+
static hashPassword(password) {
|
|
44
|
+
const salt = randomBytes(16);
|
|
45
|
+
return `scrypt$${salt.toString("hex")}$${scryptSync(password, salt, 64).toString("hex")}`;
|
|
46
|
+
}
|
|
47
|
+
static checkPassword(password, stored) {
|
|
48
|
+
const [alg, saltHex, hashHex] = stored.split("$");
|
|
49
|
+
if (alg !== "scrypt" || !saltHex || !hashHex)
|
|
50
|
+
return false;
|
|
51
|
+
const expected = Buffer.from(hashHex, "hex");
|
|
52
|
+
const got = scryptSync(password, Buffer.from(saltHex, "hex"), expected.length);
|
|
53
|
+
return got.length === expected.length && timingSafeEqual(got, expected);
|
|
54
|
+
}
|
|
55
|
+
async createUser(email, password) {
|
|
56
|
+
await this.init();
|
|
57
|
+
const id = randomUUID();
|
|
58
|
+
const rows = (await this.client.query(`INSERT INTO ${this.p}users (id, email, password_hash) VALUES ($1, $2, $3) ON CONFLICT (email) DO NOTHING RETURNING id, email, created_at`, [id, email, PortalStore.hashPassword(password)])).rows;
|
|
59
|
+
if (!rows[0])
|
|
60
|
+
throw new Error("an account with this email already exists");
|
|
61
|
+
return { id, email, createdAt: new Date(rows[0].created_at).toISOString() };
|
|
62
|
+
}
|
|
63
|
+
async authenticate(email, password) {
|
|
64
|
+
await this.init();
|
|
65
|
+
const rows = (await this.client.query(`SELECT id, email, password_hash, created_at FROM ${this.p}users WHERE email = $1`, [email])).rows;
|
|
66
|
+
const r = rows[0];
|
|
67
|
+
if (!r || !PortalStore.checkPassword(password, String(r.password_hash)))
|
|
68
|
+
return null;
|
|
69
|
+
return { id: String(r.id), email: String(r.email), createdAt: new Date(r.created_at).toISOString() };
|
|
70
|
+
}
|
|
71
|
+
async user(id) {
|
|
72
|
+
await this.init();
|
|
73
|
+
const rows = (await this.client.query(`SELECT id, email, created_at FROM ${this.p}users WHERE id = $1`, [id])).rows;
|
|
74
|
+
const r = rows[0];
|
|
75
|
+
return r ? { id: String(r.id), email: String(r.email), createdAt: new Date(r.created_at).toISOString() } : null;
|
|
76
|
+
}
|
|
77
|
+
async addMember(userId, tenantId) {
|
|
78
|
+
await this.init();
|
|
79
|
+
await this.client.query(`INSERT INTO ${this.p}members (user_id, tenant_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [userId, tenantId]);
|
|
80
|
+
}
|
|
81
|
+
/** the user's tenant; one per account today */
|
|
82
|
+
async tenantOf(userId) {
|
|
83
|
+
await this.init();
|
|
84
|
+
const rows = (await this.client.query(`SELECT tenant_id FROM ${this.p}members WHERE user_id = $1 ORDER BY created_at LIMIT 1`, [userId])).rows;
|
|
85
|
+
return rows[0]?.tenant_id ?? null;
|
|
86
|
+
}
|
|
87
|
+
async setBilling(tenantId, b) {
|
|
88
|
+
await this.init();
|
|
89
|
+
await this.client.query(`INSERT INTO ${this.p}billing (tenant_id, customer_id, subscription_id, status, updated_at) VALUES ($1, $2, $3, $4, now())
|
|
90
|
+
ON CONFLICT (tenant_id) DO UPDATE SET customer_id = COALESCE(EXCLUDED.customer_id, ${this.p}billing.customer_id), subscription_id = COALESCE(EXCLUDED.subscription_id, ${this.p}billing.subscription_id), status = EXCLUDED.status, updated_at = now()`, [tenantId, b.customerId ?? null, b.subscriptionId ?? null, b.status]);
|
|
91
|
+
}
|
|
92
|
+
async billing(tenantId) {
|
|
93
|
+
await this.init();
|
|
94
|
+
const rows = (await this.client.query(`SELECT customer_id, subscription_id, status FROM ${this.p}billing WHERE tenant_id = $1`, [tenantId])).rows;
|
|
95
|
+
const r = rows[0];
|
|
96
|
+
return r ? { customerId: r.customer_id ? String(r.customer_id) : null, subscriptionId: r.subscription_id ? String(r.subscription_id) : null, status: String(r.status) } : null;
|
|
97
|
+
}
|
|
98
|
+
async tenantBySubscription(subscriptionId) {
|
|
99
|
+
await this.init();
|
|
100
|
+
const rows = (await this.client.query(`SELECT tenant_id FROM ${this.p}billing WHERE subscription_id = $1`, [subscriptionId])).rows;
|
|
101
|
+
return rows[0]?.tenant_id ?? null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// ---- sessions ----
|
|
105
|
+
const b64u = (b) => b.toString("base64url");
|
|
106
|
+
export function signSession(secret, userId, ttlMs = 14 * 86_400_000) {
|
|
107
|
+
const body = b64u(Buffer.from(JSON.stringify({ u: userId, e: Date.now() + ttlMs })));
|
|
108
|
+
return `${body}.${createHmac("sha256", secret).update(body).digest("base64url")}`;
|
|
109
|
+
}
|
|
110
|
+
export function readSession(secret, cookie) {
|
|
111
|
+
if (!cookie)
|
|
112
|
+
return null;
|
|
113
|
+
const [body, mac] = cookie.split(".");
|
|
114
|
+
if (!body || !mac)
|
|
115
|
+
return null;
|
|
116
|
+
const expected = createHmac("sha256", secret).update(body).digest("base64url");
|
|
117
|
+
if (expected.length !== mac.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(mac)))
|
|
118
|
+
return null;
|
|
119
|
+
try {
|
|
120
|
+
const { u, e } = JSON.parse(Buffer.from(body, "base64url").toString());
|
|
121
|
+
return typeof u === "string" && typeof e === "number" && e > Date.now() ? u : null;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// ---- Stripe, over its REST API; no SDK ----
|
|
128
|
+
const form = (o) => new URLSearchParams(o).toString();
|
|
129
|
+
export async function stripeRequest(s, path, body) {
|
|
130
|
+
const f = s.fetch ?? fetch;
|
|
131
|
+
const res = await f(`https://api.stripe.com/v1/${path}`, { method: "POST", headers: { authorization: `Bearer ${s.secretKey}`, "content-type": "application/x-www-form-urlencoded" }, body: form(body), signal: AbortSignal.timeout(15_000) });
|
|
132
|
+
const json = (await res.json());
|
|
133
|
+
if (!res.ok)
|
|
134
|
+
throw new Error(`stripe ${path}: ${json.error?.message ?? res.status}`);
|
|
135
|
+
return json;
|
|
136
|
+
}
|
|
137
|
+
/** Stripe-Signature: t=<unix>,v1=<hmac>; the mac is over `${t}.${rawBody}` with the endpoint secret. */
|
|
138
|
+
export function verifyStripeSignature(header, rawBody, secret, now = Date.now(), toleranceMs = 5 * 60_000) {
|
|
139
|
+
if (!header)
|
|
140
|
+
return false;
|
|
141
|
+
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
|
|
142
|
+
const t = Number(parts.t);
|
|
143
|
+
if (!Number.isFinite(t) || Math.abs(now - t * 1000) > toleranceMs)
|
|
144
|
+
return false;
|
|
145
|
+
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
|
|
146
|
+
return header
|
|
147
|
+
.split(",")
|
|
148
|
+
.filter((kv) => kv.startsWith("v1="))
|
|
149
|
+
.some((kv) => {
|
|
150
|
+
const sig = kv.slice(3);
|
|
151
|
+
return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const TENANT_ID = /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/;
|
|
155
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
156
|
+
const RESERVED = new Set(["default", "admin", "api", "www", "log", "checkpoints", "app", "portal", "stripe", "health", "t"]);
|
|
157
|
+
export function portalHandler(o) {
|
|
158
|
+
const store = new PortalStore(o.client, o.prefix);
|
|
159
|
+
const heads = postgresCheckpoints(o.client);
|
|
160
|
+
const log = o.log ?? ((m) => console.error(m));
|
|
161
|
+
const loginFailures = new RateLimiter({ perSecond: 1 / 30, burst: 8 });
|
|
162
|
+
const registrations = new RateLimiter({ perSecond: 1 / 600, burst: 5 });
|
|
163
|
+
const base = o.publicUrl.endsWith("/") ? o.publicUrl : `${o.publicUrl}/`;
|
|
164
|
+
const cookieName = "custody_session";
|
|
165
|
+
const sheet = (tenant, logId) => welcomeSheet({ tenant, logId, publicUrl: base, ...(o.checkpointsUrl ? { checkpointsUrl: o.checkpointsUrl } : {}), ...(o.keyid ? { keyid: o.keyid } : {}) });
|
|
166
|
+
const exportCommand = (tenant) => `npx @agent-custody/receipts log-export --log-url ${base} --tenant ${tenant} --token-env AGENT_CUSTODY_LOG_TOKEN --out custody-export/`;
|
|
167
|
+
return async (req, res) => {
|
|
168
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
169
|
+
const addr = clientAddress(req, o.trustProxy);
|
|
170
|
+
const json = (status, body, headers = {}) => {
|
|
171
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers });
|
|
172
|
+
res.end(JSON.stringify(body));
|
|
173
|
+
};
|
|
174
|
+
const setCookie = (value) => `${cookieName}=${value ?? ""}; Path=/; HttpOnly; SameSite=Strict${o.trustProxy ? "; Secure" : ""}; Max-Age=${value ? 14 * 86_400 : 0}`;
|
|
175
|
+
const cookies = Object.fromEntries((req.headers.cookie ?? "").split(";").map((c) => c.trim().split("=")).filter(([k]) => k));
|
|
176
|
+
const userId = readSession(o.secret, cookies[cookieName]);
|
|
177
|
+
const rawBody = async () => {
|
|
178
|
+
let text = "";
|
|
179
|
+
for await (const chunk of req) {
|
|
180
|
+
text += chunk;
|
|
181
|
+
if (text.length > 65_536)
|
|
182
|
+
throw new Error("body too large");
|
|
183
|
+
}
|
|
184
|
+
return text;
|
|
185
|
+
};
|
|
186
|
+
const jsonBody = async () => {
|
|
187
|
+
if (!(req.headers["content-type"] ?? "").startsWith("application/json"))
|
|
188
|
+
throw new Error("expected a JSON body");
|
|
189
|
+
const t = await rawBody();
|
|
190
|
+
return t ? JSON.parse(t) : {};
|
|
191
|
+
};
|
|
192
|
+
try {
|
|
193
|
+
if (req.method === "GET" && url.pathname === "/health")
|
|
194
|
+
return json(200, { ok: true, stripe: !!o.stripe });
|
|
195
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
196
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-frame-options": "DENY", "content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'" });
|
|
197
|
+
return void res.end(PORTAL_PAGE);
|
|
198
|
+
}
|
|
199
|
+
// ---- Stripe's webhook: the only caller that is not a browser with a session ----
|
|
200
|
+
if (req.method === "POST" && url.pathname === "/stripe/webhook") {
|
|
201
|
+
if (!o.stripe)
|
|
202
|
+
return json(503, { error: "billing is not configured" });
|
|
203
|
+
const raw = await rawBody();
|
|
204
|
+
if (!verifyStripeSignature(req.headers["stripe-signature"], raw, o.stripe.webhookSecret))
|
|
205
|
+
return json(400, { error: "bad signature" });
|
|
206
|
+
const event = JSON.parse(raw);
|
|
207
|
+
const obj = event.data.object;
|
|
208
|
+
if (event.type === "checkout.session.completed") {
|
|
209
|
+
const tenant = String(obj.client_reference_id ?? "");
|
|
210
|
+
if (tenant && (await o.tenancy.tenant(tenant))) {
|
|
211
|
+
await store.setBilling(tenant, { customerId: obj.customer ? String(obj.customer) : null, subscriptionId: obj.subscription ? String(obj.subscription) : null, status: "active" });
|
|
212
|
+
await o.tenancy.setPlan(tenant, "team", "stripe:checkout");
|
|
213
|
+
log(`agent-custody portal: tenant ${tenant} moved to team by checkout ${String(obj.id ?? "")}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else if (event.type === "customer.subscription.deleted" || event.type === "customer.subscription.updated") {
|
|
217
|
+
const tenant = await store.tenantBySubscription(String(obj.id ?? ""));
|
|
218
|
+
if (tenant) {
|
|
219
|
+
const status = event.type === "customer.subscription.deleted" ? "canceled" : String(obj.status ?? "unknown");
|
|
220
|
+
await store.setBilling(tenant, { status });
|
|
221
|
+
const plan = status === "active" || status === "trialing" || status === "past_due" ? "team" : "free";
|
|
222
|
+
const current = (await o.tenancy.tenant(tenant))?.plan;
|
|
223
|
+
if (current !== "enterprise" && current !== plan)
|
|
224
|
+
await o.tenancy.setPlan(tenant, plan, `stripe:${event.type}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return json(200, { received: true });
|
|
228
|
+
}
|
|
229
|
+
// ---- registration and login ----
|
|
230
|
+
if (req.method === "POST" && url.pathname === "/api/register") {
|
|
231
|
+
if (!registrations.take(`reg:${addr}`))
|
|
232
|
+
return json(429, { error: "too many registrations from this address; try again later" });
|
|
233
|
+
const b = await jsonBody();
|
|
234
|
+
const email = String(b.email ?? "").trim().toLowerCase();
|
|
235
|
+
const password = String(b.password ?? "");
|
|
236
|
+
const tenant = String(b.tenant ?? "").trim().toLowerCase();
|
|
237
|
+
if (!EMAIL.test(email))
|
|
238
|
+
return json(400, { error: "a valid email address is needed" });
|
|
239
|
+
if (password.length < 10)
|
|
240
|
+
return json(400, { error: "the password needs at least ten characters" });
|
|
241
|
+
if (!TENANT_ID.test(tenant) || RESERVED.has(tenant))
|
|
242
|
+
return json(400, { error: "the tenant id is the name in your log's URL: three to forty lowercase letters, digits, or hyphens, and not a reserved word" });
|
|
243
|
+
if (await o.tenancy.tenant(tenant))
|
|
244
|
+
return json(409, { error: "that tenant id is taken" });
|
|
245
|
+
let user;
|
|
246
|
+
try {
|
|
247
|
+
user = await store.createUser(email, password);
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
return json(409, { error: e instanceof Error ? e.message : String(e) });
|
|
251
|
+
}
|
|
252
|
+
const t = await o.tenancy.addTenant(tenant, tenant, `portal:${email}`);
|
|
253
|
+
await store.addMember(user.id, tenant);
|
|
254
|
+
const minted = await o.tenancy.addToken(tenant, "first key", `portal:${email}`);
|
|
255
|
+
log(`agent-custody portal: ${email} registered tenant ${tenant}`);
|
|
256
|
+
return json(200, { tenant: t.id, logId: t.logId, plan: t.plan, token: minted.token, tokenHash: minted.tokenHash.slice(0, 12), welcome: sheet(t.id, t.logId), exportCommand: exportCommand(t.id) }, { "set-cookie": setCookie(signSession(o.secret, user.id)) });
|
|
257
|
+
}
|
|
258
|
+
if (req.method === "POST" && url.pathname === "/api/login") {
|
|
259
|
+
if (!loginFailures.take(`login:${addr}`))
|
|
260
|
+
return json(429, { error: "too many attempts; wait a minute" });
|
|
261
|
+
const b = await jsonBody();
|
|
262
|
+
const user = await store.authenticate(String(b.email ?? "").trim().toLowerCase(), String(b.password ?? ""));
|
|
263
|
+
if (!user)
|
|
264
|
+
return json(401, { error: "email or password not recognised" });
|
|
265
|
+
return json(200, { email: user.email }, { "set-cookie": setCookie(signSession(o.secret, user.id)) });
|
|
266
|
+
}
|
|
267
|
+
if (req.method === "POST" && url.pathname === "/api/logout")
|
|
268
|
+
return json(200, { ok: true }, { "set-cookie": setCookie(null) });
|
|
269
|
+
// ---- everything below needs a session ----
|
|
270
|
+
if (!url.pathname.startsWith("/api/"))
|
|
271
|
+
return json(404, { error: "not found" });
|
|
272
|
+
const user = userId ? await store.user(userId) : null;
|
|
273
|
+
if (!user)
|
|
274
|
+
return json(401, { error: "sign in first" });
|
|
275
|
+
const tenantId = await store.tenantOf(user.id);
|
|
276
|
+
if (!tenantId)
|
|
277
|
+
return json(409, { error: "this account has no tenant" });
|
|
278
|
+
const tenant = await o.tenancy.tenant(tenantId);
|
|
279
|
+
if (!tenant)
|
|
280
|
+
return json(409, { error: "the tenant no longer exists" });
|
|
281
|
+
if (req.method !== "GET" && !(req.headers["content-type"] ?? "").startsWith("application/json"))
|
|
282
|
+
return json(415, { error: "expected a JSON body" });
|
|
283
|
+
if (req.method === "GET" && url.pathname === "/api/me") {
|
|
284
|
+
const q = await o.tenancy.quota(tenantId);
|
|
285
|
+
return json(200, { email: user.email, tenant: tenantId, logId: tenant.logId, plan: q.plan, used: q.used, quota: q.quota, disabled: !!tenant.disabledAt, billing: !!o.stripe });
|
|
286
|
+
}
|
|
287
|
+
if (req.method === "GET" && url.pathname === "/api/overview") {
|
|
288
|
+
const q = await o.tenancy.quota(tenantId);
|
|
289
|
+
const backend = await o.tenancy.log(tenantId);
|
|
290
|
+
const size = await backend.size();
|
|
291
|
+
const latest = await heads.latest(tenantId);
|
|
292
|
+
const months = [];
|
|
293
|
+
for (let i = 5; i >= 0; i--) {
|
|
294
|
+
const d = new Date();
|
|
295
|
+
const month = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - i, 1)).toISOString().slice(0, 7);
|
|
296
|
+
const u = await o.tenancy.usage(month);
|
|
297
|
+
months.push({ month, appends: u.tenants.find((t) => t.id === tenantId)?.appends ?? 0 });
|
|
298
|
+
}
|
|
299
|
+
const keys = (await o.tenancy.listTokens(tenantId)).map((k) => ({ label: k.label, hash: k.tokenHash.slice(0, 12), createdAt: k.createdAt, revokedAt: k.revokedAt }));
|
|
300
|
+
const audit = await o.tenancy.audit({ tenant: tenantId, limit: 50 });
|
|
301
|
+
const billing = await store.billing(tenantId);
|
|
302
|
+
return json(200, { tenant: tenantId, logId: tenant.logId, plan: q.plan, used: q.used, quota: q.quota, treeSize: size, rootHash: size ? await backend.root(size) : null, latestCheckpoint: latest ? { treeSize: latest.treeSize, signedAt: latest.signedAt } : null, months, keys, audit, billing: billing ? { status: billing.status } : null, urls: { log: `${base}t/${tenantId}/`, keys: `${base}.well-known/agent-custody-log.json`, checkpoints: o.checkpointsUrl ? `${o.checkpointsUrl.replace(/\/?$/, "/")}${tenantId}/latest.json` : null }, exportCommand: exportCommand(tenantId), welcome: sheet(tenantId, tenant.logId), stripe: !!o.stripe });
|
|
303
|
+
}
|
|
304
|
+
if (req.method === "GET" && url.pathname === "/api/keys") {
|
|
305
|
+
return json(200, { keys: (await o.tenancy.listTokens(tenantId)).map((k) => ({ label: k.label, hash: k.tokenHash.slice(0, 12), createdAt: k.createdAt, revokedAt: k.revokedAt })) });
|
|
306
|
+
}
|
|
307
|
+
if (req.method === "POST" && url.pathname === "/api/keys") {
|
|
308
|
+
const b = await jsonBody();
|
|
309
|
+
const label = String(b.label ?? "").trim().slice(0, 64) || "key";
|
|
310
|
+
const minted = await o.tenancy.addToken(tenantId, label, `portal:${user.email}`);
|
|
311
|
+
return json(200, { token: minted.token, tokenHash: minted.tokenHash.slice(0, 12), label });
|
|
312
|
+
}
|
|
313
|
+
const revoke = /^\/api\/keys\/([0-9a-f]{8,64})\/revoke$/.exec(url.pathname);
|
|
314
|
+
if (req.method === "POST" && revoke) {
|
|
315
|
+
await jsonBody();
|
|
316
|
+
return json(200, { revoked: await o.tenancy.revokeToken(tenantId, revoke[1], `portal:${user.email}`) });
|
|
317
|
+
}
|
|
318
|
+
if (req.method === "POST" && url.pathname === "/api/checkout") {
|
|
319
|
+
if (!o.stripe)
|
|
320
|
+
return json(503, { error: "billing is not configured on this portal yet; email us and we move the plan by hand" });
|
|
321
|
+
await jsonBody();
|
|
322
|
+
if (tenant.plan !== "free")
|
|
323
|
+
return json(409, { error: `this tenant is already on the ${tenant.plan} plan` });
|
|
324
|
+
const portalUrl = (o.portalUrl ?? "http://localhost/").replace(/\/?$/, "/");
|
|
325
|
+
const session = await stripeRequest(o.stripe, "checkout/sessions", { mode: "subscription", "line_items[0][price]": o.stripe.priceTeam, "line_items[0][quantity]": "1", client_reference_id: tenantId, customer_email: user.email, success_url: `${portalUrl}?upgraded=1`, cancel_url: `${portalUrl}?cancelled=1`, "metadata[tenant]": tenantId });
|
|
326
|
+
return json(200, { url: String(session.url) });
|
|
327
|
+
}
|
|
328
|
+
if (req.method === "POST" && url.pathname === "/api/billing-portal") {
|
|
329
|
+
if (!o.stripe)
|
|
330
|
+
return json(503, { error: "billing is not configured" });
|
|
331
|
+
await jsonBody();
|
|
332
|
+
const b = await store.billing(tenantId);
|
|
333
|
+
if (!b?.customerId)
|
|
334
|
+
return json(409, { error: "no billing record for this tenant" });
|
|
335
|
+
const session = await stripeRequest(o.stripe, "billing_portal/sessions", { customer: b.customerId, return_url: (o.portalUrl ?? "http://localhost/").replace(/\/?$/, "/") });
|
|
336
|
+
return json(200, { url: String(session.url) });
|
|
337
|
+
}
|
|
338
|
+
return json(404, { error: "not found" });
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
if (!res.headersSent)
|
|
342
|
+
json(500, { error: e instanceof Error ? e.message : String(e) });
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
export function servePortal(o, opts) {
|
|
347
|
+
const host = opts.host ?? "127.0.0.1";
|
|
348
|
+
const handler = portalHandler(o);
|
|
349
|
+
const server = createServer((req, res) => void handler(req, res));
|
|
350
|
+
return new Promise((resolve) => {
|
|
351
|
+
server.listen(opts.port, host, () => {
|
|
352
|
+
const { port } = server.address();
|
|
353
|
+
resolve({ url: `http://${host}:${port}/`, close: () => new Promise((r) => { server.closeAllConnections?.(); server.close(() => r()); }) });
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// ---- the page: one file, no framework, no outside requests ----
|
|
358
|
+
const PORTAL_PAGE = `<!doctype html>
|
|
359
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
360
|
+
<title>agent-custody</title>
|
|
361
|
+
<style>
|
|
362
|
+
:root { color-scheme: light dark; --ink: #1b2430; --ink2: #5b6b7a; --line: #d7dfe5; --bg: #f5f7f9; --panel: #ffffff; --accent: #0f6e63; --accent-bg: #e8f3f1; --warn: #b3731a; --bad: #b3261e; --ok: #1f7a4d; --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
|
|
363
|
+
@media (prefers-color-scheme: dark) { :root { --ink: #e6ecf0; --ink2: #9fb0bd; --line: #27333c; --bg: #0e1418; --panel: #151d23; --accent: #4fc3b0; --accent-bg: #16302b; --warn: #e2b862; --bad: #ff8a80; --ok: #6fd39a; } }
|
|
364
|
+
* { box-sizing: border-box; }
|
|
365
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
366
|
+
.top { display: flex; align-items: center; gap: 1rem; padding: .7rem 1.25rem; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
367
|
+
.brand { font-weight: 700; letter-spacing: .04em; } .brand b { color: var(--accent); }
|
|
368
|
+
.top .who { margin-left: auto; color: var(--ink2); font-size: .9rem; }
|
|
369
|
+
.pill { display: inline-block; padding: .05rem .5rem; border-radius: 999px; font: 600 .72rem/1.6 var(--mono); letter-spacing: .06em; text-transform: uppercase; background: var(--accent-bg); color: var(--accent); }
|
|
370
|
+
.layout { display: grid; grid-template-columns: 15rem 1fr; min-height: calc(100vh - 3.3rem); }
|
|
371
|
+
nav { border-right: 1px solid var(--line); background: var(--panel); padding: 1rem 0; }
|
|
372
|
+
nav .group { font: 600 .68rem/1.4 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--accent); padding: 1rem 1.25rem .35rem; }
|
|
373
|
+
nav a { display: flex; justify-content: space-between; padding: .45rem 1.25rem; color: var(--ink); text-decoration: none; border-left: 3px solid transparent; }
|
|
374
|
+
nav a.on { border-left-color: var(--accent); background: var(--accent-bg); }
|
|
375
|
+
nav a span.n { color: var(--ink2); font-family: var(--mono); font-size: .8rem; }
|
|
376
|
+
main { padding: 1.5rem; max-width: 72rem; }
|
|
377
|
+
h1 { font-size: 1.25rem; margin: 0 0 1rem; } h2 { font: 600 .72rem/1.4 var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink2); margin: 1.5rem 0 .6rem; }
|
|
378
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: .9rem; }
|
|
379
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-top: 3px solid var(--accent); border-radius: 6px; padding: 1rem 1.1rem; }
|
|
380
|
+
.card.warn { border-top-color: var(--warn); } .card.bad { border-top-color: var(--bad); } .card.ok { border-top-color: var(--ok); }
|
|
381
|
+
.card .k { font: 600 .68rem/1.4 var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--ink2); }
|
|
382
|
+
.card .v { font-size: 1.9rem; font-weight: 700; margin: .2rem 0 0; font-variant-numeric: tabular-nums; }
|
|
383
|
+
.card .s { color: var(--ink2); font-size: .88rem; }
|
|
384
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 1rem 1.1rem; margin-top: .9rem; }
|
|
385
|
+
table { border-collapse: collapse; width: 100%; font-size: .92rem; }
|
|
386
|
+
th, td { text-align: left; padding: .45rem .5rem .45rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
387
|
+
th { font: 600 .68rem/1.4 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--ink2); }
|
|
388
|
+
code, pre, .mono { font-family: var(--mono); font-size: .86em; }
|
|
389
|
+
pre { background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: .8rem .9rem; overflow-x: auto; white-space: pre-wrap; }
|
|
390
|
+
.bars { display: grid; grid-template-columns: repeat(6, 1fr); gap: .6rem; align-items: end; height: 9rem; }
|
|
391
|
+
.bar { display: flex; flex-direction: column; justify-content: flex-end; align-items: center; height: 100%; font-size: .75rem; color: var(--ink2); }
|
|
392
|
+
.bar i { display: block; width: 70%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; }
|
|
393
|
+
.bar b { font-family: var(--mono); font-weight: 400; margin-top: .3rem; }
|
|
394
|
+
label { display: grid; gap: .25rem; font-size: .85rem; color: var(--ink2); margin: 0 0 .8rem; }
|
|
395
|
+
input, select { font: inherit; padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); color: var(--ink); }
|
|
396
|
+
button { font: inherit; padding: .5rem .9rem; border-radius: 4px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
|
|
397
|
+
button.quiet { background: transparent; color: var(--accent); }
|
|
398
|
+
button.link { background: none; border: 0; padding: 0; color: var(--accent); text-decoration: underline; }
|
|
399
|
+
.auth { max-width: 26rem; margin: 4rem auto; }
|
|
400
|
+
.once { border: 1px solid var(--warn); background: color-mix(in srgb, var(--warn) 10%, var(--panel)); border-radius: 6px; padding: 1rem 1.1rem; margin: 1rem 0; }
|
|
401
|
+
.tok { font-family: var(--mono); word-break: break-all; padding: .6rem; background: var(--bg); border-radius: 4px; }
|
|
402
|
+
.msg { min-height: 1.4rem; margin: .6rem 0; color: var(--ink2); } .msg.err { color: var(--bad); } .msg.ok { color: var(--ok); }
|
|
403
|
+
.muted { color: var(--ink2); }
|
|
404
|
+
[hidden] { display: none !important; }
|
|
405
|
+
@media (max-width: 48rem) { .layout { grid-template-columns: 1fr; } nav { display: flex; flex-wrap: wrap; padding: .3rem; border-right: 0; border-bottom: 1px solid var(--line); } nav .group { display: none; } nav a { border-left: 0; border-bottom: 3px solid transparent; } nav a.on { border-bottom-color: var(--accent); } }
|
|
406
|
+
</style>
|
|
407
|
+
<div class="top"><span class="brand"><b>◆</b> agent-custody</span><span id="tenantTag" class="pill" hidden></span><span id="planTag" class="pill" hidden></span><span class="who" id="who"></span></div>
|
|
408
|
+
<section id="auth" class="auth" hidden>
|
|
409
|
+
<h1 id="authTitle">Sign in</h1>
|
|
410
|
+
<form id="authForm">
|
|
411
|
+
<label>Email<input id="email" type="email" autocomplete="email" required></label>
|
|
412
|
+
<label>Password<input id="password" type="password" autocomplete="current-password" minlength="10" required></label>
|
|
413
|
+
<label id="tenantField" hidden>Tenant id, the name in your log's URL<input id="tenant" placeholder="acme" pattern="[a-z0-9][a-z0-9-]{1,38}[a-z0-9]"></label>
|
|
414
|
+
<button id="authGo" type="submit">Sign in</button>
|
|
415
|
+
<p class="msg" id="authMsg"></p>
|
|
416
|
+
</form>
|
|
417
|
+
<p class="muted"><button class="link" id="authSwap" type="button">Create an account and a tenant instead</button></p>
|
|
418
|
+
<p class="muted">The free plan is ten thousand appends a month, no card. Your gateway sends only hashes; nothing you log here can be read by us.</p>
|
|
419
|
+
</section>
|
|
420
|
+
<section id="welcome" class="auth" hidden>
|
|
421
|
+
<h1>Your tenant is ready</h1>
|
|
422
|
+
<div class="once"><p><b>Your first API key, shown once.</b> Put it in the environment your gateway reads as <code>AGENT_CUSTODY_LOG_TOKEN</code>. We keep only its hash.</p><p class="tok" id="firstToken"></p><button class="quiet" id="copyFirst">Copy key</button></div>
|
|
423
|
+
<h2>Your welcome sheet</h2>
|
|
424
|
+
<pre id="firstSheet"></pre>
|
|
425
|
+
<button id="toDash">Go to the dashboard</button>
|
|
426
|
+
</section>
|
|
427
|
+
<div class="layout" id="app" hidden>
|
|
428
|
+
<nav>
|
|
429
|
+
<div class="group">Monitor</div>
|
|
430
|
+
<a href="#overview" data-view="overview">Overview</a>
|
|
431
|
+
<a href="#usage" data-view="usage">Usage</a>
|
|
432
|
+
<div class="group">Configure</div>
|
|
433
|
+
<a href="#keys" data-view="keys">API keys <span class="n" id="nKeys"></span></a>
|
|
434
|
+
<a href="#billing" data-view="billing">Billing</a>
|
|
435
|
+
<a href="#export" data-view="export">Export</a>
|
|
436
|
+
<div class="group">Account</div>
|
|
437
|
+
<a href="#" id="signout">Sign out</a>
|
|
438
|
+
</nav>
|
|
439
|
+
<main>
|
|
440
|
+
<div data-pane="overview">
|
|
441
|
+
<h1>Overview</h1>
|
|
442
|
+
<div class="cards">
|
|
443
|
+
<div class="card" id="cUsed"><div class="k">Appends this month</div><div class="v" id="vUsed">–</div><div class="s" id="sUsed"></div></div>
|
|
444
|
+
<div class="card" id="cSize"><div class="k">Receipts in your log</div><div class="v" id="vSize">–</div><div class="s">leaf hashes, all time</div></div>
|
|
445
|
+
<div class="card" id="cKeys"><div class="k">Live keys</div><div class="v" id="vKeys">–</div><div class="s" id="sKeys"></div></div>
|
|
446
|
+
<div class="card" id="cCp"><div class="k">Latest checkpoint</div><div class="v" id="vCp">–</div><div class="s" id="sCp"></div></div>
|
|
447
|
+
</div>
|
|
448
|
+
<h2>Your log</h2>
|
|
449
|
+
<div class="panel"><table><tbody id="urls"></tbody></table></div>
|
|
450
|
+
<h2>Recent activity</h2>
|
|
451
|
+
<div class="panel"><table><thead><tr><th>when</th><th>who</th><th>action</th><th>detail</th></tr></thead><tbody id="audit"></tbody></table></div>
|
|
452
|
+
</div>
|
|
453
|
+
<div data-pane="usage" hidden>
|
|
454
|
+
<h1>Usage</h1>
|
|
455
|
+
<div class="panel"><div class="bars" id="bars"></div></div>
|
|
456
|
+
<p class="muted" id="usageNote"></p>
|
|
457
|
+
</div>
|
|
458
|
+
<div data-pane="keys" hidden>
|
|
459
|
+
<h1>API keys</h1>
|
|
460
|
+
<p class="muted">A key is a bearer token your gateway presents on append. It is shown once when minted; we keep only its hash. Mint a second key before revoking the first to rotate without a gap.</p>
|
|
461
|
+
<div class="panel"><form id="mintForm" style="display:flex;gap:.6rem;align-items:end;flex-wrap:wrap"><label style="margin:0">Label<input id="label" placeholder="support fleet"></label><button type="submit">Mint key</button></form>
|
|
462
|
+
<div id="minted" class="once" hidden><p><b>Shown once.</b></p><p class="tok" id="mintedTok"></p><button class="quiet" id="copyMinted">Copy key</button></div>
|
|
463
|
+
<table style="margin-top:1rem"><thead><tr><th>label</th><th>hash</th><th>created</th><th>state</th><th></th></tr></thead><tbody id="keys"></tbody></table></div>
|
|
464
|
+
<p class="msg" id="keysMsg"></p>
|
|
465
|
+
</div>
|
|
466
|
+
<div data-pane="billing" hidden>
|
|
467
|
+
<h1>Billing</h1>
|
|
468
|
+
<div class="panel" id="billingPanel"></div>
|
|
469
|
+
<p class="msg" id="billingMsg"></p>
|
|
470
|
+
</div>
|
|
471
|
+
<div data-pane="export" hidden>
|
|
472
|
+
<h1>Export</h1>
|
|
473
|
+
<p class="muted">Everything the log holds about you, any time, with your key: every leaf hash as a log file the verifier reads offline, the signed head, the published keys, the checkpoints, your usage, and the actions taken on your tenant. It checks itself before writing.</p>
|
|
474
|
+
<pre id="exportCmd"></pre>
|
|
475
|
+
<h2>Welcome sheet</h2>
|
|
476
|
+
<pre id="sheet"></pre>
|
|
477
|
+
</div>
|
|
478
|
+
</main>
|
|
479
|
+
</div>
|
|
480
|
+
<script>
|
|
481
|
+
(() => {
|
|
482
|
+
const $ = (id) => document.getElementById(id);
|
|
483
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
484
|
+
const api = async (method, path, body) => {
|
|
485
|
+
const r = await fetch(path, { method, headers: body !== undefined ? { "content-type": "application/json" } : {}, body: body !== undefined ? JSON.stringify(body) : undefined, credentials: "same-origin" });
|
|
486
|
+
const j = await r.json().catch(() => ({}));
|
|
487
|
+
if (!r.ok) { const e = new Error(j.error || r.statusText); e.status = r.status; throw e; }
|
|
488
|
+
return j;
|
|
489
|
+
};
|
|
490
|
+
const fmt = (n) => Number(n).toLocaleString();
|
|
491
|
+
const ago = (iso) => { const m = Math.round((Date.now() - Date.parse(iso)) / 60000); return m < 60 ? m + " min ago" : m < 1440 ? Math.round(m / 60) + " h ago" : Math.round(m / 1440) + " d ago"; };
|
|
492
|
+
let registering = false;
|
|
493
|
+
const show = (id) => { for (const s of ["auth", "welcome", "app"]) $(s).hidden = s !== id; };
|
|
494
|
+
const view = (name) => {
|
|
495
|
+
for (const p of document.querySelectorAll("[data-pane]")) p.hidden = p.dataset.pane !== name;
|
|
496
|
+
for (const a of document.querySelectorAll("nav a[data-view]")) a.classList.toggle("on", a.dataset.view === name);
|
|
497
|
+
location.hash = name;
|
|
498
|
+
};
|
|
499
|
+
$("authSwap").onclick = () => { registering = !registering; $("authTitle").textContent = registering ? "Create your tenant" : "Sign in"; $("authGo").textContent = registering ? "Create tenant" : "Sign in"; $("tenantField").hidden = !registering; $("tenant").required = registering; $("password").autocomplete = registering ? "new-password" : "current-password"; $("authSwap").textContent = registering ? "I already have an account" : "Create an account and a tenant instead"; };
|
|
500
|
+
$("authForm").onsubmit = async (e) => {
|
|
501
|
+
e.preventDefault(); $("authMsg").className = "msg"; $("authMsg").textContent = "";
|
|
502
|
+
try {
|
|
503
|
+
if (registering) {
|
|
504
|
+
const r = await api("POST", "/api/register", { email: $("email").value, password: $("password").value, tenant: $("tenant").value });
|
|
505
|
+
$("firstToken").textContent = r.token; $("firstSheet").textContent = r.welcome; show("welcome");
|
|
506
|
+
} else { await api("POST", "/api/login", { email: $("email").value, password: $("password").value }); await enter(); }
|
|
507
|
+
} catch (err) { $("authMsg").className = "msg err"; $("authMsg").textContent = err.message; }
|
|
508
|
+
};
|
|
509
|
+
$("copyFirst").onclick = () => navigator.clipboard.writeText($("firstToken").textContent);
|
|
510
|
+
$("toDash").onclick = () => enter();
|
|
511
|
+
$("signout").onclick = async (e) => { e.preventDefault(); await api("POST", "/api/logout", {}); location.hash = ""; show("auth"); };
|
|
512
|
+
for (const a of document.querySelectorAll("nav a[data-view]")) a.onclick = (e) => { e.preventDefault(); view(a.dataset.view); };
|
|
513
|
+
const render = (o) => {
|
|
514
|
+
$("tenantTag").textContent = o.tenant; $("tenantTag").hidden = false; $("planTag").textContent = o.plan + " plan"; $("planTag").hidden = false;
|
|
515
|
+
const pct = o.quota ? o.used / o.quota : 0;
|
|
516
|
+
$("cUsed").className = "card " + (o.quota === null ? "ok" : pct >= 1 ? "bad" : pct >= .8 ? "warn" : "ok");
|
|
517
|
+
$("vUsed").textContent = fmt(o.used); $("sUsed").textContent = o.quota === null ? "no allowance on " + o.plan : "of " + fmt(o.quota) + " on " + o.plan + (pct >= 1 ? ": appends are refused until next month" : "");
|
|
518
|
+
$("vSize").textContent = fmt(o.treeSize);
|
|
519
|
+
const live = o.keys.filter((k) => !k.revokedAt).length; $("vKeys").textContent = live; $("sKeys").textContent = o.keys.length - live + " revoked"; $("nKeys").textContent = live;
|
|
520
|
+
$("cCp").className = "card " + (o.latestCheckpoint ? (Date.now() - Date.parse(o.latestCheckpoint.signedAt) < 7 * 3600e3 ? "ok" : "warn") : "warn");
|
|
521
|
+
$("vCp").textContent = o.latestCheckpoint ? "size " + fmt(o.latestCheckpoint.treeSize) : "none yet"; $("sCp").textContent = o.latestCheckpoint ? "signed " + ago(o.latestCheckpoint.signedAt) : "published after your first append";
|
|
522
|
+
$("urls").innerHTML = [["Append here", o.urls.log], ["Log id on tree heads", o.logId], ["The log's keys", o.urls.keys], ["Your checkpoints", o.urls.checkpoints || "(not published)"], ["Current root", o.rootHash || "(empty)"]].map(([k, v]) => "<tr><th>" + esc(k) + "</th><td class=mono>" + esc(v) + "</td></tr>").join("");
|
|
523
|
+
$("audit").innerHTML = o.audit.map((e) => "<tr><td>" + esc(e.at.replace("T", " ").slice(0, 16)) + "</td><td class=mono>" + esc(e.actor) + "</td><td>" + esc(e.action) + "</td><td class=muted>" + esc(Object.entries(e.detail).map(([k, v]) => k + "=" + v).join(" ")) + "</td></tr>").join("") || "<tr><td colspan=4 class=muted>nothing yet</td></tr>";
|
|
524
|
+
const max = Math.max(1, ...o.months.map((m) => m.appends));
|
|
525
|
+
$("bars").innerHTML = o.months.map((m) => "<div class=bar><span class=mono>" + fmt(m.appends) + "</span><i style=\\"height:" + Math.max(2, Math.round(100 * m.appends / max)) + "%\\"></i><b>" + esc(m.month.slice(2)) + "</b></div>").join("");
|
|
526
|
+
$("usageNote").textContent = "Appends per calendar month, UTC. Your plan allows " + (o.quota === null ? "any number" : fmt(o.quota)) + " a month; the count resets on the first.";
|
|
527
|
+
$("keys").innerHTML = o.keys.map((k) => "<tr><td>" + esc(k.label) + "</td><td class=mono>" + esc(k.hash) + "</td><td>" + esc(k.createdAt.slice(0, 10)) + "</td><td>" + (k.revokedAt ? "revoked " + esc(k.revokedAt.slice(0, 10)) : "live") + "</td><td>" + (k.revokedAt ? "" : "<button class=quiet data-revoke=\\"" + esc(k.hash) + "\\">Revoke</button>") + "</td></tr>").join("");
|
|
528
|
+
$("billingPanel").innerHTML = o.plan === "free"
|
|
529
|
+
? "<p>You are on the <b>free</b> plan: ten thousand appends a month, no card.</p><p>The <b>team</b> plan is <b>$50 a month</b>: a million appends, email support within two working days, the same export and audit trail. No availability commitment yet, and the design fails closed: when the log is unreachable your gateway withholds pre-committed calls.</p>" + (o.stripe ? "<button id=upgrade>Upgrade to team, $50/month</button>" : "<p class=muted>Card payments are not switched on for this portal yet; email us and we move the plan by hand.</p>")
|
|
530
|
+
: "<p>You are on the <b>" + esc(o.plan) + "</b> plan" + (o.billing ? " (subscription " + esc(o.billing.status) + ")" : "") + ".</p>" + (o.stripe && o.billing ? "<button class=quiet id=manage>Manage billing</button>" : "");
|
|
531
|
+
$("exportCmd").textContent = o.exportCommand; $("sheet").textContent = o.welcome;
|
|
532
|
+
const up = $("upgrade"); if (up) up.onclick = async () => { try { const r = await api("POST", "/api/checkout", {}); location.href = r.url; } catch (err) { $("billingMsg").className = "msg err"; $("billingMsg").textContent = err.message; } };
|
|
533
|
+
const mg = $("manage"); if (mg) mg.onclick = async () => { try { const r = await api("POST", "/api/billing-portal", {}); location.href = r.url; } catch (err) { $("billingMsg").className = "msg err"; $("billingMsg").textContent = err.message; } };
|
|
534
|
+
};
|
|
535
|
+
const load = async () => render(await api("GET", "/api/overview"));
|
|
536
|
+
const enter = async () => {
|
|
537
|
+
try { const me = await api("GET", "/api/me"); $("who").textContent = me.email; show("app"); await load(); view((location.hash || "#overview").slice(1) || "overview"); }
|
|
538
|
+
catch (err) { if (err.status === 401) show("auth"); else { show("app"); $("who").textContent = err.message; } }
|
|
539
|
+
};
|
|
540
|
+
$("mintForm").onsubmit = async (e) => { e.preventDefault(); try { const r = await api("POST", "/api/keys", { label: $("label").value }); $("mintedTok").textContent = r.token; $("minted").hidden = false; $("keysMsg").className = "msg ok"; $("keysMsg").textContent = "minted " + r.label + ", stored as hash " + r.tokenHash; await load(); } catch (err) { $("keysMsg").className = "msg err"; $("keysMsg").textContent = err.message; } };
|
|
541
|
+
$("copyMinted").onclick = () => navigator.clipboard.writeText($("mintedTok").textContent);
|
|
542
|
+
document.addEventListener("click", async (e) => { const b = e.target.closest("button[data-revoke]"); if (!b) return; if (!confirm("Revoke key " + b.dataset.revoke + "? A gateway using it stops appending at once.")) return; try { await api("POST", "/api/keys/" + b.dataset.revoke + "/revoke", {}); $("keysMsg").className = "msg ok"; $("keysMsg").textContent = "revoked"; await load(); } catch (err) { $("keysMsg").className = "msg err"; $("keysMsg").textContent = err.message; } });
|
|
543
|
+
if (new URLSearchParams(location.search).get("upgraded")) history.replaceState(null, "", "/#billing");
|
|
544
|
+
enter();
|
|
545
|
+
})();
|
|
546
|
+
</script>
|
|
547
|
+
`;
|
package/dist/verify.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Independent verification of a receipt bundle. Needs only public keys, and optionally a copy of the log.
|
|
2
2
|
import { canonicalize, digestOf, dsseVerifiers, dsseVerify } from "./crypto.js";
|
|
3
|
-
import { delegationValidAt, verifyDelegation } from "./delegation.js";
|
|
3
|
+
import { decodeDelegation, delegationValidAt, describeChain, verifyDelegation } from "./delegation.js";
|
|
4
4
|
import { leafHash, MerkleLog, verifyConsistency, verifyInclusion } from "./log.js";
|
|
5
5
|
import { checkProvider, checkUpstream, contentDigest, isProviderAttestation } from "./upstream.js";
|
|
6
6
|
import { AUTHORIZATION_PREDICATE_TYPE, RECEIPT_PREDICATE_TYPE, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
|
|
@@ -31,6 +31,11 @@ export function verifyBundle(bundle, opts) {
|
|
|
31
31
|
if (p.delegation) {
|
|
32
32
|
const del = verifyDelegation(p.delegation.envelope, opts.principalKeys);
|
|
33
33
|
add("delegation signature (principal key)", del.ok, del.ok ? `signed by ${short(del.keyid)}` : del.error);
|
|
34
|
+
// A chained grant: every link signed by the key its parent names, scopes and windows nested, one principal
|
|
35
|
+
// throughout. The line appears only when the grant embeds a parent, and fails with the link that broke.
|
|
36
|
+
const chained = decodeDelegation(p.delegation.envelope)?.parent !== undefined;
|
|
37
|
+
if (chained)
|
|
38
|
+
add("delegation chain to the principal", del.ok, del.ok ? `${describeChain(del.chain)} (${del.chain.length - 1} delegation(s))` : del.error);
|
|
34
39
|
if (del.ok) {
|
|
35
40
|
const d = del.delegation;
|
|
36
41
|
const principalKeyid = p.principal.provenance === "attested" ? p.principal.keyid : null;
|
package/docs/tutorials.md
CHANGED
|
@@ -29,6 +29,7 @@ Suggested reading order is the numbering. Output lands in `examples-out/`, which
|
|
|
29
29
|
| 17 | a REST API as an upstream | [17-rest-upstream.ts](../examples/17-rest-upstream.ts) | a stand-in payments API described as two tools, the token from the environment, a refund allowed on the gateway's own lookup and one denied before reaching the API, the receipt verified | `src/rest.ts`, `src/gateway.ts` |
|
|
30
30
|
| 18 | OpenTelemetry export | [18-opentelemetry.ts](../examples/18-opentelemetry.ts) | a stand-in OTLP collector, `otel` in the config, one span per receipt with the receipt id as trace id, the collector going away and the next receipt still issued | `src/otel.ts`, `src/issue.ts` |
|
|
31
31
|
| 19 | Splunk export | [19-splunk.ts](../examples/19-splunk.ts) | a stand-in HTTP Event Collector, `splunk` in the config with the token from the environment, one event per receipt with the receipt id and log position as fields, the collector going away and the next receipt still issued | `src/splunk.ts`, `src/otel.ts` |
|
|
32
|
+
| 20 | one gateway for many agents, over HTTP | [20-http-gateway.ts](../examples/20-http-gateway.ts) | a gateway host served over Streamable HTTP, two agents connecting with their own grants and seeing their own tools, receipts naming the right agent, a refusal by grant, and a stranger's grant getting no session | `src/gateway-http.ts`, `src/gateway.ts` |
|
|
32
33
|
|
|
33
34
|
## How policies are defined, in one paragraph
|
|
34
35
|
|