@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 +101 -2
- package/dist/agent-proof.d.ts +56 -0
- package/dist/agent-proof.js +83 -0
- package/dist/index.cjs +421 -0
- package/dist/index.d.ts +142 -0
- package/dist/index.js +215 -0
- package/dist/verify.d.ts +78 -0
- package/dist/verify.js +144 -0
- package/package.json +42 -3
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tuppence/http402
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|