@katrinalaszlo/agentkey-clerk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # agentkey-clerk
2
+
3
+ Spend caps for [Clerk](https://clerk.com) M2M tokens. Clerk authenticates your agent; agentkey-clerk caps what it can spend, scopes what it can do, and sets when its access ends.
4
+
5
+ ## Why
6
+
7
+ Clerk's [machine-to-machine (M2M) tokens](https://clerk.com/docs/guides/development/machine-auth/m2m-tokens) authenticate your agents: which machine is calling, and which machines it may talk to. What they don't do is cap how much a machine can spend, meter its usage, or scope what it can do. So once your agent has a valid M2M token, nothing stops it from burning through a month of budget in twenty minutes.
8
+
9
+ This package adds that layer. The agent keeps carrying its Clerk M2M token. You add one middleware, and every request is checked against a per-machine budget, scope, and expiry before it runs.
10
+
11
+ | Layer | What it controls | Who covers it |
12
+ |---|---|---|
13
+ | Identity | Which machine is calling | **Clerk M2M** |
14
+ | Budget | How much this machine can spend | **agentkey** |
15
+ | Scope | What this machine can do | **agentkey** |
16
+ | Expiry | When access ends | **agentkey** |
17
+
18
+ Built on [`@katrinalaszlo/agentkey`](https://github.com/katrinalaszlo/agentkey).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install @katrinalaszlo/agentkey-clerk @katrinalaszlo/agentkey
24
+ ```
25
+
26
+ You bring your own Clerk client (`@clerk/backend` or `@clerk/express`) and a Postgres pool — this package doesn't wrap either.
27
+
28
+ ## Quick start
29
+
30
+ ```typescript
31
+ import express from "express";
32
+ import pg from "pg";
33
+ import { createClerkClient } from "@clerk/backend";
34
+ import { AgentKey } from "@katrinalaszlo/agentkey";
35
+ import { clerkAgentKeyMiddleware, trackByM2M } from "@katrinalaszlo/agentkey-clerk";
36
+
37
+ const pool = new pg.Pool();
38
+ const ak = new AgentKey({ pool });
39
+ await ak.migrate(); // adds the columns agentkey needs to your keys table
40
+
41
+ const clerkClient = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY });
42
+
43
+ const app = express();
44
+
45
+ app.use(
46
+ "/api/agent",
47
+ clerkAgentKeyMiddleware({
48
+ clerkClient,
49
+ ak,
50
+ // Applied the first time each machine is seen.
51
+ defaults: { scopes: ["proxy.chat"], budgetCents: 5000, budgetPeriod: "month" },
52
+ scope: "proxy.chat", // optional: required capability for this route
53
+ }),
54
+ );
55
+
56
+ // Inside a handler, after the agent's billable work:
57
+ app.post("/api/agent/chat", async (req, res) => {
58
+ const cost = await callTheModel(req.body);
59
+ await trackByM2M(ak, req.m2m!.subject, cost.cents);
60
+ res.json(cost.result);
61
+ });
62
+ ```
63
+
64
+ The agent calls your API with its Clerk M2M token in `Authorization: Bearer <token>`. The middleware:
65
+
66
+ 1. verifies the token with Clerk (`clerkClient.m2m.verify`),
67
+ 2. provisions a budget row for the machine on first sight (from `onFirstSeen` or `defaults`),
68
+ 3. enforces budget, scope, and expiry,
69
+ 4. attaches `req.m2m` (the verified token) and `req.agentKey` (the budget state).
70
+
71
+ ## Responses
72
+
73
+ | Condition | Status |
74
+ |---|---|
75
+ | Valid, in budget, has scope | `next()` |
76
+ | Missing `Bearer` token | `401 Missing M2M token` |
77
+ | Revoked or expired Clerk token | `401 invalid_token` |
78
+ | Over budget | `429 budget_exceeded` |
79
+ | Missing required scope | `403 insufficient_scope` |
80
+ | Clerk or DB fault | `500 auth_unavailable` (fails closed) |
81
+
82
+ ## Per-machine budgets
83
+
84
+ Pass `onFirstSeen` to set budget/scope from the verified token instead of a flat default:
85
+
86
+ ```typescript
87
+ clerkAgentKeyMiddleware({
88
+ clerkClient,
89
+ ak,
90
+ onFirstSeen: (token) => ({
91
+ accountId: (token.claims?.org_id as string) ?? token.subject,
92
+ scopes: ["proxy.chat"],
93
+ budgetCents: 2000,
94
+ budgetPeriod: "day",
95
+ expiresIn: "30d",
96
+ }),
97
+ });
98
+ ```
99
+
100
+ `accountId` defaults to the machine subject if you don't set it.
101
+
102
+ ## A note on scope
103
+
104
+ agentkey's `scopes` (what the agent may *do* — `proxy.chat`, `usage.read`) are not the same as Clerk's machine scopes (which machines may *talk to* which). This package enforces the agentkey kind. Set them in `defaults`/`onFirstSeen`.
105
+
106
+ ## What it doesn't do
107
+
108
+ This is the per-machine spend layer only. It does not do hierarchical org > team > agent budget composition, and it does not put a human's Clerk session on the agent's request path (the agent carries its own M2M token — that's the point). For non-Clerk apps, use [`@katrinalaszlo/agentkey`](https://github.com/katrinalaszlo/agentkey) directly with its own `ak_` keys.
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,35 @@
1
+ import type { Request, Response, NextFunction } from "express";
2
+ import type { AgentKey, ValidateResult, EnsureSubjectOptions } from "@katrinalaszlo/agentkey";
3
+ export interface VerifiedM2MToken {
4
+ id: string;
5
+ subject: string;
6
+ scopes?: string[];
7
+ claims?: Record<string, unknown> | null;
8
+ revoked?: boolean;
9
+ expired?: boolean;
10
+ expiration?: number | null;
11
+ }
12
+ export interface ClerkM2MClient {
13
+ m2m: {
14
+ verify(params: {
15
+ token: string;
16
+ }): Promise<VerifiedM2MToken>;
17
+ };
18
+ }
19
+ export interface ClerkAgentKeyOptions {
20
+ clerkClient: ClerkM2MClient;
21
+ ak: AgentKey;
22
+ defaults?: EnsureSubjectOptions;
23
+ onFirstSeen?: (token: VerifiedM2MToken) => EnsureSubjectOptions;
24
+ scope?: string;
25
+ }
26
+ declare global {
27
+ namespace Express {
28
+ interface Request {
29
+ m2m?: VerifiedM2MToken;
30
+ agentKey?: ValidateResult;
31
+ }
32
+ }
33
+ }
34
+ export declare function clerkAgentKeyMiddleware(opts: ClerkAgentKeyOptions): (req: Request, res: Response, next: NextFunction) => Promise<Response<any, Record<string, any>> | undefined>;
35
+ export declare function trackByM2M(ak: AgentKey, subject: string, costCents: number): Promise<import("@katrinalaszlo/agentkey").TrackUsageResult>;
package/dist/index.js ADDED
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clerkAgentKeyMiddleware = clerkAgentKeyMiddleware;
4
+ exports.trackByM2M = trackByM2M;
5
+ // Express middleware: verify the incoming Clerk M2M token, then enforce
6
+ // agentkey's budget/scope/expiry on the machine behind it. Clerk says *who* the
7
+ // machine is; agentkey says *how much it may spend and do*. On first sight of a
8
+ // machine, its budget row is provisioned from `onFirstSeen`/`defaults`.
9
+ function clerkAgentKeyMiddleware(opts) {
10
+ const { clerkClient, ak, defaults, onFirstSeen, scope } = opts;
11
+ return async (req, res, next) => {
12
+ try {
13
+ const authHeader = req.headers.authorization;
14
+ if (!authHeader?.startsWith("Bearer ")) {
15
+ return res.status(401).json({ error: "Missing M2M token" });
16
+ }
17
+ const token = authHeader.slice(7);
18
+ const verified = await clerkClient.m2m.verify({ token });
19
+ // Clerk already computed these; don't re-derive them.
20
+ if (verified.revoked || verified.expired) {
21
+ return res.status(401).json({ error: "invalid_token" });
22
+ }
23
+ const subject = verified.subject;
24
+ let result = await ak.validateBySubject(subject);
25
+ // First time we've seen this machine: provision its budget row, then
26
+ // re-validate. ensureSubject is idempotent, so a concurrent first request
27
+ // racing on the same machine is safe.
28
+ if (!result.valid && result.reason === "invalid") {
29
+ await ak.ensureSubject(subject, onFirstSeen?.(verified) ?? defaults ?? {});
30
+ result = await ak.validateBySubject(subject);
31
+ }
32
+ if (!result.valid) {
33
+ const status = result.reason === "budget_exceeded" ? 429 : 401;
34
+ return res.status(status).json({ error: result.reason });
35
+ }
36
+ if (scope && !ak.hasScope(result, scope)) {
37
+ return res.status(403).json({
38
+ error: "insufficient_scope",
39
+ required: scope,
40
+ available: result.scopes,
41
+ });
42
+ }
43
+ req.m2m = verified;
44
+ req.agentKey = result;
45
+ next();
46
+ }
47
+ catch (err) {
48
+ // clerkClient.m2m.verify() and the agentkey DB lookups reject on routine
49
+ // faults (network, pool exhaustion, statement timeout). Express 4 does not
50
+ // forward a rejected promise from async middleware, so without this the
51
+ // request hangs. Fail closed with a 500.
52
+ console.error("clerkAgentKeyMiddleware error:", err);
53
+ return res.status(500).json({ error: "auth_unavailable" });
54
+ }
55
+ };
56
+ }
57
+ // Charge a machine's budget after its billable work (e.g. after an LLM call).
58
+ // Thin wrapper over agentkey's subject-keyed usage tracking.
59
+ function trackByM2M(ak, subject, costCents) {
60
+ return ak.trackUsageBySubject(subject, { costCents });
61
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@katrinalaszlo/agentkey-clerk",
3
+ "version": "0.1.0",
4
+ "description": "Spend caps for Clerk M2M tokens — budget, scope, and expiry on top of Clerk machine auth",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "test": "vitest run",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "keywords": [
16
+ "clerk",
17
+ "m2m",
18
+ "machine-to-machine",
19
+ "agent",
20
+ "budget",
21
+ "spend",
22
+ "scoped-keys",
23
+ "api-key",
24
+ "agentkey",
25
+ "agent-experience"
26
+ ],
27
+ "author": "Katrina Laszlo <katrina.j.laszlo@gmail.com>",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/katrinalaszlo/agentkey-clerk.git"
32
+ },
33
+ "peerDependencies": {
34
+ "@katrinalaszlo/agentkey": ">=0.2.0",
35
+ "express": ">=4.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@katrinalaszlo/agentkey": "file:../agentkey",
39
+ "typescript": "^5.0.0",
40
+ "vitest": "^3.0.0",
41
+ "express": "^4.0.0",
42
+ "@types/express": "^4.0.0"
43
+ }
44
+ }