@tollbase/middleware 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/express.d.ts +37 -0
- package/dist/express.js +68 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +200 -0
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +38 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tollbase contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @tollbase/middleware
|
|
2
|
+
|
|
3
|
+
Let AI agents become paying customers of your API or MCP server: verified agent
|
|
4
|
+
identity, human-approved spending caps, scoped keys, per-call metering, and
|
|
5
|
+
billing through your own Stripe account.
|
|
6
|
+
|
|
7
|
+
This is the vendor middleware. It mounts inside your existing server, serves
|
|
8
|
+
your agent-facing manifest, verifies agent tokens locally (your latency and
|
|
9
|
+
uptime never depend on the Tollbase control plane), enforces spending caps, and
|
|
10
|
+
streams idempotency-keyed usage for exact billing.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @tollbase/middleware
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Integrate (Express)
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { Tollbase } from "@tollbase/middleware";
|
|
22
|
+
import { tollbaseRouter, protect } from "@tollbase/middleware/express";
|
|
23
|
+
|
|
24
|
+
const tb = new Tollbase({ serviceSlug, serviceKey, controlPlaneUrl });
|
|
25
|
+
await tb.init();
|
|
26
|
+
|
|
27
|
+
app.use(tollbaseRouter(tb)); // the agent front door
|
|
28
|
+
app.post("/v1/enrich", protect(tb, "enrich", "enrich:read"), (req, res) => res.json(doWork(req.body)));
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
High-value routes can opt into a synchronous ledger check:
|
|
32
|
+
`protect(tb, "bulk", "enrich:read", { strict: true, costCents: 30 })`.
|
|
33
|
+
|
|
34
|
+
## MCP servers
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { paidTool } from "@tollbase/middleware/mcp";
|
|
38
|
+
server.registerTool("enrich", schema, paidTool(tb, "enrich", "enrich:read", handler));
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## How it behaves
|
|
42
|
+
|
|
43
|
+
- Local JWT verification against a cached JWKS; no network on the hot path.
|
|
44
|
+
- Optimistic cap enforcement from token claims, reconciled at every key refresh;
|
|
45
|
+
overspend bounded to one 15-minute token window by design.
|
|
46
|
+
- Usage streams asynchronously with client idempotency keys; at-least-once
|
|
47
|
+
delivery cannot double-bill.
|
|
48
|
+
- Revocation propagates through a pulled feed; in-flight tokens die within one
|
|
49
|
+
poll interval.
|
|
50
|
+
|
|
51
|
+
A Python SDK with identical behavior is available: `pip install tollbase`.
|
|
52
|
+
|
|
53
|
+
MIT.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Router, type Request, type Response, type NextFunction } from "express";
|
|
2
|
+
import type { Tollbase, VerifiedAgent } from "./index.js";
|
|
3
|
+
declare global {
|
|
4
|
+
namespace Express {
|
|
5
|
+
interface Request {
|
|
6
|
+
tollbase?: {
|
|
7
|
+
agent: VerifiedAgent;
|
|
8
|
+
meter: (tool: string, units?: number, costCents?: number) => void;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Mounts the agent-facing front door on the vendor's app:
|
|
15
|
+
* GET /.well-known/agent.json discovery manifest
|
|
16
|
+
* POST /agent/register proxied to the control plane
|
|
17
|
+
* POST /agent/keys proxied (mint/refresh; carries agent_secret or refresh_token)
|
|
18
|
+
* POST /agent/topup-request proxied
|
|
19
|
+
* GET /agent/accounts/:id/status proxied
|
|
20
|
+
* Agents interact with ONE origin (the vendor); Tollbase stays behind it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function tollbaseRouter(dm: Tollbase): Router;
|
|
23
|
+
export interface ProtectOptions {
|
|
24
|
+
/**
|
|
25
|
+
* strict: one synchronous cap check per call against the control plane —
|
|
26
|
+
* for high-value routes where exactness beats latency. Default false
|
|
27
|
+
* (optimistic local enforcement, bounded by one token TTL window).
|
|
28
|
+
*/
|
|
29
|
+
strict?: boolean;
|
|
30
|
+
/** Override the per-call price; defaults to the plan's unit_cents. */
|
|
31
|
+
costCents?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Protect a paid route: verify locally, check scope, gate against the cap,
|
|
35
|
+
* and auto-meter one unit. `requiredScope` maps the route to a plan scope.
|
|
36
|
+
*/
|
|
37
|
+
export declare function protect(dm: Tollbase, tool: string, requiredScope: string, opts?: ProtectOptions): (req: Request, res: Response, next: NextFunction) => Promise<Response<any, Record<string, any>> | undefined>;
|
package/dist/express.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import express from "express";
|
|
3
|
+
/**
|
|
4
|
+
* Mounts the agent-facing front door on the vendor's app:
|
|
5
|
+
* GET /.well-known/agent.json discovery manifest
|
|
6
|
+
* POST /agent/register proxied to the control plane
|
|
7
|
+
* POST /agent/keys proxied (mint/refresh; carries agent_secret or refresh_token)
|
|
8
|
+
* POST /agent/topup-request proxied
|
|
9
|
+
* GET /agent/accounts/:id/status proxied
|
|
10
|
+
* Agents interact with ONE origin (the vendor); Tollbase stays behind it.
|
|
11
|
+
*/
|
|
12
|
+
export function tollbaseRouter(dm) {
|
|
13
|
+
const r = Router();
|
|
14
|
+
r.use(express.json());
|
|
15
|
+
const cp = dm.cfg.controlPlaneUrl;
|
|
16
|
+
const proxy = (method, target) => async (req, res) => {
|
|
17
|
+
const headers = { "content-type": "application/json" };
|
|
18
|
+
const sig = req.header("signature-agent");
|
|
19
|
+
if (sig)
|
|
20
|
+
headers["signature-agent"] = sig; // forward Web Bot Auth identity
|
|
21
|
+
const upstream = await fetch(target(req), {
|
|
22
|
+
method,
|
|
23
|
+
headers,
|
|
24
|
+
body: method === "GET" ? undefined : JSON.stringify(req.body ?? {}),
|
|
25
|
+
});
|
|
26
|
+
res.status(upstream.status).json(await upstream.json());
|
|
27
|
+
};
|
|
28
|
+
r.get("/.well-known/agent.json", (_req, res) => {
|
|
29
|
+
if (!dm.manifest)
|
|
30
|
+
return res.status(503).json({ error: "manifest_not_loaded" });
|
|
31
|
+
res.json(dm.manifest);
|
|
32
|
+
});
|
|
33
|
+
r.post("/agent/register", proxy("POST", () => `${cp}/services/${dm.cfg.serviceSlug}/register`));
|
|
34
|
+
r.post("/agent/keys", proxy("POST", (req) => `${cp}/accounts/${req.body?.account_id}/keys`));
|
|
35
|
+
r.post("/agent/topup-request", proxy("POST", (req) => `${cp}/accounts/${req.body?.account_id}/topup-request`));
|
|
36
|
+
r.get("/agent/accounts/:id/status", proxy("GET", (req) => `${cp}/accounts/${req.params.id}/status`));
|
|
37
|
+
return r;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Protect a paid route: verify locally, check scope, gate against the cap,
|
|
41
|
+
* and auto-meter one unit. `requiredScope` maps the route to a plan scope.
|
|
42
|
+
*/
|
|
43
|
+
export function protect(dm, tool, requiredScope, opts = {}) {
|
|
44
|
+
return async (req, res, next) => {
|
|
45
|
+
const agent = await dm.verify(req.header("authorization"));
|
|
46
|
+
if (!agent) {
|
|
47
|
+
return res
|
|
48
|
+
.status(401)
|
|
49
|
+
.json({ error: "missing_or_invalid_bearer_token", hint: "see /.well-known/agent.json" });
|
|
50
|
+
}
|
|
51
|
+
if (!agent.scopes.includes(requiredScope)) {
|
|
52
|
+
return res.status(403).json({ error: "insufficient_scope", required: requiredScope });
|
|
53
|
+
}
|
|
54
|
+
const cost = opts.costCents ?? dm.unitCents();
|
|
55
|
+
const gate = opts.strict ? await dm.gateStrict(agent, tool, cost) : dm.gate(agent, tool, cost);
|
|
56
|
+
if (!gate.ok)
|
|
57
|
+
return res.status(402).json(gate.body);
|
|
58
|
+
req.tollbase = {
|
|
59
|
+
agent,
|
|
60
|
+
meter: (t, units = 1, costCents = dm.unitCents()) => {
|
|
61
|
+
// Additional units beyond the auto-metered one (e.g. per-row pricing).
|
|
62
|
+
for (let i = 0; i < units; i++)
|
|
63
|
+
dm.gate(agent, t, costCents);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
next();
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { err402, type Err402Body, type Manifest } from "@tollbase/core/types";
|
|
2
|
+
export interface TollbaseConfig {
|
|
3
|
+
serviceSlug: string;
|
|
4
|
+
serviceKey: string;
|
|
5
|
+
controlPlaneUrl: string;
|
|
6
|
+
/** Flush interval for the usage queue (ms). Default 2000. */
|
|
7
|
+
flushMs?: number;
|
|
8
|
+
/** Poll interval for the revocation feed (ms). Default 30000. 0 disables. */
|
|
9
|
+
revocationPollMs?: number;
|
|
10
|
+
/** Refresh interval for the service manifest (ms), so dashboard price
|
|
11
|
+
* edits reach running vendors without a restart. Default 60000. 0 disables. */
|
|
12
|
+
manifestRefreshMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface VerifiedAgent {
|
|
15
|
+
accountId: string;
|
|
16
|
+
scopes: string[];
|
|
17
|
+
capCents: number;
|
|
18
|
+
iat: number;
|
|
19
|
+
jti: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Framework-agnostic middleware core. Design principle P1: the vendor's hot
|
|
23
|
+
* path never synchronously depends on the control plane.
|
|
24
|
+
* - Token verification is local (jose caches the JWKS).
|
|
25
|
+
* - Spend caps enforce optimistically from the `cap` claim minus local
|
|
26
|
+
* spend since mint; the control plane reconciles at every key refresh,
|
|
27
|
+
* bounding overspend to one 15-minute TTL window by design.
|
|
28
|
+
* - Revocation is a pull feed: an in-TTL token dies within one poll
|
|
29
|
+
* interval, still without a hot-path network call.
|
|
30
|
+
* - Usage streams asynchronously with client idempotency keys, so
|
|
31
|
+
* at-least-once delivery cannot double-bill.
|
|
32
|
+
*/
|
|
33
|
+
export declare class Tollbase {
|
|
34
|
+
readonly cfg: TollbaseConfig;
|
|
35
|
+
private jwks;
|
|
36
|
+
manifest: Manifest | null;
|
|
37
|
+
private localSpend;
|
|
38
|
+
private queue;
|
|
39
|
+
private flushTimer;
|
|
40
|
+
private revokedJtis;
|
|
41
|
+
private revocationCursor;
|
|
42
|
+
private revocationTimer;
|
|
43
|
+
private manifestTimer;
|
|
44
|
+
constructor(cfg: TollbaseConfig);
|
|
45
|
+
/** Load the service manifest and start background loops. Call once at boot. */
|
|
46
|
+
init(): Promise<void>;
|
|
47
|
+
/** Fetch the current manifest; keep the previous one on transient failure. */
|
|
48
|
+
private refreshManifest;
|
|
49
|
+
unitCents(): number;
|
|
50
|
+
/** Local JWT verification; no network on the hot path. */
|
|
51
|
+
verify(authHeader: string | undefined): Promise<VerifiedAgent | null>;
|
|
52
|
+
/**
|
|
53
|
+
* Gate and meter one paid call (optimistic mode). Enforces the cap claim
|
|
54
|
+
* minus local spend since mint.
|
|
55
|
+
*/
|
|
56
|
+
gate(agent: VerifiedAgent, tool: string, costCents?: number): {
|
|
57
|
+
ok: true;
|
|
58
|
+
} | {
|
|
59
|
+
ok: false;
|
|
60
|
+
body: Err402Body;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Strict-mode gate for high-value routes: one synchronous cap check against
|
|
64
|
+
* the control plane before allowing the call. Trades ~one round trip for
|
|
65
|
+
* exactness; falls CLOSED on control-plane failure (a strict route would
|
|
66
|
+
* rather refuse than overspend).
|
|
67
|
+
*/
|
|
68
|
+
gateStrict(agent: VerifiedAgent, tool: string, costCents?: number): Promise<{
|
|
69
|
+
ok: true;
|
|
70
|
+
} | {
|
|
71
|
+
ok: false;
|
|
72
|
+
body: Err402Body;
|
|
73
|
+
}>;
|
|
74
|
+
challenge(accountId: string, reason: Parameters<typeof err402>[0]): Err402Body;
|
|
75
|
+
private enqueue;
|
|
76
|
+
/** At-least-once delivery; server-side idempotency makes redelivery safe. */
|
|
77
|
+
flush(): Promise<void>;
|
|
78
|
+
private pollRevocations;
|
|
79
|
+
stop(): Promise<void>;
|
|
80
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { err402 } from "@tollbase/core/types";
|
|
4
|
+
/**
|
|
5
|
+
* Framework-agnostic middleware core. Design principle P1: the vendor's hot
|
|
6
|
+
* path never synchronously depends on the control plane.
|
|
7
|
+
* - Token verification is local (jose caches the JWKS).
|
|
8
|
+
* - Spend caps enforce optimistically from the `cap` claim minus local
|
|
9
|
+
* spend since mint; the control plane reconciles at every key refresh,
|
|
10
|
+
* bounding overspend to one 15-minute TTL window by design.
|
|
11
|
+
* - Revocation is a pull feed: an in-TTL token dies within one poll
|
|
12
|
+
* interval, still without a hot-path network call.
|
|
13
|
+
* - Usage streams asynchronously with client idempotency keys, so
|
|
14
|
+
* at-least-once delivery cannot double-bill.
|
|
15
|
+
*/
|
|
16
|
+
export class Tollbase {
|
|
17
|
+
cfg;
|
|
18
|
+
jwks;
|
|
19
|
+
manifest = null;
|
|
20
|
+
localSpend = new Map(); // token jti -> cents spent since mint
|
|
21
|
+
queue = [];
|
|
22
|
+
flushTimer = null;
|
|
23
|
+
revokedJtis = new Set();
|
|
24
|
+
revocationCursor = "1970-01-01";
|
|
25
|
+
revocationTimer = null;
|
|
26
|
+
manifestTimer = null;
|
|
27
|
+
constructor(cfg) {
|
|
28
|
+
this.cfg = cfg;
|
|
29
|
+
this.jwks = createRemoteJWKSet(new URL(`${cfg.controlPlaneUrl}/jwks.json`));
|
|
30
|
+
}
|
|
31
|
+
/** Load the service manifest and start background loops. Call once at boot. */
|
|
32
|
+
async init() {
|
|
33
|
+
await this.refreshManifest();
|
|
34
|
+
if (!this.manifest)
|
|
35
|
+
throw new Error(`tollbase: cannot load manifest for ${this.cfg.serviceSlug}`);
|
|
36
|
+
this.flushTimer = setInterval(() => void this.flush(), this.cfg.flushMs ?? 2000);
|
|
37
|
+
this.flushTimer.unref?.();
|
|
38
|
+
const mrMs = this.cfg.manifestRefreshMs ?? 60_000;
|
|
39
|
+
if (mrMs > 0) {
|
|
40
|
+
this.manifestTimer = setInterval(() => void this.refreshManifest(), mrMs);
|
|
41
|
+
this.manifestTimer.unref?.();
|
|
42
|
+
}
|
|
43
|
+
const pollMs = this.cfg.revocationPollMs ?? 30_000;
|
|
44
|
+
if (pollMs > 0) {
|
|
45
|
+
this.revocationTimer = setInterval(() => void this.pollRevocations(), pollMs);
|
|
46
|
+
this.revocationTimer.unref?.();
|
|
47
|
+
await this.pollRevocations(); // pick up pre-existing revocations at boot
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Fetch the current manifest; keep the previous one on transient failure. */
|
|
51
|
+
async refreshManifest() {
|
|
52
|
+
try {
|
|
53
|
+
const r = await fetch(`${this.cfg.controlPlaneUrl}/services/${this.cfg.serviceSlug}/manifest`);
|
|
54
|
+
if (r.ok)
|
|
55
|
+
this.manifest = (await r.json());
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Control plane unreachable: the cached manifest keeps serving (P1).
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
unitCents() {
|
|
62
|
+
return this.manifest?.plans[0]?.unit_cents ?? 0;
|
|
63
|
+
}
|
|
64
|
+
/** Local JWT verification; no network on the hot path. */
|
|
65
|
+
async verify(authHeader) {
|
|
66
|
+
if (!authHeader?.startsWith("Bearer "))
|
|
67
|
+
return null;
|
|
68
|
+
try {
|
|
69
|
+
const { payload } = await jwtVerify(authHeader.slice(7), this.jwks, {
|
|
70
|
+
issuer: "tollbase",
|
|
71
|
+
algorithms: ["ES256"],
|
|
72
|
+
});
|
|
73
|
+
if (payload.svc !== this.cfg.serviceSlug)
|
|
74
|
+
return null;
|
|
75
|
+
const jti = String(payload.jti ?? "");
|
|
76
|
+
if (this.revokedJtis.has(jti))
|
|
77
|
+
return null;
|
|
78
|
+
return {
|
|
79
|
+
accountId: payload.sub,
|
|
80
|
+
scopes: (typeof payload.scope === "string" ? payload.scope : "").split(" ").filter(Boolean),
|
|
81
|
+
capCents: Number(payload.cap ?? 0),
|
|
82
|
+
iat: Number(payload.iat),
|
|
83
|
+
jti,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Gate and meter one paid call (optimistic mode). Enforces the cap claim
|
|
92
|
+
* minus local spend since mint.
|
|
93
|
+
*/
|
|
94
|
+
gate(agent, tool, costCents = this.unitCents()) {
|
|
95
|
+
// Keyed by jti: each minted token carries its own cap claim and its own
|
|
96
|
+
// local ledger (iat has one-second resolution and can collide on rotation).
|
|
97
|
+
const key = agent.jti;
|
|
98
|
+
const spent = this.localSpend.get(key) ?? 0;
|
|
99
|
+
if (spent + costCents > agent.capCents) {
|
|
100
|
+
return { ok: false, body: this.challenge(agent.accountId, "cap_exceeded") };
|
|
101
|
+
}
|
|
102
|
+
this.localSpend.set(key, spent + costCents);
|
|
103
|
+
this.enqueue(agent.accountId, tool, 1, costCents);
|
|
104
|
+
return { ok: true };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Strict-mode gate for high-value routes: one synchronous cap check against
|
|
108
|
+
* the control plane before allowing the call. Trades ~one round trip for
|
|
109
|
+
* exactness; falls CLOSED on control-plane failure (a strict route would
|
|
110
|
+
* rather refuse than overspend).
|
|
111
|
+
*/
|
|
112
|
+
async gateStrict(agent, tool, costCents = this.unitCents()) {
|
|
113
|
+
// Drain this instance's own metering queue first so the ledger the
|
|
114
|
+
// control plane consults reflects everything we already allowed.
|
|
115
|
+
await this.flush();
|
|
116
|
+
try {
|
|
117
|
+
const r = await fetch(`${this.cfg.controlPlaneUrl}/internal/cap-check`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "content-type": "application/json", "x-tollbase-service-key": this.cfg.serviceKey },
|
|
120
|
+
body: JSON.stringify({ account_id: agent.accountId, cost_cents: costCents }),
|
|
121
|
+
});
|
|
122
|
+
const body = (await r.json());
|
|
123
|
+
if (!r.ok || !body.allowed)
|
|
124
|
+
return { ok: false, body: this.challenge(agent.accountId, "cap_exceeded") };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { ok: false, body: this.challenge(agent.accountId, "cap_exceeded") };
|
|
128
|
+
}
|
|
129
|
+
// Record the spend locally as well, so optimistic gates on the same
|
|
130
|
+
// token account for what strict routes consumed.
|
|
131
|
+
const key = agent.jti;
|
|
132
|
+
this.localSpend.set(key, (this.localSpend.get(key) ?? 0) + costCents);
|
|
133
|
+
this.enqueue(agent.accountId, tool, 1, costCents);
|
|
134
|
+
return { ok: true };
|
|
135
|
+
}
|
|
136
|
+
challenge(accountId, reason) {
|
|
137
|
+
return err402(reason, {
|
|
138
|
+
accountId,
|
|
139
|
+
topupUrl: "/agent/topup-request", // vendor-origin proxy path; agents keep one origin
|
|
140
|
+
plans: this.manifest?.plans ?? [],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
enqueue(accountId, tool, units, costCents) {
|
|
144
|
+
this.queue.push({
|
|
145
|
+
idempotency_key: randomUUID(),
|
|
146
|
+
account_id: accountId,
|
|
147
|
+
tool,
|
|
148
|
+
units,
|
|
149
|
+
cost_cents: costCents,
|
|
150
|
+
occurred_at: new Date().toISOString(),
|
|
151
|
+
});
|
|
152
|
+
if (this.queue.length >= 20)
|
|
153
|
+
void this.flush();
|
|
154
|
+
}
|
|
155
|
+
/** At-least-once delivery; server-side idempotency makes redelivery safe. */
|
|
156
|
+
async flush() {
|
|
157
|
+
if (this.queue.length === 0)
|
|
158
|
+
return;
|
|
159
|
+
const batch = this.queue.splice(0, this.queue.length);
|
|
160
|
+
try {
|
|
161
|
+
const r = await fetch(`${this.cfg.controlPlaneUrl}/usage`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "content-type": "application/json", "x-tollbase-service-key": this.cfg.serviceKey },
|
|
164
|
+
body: JSON.stringify({ events: batch }),
|
|
165
|
+
});
|
|
166
|
+
if (!r.ok)
|
|
167
|
+
throw new Error(`usage ingest ${r.status}`);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
this.queue.unshift(...batch); // retry on the next tick; dedup on the server
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async pollRevocations() {
|
|
174
|
+
try {
|
|
175
|
+
const r = await fetch(`${this.cfg.controlPlaneUrl}/revocations?since=${encodeURIComponent(this.revocationCursor)}`, { headers: { "x-tollbase-service-key": this.cfg.serviceKey } });
|
|
176
|
+
if (!r.ok)
|
|
177
|
+
return;
|
|
178
|
+
const body = (await r.json());
|
|
179
|
+
for (const rec of body.revocations)
|
|
180
|
+
this.revokedJtis.add(rec.jti);
|
|
181
|
+
if (body.revocations.length) {
|
|
182
|
+
const last = body.revocations[body.revocations.length - 1];
|
|
183
|
+
if (last)
|
|
184
|
+
this.revocationCursor = last.revoked_at;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Transient failure: keep the previous cursor; the next poll catches up.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async stop() {
|
|
192
|
+
if (this.flushTimer)
|
|
193
|
+
clearInterval(this.flushTimer);
|
|
194
|
+
if (this.revocationTimer)
|
|
195
|
+
clearInterval(this.revocationTimer);
|
|
196
|
+
if (this.manifestTimer)
|
|
197
|
+
clearInterval(this.manifestTimer);
|
|
198
|
+
await this.flush();
|
|
199
|
+
}
|
|
200
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Tollbase } from "./index.js";
|
|
2
|
+
/**
|
|
3
|
+
* MCP integration without a hard dependency on any MCP SDK version.
|
|
4
|
+
*
|
|
5
|
+
* MCP servers speak JSON-RPC over a transport that carries HTTP headers
|
|
6
|
+
* (Streamable HTTP). This wrapper factors the Tollbase concerns — verify,
|
|
7
|
+
* scope, gate, meter — into a single function the vendor applies to each
|
|
8
|
+
* paid tool handler, whatever SDK they use:
|
|
9
|
+
*
|
|
10
|
+
* server.registerTool("enrich", schema,
|
|
11
|
+
* paidTool(dm, "enrich", "enrich:read", async (args) => doEnrich(args)));
|
|
12
|
+
*
|
|
13
|
+
* The bearer token is read from the request's Authorization header (exposed
|
|
14
|
+
* by MCP SDKs via the per-request extra/context object; pass an accessor if
|
|
15
|
+
* yours differs). On a cap violation the tool returns an MCP tool error
|
|
16
|
+
* whose text is the machine-readable 402 JSON, so agent frameworks can parse
|
|
17
|
+
* the same payload they would receive over plain HTTP.
|
|
18
|
+
*/
|
|
19
|
+
export interface McpToolResult {
|
|
20
|
+
content: {
|
|
21
|
+
type: "text";
|
|
22
|
+
text: string;
|
|
23
|
+
}[];
|
|
24
|
+
isError?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function paidTool<Args>(dm: Tollbase, tool: string, requiredScope: string, handler: (args: Args) => Promise<McpToolResult | {
|
|
27
|
+
[k: string]: unknown;
|
|
28
|
+
}>, getAuthHeader?: (extra: unknown) => string | undefined): (args: Args, extra?: unknown) => Promise<McpToolResult>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { err402 } from "@tollbase/core/types";
|
|
2
|
+
export function paidTool(dm, tool, requiredScope, handler, getAuthHeader = defaultAuthAccessor) {
|
|
3
|
+
return async (args, extra) => {
|
|
4
|
+
const agent = await dm.verify(getAuthHeader(extra));
|
|
5
|
+
if (!agent) {
|
|
6
|
+
return mcpError({
|
|
7
|
+
...err402("not_claimed", { plans: dm.manifest?.plans ?? [] }),
|
|
8
|
+
message: "Missing or invalid bearer token. Register via the service manifest.",
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
if (!agent.scopes.includes(requiredScope)) {
|
|
12
|
+
return mcpError({
|
|
13
|
+
...dm.challenge(agent.accountId, "no_plan"),
|
|
14
|
+
message: `Scope ${requiredScope} not granted.`,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const gate = dm.gate(agent, tool);
|
|
18
|
+
if (!gate.ok)
|
|
19
|
+
return mcpError(gate.body);
|
|
20
|
+
const out = await handler(args);
|
|
21
|
+
// Pass through well-formed MCP results; wrap plain objects as text.
|
|
22
|
+
if (out && typeof out === "object" && "content" in out)
|
|
23
|
+
return out;
|
|
24
|
+
return { content: [{ type: "text", text: JSON.stringify(out) }] };
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function mcpError(body) {
|
|
28
|
+
return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Default accessor for the Authorization header across common MCP SDK shapes
|
|
32
|
+
* ({requestInfo:{headers}} in the TypeScript SDK's Streamable HTTP transport).
|
|
33
|
+
*/
|
|
34
|
+
function defaultAuthAccessor(extra) {
|
|
35
|
+
const e = extra;
|
|
36
|
+
const h = e?.requestInfo?.headers?.authorization ?? e?.requestInfo?.headers?.Authorization;
|
|
37
|
+
return Array.isArray(h) ? h[0] : h;
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tollbase/middleware",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Tollbase vendor middleware: let AI agents become paying customers of your API or MCP server.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"source": "./src/index.ts",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./express": {
|
|
14
|
+
"source": "./src/express.ts",
|
|
15
|
+
"types": "./dist/express.d.ts",
|
|
16
|
+
"default": "./dist/express.js"
|
|
17
|
+
},
|
|
18
|
+
"./mcp": {
|
|
19
|
+
"source": "./src/mcp.ts",
|
|
20
|
+
"types": "./dist/mcp.d.ts",
|
|
21
|
+
"default": "./dist/mcp.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@tollbase/core": "0.3.0",
|
|
26
|
+
"jose": "^5.9.2"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"express": "^4.19.2"
|
|
30
|
+
},
|
|
31
|
+
"peerDependenciesMeta": {
|
|
32
|
+
"express": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/tollbase/tollbase.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://tollbase.dev",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"ai-agents",
|
|
43
|
+
"agents",
|
|
44
|
+
"billing",
|
|
45
|
+
"metering",
|
|
46
|
+
"mcp",
|
|
47
|
+
"payments",
|
|
48
|
+
"402",
|
|
49
|
+
"usage-based"
|
|
50
|
+
],
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=20"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"files": [
|
|
58
|
+
"dist",
|
|
59
|
+
"README.md",
|
|
60
|
+
"LICENSE"
|
|
61
|
+
],
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
64
|
+
"prepublishOnly": "npm run build"
|
|
65
|
+
}
|
|
66
|
+
}
|