@tuppence/http402 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # @tuppence/http402
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
+ Put a price on an API route in one line, and let people *and agents* pay for it.
4
+
5
+ ```ts
6
+ import { Tuppence } from "@tuppence/node";
7
+ import { http402 } from "@tuppence/http402";
8
+
9
+ const tuppence = new Tuppence(process.env.TUPPENCE_SECRET_KEY!);
10
+
11
+ app.get(
12
+ "/v1/report",
13
+ http402({ tuppence, price: 50, currency: "gbp", account: "acct_…" }),
14
+ (req, res) => res.json(buildReport()),
15
+ );
16
+ ```
17
+
18
+ ## What the caller sees
19
+
20
+ An unpaid request gets **`402 Payment Required`**:
21
+
22
+ ```json
23
+ {
24
+ "error": { "type": "payment_required", "code": "payment_required", "message": "…" },
25
+ "price": 50,
26
+ "currency": "gbp",
27
+ "resource": "GET /v1/report",
28
+ "payment_id": "pay_…",
29
+ "accepts": [{ "type": "card_link", "url": "https://pay.tuppence.ai/pay/pay_…#…" }],
30
+ "receipt": {
31
+ "url": "https://api.tuppence.ai/public/payments/pay_…",
32
+ "header": "Tuppence-Client-Secret",
33
+ "client_secret": "…"
34
+ }
35
+ }
36
+ ```
37
+
38
+ with a `Link: <…>; rel="payment"` header. The caller pays at the URL, fetches `receipt.url`
39
+ with the `Tuppence-Client-Secret` header, and retries with
40
+
41
+ ```
42
+ Authorization: Tuppence-Receipt <receipt_token>
43
+ ```
44
+
45
+ and gets the resource. A caller who retries before paying gets the **same** payment, not a
46
+ new one each time.
47
+
48
+ ## Why it is safe
49
+
50
+ - **Verified offline.** The receipt is a JWT signed by Tuppence (EdDSA); it is checked against
51
+ Tuppence's published keys (`/.well-known/jwks.json`, cached). No call to Tuppence on the hot
52
+ path.
53
+ - **Bound to what was bought.** The receipt names its `resource` (default `METHOD /path`) and
54
+ amount: a 1p receipt for another route opens nothing here.
55
+ - **Used once.** Its `jti` is remembered until it expires (fifteen minutes). The default store is
56
+ in memory. Running more than one process? Pass a shared `store` (`{ claim(jti, expiresAt) }`,
57
+ e.g. Redis `SET NX EX`).
58
+ - **Paid to you.** Pass `account` and receipts paid to anyone else are refused.
59
+
60
+ ## Credits: agents paying from prepaid balance (no card per call)
61
+
62
+ Give the route a meter priced the same, and your customers' agents can pay from their prepaid
63
+ credit instead of a card:
64
+
65
+ ```ts
66
+ app.get("/v1/report", http402({ tuppence, price: 50, currency: "gbp", credits: { meter: "report" } }), handler);
67
+ ```
68
+
69
+ Your server mints the agent a token for its customer, capped and short-lived:
70
+
71
+ ```ts
72
+ const { token } = await tuppence.request("POST", `/v1/credits/${customerId}/tokens`, {
73
+ body: { max_amount: 1000, expires_in: 3600 },
74
+ });
75
+ ```
76
+
77
+ and the agent sends `Authorization: Tuppence-Credits <token>` (with an `Idempotency-Key`, so a
78
+ retried request is one use, not two). Tuppence enforces the cap and the expiry as it draws the
79
+ credit; past either, the agent gets a `402` saying why (`credit_token_limit`,
80
+ `credit_token_expired`, `insufficient_credits` with a `topup_url`).
81
+
82
+ ## Other frameworks
83
+
84
+ ```ts
85
+ // Next.js route handlers, Hono, Bun, Deno, Workers
86
+ export const GET = withPayment({ tuppence, price: 50, currency: "gbp" }, async (req, paid) =>
87
+ Response.json(await buildReport()),
88
+ );
89
+
90
+ // An MCP tool: an optional `receipt` argument; without it the agent is told where to pay
91
+ server.tool(
92
+ "deep_research",
93
+ { topic: z.string(), receipt: z.string().optional() },
94
+ paidTool({ tuppence, price: 200, currency: "gbp", name: "deep_research" }, async ({ topic }) => ({
95
+ content: [{ type: "text", text: await research(topic) }],
96
+ })),
97
+ );
98
+
99
+ // Anything else: the framework-free core
100
+ const decide = payWall({ tuppence, price: 50, currency: "gbp" });
101
+ const decision = await decide({ method, path, authorization, caller });
102
+ ```
@@ -0,0 +1,56 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ import { Jwks, type ReceiptStore } from "./verify.js";
3
+ /**
4
+ * Agent identity (plan 20-06). A restricted API key says *what* may be done; it does not say
5
+ * *who* did it, and a key that leaks is anyone. An agent can instead hold a key pair of its own:
6
+ * Tuppence publishes the public half, the agent keeps the private half and signs a short-lived
7
+ * PROOF for each request. Anyone — us, or the developer's own server — can then check that this
8
+ * request was made by that agent, offline, against the published keys.
9
+ *
10
+ * The proof is a JWT the agent signs (EdDSA), shaped like DPoP (RFC 9449): it names the method
11
+ * and URL it is for, lives seconds, and carries a `jti` so it cannot be replayed. It proves
12
+ * possession of the key for THIS request — not a bearer token that works anywhere.
13
+ */
14
+ export type AgentProofClaims = {
15
+ /** The agent: `agt_…`. */
16
+ iss: string;
17
+ /** HTTP method, upper case. */
18
+ htm: string;
19
+ /** The URL it is for, without the fragment. */
20
+ htu: string;
21
+ iat: number;
22
+ exp: number;
23
+ jti: string;
24
+ /** base64url SHA-256 of the request body, when there is one. */
25
+ bh?: string;
26
+ /** Who it is for, if the caller wants it bound: a host, a merchant id, anything agreed. */
27
+ aud?: string;
28
+ };
29
+ export type AgentProofErrorCode = "malformed_proof" | "unknown_key" | "bad_signature" | "expired_proof" | "wrong_request" | "wrong_agent" | "wrong_audience" | "body_changed" | "proof_already_used";
30
+ export declare class AgentProofError extends Error {
31
+ readonly code: AgentProofErrorCode;
32
+ constructor(code: AgentProofErrorCode, message: string);
33
+ }
34
+ /** Tuppence publishes each agent's public keys here. */
35
+ export declare const agentJwksUrl: (agent: string, baseUrl?: string) => string;
36
+ /** The keys of ONE agent, cached like any other JWKS. */
37
+ export declare const agentKeys: (agent: string, baseUrl?: string, fetcher?: typeof fetch) => Jwks;
38
+ /** The URL a proof is compared against: no fragment, and no trailing "?" for an empty query. */
39
+ export declare function canonicalUrl(url: string): string;
40
+ export declare const bodyHash: (body: string | Uint8Array) => string;
41
+ /**
42
+ * Checks a proof completely: it is a real EdDSA JWT signed by a key the agent publishes, it was
43
+ * made for THIS method and URL, it has not expired, and — when given — the body is the one it was
44
+ * made for. It does NOT mark the proof used: `claim` its `jti` in a store for that, or pass one.
45
+ */
46
+ export declare function verifyAgentProof(proof: string, keys: Jwks | {
47
+ key(kid: string): Promise<KeyObject | undefined>;
48
+ }, expected: {
49
+ agent: string;
50
+ method: string;
51
+ url: string;
52
+ body?: string | Uint8Array;
53
+ audience?: string;
54
+ nowSeconds?: number;
55
+ store?: ReceiptStore;
56
+ }): Promise<AgentProofClaims>;
@@ -0,0 +1,83 @@
1
+ import { createHash, verify } from "node:crypto";
2
+ import { Jwks } from "./verify.js";
3
+ export class AgentProofError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = "AgentProofError";
9
+ }
10
+ }
11
+ /** Tuppence publishes each agent's public keys here. */
12
+ export const agentJwksUrl = (agent, baseUrl = "https://api.tuppence.ai") => `${baseUrl.replace(/\/+$/, "")}/public/agents/${agent}/jwks.json`;
13
+ /** The keys of ONE agent, cached like any other JWKS. */
14
+ export const agentKeys = (agent, baseUrl, fetcher) => new Jwks(agentJwksUrl(agent, baseUrl), fetcher);
15
+ /** The URL a proof is compared against: no fragment, and no trailing "?" for an empty query. */
16
+ export function canonicalUrl(url) {
17
+ const parsed = new URL(url);
18
+ parsed.hash = "";
19
+ return parsed.search === "?" ? parsed.toString().replace(/\?$/, "") : parsed.toString();
20
+ }
21
+ export const bodyHash = (body) => createHash("sha256").update(body).digest("base64url");
22
+ /** How far out of step the agent's clock may be, in seconds. */
23
+ const CLOCK_SKEW_SECONDS = 30;
24
+ /**
25
+ * Checks a proof completely: it is a real EdDSA JWT signed by a key the agent publishes, it was
26
+ * made for THIS method and URL, it has not expired, and — when given — the body is the one it was
27
+ * made for. It does NOT mark the proof used: `claim` its `jti` in a store for that, or pass one.
28
+ */
29
+ export async function verifyAgentProof(proof, keys, expected) {
30
+ const parts = proof.split(".");
31
+ if (parts.length !== 3)
32
+ throw new AgentProofError("malformed_proof", "That is not a proof.");
33
+ const [h, p, s] = parts;
34
+ let header;
35
+ let claims;
36
+ try {
37
+ header = JSON.parse(Buffer.from(h, "base64url").toString());
38
+ claims = JSON.parse(Buffer.from(p, "base64url").toString());
39
+ }
40
+ catch {
41
+ throw new AgentProofError("malformed_proof", "That is not a proof.");
42
+ }
43
+ if (header.alg !== "EdDSA" || !header.kid) {
44
+ throw new AgentProofError("malformed_proof", "A proof is an EdDSA JWT with a key id.");
45
+ }
46
+ const key = await keys.key(header.kid);
47
+ if (!key) {
48
+ throw new AgentProofError("unknown_key", "That agent does not publish the key it signed with.");
49
+ }
50
+ if (!verify(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"))) {
51
+ throw new AgentProofError("bad_signature", "The proof's signature does not verify.");
52
+ }
53
+ if (claims.iss !== expected.agent) {
54
+ throw new AgentProofError("wrong_agent", "The proof was made by a different agent.");
55
+ }
56
+ const now = expected.nowSeconds ?? Math.floor(Date.now() / 1000);
57
+ if (!Number.isFinite(claims.exp) || claims.exp + CLOCK_SKEW_SECONDS < now) {
58
+ throw new AgentProofError("expired_proof", "The proof has expired.");
59
+ }
60
+ if (!Number.isFinite(claims.iat) || claims.iat - CLOCK_SKEW_SECONDS > now) {
61
+ throw new AgentProofError("expired_proof", "The proof was made in the future.");
62
+ }
63
+ if (claims.htm !== expected.method.toUpperCase()) {
64
+ throw new AgentProofError("wrong_request", "The proof was made for another method.");
65
+ }
66
+ if (claims.htu !== canonicalUrl(expected.url)) {
67
+ throw new AgentProofError("wrong_request", "The proof was made for another URL.");
68
+ }
69
+ if (expected.audience !== undefined && claims.aud !== expected.audience) {
70
+ throw new AgentProofError("wrong_audience", "The proof was made for somebody else.");
71
+ }
72
+ if (expected.body !== undefined) {
73
+ if (claims.bh !== bodyHash(expected.body)) {
74
+ throw new AgentProofError("body_changed", "The proof was made for a different body.");
75
+ }
76
+ }
77
+ if (expected.store) {
78
+ const fresh = await expected.store.claim(claims.jti, claims.exp);
79
+ if (!fresh)
80
+ throw new AgentProofError("proof_already_used", "That proof has been used.");
81
+ }
82
+ return claims;
83
+ }
package/dist/index.cjs ADDED
@@ -0,0 +1,421 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ AgentProofError: () => AgentProofError,
24
+ Jwks: () => Jwks,
25
+ MemoryReceiptStore: () => MemoryReceiptStore,
26
+ ReceiptError: () => ReceiptError,
27
+ agentJwksUrl: () => agentJwksUrl,
28
+ agentKeys: () => agentKeys,
29
+ bodyHash: () => bodyHash,
30
+ canonicalUrl: () => canonicalUrl,
31
+ http402: () => http402,
32
+ paidTool: () => paidTool,
33
+ payWall: () => payWall,
34
+ verifyAgentProof: () => verifyAgentProof,
35
+ verifyCreditToken: () => verifyCreditToken,
36
+ verifyReceipt: () => verifyReceipt,
37
+ withPayment: () => withPayment
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+ var import_node_crypto3 = require("node:crypto");
41
+
42
+ // src/verify.ts
43
+ var import_node_crypto = require("node:crypto");
44
+ var ReceiptError = class extends Error {
45
+ constructor(code, message) {
46
+ super(message);
47
+ this.code = code;
48
+ this.name = "ReceiptError";
49
+ }
50
+ };
51
+ var Jwks = class {
52
+ constructor(url, fetcher = globalThis.fetch, ttlMs = 5 * 6e4) {
53
+ this.url = url;
54
+ this.fetcher = fetcher;
55
+ this.ttlMs = ttlMs;
56
+ }
57
+ keys = /* @__PURE__ */ new Map();
58
+ fetchedAt = 0;
59
+ inflight;
60
+ async key(kid) {
61
+ const stale = Date.now() - this.fetchedAt > this.ttlMs;
62
+ if (stale || !this.keys.has(kid)) await this.refresh();
63
+ return this.keys.get(kid);
64
+ }
65
+ refresh() {
66
+ this.inflight ??= (async () => {
67
+ try {
68
+ const response = await this.fetcher(this.url, { headers: { Accept: "application/json" } });
69
+ if (!response.ok) throw new Error(`Could not fetch Tuppence's keys (${response.status})`);
70
+ const { keys } = await response.json();
71
+ this.keys = new Map(
72
+ keys.filter((k) => k.kty === "OKP" && k.crv === "Ed25519").map((k) => [k.kid, (0, import_node_crypto.createPublicKey)({ key: k, format: "jwk" })])
73
+ );
74
+ this.fetchedAt = Date.now();
75
+ } finally {
76
+ this.inflight = void 0;
77
+ }
78
+ })();
79
+ return this.inflight;
80
+ }
81
+ };
82
+ var MemoryReceiptStore = class {
83
+ used = /* @__PURE__ */ new Map();
84
+ claim(jti, expiresAt) {
85
+ const now = Date.now() / 1e3;
86
+ for (const [id, exp] of this.used) if (exp < now) this.used.delete(id);
87
+ if (this.used.has(jti)) return false;
88
+ this.used.set(jti, expiresAt);
89
+ return true;
90
+ }
91
+ };
92
+ async function verifyReceipt(token, jwks, expected) {
93
+ const parts = token.split(".");
94
+ if (parts.length !== 3)
95
+ throw new ReceiptError("malformed_receipt", "That is not a receipt token.");
96
+ const [h, p, s] = parts;
97
+ let header;
98
+ let claims;
99
+ try {
100
+ header = JSON.parse(Buffer.from(h, "base64url").toString());
101
+ claims = JSON.parse(Buffer.from(p, "base64url").toString());
102
+ } catch {
103
+ throw new ReceiptError("malformed_receipt", "That is not a receipt token.");
104
+ }
105
+ if (header.alg !== "EdDSA" || !header.kid) {
106
+ throw new ReceiptError("malformed_receipt", "Receipts are EdDSA-signed JWTs with a key id.");
107
+ }
108
+ const key = await jwks.key(header.kid);
109
+ if (!key) {
110
+ throw new ReceiptError(
111
+ "unknown_key",
112
+ "The receipt was signed by a key Tuppence does not publish."
113
+ );
114
+ }
115
+ if (!(0, import_node_crypto.verify)(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"))) {
116
+ throw new ReceiptError("bad_signature", "The receipt's signature does not verify.");
117
+ }
118
+ const now = expected.nowSeconds ?? Math.floor(Date.now() / 1e3);
119
+ if (typeof claims.exp !== "number" || now >= claims.exp) {
120
+ throw new ReceiptError("expired_receipt", "The receipt has expired. Pay again.");
121
+ }
122
+ if (claims.resource !== expected.resource) {
123
+ throw new ReceiptError(
124
+ "wrong_resource",
125
+ `The receipt is for ${claims.resource ?? "nothing in particular"}, not ${expected.resource}.`
126
+ );
127
+ }
128
+ if (claims.currency !== expected.currency || claims.amount < expected.price) {
129
+ throw new ReceiptError("wrong_amount", "The receipt is for less than the price.");
130
+ }
131
+ if (expected.account && claims.account !== expected.account) {
132
+ throw new ReceiptError("wrong_account", "The receipt was paid to someone else.");
133
+ }
134
+ return claims;
135
+ }
136
+ async function verifyCreditToken(token, jwks, expected = {}) {
137
+ const parts = token.split(".");
138
+ if (parts.length !== 3)
139
+ throw new ReceiptError("malformed_receipt", "That is not a credit token.");
140
+ const [h, p, s] = parts;
141
+ let header;
142
+ let claims;
143
+ try {
144
+ header = JSON.parse(Buffer.from(h, "base64url").toString());
145
+ claims = JSON.parse(Buffer.from(p, "base64url").toString());
146
+ } catch {
147
+ throw new ReceiptError("malformed_receipt", "That is not a credit token.");
148
+ }
149
+ const key = header.alg === "EdDSA" && header.kid ? await jwks.key(header.kid) : void 0;
150
+ if (!key) throw new ReceiptError("unknown_key", "The credit token was not signed by Tuppence.");
151
+ if (!(0, import_node_crypto.verify)(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"))) {
152
+ throw new ReceiptError("bad_signature", "The credit token's signature does not verify.");
153
+ }
154
+ if (claims.typ !== "credit")
155
+ throw new ReceiptError("malformed_receipt", "That is not a credit token.");
156
+ const now = expected.nowSeconds ?? Math.floor(Date.now() / 1e3);
157
+ if (typeof claims.exp !== "number" || now >= claims.exp) {
158
+ throw new ReceiptError("expired_receipt", "The credit token has expired.");
159
+ }
160
+ if (expected.account && claims.account !== expected.account) {
161
+ throw new ReceiptError("wrong_account", "The credit token was issued by someone else.");
162
+ }
163
+ return claims;
164
+ }
165
+
166
+ // src/agent-proof.ts
167
+ var import_node_crypto2 = require("node:crypto");
168
+ var AgentProofError = class extends Error {
169
+ constructor(code, message) {
170
+ super(message);
171
+ this.code = code;
172
+ this.name = "AgentProofError";
173
+ }
174
+ };
175
+ var agentJwksUrl = (agent, baseUrl = "https://api.tuppence.ai") => `${baseUrl.replace(/\/+$/, "")}/public/agents/${agent}/jwks.json`;
176
+ var agentKeys = (agent, baseUrl, fetcher) => new Jwks(agentJwksUrl(agent, baseUrl), fetcher);
177
+ function canonicalUrl(url) {
178
+ const parsed = new URL(url);
179
+ parsed.hash = "";
180
+ return parsed.search === "?" ? parsed.toString().replace(/\?$/, "") : parsed.toString();
181
+ }
182
+ var bodyHash = (body) => (0, import_node_crypto2.createHash)("sha256").update(body).digest("base64url");
183
+ var CLOCK_SKEW_SECONDS = 30;
184
+ async function verifyAgentProof(proof, keys, expected) {
185
+ const parts = proof.split(".");
186
+ if (parts.length !== 3) throw new AgentProofError("malformed_proof", "That is not a proof.");
187
+ const [h, p, s] = parts;
188
+ let header;
189
+ let claims;
190
+ try {
191
+ header = JSON.parse(Buffer.from(h, "base64url").toString());
192
+ claims = JSON.parse(Buffer.from(p, "base64url").toString());
193
+ } catch {
194
+ throw new AgentProofError("malformed_proof", "That is not a proof.");
195
+ }
196
+ if (header.alg !== "EdDSA" || !header.kid) {
197
+ throw new AgentProofError("malformed_proof", "A proof is an EdDSA JWT with a key id.");
198
+ }
199
+ const key = await keys.key(header.kid);
200
+ if (!key) {
201
+ throw new AgentProofError("unknown_key", "That agent does not publish the key it signed with.");
202
+ }
203
+ if (!(0, import_node_crypto2.verify)(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"))) {
204
+ throw new AgentProofError("bad_signature", "The proof's signature does not verify.");
205
+ }
206
+ if (claims.iss !== expected.agent) {
207
+ throw new AgentProofError("wrong_agent", "The proof was made by a different agent.");
208
+ }
209
+ const now = expected.nowSeconds ?? Math.floor(Date.now() / 1e3);
210
+ if (!Number.isFinite(claims.exp) || claims.exp + CLOCK_SKEW_SECONDS < now) {
211
+ throw new AgentProofError("expired_proof", "The proof has expired.");
212
+ }
213
+ if (!Number.isFinite(claims.iat) || claims.iat - CLOCK_SKEW_SECONDS > now) {
214
+ throw new AgentProofError("expired_proof", "The proof was made in the future.");
215
+ }
216
+ if (claims.htm !== expected.method.toUpperCase()) {
217
+ throw new AgentProofError("wrong_request", "The proof was made for another method.");
218
+ }
219
+ if (claims.htu !== canonicalUrl(expected.url)) {
220
+ throw new AgentProofError("wrong_request", "The proof was made for another URL.");
221
+ }
222
+ if (expected.audience !== void 0 && claims.aud !== expected.audience) {
223
+ throw new AgentProofError("wrong_audience", "The proof was made for somebody else.");
224
+ }
225
+ if (expected.body !== void 0) {
226
+ if (claims.bh !== bodyHash(expected.body)) {
227
+ throw new AgentProofError("body_changed", "The proof was made for a different body.");
228
+ }
229
+ }
230
+ if (expected.store) {
231
+ const fresh = await expected.store.claim(claims.jti, claims.exp);
232
+ if (!fresh) throw new AgentProofError("proof_already_used", "That proof has been used.");
233
+ }
234
+ return claims;
235
+ }
236
+
237
+ // src/index.ts
238
+ function payWall(options) {
239
+ const apiBase = (options.apiBase ?? "https://api.tuppence.ai").replace(/\/+$/, "");
240
+ const store = options.store ?? new MemoryReceiptStore();
241
+ const jwks = options.jwks ?? new Jwks(`${apiBase}/.well-known/jwks.json`);
242
+ const offers = /* @__PURE__ */ new Map();
243
+ const reuseMs = (options.reuseSeconds ?? 300) * 1e3;
244
+ return async (request) => {
245
+ const resource = request.resource ?? `${request.method.toUpperCase()} ${request.path}`;
246
+ const presented = /^Tuppence-Receipt\s+(\S+)$/i.exec(request.authorization ?? "")?.[1];
247
+ const credit = /^Tuppence-Credits\s+(\S+)$/i.exec(request.authorization ?? "")?.[1];
248
+ let refusal;
249
+ if (credit && options.credits && options.tuppence.meterEvents) {
250
+ try {
251
+ const claims = await verifyCreditToken(credit, jwks, {
252
+ ...options.account ? { account: options.account } : {}
253
+ });
254
+ await options.tuppence.meterEvents.create({
255
+ customer: claims.customer,
256
+ meter: options.credits.meter,
257
+ identifier: request.idempotencyKey?.slice(0, 200) || `h402_${(0, import_node_crypto3.randomUUID)()}`,
258
+ credit_token: credit
259
+ });
260
+ return { paid: true, credits: claims };
261
+ } catch (error) {
262
+ const known = error;
263
+ if (error instanceof ReceiptError) refusal = { code: error.code, message: error.message };
264
+ else if (known.code && /^(insufficient_credits|credit_token_)/.test(known.code)) {
265
+ refusal = {
266
+ code: known.code,
267
+ message: known.message ?? "The credit could not be used.",
268
+ ...known.raw?.topup_url ? { topup_url: known.raw.topup_url } : {}
269
+ };
270
+ } else throw error;
271
+ }
272
+ } else if (presented) {
273
+ try {
274
+ const receipt = await verifyReceipt(presented, jwks, {
275
+ resource,
276
+ price: options.price,
277
+ currency: options.currency,
278
+ ...options.account ? { account: options.account } : {}
279
+ });
280
+ if (await store.claim(receipt.jti, receipt.exp)) return { paid: true, receipt };
281
+ refusal = {
282
+ code: "receipt_already_used",
283
+ message: "That receipt has been used. Pay again."
284
+ };
285
+ } catch (error) {
286
+ if (!(error instanceof ReceiptError)) throw error;
287
+ refusal = { code: error.code, message: error.message };
288
+ }
289
+ }
290
+ const key = `${request.caller} ${resource}`;
291
+ const now = Date.now();
292
+ let offer = offers.get(key);
293
+ if (!offer || now - offer.at > reuseMs || refusal?.code === "receipt_already_used") {
294
+ const payment = await options.tuppence.payments.create({
295
+ amount: options.price,
296
+ currency: options.currency,
297
+ resource,
298
+ ...options.description ? { description: options.description } : {}
299
+ });
300
+ const clientSecret = String(payment.approval_url).split("#")[1] ?? "";
301
+ offer = {
302
+ at: now,
303
+ headers: { Link: `<${payment.approval_url}>; rel="payment"` },
304
+ body: {
305
+ price: options.price,
306
+ currency: options.currency,
307
+ ...options.description ? { description: options.description } : {},
308
+ resource,
309
+ payment_id: payment.id,
310
+ accepts: [
311
+ { type: "card_link", url: payment.approval_url },
312
+ ...options.credits ? [{ type: "credits", scheme: "Tuppence-Credits", meter: options.credits.meter }] : []
313
+ ],
314
+ // Once paid: GET `url` with `header: client_secret`, and retry with its receipt_token.
315
+ receipt: {
316
+ url: `${apiBase}/public/payments/${payment.id}`,
317
+ header: "Tuppence-Client-Secret",
318
+ client_secret: clientSecret
319
+ }
320
+ }
321
+ };
322
+ offers.set(key, offer);
323
+ }
324
+ return {
325
+ paid: false,
326
+ status: 402,
327
+ headers: offer.headers,
328
+ body: {
329
+ error: {
330
+ type: "payment_required",
331
+ code: refusal?.code ?? "payment_required",
332
+ message: refusal?.message ?? "Payment required. Pay at the URL, then retry with the receipt.",
333
+ ...refusal?.topup_url ? { topup_url: refusal.topup_url } : {}
334
+ },
335
+ ...offer.body
336
+ }
337
+ };
338
+ };
339
+ }
340
+ var paidBy = (decision) => decision.receipt ? { receipt: decision.receipt } : { credits: decision.credits };
341
+ function http402(options) {
342
+ const decide = payWall(options);
343
+ return (req, res, next) => {
344
+ decide({
345
+ method: req.method ?? "GET",
346
+ path: req.path ?? new URL(req.url ?? "/", "http://localhost").pathname,
347
+ authorization: req.headers.authorization,
348
+ idempotencyKey: req.headers["idempotency-key"],
349
+ caller: options.caller?.(req) ?? req.ip ?? req.socket.remoteAddress ?? "unknown",
350
+ ...options.resource ? { resource: options.resource(req) } : {}
351
+ }).then(
352
+ (decision) => {
353
+ if (decision.paid) {
354
+ if (decision.receipt) req.tuppenceReceipt = decision.receipt;
355
+ if (decision.credits) req.tuppenceCredits = decision.credits;
356
+ next();
357
+ return;
358
+ }
359
+ res.writeHead(decision.status, { "content-type": "application/json", ...decision.headers });
360
+ res.end(JSON.stringify(decision.body));
361
+ },
362
+ (error) => next(error)
363
+ );
364
+ };
365
+ }
366
+ function withPayment(options, handler) {
367
+ const decide = payWall(options);
368
+ return async (request) => {
369
+ const url = new URL(request.url);
370
+ const decision = await decide({
371
+ method: request.method,
372
+ path: url.pathname,
373
+ authorization: request.headers.get("authorization"),
374
+ idempotencyKey: request.headers.get("idempotency-key"),
375
+ caller: options.callerOf?.(request) ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"
376
+ });
377
+ if (decision.paid) return handler(request, paidBy(decision));
378
+ return Response.json(decision.body, { status: decision.status, headers: decision.headers });
379
+ };
380
+ }
381
+ function paidTool(options, handler) {
382
+ const decide = payWall(options);
383
+ return async (args, extra) => {
384
+ const decision = await decide({
385
+ method: "TOOL",
386
+ path: options.name,
387
+ resource: `tool:${options.name}`,
388
+ authorization: args.credit_token ? `Tuppence-Credits ${args.credit_token}` : args.receipt ? `Tuppence-Receipt ${args.receipt}` : null,
389
+ caller: extra?.sessionId ?? "mcp"
390
+ });
391
+ if (decision.paid) return handler(args, paidBy(decision));
392
+ return {
393
+ isError: true,
394
+ content: [
395
+ {
396
+ type: "text",
397
+ text: `Payment required: pay ${options.price} ${options.currency} at the URL below, fetch the receipt, then call this tool again with \`receipt\` set.
398
+ ${JSON.stringify(decision.body, null, 2)}`
399
+ }
400
+ ]
401
+ };
402
+ };
403
+ }
404
+ // Annotate the CommonJS export names for ESM import in node:
405
+ 0 && (module.exports = {
406
+ AgentProofError,
407
+ Jwks,
408
+ MemoryReceiptStore,
409
+ ReceiptError,
410
+ agentJwksUrl,
411
+ agentKeys,
412
+ bodyHash,
413
+ canonicalUrl,
414
+ http402,
415
+ paidTool,
416
+ payWall,
417
+ verifyAgentProof,
418
+ verifyCreditToken,
419
+ verifyReceipt,
420
+ withPayment
421
+ });
@@ -0,0 +1,142 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { type CreditClaims, Jwks, type ReceiptClaims, type ReceiptStore } from "./verify.js";
3
+ export type { AgentProofClaims, AgentProofErrorCode } from "./agent-proof.js";
4
+ export { AgentProofError, agentJwksUrl, agentKeys, bodyHash, canonicalUrl, verifyAgentProof, } from "./agent-proof.js";
5
+ export type { CreditClaims, ReceiptClaims, ReceiptErrorCode, ReceiptStore } from "./verify.js";
6
+ export { Jwks, MemoryReceiptStore, ReceiptError, verifyCreditToken, verifyReceipt, } from "./verify.js";
7
+ /**
8
+ * What the pay wall needs from a Tuppence client: to create a payment. `new Tuppence(key)` from
9
+ * `@tuppence/node` is exactly this — any object with the same method will do.
10
+ */
11
+ export type PaymentsClient = {
12
+ payments: {
13
+ create(body: {
14
+ amount: number;
15
+ currency: string;
16
+ resource?: string;
17
+ description?: string;
18
+ }): Promise<{
19
+ id: string;
20
+ approval_url: string;
21
+ }>;
22
+ };
23
+ /** Needed for the credits rail (`credits` below). */
24
+ meterEvents?: {
25
+ create(body: {
26
+ customer: string;
27
+ meter: string;
28
+ identifier: string;
29
+ credit_token: string;
30
+ }): Promise<unknown>;
31
+ };
32
+ };
33
+ export type Http402Options = {
34
+ /** `new Tuppence(process.env.TUPPENCE_SECRET_KEY)` from `@tuppence/node`. */
35
+ tuppence: PaymentsClient;
36
+ /** In minor units: 50 = 50p. */
37
+ price: number;
38
+ currency: string;
39
+ description?: string;
40
+ /** What is being bought. Default `<METHOD> <path>`: a receipt for one route opens no other. */
41
+ resource?: (req: IncomingMessage) => string;
42
+ /** Your account id (`acct_…`): receipts paid to anyone else are refused. Recommended. */
43
+ account?: string;
44
+ /** Where used receipts are remembered. Default: this process's memory. */
45
+ store?: ReceiptStore;
46
+ /** Default `https://api.tuppence.ai`: where the keys (JWKS) and receipts live. */
47
+ apiBase?: string;
48
+ /** Where public keys come from. Default: `<apiBase>/.well-known/jwks.json`, cached. */
49
+ jwks?: Jwks;
50
+ /** How long an unpaid caller keeps getting the SAME payment to pay. Default 300 s. */
51
+ reuseSeconds?: number;
52
+ /** Who is asking, for that reuse. Default: the caller's address. */
53
+ caller?: (req: IncomingMessage) => string;
54
+ /**
55
+ * The credits rail (plan 12-06): let a customer's agent pay from their prepaid credit with a
56
+ * credit token (`Authorization: Tuppence-Credits <token>`) — no card, no fee per call. Each
57
+ * request records one use of `meter` (price it the same as `price`).
58
+ */
59
+ credits?: {
60
+ meter: string;
61
+ };
62
+ };
63
+ export type Decision = {
64
+ paid: true;
65
+ receipt: ReceiptClaims;
66
+ credits?: undefined;
67
+ } | {
68
+ paid: true;
69
+ credits: CreditClaims;
70
+ receipt?: undefined;
71
+ } | {
72
+ paid: false;
73
+ status: 402;
74
+ headers: Record<string, string>;
75
+ body: Record<string, unknown>;
76
+ };
77
+ /**
78
+ * HTTP 402 — "one line to put a price on a route" (plan 11-09). The framework-free core: given
79
+ * who is asking, for what, with which `Authorization`, decide. Adapters wrap it.
80
+ *
81
+ * Unpaid → `402 Payment Required` with where to pay (and a `Link` header). Once paid, the caller
82
+ * retries with `Authorization: Tuppence-Receipt <token>`, checked OFFLINE against Tuppence's
83
+ * published keys — no call to Tuppence on the hot path — and accepted once.
84
+ */
85
+ export declare function payWall(options: Http402Options): (request: {
86
+ method: string;
87
+ path: string;
88
+ authorization?: string | null;
89
+ caller: string;
90
+ resource?: string;
91
+ /** The caller's `Idempotency-Key`: a retried request is then one use of credit, not two. */
92
+ idempotencyKey?: string | null;
93
+ }) => Promise<Decision>;
94
+ /** How a request was paid for: a receipt (card link) or a credit token (credits rail). */
95
+ export type Paid = {
96
+ receipt?: ReceiptClaims;
97
+ credits?: CreditClaims;
98
+ };
99
+ type NodeRequest = IncomingMessage & {
100
+ tuppenceReceipt?: ReceiptClaims;
101
+ tuppenceCredits?: CreditClaims;
102
+ path?: string;
103
+ ip?: string;
104
+ };
105
+ /**
106
+ * Node `http`, Express or Connect middleware:
107
+ *
108
+ * app.get("/v1/report", http402({ tuppence, price: 50, currency: "gbp" }), handler);
109
+ *
110
+ * On success, `req.tuppenceReceipt` holds the receipt's claims.
111
+ */
112
+ export declare function http402(options: Http402Options): (req: NodeRequest, res: ServerResponse, next: (error?: unknown) => void) => void;
113
+ /**
114
+ * Fetch-style handlers (Next.js route handlers, Hono, Bun, Deno, Workers):
115
+ *
116
+ * export const GET = withPayment({ tuppence, price: 50, currency: "gbp" }, async () => Response.json(report));
117
+ */
118
+ export declare function withPayment(options: Http402Options & {
119
+ callerOf?: (request: Request) => string;
120
+ }, handler: (request: Request, paid: Paid) => Response | Promise<Response>): (request: Request) => Promise<Response>;
121
+ /**
122
+ * A paid MCP tool (any MCP server SDK): the tool takes an optional `receipt` argument. Without
123
+ * a good one it answers with where to pay — as a tool error the agent can act on — and with one
124
+ * it runs, once.
125
+ *
126
+ * server.tool("deep_research", schema, paidTool({ tuppence, price: 200, currency: "gbp", name: "deep_research" }, run));
127
+ */
128
+ export declare function paidTool<A extends {
129
+ receipt?: string;
130
+ credit_token?: string;
131
+ }, R>(options: Omit<Http402Options, "resource" | "caller"> & {
132
+ name: string;
133
+ }, handler: (args: A, paid: Paid) => R | Promise<R>): (args: A, extra?: {
134
+ sessionId?: string;
135
+ }) => Promise<R | ToolPaymentRequired>;
136
+ export type ToolPaymentRequired = {
137
+ isError: true;
138
+ content: {
139
+ type: "text";
140
+ text: string;
141
+ }[];
142
+ };
package/dist/index.js ADDED
@@ -0,0 +1,215 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Jwks, MemoryReceiptStore, ReceiptError, verifyCreditToken, verifyReceipt, } from "./verify.js";
3
+ export { AgentProofError, agentJwksUrl, agentKeys, bodyHash, canonicalUrl, verifyAgentProof, } from "./agent-proof.js";
4
+ export { Jwks, MemoryReceiptStore, ReceiptError, verifyCreditToken, verifyReceipt, } from "./verify.js";
5
+ /**
6
+ * HTTP 402 — "one line to put a price on a route" (plan 11-09). The framework-free core: given
7
+ * who is asking, for what, with which `Authorization`, decide. Adapters wrap it.
8
+ *
9
+ * Unpaid → `402 Payment Required` with where to pay (and a `Link` header). Once paid, the caller
10
+ * retries with `Authorization: Tuppence-Receipt <token>`, checked OFFLINE against Tuppence's
11
+ * published keys — no call to Tuppence on the hot path — and accepted once.
12
+ */
13
+ export function payWall(options) {
14
+ const apiBase = (options.apiBase ?? "https://api.tuppence.ai").replace(/\/+$/, "");
15
+ const store = options.store ?? new MemoryReceiptStore();
16
+ const jwks = options.jwks ?? new Jwks(`${apiBase}/.well-known/jwks.json`);
17
+ const offers = new Map();
18
+ const reuseMs = (options.reuseSeconds ?? 300) * 1000;
19
+ return async (request) => {
20
+ const resource = request.resource ?? `${request.method.toUpperCase()} ${request.path}`;
21
+ const presented = /^Tuppence-Receipt\s+(\S+)$/i.exec(request.authorization ?? "")?.[1];
22
+ const credit = /^Tuppence-Credits\s+(\S+)$/i.exec(request.authorization ?? "")?.[1];
23
+ let refusal;
24
+ if (credit && options.credits && options.tuppence.meterEvents) {
25
+ try {
26
+ const claims = await verifyCreditToken(credit, jwks, {
27
+ ...(options.account ? { account: options.account } : {}),
28
+ });
29
+ await options.tuppence.meterEvents.create({
30
+ customer: claims.customer,
31
+ meter: options.credits.meter,
32
+ identifier: request.idempotencyKey?.slice(0, 200) || `h402_${randomUUID()}`,
33
+ credit_token: credit,
34
+ });
35
+ return { paid: true, credits: claims };
36
+ }
37
+ catch (error) {
38
+ const known = error;
39
+ if (error instanceof ReceiptError)
40
+ refusal = { code: error.code, message: error.message };
41
+ else if (known.code && /^(insufficient_credits|credit_token_)/.test(known.code)) {
42
+ refusal = {
43
+ code: known.code,
44
+ message: known.message ?? "The credit could not be used.",
45
+ ...(known.raw?.topup_url ? { topup_url: known.raw.topup_url } : {}),
46
+ };
47
+ }
48
+ else
49
+ throw error;
50
+ }
51
+ }
52
+ else if (presented) {
53
+ try {
54
+ const receipt = await verifyReceipt(presented, jwks, {
55
+ resource,
56
+ price: options.price,
57
+ currency: options.currency,
58
+ ...(options.account ? { account: options.account } : {}),
59
+ });
60
+ if (await store.claim(receipt.jti, receipt.exp))
61
+ return { paid: true, receipt };
62
+ refusal = {
63
+ code: "receipt_already_used",
64
+ message: "That receipt has been used. Pay again.",
65
+ };
66
+ }
67
+ catch (error) {
68
+ if (!(error instanceof ReceiptError))
69
+ throw error;
70
+ refusal = { code: error.code, message: error.message };
71
+ }
72
+ }
73
+ // Where to pay — the same payment for a few minutes, so an agent that retries does not
74
+ // leave a trail of unpaid payments behind it. A spent receipt needs a new payment.
75
+ const key = `${request.caller} ${resource}`;
76
+ const now = Date.now();
77
+ let offer = offers.get(key);
78
+ if (!offer || now - offer.at > reuseMs || refusal?.code === "receipt_already_used") {
79
+ const payment = await options.tuppence.payments.create({
80
+ amount: options.price,
81
+ currency: options.currency,
82
+ resource,
83
+ ...(options.description ? { description: options.description } : {}),
84
+ });
85
+ const clientSecret = String(payment.approval_url).split("#")[1] ?? "";
86
+ offer = {
87
+ at: now,
88
+ headers: { Link: `<${payment.approval_url}>; rel="payment"` },
89
+ body: {
90
+ price: options.price,
91
+ currency: options.currency,
92
+ ...(options.description ? { description: options.description } : {}),
93
+ resource,
94
+ payment_id: payment.id,
95
+ accepts: [
96
+ { type: "card_link", url: payment.approval_url },
97
+ ...(options.credits
98
+ ? [{ type: "credits", scheme: "Tuppence-Credits", meter: options.credits.meter }]
99
+ : []),
100
+ ],
101
+ // Once paid: GET `url` with `header: client_secret`, and retry with its receipt_token.
102
+ receipt: {
103
+ url: `${apiBase}/public/payments/${payment.id}`,
104
+ header: "Tuppence-Client-Secret",
105
+ client_secret: clientSecret,
106
+ },
107
+ },
108
+ };
109
+ offers.set(key, offer);
110
+ }
111
+ return {
112
+ paid: false,
113
+ status: 402,
114
+ headers: offer.headers,
115
+ body: {
116
+ error: {
117
+ type: "payment_required",
118
+ code: refusal?.code ?? "payment_required",
119
+ message: refusal?.message ?? "Payment required. Pay at the URL, then retry with the receipt.",
120
+ ...(refusal?.topup_url ? { topup_url: refusal.topup_url } : {}),
121
+ },
122
+ ...offer.body,
123
+ },
124
+ };
125
+ };
126
+ }
127
+ const paidBy = (decision) => decision.receipt ? { receipt: decision.receipt } : { credits: decision.credits };
128
+ /**
129
+ * Node `http`, Express or Connect middleware:
130
+ *
131
+ * app.get("/v1/report", http402({ tuppence, price: 50, currency: "gbp" }), handler);
132
+ *
133
+ * On success, `req.tuppenceReceipt` holds the receipt's claims.
134
+ */
135
+ export function http402(options) {
136
+ const decide = payWall(options);
137
+ return (req, res, next) => {
138
+ decide({
139
+ method: req.method ?? "GET",
140
+ path: req.path ?? new URL(req.url ?? "/", "http://localhost").pathname,
141
+ authorization: req.headers.authorization,
142
+ idempotencyKey: req.headers["idempotency-key"],
143
+ caller: options.caller?.(req) ?? req.ip ?? req.socket.remoteAddress ?? "unknown",
144
+ ...(options.resource ? { resource: options.resource(req) } : {}),
145
+ }).then((decision) => {
146
+ if (decision.paid) {
147
+ if (decision.receipt)
148
+ req.tuppenceReceipt = decision.receipt;
149
+ if (decision.credits)
150
+ req.tuppenceCredits = decision.credits;
151
+ next();
152
+ return;
153
+ }
154
+ res.writeHead(decision.status, { "content-type": "application/json", ...decision.headers });
155
+ res.end(JSON.stringify(decision.body));
156
+ }, (error) => next(error));
157
+ };
158
+ }
159
+ /**
160
+ * Fetch-style handlers (Next.js route handlers, Hono, Bun, Deno, Workers):
161
+ *
162
+ * export const GET = withPayment({ tuppence, price: 50, currency: "gbp" }, async () => Response.json(report));
163
+ */
164
+ export function withPayment(options, handler) {
165
+ const decide = payWall(options);
166
+ return async (request) => {
167
+ const url = new URL(request.url);
168
+ const decision = await decide({
169
+ method: request.method,
170
+ path: url.pathname,
171
+ authorization: request.headers.get("authorization"),
172
+ idempotencyKey: request.headers.get("idempotency-key"),
173
+ caller: options.callerOf?.(request) ??
174
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
175
+ "unknown",
176
+ });
177
+ if (decision.paid)
178
+ return handler(request, paidBy(decision));
179
+ return Response.json(decision.body, { status: decision.status, headers: decision.headers });
180
+ };
181
+ }
182
+ /**
183
+ * A paid MCP tool (any MCP server SDK): the tool takes an optional `receipt` argument. Without
184
+ * a good one it answers with where to pay — as a tool error the agent can act on — and with one
185
+ * it runs, once.
186
+ *
187
+ * server.tool("deep_research", schema, paidTool({ tuppence, price: 200, currency: "gbp", name: "deep_research" }, run));
188
+ */
189
+ export function paidTool(options, handler) {
190
+ const decide = payWall(options);
191
+ return async (args, extra) => {
192
+ const decision = await decide({
193
+ method: "TOOL",
194
+ path: options.name,
195
+ resource: `tool:${options.name}`,
196
+ authorization: args.credit_token
197
+ ? `Tuppence-Credits ${args.credit_token}`
198
+ : args.receipt
199
+ ? `Tuppence-Receipt ${args.receipt}`
200
+ : null,
201
+ caller: extra?.sessionId ?? "mcp",
202
+ });
203
+ if (decision.paid)
204
+ return handler(args, paidBy(decision));
205
+ return {
206
+ isError: true,
207
+ content: [
208
+ {
209
+ type: "text",
210
+ text: `Payment required: pay ${options.price} ${options.currency} at the URL below, fetch the receipt, then call this tool again with \`receipt\` set.\n${JSON.stringify(decision.body, null, 2)}`,
211
+ },
212
+ ],
213
+ };
214
+ };
215
+ }
@@ -0,0 +1,78 @@
1
+ import { type KeyObject } from "node:crypto";
2
+ /** The claims inside a Tuppence receipt token (plan 11-08). */
3
+ export type ReceiptClaims = {
4
+ iss: string;
5
+ payment_id: string;
6
+ account: string;
7
+ livemode: boolean;
8
+ amount: number;
9
+ currency: string;
10
+ resource: string | null;
11
+ iat: number;
12
+ exp: number;
13
+ jti: string;
14
+ };
15
+ export type ReceiptErrorCode = "malformed_receipt" | "unknown_key" | "bad_signature" | "expired_receipt" | "wrong_resource" | "wrong_amount" | "wrong_account" | "receipt_already_used";
16
+ export declare class ReceiptError extends Error {
17
+ readonly code: ReceiptErrorCode;
18
+ constructor(code: ReceiptErrorCode, message: string);
19
+ }
20
+ /**
21
+ * Tuppence's public keys, fetched from its JWKS and kept for five minutes — and fetched again
22
+ * at once if a token names a key we have not seen (a rotation). Nothing else calls Tuppence:
23
+ * verifying a receipt is offline.
24
+ */
25
+ export declare class Jwks {
26
+ private readonly url;
27
+ private readonly fetcher;
28
+ private readonly ttlMs;
29
+ private keys;
30
+ private fetchedAt;
31
+ private inflight;
32
+ constructor(url: string, fetcher?: typeof fetch, ttlMs?: number);
33
+ key(kid: string): Promise<KeyObject | undefined>;
34
+ private refresh;
35
+ }
36
+ /** Where a receipt's `jti` is remembered until it expires, so each receipt is used once. */
37
+ export interface ReceiptStore {
38
+ /** True if `jti` was new (and is now taken); false if it had been used. */
39
+ claim(jti: string, expiresAt: number): Promise<boolean> | boolean;
40
+ }
41
+ /** The default store: this process's memory. Run more than one process? Share a store. */
42
+ export declare class MemoryReceiptStore implements ReceiptStore {
43
+ private readonly used;
44
+ claim(jti: string, expiresAt: number): boolean;
45
+ }
46
+ /**
47
+ * Checks a receipt token completely: signature (against the JWKS), expiry, the resource it was
48
+ * bought for, amount and currency, and — if you say — the account it was paid to. It does NOT
49
+ * mark the receipt used: `claim` its `jti` in a store for that.
50
+ */
51
+ export declare function verifyReceipt(token: string, jwks: Jwks, expected: {
52
+ resource: string;
53
+ price: number;
54
+ currency: string;
55
+ account?: string;
56
+ nowSeconds?: number;
57
+ }): Promise<ReceiptClaims>;
58
+ /** The claims inside a Tuppence credit token (plan 12-06): an agent's permission to spend. */
59
+ export type CreditClaims = {
60
+ typ: "credit";
61
+ iss: string;
62
+ account: string;
63
+ livemode: boolean;
64
+ customer: string;
65
+ max_amount: number;
66
+ currency: string;
67
+ iat: number;
68
+ exp: number;
69
+ jti: string;
70
+ };
71
+ /**
72
+ * A credit token, checked offline before anything is spent: signature, kind, expiry and — if
73
+ * you say — the account. The cap is enforced by Tuppence when the usage is recorded.
74
+ */
75
+ export declare function verifyCreditToken(token: string, jwks: Jwks, expected?: {
76
+ account?: string;
77
+ nowSeconds?: number;
78
+ }): Promise<CreditClaims>;
package/dist/verify.js ADDED
@@ -0,0 +1,144 @@
1
+ import { createPublicKey, verify } from "node:crypto";
2
+ export class ReceiptError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "ReceiptError";
8
+ }
9
+ }
10
+ /**
11
+ * Tuppence's public keys, fetched from its JWKS and kept for five minutes — and fetched again
12
+ * at once if a token names a key we have not seen (a rotation). Nothing else calls Tuppence:
13
+ * verifying a receipt is offline.
14
+ */
15
+ export class Jwks {
16
+ url;
17
+ fetcher;
18
+ ttlMs;
19
+ keys = new Map();
20
+ fetchedAt = 0;
21
+ inflight;
22
+ constructor(url, fetcher = globalThis.fetch, ttlMs = 5 * 60_000) {
23
+ this.url = url;
24
+ this.fetcher = fetcher;
25
+ this.ttlMs = ttlMs;
26
+ }
27
+ async key(kid) {
28
+ const stale = Date.now() - this.fetchedAt > this.ttlMs;
29
+ if (stale || !this.keys.has(kid))
30
+ await this.refresh();
31
+ return this.keys.get(kid);
32
+ }
33
+ refresh() {
34
+ this.inflight ??= (async () => {
35
+ try {
36
+ const response = await this.fetcher(this.url, { headers: { Accept: "application/json" } });
37
+ if (!response.ok)
38
+ throw new Error(`Could not fetch Tuppence's keys (${response.status})`);
39
+ const { keys } = (await response.json());
40
+ this.keys = new Map(keys
41
+ .filter((k) => k.kty === "OKP" && k.crv === "Ed25519")
42
+ .map((k) => [k.kid, createPublicKey({ key: k, format: "jwk" })]));
43
+ this.fetchedAt = Date.now();
44
+ }
45
+ finally {
46
+ this.inflight = undefined;
47
+ }
48
+ })();
49
+ return this.inflight;
50
+ }
51
+ }
52
+ /** The default store: this process's memory. Run more than one process? Share a store. */
53
+ export class MemoryReceiptStore {
54
+ used = new Map();
55
+ claim(jti, expiresAt) {
56
+ const now = Date.now() / 1000;
57
+ for (const [id, exp] of this.used)
58
+ if (exp < now)
59
+ this.used.delete(id);
60
+ if (this.used.has(jti))
61
+ return false;
62
+ this.used.set(jti, expiresAt);
63
+ return true;
64
+ }
65
+ }
66
+ /**
67
+ * Checks a receipt token completely: signature (against the JWKS), expiry, the resource it was
68
+ * bought for, amount and currency, and — if you say — the account it was paid to. It does NOT
69
+ * mark the receipt used: `claim` its `jti` in a store for that.
70
+ */
71
+ export async function verifyReceipt(token, jwks, expected) {
72
+ const parts = token.split(".");
73
+ if (parts.length !== 3)
74
+ throw new ReceiptError("malformed_receipt", "That is not a receipt token.");
75
+ const [h, p, s] = parts;
76
+ let header;
77
+ let claims;
78
+ try {
79
+ header = JSON.parse(Buffer.from(h, "base64url").toString());
80
+ claims = JSON.parse(Buffer.from(p, "base64url").toString());
81
+ }
82
+ catch {
83
+ throw new ReceiptError("malformed_receipt", "That is not a receipt token.");
84
+ }
85
+ if (header.alg !== "EdDSA" || !header.kid) {
86
+ throw new ReceiptError("malformed_receipt", "Receipts are EdDSA-signed JWTs with a key id.");
87
+ }
88
+ const key = await jwks.key(header.kid);
89
+ if (!key) {
90
+ throw new ReceiptError("unknown_key", "The receipt was signed by a key Tuppence does not publish.");
91
+ }
92
+ if (!verify(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"))) {
93
+ throw new ReceiptError("bad_signature", "The receipt's signature does not verify.");
94
+ }
95
+ const now = expected.nowSeconds ?? Math.floor(Date.now() / 1000);
96
+ if (typeof claims.exp !== "number" || now >= claims.exp) {
97
+ throw new ReceiptError("expired_receipt", "The receipt has expired. Pay again.");
98
+ }
99
+ if (claims.resource !== expected.resource) {
100
+ throw new ReceiptError("wrong_resource", `The receipt is for ${claims.resource ?? "nothing in particular"}, not ${expected.resource}.`);
101
+ }
102
+ if (claims.currency !== expected.currency || claims.amount < expected.price) {
103
+ throw new ReceiptError("wrong_amount", "The receipt is for less than the price.");
104
+ }
105
+ if (expected.account && claims.account !== expected.account) {
106
+ throw new ReceiptError("wrong_account", "The receipt was paid to someone else.");
107
+ }
108
+ return claims;
109
+ }
110
+ /**
111
+ * A credit token, checked offline before anything is spent: signature, kind, expiry and — if
112
+ * you say — the account. The cap is enforced by Tuppence when the usage is recorded.
113
+ */
114
+ export async function verifyCreditToken(token, jwks, expected = {}) {
115
+ const parts = token.split(".");
116
+ if (parts.length !== 3)
117
+ throw new ReceiptError("malformed_receipt", "That is not a credit token.");
118
+ const [h, p, s] = parts;
119
+ let header;
120
+ let claims;
121
+ try {
122
+ header = JSON.parse(Buffer.from(h, "base64url").toString());
123
+ claims = JSON.parse(Buffer.from(p, "base64url").toString());
124
+ }
125
+ catch {
126
+ throw new ReceiptError("malformed_receipt", "That is not a credit token.");
127
+ }
128
+ const key = header.alg === "EdDSA" && header.kid ? await jwks.key(header.kid) : undefined;
129
+ if (!key)
130
+ throw new ReceiptError("unknown_key", "The credit token was not signed by Tuppence.");
131
+ if (!verify(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"))) {
132
+ throw new ReceiptError("bad_signature", "The credit token's signature does not verify.");
133
+ }
134
+ if (claims.typ !== "credit")
135
+ throw new ReceiptError("malformed_receipt", "That is not a credit token.");
136
+ const now = expected.nowSeconds ?? Math.floor(Date.now() / 1000);
137
+ if (typeof claims.exp !== "number" || now >= claims.exp) {
138
+ throw new ReceiptError("expired_receipt", "The credit token has expired.");
139
+ }
140
+ if (expected.account && claims.account !== expected.account) {
141
+ throw new ReceiptError("wrong_account", "The credit token was issued by someone else.");
142
+ }
143
+ return claims;
144
+ }
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@tuppence/http402",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Put a price on an API route or an MCP tool: HTTP 402 with Tuppence receipts, verified offline.",
5
+ "license": "MIT",
6
+ "homepage": "https://docs.tuppence.ai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ZenningAI/tuppence.git",
10
+ "directory": "packages/http402"
11
+ },
12
+ "bugs": "https://github.com/ZenningAI/tuppence/issues",
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "sideEffects": false,
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js",
22
+ "require": "./dist/index.cjs"
23
+ }
24
+ },
25
+ "main": "./dist/index.cjs",
26
+ "types": "./dist/index.d.ts",
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "devDependencies": {
32
+ "@types/node": "^22.20.4",
33
+ "esbuild": "^0.25.12",
34
+ "typescript": "^5.9.3",
35
+ "vitest": "^4.1.12"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && esbuild src/index.ts --bundle --platform=node --target=node18 --format=cjs --outfile=dist/index.cjs --log-level=warning",
42
+ "typecheck": "tsc -p tsconfig.json --noEmit",
43
+ "test": "vitest run"
44
+ }
6
45
  }