@leashkit/grant 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Media Yard LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,56 @@
1
- # Temporary Holding Version
1
+ # @leashkit/grant
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Let your customer set the limits on what your agent may do on their account, and hold the agent to them.
4
+ Guide and a live demo: https://leashkit.com/grants
5
+
6
+ ```js
7
+ import { grantRequestUrl, verifyGrant, fetchGrantStatus, withStatus, evaluate, ZERO_CONSUMPTION, LEASH_GRANT_KEY } from '@leashkit/grant';
8
+
9
+ // 1. Ask. Send the customer here.
10
+ const url = grantRequestUrl({
11
+ v: 1,
12
+ vendor: { id: 'acme-crm', name: 'Acme CRM', class: 'crm' },
13
+ agent: { id: 'acme-assistant' },
14
+ subjectRef: 'cust_8f3a', // your opaque handle, never an email
15
+ scopes: [{ tool: 'email.outbox', action: 'send' }],
16
+ budget: { maxSends: 20 },
17
+ ttlSeconds: 7 * 86400,
18
+ returnUrl: 'https://app.acme.dev/leash/return',
19
+ state: 'abc123',
20
+ });
21
+
22
+ // 2. Receive. The customer comes back with #grant=<token>&state=abc123.
23
+ const signed = await verifyGrant(token, LEASH_GRANT_KEY);
24
+ if (!signed || signed.host !== 'app.acme.dev') throw new Error('not a grant for us');
25
+ let grant = signed.grant;
26
+ let consumed = ZERO_CONSUMPTION; // keep this per grant, in your own storage
27
+
28
+ // 3. Enforce, before every action.
29
+ const status = await fetchGrantStatus(grant.id, fetch); // reuse an answer for up to 60 seconds
30
+ if (status) grant = withStatus(grant, status); // undefined = unknown: keep your last answer
31
+ const result = evaluate({ permit: grant, consumed, scope: { tool: 'email.outbox', action: 'send' }, at: Date.now() });
32
+ consumed = result.consumed;
33
+ if (result.decision.verdict === 'allow') await sendTheEmail();
34
+ // result.decision.summary is a plain sentence for the customer; result.outcome is the record of the decision.
35
+ ```
36
+
37
+ ## Let the customer see what the agent did (optional)
38
+
39
+ Each decision has a receipt, and each receipt carries the hash of the one before it. Send them, and the customer can
40
+ read the log from their own grant page.
41
+
42
+ ```js
43
+ import { append, sendReceipts } from '@leashkit/grant';
44
+
45
+ chain = append(chain, { permitId: grant.id, decision: result.decision, outcome: result.outcome, at: Date.now() });
46
+ const { ok, stored, problem } = await sendReceipts(permitToken, chain.slice(alreadySent), fetch);
47
+ if (ok) alreadySent = stored;
48
+ ```
49
+
50
+ The signed grant is what entitles you to write to its log. A receipt holds a time, the decision, the tool, the action
51
+ and the ids already in the grant; Leash refuses one that holds anything else. The log is deleted 30 days after the
52
+ grant ends.
53
+
54
+ A grant holds an agent only where your code checks it. Leash signs what the customer allowed and publishes whether
55
+ they switched it off; it is not in the path of your agent's calls. Your counts stay with you, and so do your receipts
56
+ unless you choose to send them.
package/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './permit-client.js';
package/index.js ADDED
@@ -0,0 +1,431 @@
1
+ // src/types.ts
2
+ var ACTION_KINDS = ["read", "write", "send", "delete", "pay"];
3
+ var ZERO_CONSUMPTION = Object.freeze({
4
+ read: 0,
5
+ write: 0,
6
+ send: 0,
7
+ delete: 0,
8
+ pay: 0,
9
+ usd: 0
10
+ });
11
+
12
+ // src/unlock.ts
13
+ var ALG = { name: "ECDSA", namedCurve: "P-256" };
14
+ var SIGN = { name: "ECDSA", hash: "SHA-256" };
15
+ function b64url(bytes) {
16
+ let bin = "";
17
+ for (const b of bytes) bin += String.fromCharCode(b);
18
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
19
+ }
20
+ function fromB64url(text) {
21
+ if (!/^[A-Za-z0-9_-]*$/.test(text)) return void 0;
22
+ try {
23
+ const bin = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - text.length % 4) % 4));
24
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
25
+ } catch {
26
+ return void 0;
27
+ }
28
+ }
29
+ async function verifyJson(token, publicJwk, maxLength = 16384) {
30
+ const [body, sig, extra] = token.split(".");
31
+ if (body === void 0 || sig === void 0 || extra !== void 0 || token.length > maxLength) return void 0;
32
+ const sigBytes = fromB64url(sig);
33
+ const bodyBytes = fromB64url(body);
34
+ if (sigBytes === void 0 || bodyBytes === void 0) return void 0;
35
+ try {
36
+ const key = await crypto.subtle.importKey("jwk", publicJwk, ALG, false, ["verify"]);
37
+ if (!await crypto.subtle.verify(SIGN, key, sigBytes, new TextEncoder().encode(body))) return void 0;
38
+ return JSON.parse(new TextDecoder().decode(bodyBytes));
39
+ } catch {
40
+ return void 0;
41
+ }
42
+ }
43
+ function packJson(value) {
44
+ return b64url(new TextEncoder().encode(JSON.stringify(value)));
45
+ }
46
+ function unpackJson(text) {
47
+ const bytes = fromB64url(text);
48
+ if (bytes === void 0) return void 0;
49
+ try {
50
+ return JSON.parse(new TextDecoder().decode(bytes));
51
+ } catch {
52
+ return void 0;
53
+ }
54
+ }
55
+
56
+ // src/permit.ts
57
+ var MAX_SCOPES = 50;
58
+ var MIN_TTL_SECONDS = 60;
59
+ var MAX_TTL_SECONDS = 90 * 86400;
60
+ var STATUS_MAX_AGE_SECONDS = 60;
61
+ var ID = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,99}$/;
62
+ var TOOL = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/;
63
+ var BUDGET_KEYS = ["maxReads", "maxWrites", "maxSends", "maxDeletes", "maxPayments", "maxUsd"];
64
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
65
+ var isText = (v, max) => typeof v === "string" && v.length > 0 && v.length <= max && !/[\u0000-\u001f\u007f]/.test(v);
66
+ var isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1e9;
67
+ function returnTarget(url) {
68
+ if (typeof url !== "string" || url.length > 600) return void 0;
69
+ let parsed;
70
+ try {
71
+ parsed = new URL(url);
72
+ } catch {
73
+ return void 0;
74
+ }
75
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
76
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && local)) return void 0;
77
+ if (parsed.username !== "" || parsed.password !== "" || parsed.hash !== "") return void 0;
78
+ return parsed;
79
+ }
80
+ function parsePermitRequest(raw) {
81
+ const problems = [];
82
+ const bad = (path, message) => void problems.push({ path, message });
83
+ if (!isObject(raw)) return { request: void 0, problems: [{ path: "", message: "the request must be a JSON object" }] };
84
+ if (raw["v"] !== 1) bad("v", "must be 1");
85
+ const vendor = raw["vendor"];
86
+ if (!isObject(vendor)) bad("vendor", "is missing");
87
+ else {
88
+ if (typeof vendor["id"] !== "string" || !ID.test(vendor["id"])) bad("vendor.id", "is letters, digits and . _ : / @ -, up to 100");
89
+ if (!isText(vendor["name"], 80)) bad("vendor.name", "is 1 to 80 characters");
90
+ if (typeof vendor["class"] !== "string" || !ID.test(vendor["class"])) bad("vendor.class", 'is a short category such as "crm" or "support"');
91
+ }
92
+ const agent = raw["agent"];
93
+ if (!isObject(agent)) bad("agent", "is missing");
94
+ else {
95
+ if (typeof agent["id"] !== "string" || !ID.test(agent["id"])) bad("agent.id", "is letters, digits and . _ : / @ -, up to 100");
96
+ if (agent["model"] !== void 0 && !isText(agent["model"], 80)) bad("agent.model", "is up to 80 characters");
97
+ }
98
+ if (typeof raw["subjectRef"] !== "string" || !ID.test(raw["subjectRef"])) bad("subjectRef", "is an opaque handle: letters, digits and . _ : / -, up to 100");
99
+ else if (raw["subjectRef"].includes("@")) bad("subjectRef", "must not be an email address: use an opaque id");
100
+ const scopes = [];
101
+ if (!Array.isArray(raw["scopes"]) || raw["scopes"].length === 0) bad("scopes", "must list at least one capability");
102
+ else if (raw["scopes"].length > MAX_SCOPES) bad("scopes", `lists more than ${MAX_SCOPES} capabilities`);
103
+ else {
104
+ const seen = /* @__PURE__ */ new Set();
105
+ raw["scopes"].forEach((s, i) => {
106
+ if (!isObject(s) || typeof s["tool"] !== "string" || !TOOL.test(s["tool"])) return bad(`scopes.${i}.tool`, "is a tool name: letters, digits and . _ : / -");
107
+ if (!ACTION_KINDS.includes(s["action"])) return bad(`scopes.${i}.action`, "is one of read, write, send, delete, pay");
108
+ if (s["requiresApproval"] !== void 0 && typeof s["requiresApproval"] !== "boolean") return bad(`scopes.${i}.requiresApproval`, "is true or false");
109
+ const key = `${s["action"]} ${s["tool"]}`;
110
+ if (seen.has(key)) return bad(`scopes.${i}`, "repeats an earlier capability");
111
+ seen.add(key);
112
+ scopes.push({ tool: s["tool"], action: s["action"], ...s["requiresApproval"] === true ? { requiresApproval: true } : {} });
113
+ });
114
+ }
115
+ const budget = {};
116
+ const rawBudget = raw["budget"] ?? {};
117
+ if (!isObject(rawBudget)) bad("budget", "must be an object");
118
+ else
119
+ for (const [k, v2] of Object.entries(rawBudget)) {
120
+ if (!BUDGET_KEYS.includes(k)) bad(`budget.${k}`, "is not a limit this grant can carry");
121
+ else if (!isCount(v2)) bad(`budget.${k}`, "is a whole number, zero or more");
122
+ else budget[k] = v2;
123
+ }
124
+ const ttl = raw["ttlSeconds"];
125
+ if (typeof ttl !== "number" || !Number.isInteger(ttl) || ttl < MIN_TTL_SECONDS || ttl > MAX_TTL_SECONDS) {
126
+ bad("ttlSeconds", `is a whole number of seconds from ${MIN_TTL_SECONDS} to ${MAX_TTL_SECONDS} (90 days)`);
127
+ }
128
+ if (returnTarget(raw["returnUrl"]) === void 0) bad("returnUrl", "is an https address with no fragment (http only on localhost)");
129
+ if (raw["state"] !== void 0 && (typeof raw["state"] !== "string" || !/^[A-Za-z0-9._~-]{1,200}$/.test(raw["state"]))) bad("state", "is up to 200 URL-safe characters");
130
+ if (problems.length > 0) return { request: void 0, problems };
131
+ const v = vendor;
132
+ const a = agent;
133
+ return {
134
+ request: {
135
+ v: 1,
136
+ vendor: { id: v["id"], name: v["name"], class: v["class"] },
137
+ agent: { id: a["id"], ...a["model"] === void 0 ? {} : { model: a["model"] } },
138
+ subjectRef: raw["subjectRef"],
139
+ scopes,
140
+ budget,
141
+ ttlSeconds: ttl,
142
+ returnUrl: raw["returnUrl"],
143
+ ...raw["state"] === void 0 ? {} : { state: raw["state"] }
144
+ },
145
+ problems
146
+ };
147
+ }
148
+ function permitRequestUrl(request, base = "https://leashkit.com") {
149
+ return `${base.replace(/\/+$/, "")}/grant.html#req=${packJson(request)}`;
150
+ }
151
+ function readPermitRequest(packed) {
152
+ return parsePermitRequest(unpackJson(packed));
153
+ }
154
+ async function verifyPermit(token, publicJwk) {
155
+ const value = await verifyJson(token, publicJwk);
156
+ if (!isObject(value) || value["v"] !== 1 || typeof value["aud"] !== "string") return void 0;
157
+ const p = value["t"] === "grant" ? value["grant"] : value["t"] === "permit" ? value["permit"] : void 0;
158
+ if (!isObject(p)) return void 0;
159
+ if (typeof p["id"] !== "string" || typeof p["issuedAt"] !== "number" || typeof p["expiresAt"] !== "number" || !Array.isArray(p["scopes"]) || !isObject(p["budget"])) return void 0;
160
+ return { grant: p, host: value["aud"] };
161
+ }
162
+ function withStatus(grant, status) {
163
+ if (!status.revoked || status.id !== grant.id) return grant;
164
+ return { ...grant, revokedAt: status.revokedAt ?? grant.issuedAt, revokedBy: "customer" };
165
+ }
166
+ async function fetchPermitStatus(id, fetchImpl, base = "https://leashkit.com") {
167
+ try {
168
+ const res = await fetchImpl(`${base.replace(/\/+$/, "")}/api/grant-status?id=${encodeURIComponent(id)}`);
169
+ if (!res.ok) return void 0;
170
+ const body = await res.json();
171
+ return body.id === id && typeof body.revoked === "boolean" ? body : void 0;
172
+ } catch {
173
+ return void 0;
174
+ }
175
+ }
176
+ var RECEIPTS_PER_REQUEST = 200;
177
+ async function sendReceipts(permitToken, receipts, fetchImpl, base = "https://leashkit.com") {
178
+ let stored;
179
+ try {
180
+ for (let from = 0; from < receipts.length; from += RECEIPTS_PER_REQUEST) {
181
+ const res = await fetchImpl(`${base.replace(/\/+$/, "")}/api/grant-receipts`, {
182
+ method: "POST",
183
+ headers: { "content-type": "application/json" },
184
+ body: JSON.stringify({ grant: permitToken, receipts: receipts.slice(from, from + RECEIPTS_PER_REQUEST) })
185
+ });
186
+ const body = await res.json().catch(() => ({}));
187
+ if (!res.ok) return { ok: false, ...stored === void 0 ? {} : { stored }, problem: body.detail ?? body.error ?? `HTTP ${res.status}` };
188
+ stored = body.stored;
189
+ }
190
+ } catch (error) {
191
+ return { ok: false, ...stored === void 0 ? {} : { stored }, problem: error instanceof Error ? error.message : "the request failed" };
192
+ }
193
+ return { ok: true, ...stored === void 0 ? {} : { stored } };
194
+ }
195
+ var VERB = { read: "Read from", write: "Make changes in", send: "Send through", delete: "Delete from", pay: "Move money through" };
196
+ function describeScope(scope) {
197
+ return `${VERB[scope.action]} ${scope.tool}`;
198
+ }
199
+
200
+ // src/budget.ts
201
+ var CAP_FIELD = Object.freeze({
202
+ read: "maxReads",
203
+ write: "maxWrites",
204
+ send: "maxSends",
205
+ delete: "maxDeletes",
206
+ pay: "maxPayments"
207
+ });
208
+ function capFor(budget, action) {
209
+ const value = budget[CAP_FIELD[action]];
210
+ return typeof value === "number" ? value : null;
211
+ }
212
+ function spentOn(consumed, action) {
213
+ return consumed[action];
214
+ }
215
+ function wouldExhaust(budget, consumed, action, units) {
216
+ const cap = capFor(budget, action);
217
+ if (cap === null) return false;
218
+ return spentOn(consumed, action) + units > cap;
219
+ }
220
+ function wouldExhaustUsd(budget, consumed, usd) {
221
+ if (typeof budget.maxUsd !== "number") return false;
222
+ return consumed.usd + usd > budget.maxUsd;
223
+ }
224
+ function utilization(budget, consumed, action) {
225
+ const cap = capFor(budget, action);
226
+ if (cap === null) return null;
227
+ if (cap === 0) return 1;
228
+ return Math.min(1, spentOn(consumed, action) / cap);
229
+ }
230
+ function applySpend(consumed, action, units, usd) {
231
+ return {
232
+ ...consumed,
233
+ [action]: consumed[action] + units,
234
+ usd: consumed.usd + usd
235
+ };
236
+ }
237
+
238
+ // src/outcome.ts
239
+ var MS_PER_SECOND = 1e3;
240
+ var FORBIDDEN_OUTCOME_FIELDS = Object.freeze([
241
+ "subjectRef",
242
+ "subject",
243
+ "customerId",
244
+ "customerEmail",
245
+ "email",
246
+ "name",
247
+ "phone",
248
+ "address",
249
+ "ip",
250
+ "userId"
251
+ ]);
252
+ function outcomeKindFor(verdict, reasons) {
253
+ if (verdict === "allow") return "approved";
254
+ if (verdict === "escalate") return "escalated";
255
+ if (reasons.includes("revoked")) return "revoked-mid-run";
256
+ if (reasons.includes("cap-exhausted") || reasons.includes("usd-cap-exhausted")) {
257
+ return "cap-exhausted";
258
+ }
259
+ return "denied";
260
+ }
261
+ function buildOutcome(input) {
262
+ const { permit, scope, verdict, reasons, consumedAfter, at } = input;
263
+ const action = scope.action;
264
+ const base = {
265
+ recordedAt: at,
266
+ outcome: outcomeKindFor(verdict, reasons),
267
+ vendorId: permit.vendorId,
268
+ vendorClass: permit.vendorClass,
269
+ agentId: permit.agentId,
270
+ tool: scope.tool,
271
+ action,
272
+ ttlSeconds: Math.round((permit.expiresAt - permit.issuedAt) / MS_PER_SECOND),
273
+ capValue: capFor(permit.budget, action),
274
+ capUtilization: utilization(permit.budget, consumedAfter, action),
275
+ reasons
276
+ };
277
+ return permit.agentModel === void 0 ? base : { ...base, agentModel: permit.agentModel };
278
+ }
279
+
280
+ // src/policy.ts
281
+ var DEFAULT_UNITS = 1;
282
+ var DEFAULT_USD = 0;
283
+ var DENY_LADDER = Object.freeze([
284
+ "revoked",
285
+ "not-yet-valid",
286
+ "expired",
287
+ "scope-not-granted",
288
+ "cap-exhausted",
289
+ "usd-cap-exhausted"
290
+ ]);
291
+ function isRevoked(permit, at) {
292
+ return typeof permit.revokedAt === "number" && permit.revokedAt <= at;
293
+ }
294
+ function grantFor(permit, requested) {
295
+ return permit.scopes.find(
296
+ (s) => s.tool === requested.tool && s.action === requested.action
297
+ );
298
+ }
299
+ function summarize(verdict, reasons, scope) {
300
+ const what = `${scope.action} on ${scope.tool}`;
301
+ if (verdict === "allow") return `Allowed: ${what}.`;
302
+ if (verdict === "escalate") return `Held for your approval: ${what}.`;
303
+ const reason = reasons[0];
304
+ switch (reason) {
305
+ case "revoked":
306
+ return `Blocked: ${what}. This permission was turned off.`;
307
+ case "expired":
308
+ return `Blocked: ${what}. This permission had run out of time.`;
309
+ case "not-yet-valid":
310
+ return `Blocked: ${what}. This permission had not started yet.`;
311
+ case "scope-not-granted":
312
+ return `Blocked: ${what}. That was never allowed.`;
313
+ case "cap-exhausted":
314
+ return `Blocked: ${what}. The limit you set had been reached.`;
315
+ case "usd-cap-exhausted":
316
+ return `Blocked: ${what}. The spending limit you set had been reached.`;
317
+ default:
318
+ return `Blocked: ${what}.`;
319
+ }
320
+ }
321
+ function denyReasons(request, units, usd) {
322
+ const { permit, consumed, scope, at } = request;
323
+ const grant = grantFor(permit, scope);
324
+ const applies = {
325
+ revoked: isRevoked(permit, at),
326
+ "not-yet-valid": at < permit.issuedAt,
327
+ expired: at >= permit.expiresAt,
328
+ "scope-not-granted": grant === void 0,
329
+ "cap-exhausted": wouldExhaust(permit.budget, consumed, scope.action, units),
330
+ "usd-cap-exhausted": wouldExhaustUsd(permit.budget, consumed, usd)
331
+ };
332
+ return DENY_LADDER.filter((reason) => applies[reason]);
333
+ }
334
+ function evaluate(request) {
335
+ const units = request.units ?? DEFAULT_UNITS;
336
+ const usd = request.usd ?? DEFAULT_USD;
337
+ const { permit, consumed, scope, at } = request;
338
+ const denies = denyReasons(request, units, usd);
339
+ let verdict;
340
+ let reasons;
341
+ if (denies.length > 0) {
342
+ verdict = "deny";
343
+ reasons = denies;
344
+ } else if (grantFor(permit, scope)?.requiresApproval === true) {
345
+ verdict = "escalate";
346
+ reasons = ["scope-requires-approval"];
347
+ } else {
348
+ verdict = "allow";
349
+ reasons = [];
350
+ }
351
+ const consumedAfter = verdict === "allow" ? applySpend(consumed, scope.action, units, usd) : consumed;
352
+ const decision = {
353
+ verdict,
354
+ reasons,
355
+ summary: summarize(verdict, reasons, scope)
356
+ };
357
+ return {
358
+ decision,
359
+ outcome: buildOutcome({ permit, scope, verdict, reasons, consumedAfter, at }),
360
+ consumed: consumedAfter
361
+ };
362
+ }
363
+
364
+ // src/receipt.ts
365
+ import { createHash } from "node:crypto";
366
+
367
+ // src/canonical.ts
368
+ function canonicalJson(value) {
369
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
370
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
371
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
372
+ return `{${entries.join(",")}}`;
373
+ }
374
+
375
+ // src/receipt.ts
376
+ var GENESIS_HASH = "0".repeat(64);
377
+ function digest(body) {
378
+ return createHash("sha256").update(canonicalJson(body)).digest("hex");
379
+ }
380
+ function append(chain, input) {
381
+ const prev = chain[chain.length - 1];
382
+ const body = {
383
+ seq: chain.length,
384
+ at: input.at,
385
+ permitId: input.permitId,
386
+ verdict: input.decision.verdict,
387
+ summary: input.decision.summary,
388
+ outcome: input.outcome,
389
+ prevHash: prev?.hash ?? GENESIS_HASH
390
+ };
391
+ return [...chain, { ...body, hash: digest(body) }];
392
+ }
393
+ function verify(chain) {
394
+ const faults = [];
395
+ chain.forEach((receipt, index) => {
396
+ if (receipt.seq !== index) faults.push({ kind: "bad-sequence", at: index });
397
+ const expectedPrev = index === 0 ? GENESIS_HASH : chain[index - 1].hash;
398
+ if (receipt.prevHash !== expectedPrev) faults.push({ kind: "broken-link", at: index });
399
+ const { hash, ...body } = receipt;
400
+ if (digest(body) !== hash) faults.push({ kind: "tampered", at: index });
401
+ });
402
+ return { ok: faults.length === 0, faults };
403
+ }
404
+
405
+ // src/permit-client.ts
406
+ var LEASH_GRANT_KEY = {
407
+ kty: "EC",
408
+ crv: "P-256",
409
+ x: "fGWTh5kNhl9gA2fuq53IU7-PxIhei-Nq64NOGN5l13A",
410
+ y: "wUWZ49qT48ZtWcagElshI78A25mFjTXTuTL3CaMo9RU"
411
+ };
412
+ export {
413
+ ACTION_KINDS,
414
+ GENESIS_HASH,
415
+ LEASH_GRANT_KEY,
416
+ MAX_TTL_SECONDS,
417
+ RECEIPTS_PER_REQUEST,
418
+ STATUS_MAX_AGE_SECONDS,
419
+ ZERO_CONSUMPTION,
420
+ append,
421
+ describeScope,
422
+ evaluate,
423
+ fetchPermitStatus as fetchGrantStatus,
424
+ permitRequestUrl as grantRequestUrl,
425
+ parsePermitRequest as parseGrantRequest,
426
+ readPermitRequest as readGrantRequest,
427
+ sendReceipts,
428
+ verify,
429
+ verifyPermit as verifyGrant,
430
+ withStatus
431
+ };
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@leashkit/grant",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "Leash Grant for agents: ask a customer to approve limits, verify the signed grant, and hold your agent to it.",
7
+ "keywords": [
8
+ "mcp",
9
+ "agents",
10
+ "permissions",
11
+ "consent",
12
+ "leash"
13
+ ],
14
+ "main": "index.js",
15
+ "types": "index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./index.d.ts",
19
+ "default": "./index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "*.js",
24
+ "*.d.ts",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "homepage": "https://leashkit.com/grants"
32
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What a vendor needs to use Leash Grant: build a request, read the signed
3
+ * grant that comes back, learn whether it was revoked, and hold an agent to it
4
+ * with the gate. This is the public surface of the published package; the
5
+ * endpoints Leash itself answers are not part of it.
6
+ *
7
+ * The names here say "grant". The modules behind them were written when the
8
+ * product had another name and still say "permit"; nobody outside reads those.
9
+ * The gate's own input keeps its field: `evaluate({ permit: grant, … })`.
10
+ */
11
+ export { permitRequestUrl as grantRequestUrl, parsePermitRequest as parseGrantRequest, readPermitRequest as readGrantRequest, verifyPermit as verifyGrant, fetchPermitStatus as fetchGrantStatus, withStatus, sendReceipts, RECEIPTS_PER_REQUEST, describeScope, STATUS_MAX_AGE_SECONDS, MAX_TTL_SECONDS, } from './permit.js';
12
+ export type { PermitRequest as GrantRequest, PermitStatus as GrantStatus, Problem, SendResult } from './permit.js';
13
+ export { evaluate } from './policy.js';
14
+ export { append, verify, GENESIS_HASH } from './receipt.js';
15
+ export type { Receipt, Verification, ChainFault, AppendInput } from './receipt.js';
16
+ export { ZERO_CONSUMPTION, ACTION_KINDS } from './types.js';
17
+ export type { ActionKind, ActionBudget, Consumption, Decision, GateRequest, Permit as Grant, Scope, Verdict } from './types.js';
18
+ import type { Jwk } from './unlock.js';
19
+ /** The public key Leash signs grants with. A test holds it equal to the key the site publishes. */
20
+ export declare const LEASH_GRANT_KEY: Jwk;
package/permit.d.ts ADDED
@@ -0,0 +1,167 @@
1
+ import type { ActionBudget, ActionKind, Permit, Scope } from './types.js';
2
+ import type { Jwk } from './unlock.js';
3
+ /**
4
+ * Leash Grant: a vendor asks, the customer narrows and approves, and the
5
+ * vendor's agent is handed a signed grant it must obey.
6
+ *
7
+ * What Leash holds is deliberately almost nothing. The grant is a signed token
8
+ * the vendor keeps; the counters and receipts stay with the vendor, in the gate
9
+ * they run themselves. The one thing kept here is a revocation: a grant id and
10
+ * the moment its customer switched it off. A grant nobody revoked has no record
11
+ * at all.
12
+ *
13
+ * Nothing in a request is trusted for what it says about itself. The name a
14
+ * vendor gives is shown as a claim; the address the customer is sent back to is
15
+ * shown as the fact, because that is the only part a vendor cannot make up.
16
+ */
17
+ export interface PermitRequest {
18
+ readonly v: 1;
19
+ readonly vendor: {
20
+ readonly id: string;
21
+ readonly name: string;
22
+ readonly class: string;
23
+ };
24
+ readonly agent: {
25
+ readonly id: string;
26
+ readonly model?: string;
27
+ };
28
+ /** The vendor's opaque handle for this customer. Never an email or a name; it is not shown and not stored. */
29
+ readonly subjectRef: string;
30
+ readonly scopes: readonly Scope[];
31
+ readonly budget: ActionBudget;
32
+ /** How long the grant should last. The customer may shorten it. */
33
+ readonly ttlSeconds: number;
34
+ /** Where the customer is sent with the signed grant. https, or http on localhost while developing. */
35
+ readonly returnUrl: string;
36
+ /** Echoed back unchanged, so the vendor can tie the answer to the question. */
37
+ readonly state?: string;
38
+ }
39
+ export type Problem = {
40
+ readonly path: string;
41
+ readonly message: string;
42
+ };
43
+ export declare const MAX_SCOPES = 50;
44
+ export declare const MIN_TTL_SECONDS = 60;
45
+ export declare const MAX_TTL_SECONDS: number;
46
+ /** How long a vendor may reuse a status answer before asking again. A revocation is in force within this long. */
47
+ export declare const STATUS_MAX_AGE_SECONDS = 60;
48
+ export declare const BUDGET_FOR: Readonly<Record<ActionKind, keyof ActionBudget>>;
49
+ /** The address a customer may be sent back to, or undefined. Its host is the vendor's identity on the page. */
50
+ export declare function returnTarget(url: unknown): URL | undefined;
51
+ /** Reads a grant request. Anything wrong and there is no request: a consent screen must not guess what was meant. */
52
+ export declare function parsePermitRequest(raw: unknown): {
53
+ readonly request: PermitRequest | undefined;
54
+ readonly problems: readonly Problem[];
55
+ };
56
+ /** The link a vendor sends a customer to. The request rides in the fragment, which browsers send to no server. */
57
+ export declare function permitRequestUrl(request: PermitRequest, base?: string): string;
58
+ export declare function readPermitRequest(packed: string): ReturnType<typeof parsePermitRequest>;
59
+ /** What the customer chose on the consent screen. Every field can only take away. */
60
+ export interface Choices {
61
+ /** Indexes of the requested scopes the customer kept. */
62
+ readonly keep: readonly number[];
63
+ /** Indexes of kept scopes the customer wants to be asked about each time. */
64
+ readonly ask: readonly number[];
65
+ readonly budget: ActionBudget;
66
+ readonly ttlSeconds: number;
67
+ }
68
+ /**
69
+ * The request as the customer approved it. Nothing here can exceed what was
70
+ * asked: a larger cap is cut to the request's, an uncapped action stays capped
71
+ * if the customer set a number, and an approval the vendor asked for stays.
72
+ */
73
+ export declare function narrow(request: PermitRequest, choices: Choices): PermitRequest;
74
+ export declare function permitFrom(request: PermitRequest, id: string, nowMs: number): Permit;
75
+ /** What the customer keeps: the right to look at and switch off one grant. Never shown to the vendor. */
76
+ export interface ManageClaims {
77
+ readonly v: 1;
78
+ readonly t: 'manage';
79
+ readonly id: string;
80
+ readonly vendorName: string;
81
+ readonly host: string;
82
+ readonly scopes: readonly Scope[];
83
+ readonly budget: ActionBudget;
84
+ readonly issuedAt: number;
85
+ readonly expiresAt: number;
86
+ }
87
+ export declare function signPermit(grant: Permit, host: string, privateJwk: Jwk): Promise<string>;
88
+ /**
89
+ * The grant inside a token, if Leash signed it. It says nothing about whether
90
+ * the grant has since been revoked or has run out: `evaluate()` checks the
91
+ * dates, and `withStatus()` carries a revocation into it.
92
+ */
93
+ export declare function verifyPermit(token: string, publicJwk: Jwk): Promise<{
94
+ readonly grant: Permit;
95
+ readonly host: string;
96
+ } | undefined>;
97
+ export declare function verifyManage(token: string, publicJwk: Jwk): Promise<ManageClaims | undefined>;
98
+ export interface Revocation {
99
+ readonly revokedAt: number;
100
+ /** Kept so the record can be dropped once the grant would have ended anyway. */
101
+ readonly expiresAt: number;
102
+ }
103
+ export interface PermitStatus {
104
+ readonly id: string;
105
+ readonly revoked: boolean;
106
+ readonly revokedAt?: number;
107
+ }
108
+ /** The grant as the gate should see it: a revocation becomes `revokedAt`, so `evaluate()` declines every call. */
109
+ export declare function withStatus(grant: Permit, status: PermitStatus): Permit;
110
+ /** Asks Leash whether a grant has been revoked. One GET, no credentials: the id is the unguessable part. */
111
+ export declare function fetchPermitStatus(id: string, fetchImpl: (url: string) => Promise<Response>, base?: string): Promise<PermitStatus | undefined>;
112
+ /** How many receipts one call to `sendReceipts` may carry. */
113
+ export declare const RECEIPTS_PER_REQUEST = 200;
114
+ /** How many receipts Leash holds for one grant. */
115
+ export declare const MAX_RECEIPTS_PER_PERMIT = 5000;
116
+ /** How long after a grant ends its log and any revocation are kept before the daily sweep removes them. */
117
+ export declare const KEEP_DAYS_AFTER_END = 30;
118
+ export interface SendResult {
119
+ readonly ok: boolean;
120
+ /** How many receipts Leash now holds for the grant. Send from this position next time. */
121
+ readonly stored?: number;
122
+ /** Why it was refused, in Leash's words. */
123
+ readonly problem?: string;
124
+ }
125
+ /**
126
+ * Optional: sends receipts to Leash so the customer can read what the agent did
127
+ * from their own grant page. `permitToken` is the signed grant you were handed,
128
+ * and is what entitles you to write to its log. Send the chain from wherever you
129
+ * left off; receipts Leash already holds are skipped, so sending again is safe.
130
+ * Nothing but the receipts leaves: they describe tools and decisions, never a person.
131
+ */
132
+ export declare function sendReceipts(permitToken: string, receipts: readonly unknown[], fetchImpl: (url: string, init: {
133
+ method: string;
134
+ headers: Record<string, string>;
135
+ body: string;
136
+ }) => Promise<Response>, base?: string): Promise<SendResult>;
137
+ /** One capability as a customer reads it. Written here, from the action and the tool, never from words the vendor supplied. */
138
+ export declare function describeScope(scope: Scope): string;
139
+ export declare function describeCap(action: ActionKind, cap: number | undefined): string;
140
+ export declare function describeDuration(seconds: number): string;
141
+ export interface PermitEnv {
142
+ readonly LEASH_UNLOCK_PRIVATE_KEY?: string | undefined;
143
+ }
144
+ export interface PermitDeps {
145
+ readonly env: PermitEnv;
146
+ readonly nowMs: () => number;
147
+ readonly newId: () => string;
148
+ readonly revocations: {
149
+ get(id: string): Promise<Revocation | undefined>;
150
+ set(id: string, value: Revocation): Promise<void>;
151
+ };
152
+ }
153
+ export declare const PERMIT_PAGE = "/grant.html";
154
+ export declare const MANAGE_PAGE = "/grant-manage.html";
155
+ /**
156
+ * POST /api/grant-approve — the consent screen's Approve button.
157
+ *
158
+ * The form carries the request as the vendor made it and what the customer
159
+ * chose. The grant is built from both, so a choice can only narrow. The answer
160
+ * is a redirect to the customer's own page for this grant, which holds the link
161
+ * that carries the signed grant on to the vendor. Nothing is stored.
162
+ */
163
+ export declare function grantPermit(request: Request, deps: PermitDeps): Promise<Response>;
164
+ /** POST /api/grant-revoke — the customer's off switch. Holding the manage link is the right to use it. */
165
+ export declare function revokePermit(request: Request, deps: PermitDeps): Promise<Response>;
166
+ /** GET /api/grant-status?id=… — whether a grant has been revoked. Public: it says nothing else about the grant. */
167
+ export declare function permitStatus(request: Request, deps: PermitDeps): Promise<Response>;
package/policy.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { DecisionReason, GateRequest, GateResult, Scope, Verdict } from './types.js';
2
+ /** The one sentence a decision is recorded with. A function of the decision alone, so a receipt's sentence can be checked. */
3
+ export declare function summarize(verdict: Verdict, reasons: readonly DecisionReason[], scope: Scope): string;
4
+ /**
5
+ * The gate. Pure: same inputs, same result, no clock and no I/O of its own.
6
+ *
7
+ * Returns the decision and its corpus row together, so a caller cannot obtain a
8
+ * verdict without also holding the OutcomeRecord it must write. Instrumentation
9
+ * is not a step someone can forget — it is the return type.
10
+ */
11
+ export declare function evaluate(request: GateRequest): GateResult;
package/receipt.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { Decision, OutcomeRecord } from './types.js';
2
+ /**
3
+ * Hash-chained operational receipts.
4
+ *
5
+ * This is an integrity check on our own ledger and an audit aid for the
6
+ * customer. It is not court certification, not insurance, and not a guarantee
7
+ * that the agent behaved well — only that this log has not been edited after
8
+ * the fact.
9
+ */
10
+ export declare const GENESIS_HASH: string;
11
+ export interface Receipt {
12
+ readonly seq: number;
13
+ readonly at: number;
14
+ readonly permitId: string;
15
+ readonly verdict: Decision['verdict'];
16
+ /** Plain-English line the customer reads. */
17
+ readonly summary: string;
18
+ readonly outcome: OutcomeRecord;
19
+ readonly prevHash: string;
20
+ readonly hash: string;
21
+ }
22
+ export interface AppendInput {
23
+ readonly permitId: string;
24
+ readonly decision: Decision;
25
+ readonly outcome: OutcomeRecord;
26
+ readonly at: number;
27
+ }
28
+ /** Appends one receipt to a chain, returning a new chain. Never mutates. */
29
+ export declare function append(chain: readonly Receipt[], input: AppendInput): readonly Receipt[];
30
+ export type ChainFault = {
31
+ readonly kind: 'bad-sequence';
32
+ readonly at: number;
33
+ } | {
34
+ readonly kind: 'broken-link';
35
+ readonly at: number;
36
+ } | {
37
+ readonly kind: 'tampered';
38
+ readonly at: number;
39
+ };
40
+ export interface Verification {
41
+ readonly ok: boolean;
42
+ readonly faults: readonly ChainFault[];
43
+ }
44
+ /** Verifies sequence, linkage and per-entry digests across the whole chain. */
45
+ export declare function verify(chain: readonly Receipt[]): Verification;
package/types.d.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Core domain types for Leash.
3
+ *
4
+ * Two rules govern everything in this file and are enforced by tests:
5
+ *
6
+ * 1. A verdict can never be obtained without an OutcomeRecord. The corpus does
7
+ * not backfill, so instrumentation is structural, not optional.
8
+ * 2. An OutcomeRecord describes agents, scopes and vendors — never an end
9
+ * consumer. Scoring an individual in a way that influences an eligibility
10
+ * decision about them risks classification as a consumer reporting agency.
11
+ * The line is enforced in the schema, because a policy document is not a
12
+ * control.
13
+ */
14
+ /** What an agent is trying to do. Budgets are denominated in these. */
15
+ /**
16
+ * `pay` is the agent moving money on the customer's behalf — a charge, a refund,
17
+ * a payout. Classifying an agent's money action is the opposite of Leash moving
18
+ * money: it is how a permit bounds it. Leash itself still has no money surface.
19
+ */
20
+ export type ActionKind = 'read' | 'write' | 'send' | 'delete' | 'pay';
21
+ export declare const ACTION_KINDS: readonly ActionKind[];
22
+ /** A single granted capability: one action against one tool. */
23
+ export interface Scope {
24
+ /** Dotted tool identifier, e.g. "crm.contacts" or "email.outbox". */
25
+ readonly tool: string;
26
+ readonly action: ActionKind;
27
+ /** When true, a matching request escalates to a human instead of allowing. */
28
+ readonly requiresApproval?: boolean;
29
+ }
30
+ /**
31
+ * Hard caps on what the agent may do inside the permit's lifetime.
32
+ *
33
+ * `maxUsd` is a POLICY NUMBER ONLY. Leash never charges a card, holds funds,
34
+ * or moves money. It is displayed and enforced as a limit; it is not a rail.
35
+ */
36
+ export interface ActionBudget {
37
+ readonly maxReads?: number;
38
+ readonly maxWrites?: number;
39
+ readonly maxSends?: number;
40
+ readonly maxDeletes?: number;
41
+ /** How many money actions. Counts payments, not dollars — `maxUsd` bounds the amount. */
42
+ readonly maxPayments?: number;
43
+ readonly maxUsd?: number;
44
+ }
45
+ export type RevokedBy = 'customer' | 'vendor';
46
+ /** A time-boxed, capped grant from one end customer to one vendor's agent. */
47
+ export interface Permit {
48
+ readonly id: string;
49
+ readonly vendorId: string;
50
+ /** Coarse vendor category used for corpus aggregation, e.g. "crm", "support". */
51
+ readonly vendorClass: string;
52
+ readonly agentId: string;
53
+ /** Model behind the agent, recorded for the corpus. Optional. */
54
+ readonly agentModel?: string;
55
+ /**
56
+ * Opaque handle for the end customer. NEVER an email, name, or any other
57
+ * identifier. Present on the permit so the vendor can route; deliberately
58
+ * absent from every OutcomeRecord.
59
+ */
60
+ readonly subjectRef: string;
61
+ readonly scopes: readonly Scope[];
62
+ readonly budget: ActionBudget;
63
+ readonly issuedAt: number;
64
+ readonly expiresAt: number;
65
+ readonly revokedAt?: number;
66
+ readonly revokedBy?: RevokedBy;
67
+ }
68
+ /** Units already spent against a permit, per action kind, plus dollars. */
69
+ export interface Consumption {
70
+ readonly read: number;
71
+ readonly write: number;
72
+ readonly send: number;
73
+ readonly delete: number;
74
+ readonly pay: number;
75
+ readonly usd: number;
76
+ }
77
+ export declare const ZERO_CONSUMPTION: Consumption;
78
+ /** One attempted tool call, evaluated before it runs. */
79
+ export interface GateRequest {
80
+ readonly permit: Permit;
81
+ readonly consumed: Consumption;
82
+ readonly scope: Scope;
83
+ /** Evaluation time, epoch ms. Injected so the gate stays pure. */
84
+ readonly at: number;
85
+ /** Units this call would spend. Defaults to 1. */
86
+ readonly units?: number;
87
+ /** Dollars this call would represent, for `maxUsd` policy display. */
88
+ readonly usd?: number;
89
+ }
90
+ export type Verdict = 'allow' | 'escalate' | 'deny';
91
+ export type DenyReason = 'revoked' | 'expired' | 'not-yet-valid' | 'scope-not-granted' | 'cap-exhausted' | 'usd-cap-exhausted';
92
+ export type EscalateReason = 'scope-requires-approval';
93
+ export type DecisionReason = DenyReason | EscalateReason;
94
+ export interface Decision {
95
+ readonly verdict: Verdict;
96
+ /** Empty for `allow`. Populated and stable for deny/escalate. */
97
+ readonly reasons: readonly DecisionReason[];
98
+ /** Plain-English line shown to the customer on the receipt. */
99
+ readonly summary: string;
100
+ }
101
+ /**
102
+ * The corpus row. One per gate decision, always.
103
+ *
104
+ * Note what is absent: subjectRef, and any other end-consumer field. Adding one
105
+ * breaks `invariants.test.ts` on purpose.
106
+ */
107
+ export type OutcomeKind = 'approved' | 'denied' | 'escalated' | 'cap-exhausted' | 'revoked-mid-run' | 'reverted';
108
+ export interface OutcomeRecord {
109
+ readonly recordedAt: number;
110
+ readonly outcome: OutcomeKind;
111
+ readonly vendorId: string;
112
+ readonly vendorClass: string;
113
+ readonly agentId: string;
114
+ readonly agentModel?: string;
115
+ readonly tool: string;
116
+ readonly action: ActionKind;
117
+ /** Full permit lifetime in seconds, as issued. */
118
+ readonly ttlSeconds: number;
119
+ /** Cap that applied to this action kind, or null when uncapped. */
120
+ readonly capValue: number | null;
121
+ /** Fraction of that cap consumed after this call, or null when uncapped. */
122
+ readonly capUtilization: number | null;
123
+ readonly reasons: readonly DecisionReason[];
124
+ }
125
+ /**
126
+ * What `evaluate` returns. The decision and its corpus row are produced
127
+ * together so a caller cannot hold a verdict without also holding the record.
128
+ */
129
+ export interface GateResult {
130
+ readonly decision: Decision;
131
+ readonly outcome: OutcomeRecord;
132
+ /** Consumption after this call. Unchanged when the verdict is not `allow`. */
133
+ readonly consumed: Consumption;
134
+ }
package/unlock.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The unlock token for the self-serve report.
3
+ *
4
+ * The report is built in the buyer's browser from a config that must never leave
5
+ * it, on a page whose CSP forbids every network request. So the page cannot ask a
6
+ * server "has this person paid?". Instead the checkout function, after Stripe
7
+ * confirms payment, signs a short token with a private key only it holds, and the
8
+ * page verifies the signature with the public key it shipped with. No request, no
9
+ * account, no config sent anywhere.
10
+ *
11
+ * ECDSA P-256 over SHA-256 via WebCrypto, so the same code runs in the browser and
12
+ * in node. Verification never throws: a bad token is simply not valid.
13
+ */
14
+ export interface UnlockClaims {
15
+ readonly v: 1;
16
+ /** A hash of the Stripe session, so a receipt can be matched without storing anything about the buyer. */
17
+ readonly id: string;
18
+ /** Issued / expires, epoch seconds. */
19
+ readonly iat: number;
20
+ readonly exp: number;
21
+ /** What was bought. Absent on tokens issued when the report was the only product, which are report tokens. */
22
+ readonly p?: UnlockProduct;
23
+ /** A team licence only: the Stripe subscription it belongs to, so a later token can be issued while that subscription is paid. */
24
+ readonly sub?: string;
25
+ }
26
+ /** The things a purchase can unlock. One key signs them all, so a token must say which it is for. */
27
+ export type UnlockProduct = 'report' | 'preflight' | 'data' | 'team';
28
+ /** How long a data licence lasts: a year of weekly updates from one purchase, with no automatic renewal. */
29
+ export declare const DATA_LICENCE_DAYS = 365;
30
+ /** True when a verified token was bought for this product. A cheaper product's token never opens a dearer one. */
31
+ export declare function unlocks(claims: UnlockClaims | undefined, product: UnlockProduct): boolean;
32
+ /** How long a purchase can regenerate its report. Long enough to fix a config and run it again. */
33
+ export declare const UNLOCK_DAYS = 30;
34
+ /** A JSON Web Key. Typed loosely so this module compiles without the DOM library. */
35
+ export type Jwk = Record<string, unknown>;
36
+ export declare function sessionFingerprint(sessionId: string): Promise<string>;
37
+ export declare function signUnlock(claims: UnlockClaims, privateJwk: Jwk): Promise<string>;
38
+ /** `graceSeconds` lets a token that ended that recently still verify. Only the licence renewal passes one. */
39
+ export declare function verifyUnlock(token: string, publicJwk: Jwk, nowSeconds: number, graceSeconds?: number): Promise<UnlockClaims | undefined>;
40
+ /** Any JSON value, signed the same way as an unlock token. The caller decides what the value must look like. */
41
+ export declare function signJson(value: unknown, privateJwk: Jwk): Promise<string>;
42
+ /** The value inside a token signed by `signJson`, or undefined when the signature does not hold. Never throws. */
43
+ export declare function verifyJson(token: string, publicJwk: Jwk, maxLength?: number): Promise<unknown>;
44
+ /** JSON carried in a URL: base64url of its UTF-8 bytes. */
45
+ export declare function packJson(value: unknown): string;
46
+ export declare function unpackJson(text: string): unknown;
47
+ /** A public key fit to embed: EC P-256, public part only. Refuses anything carrying a private component. */
48
+ export declare function isPublicJwk(value: unknown): value is Jwk;