@katrinalaszlo/agentkey-clerk 0.1.0 → 0.2.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 CHANGED
@@ -1,18 +1,20 @@
1
1
  # agentkey-clerk
2
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.
3
+ Spend caps for the [Clerk](https://clerk.com) API keys your users create. Your customers make API keys with Clerk; agentkey-clerk caps what each one can spend, scopes what it can do, and sets when its access ends.
4
4
 
5
5
  ## Why
6
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.
7
+ When you give your customers [API keys](https://clerk.com/docs/guides/development/machine-auth/api-keys) through Clerk, each key calls your API on that customer's behalf. Clerk issues, verifies, and revokes the key. What it doesn't do is cap how much a customer's key can spend against your paid API, so one customer's runaway script can burn through usage that affects everyone else.
8
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.
9
+ agentkey-clerk adds that layer. The customer keeps using their Clerk-issued key. You add one middleware, and every request is checked against a per-customer budget, scope, and expiry before it runs. It caps **dollars** (not just request counts) and blocks the call the moment a key crosses its budget.
10
+
11
+ > agentkey-clerk is an independent, open-source companion to Clerk. It is not affiliated with Clerk.
10
12
 
11
13
  | Layer | What it controls | Who covers it |
12
14
  |---|---|---|
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** |
15
+ | Identity | Which customer's key is calling | **Clerk API Keys** |
16
+ | Budget | How much this key can spend (in dollars) | **agentkey** |
17
+ | Scope | What this key can do | **agentkey** |
16
18
  | Expiry | When access ends | **agentkey** |
17
19
 
18
20
  Built on [`@katrinalaszlo/agentkey`](https://github.com/katrinalaszlo/agentkey).
@@ -32,7 +34,7 @@ import express from "express";
32
34
  import pg from "pg";
33
35
  import { createClerkClient } from "@clerk/backend";
34
36
  import { AgentKey } from "@katrinalaszlo/agentkey";
35
- import { clerkAgentKeyMiddleware, trackByM2M } from "@katrinalaszlo/agentkey-clerk";
37
+ import { clerkApiKeyMiddleware, trackByApiKey } from "@katrinalaszlo/agentkey-clerk";
36
38
 
37
39
  const pool = new pg.Pool();
38
40
  const ak = new AgentKey({ pool });
@@ -43,69 +45,72 @@ const clerkClient = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY
43
45
  const app = express();
44
46
 
45
47
  app.use(
46
- "/api/agent",
47
- clerkAgentKeyMiddleware({
48
+ "/api",
49
+ clerkApiKeyMiddleware({
48
50
  clerkClient,
49
51
  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
52
+ // Applied the first time each customer's key is seen.
53
+ defaults: { budgetCents: 5000, budgetPeriod: "month" },
53
54
  }),
54
55
  );
55
56
 
56
- // Inside a handler, after the agent's billable work:
57
- app.post("/api/agent/chat", async (req, res) => {
57
+ // Inside a handler, after the customer's billable work:
58
+ app.post("/api/chat", async (req, res) => {
58
59
  const cost = await callTheModel(req.body);
59
- await trackByM2M(ak, req.m2m!.subject, cost.cents);
60
+ await trackByApiKey(ak, req.clerkApiKey!.subject, cost.cents);
60
61
  res.json(cost.result);
61
62
  });
62
63
  ```
63
64
 
64
- The agent calls your API with its Clerk M2M token in `Authorization: Bearer <token>`. The middleware:
65
+ The customer calls your API with their Clerk API key in `Authorization: Bearer <key>`. The middleware:
65
66
 
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`),
67
+ 1. verifies the key with Clerk (`clerkClient.apiKeys.verify`),
68
+ 2. provisions a budget row for the customer the key's `subject`, a `user_` or `org_` id — on first sight,
68
69
  3. enforces budget, scope, and expiry,
69
- 4. attaches `req.m2m` (the verified token) and `req.agentKey` (the budget state).
70
+ 4. attaches `req.clerkApiKey` (the verified key) and `req.agentKey` (the budget state).
70
71
 
71
72
  ## Responses
72
73
 
73
74
  | Condition | Status |
74
75
  |---|---|
75
76
  | Valid, in budget, has scope | `next()` |
76
- | Missing `Bearer` token | `401 Missing M2M token` |
77
- | Revoked or expired Clerk token | `401 invalid_token` |
77
+ | Missing `Bearer` key | `401 Missing API key` |
78
+ | Invalid / revoked / expired key | `401 invalid_key` |
78
79
  | Over budget | `429 budget_exceeded` |
79
80
  | Missing required scope | `403 insufficient_scope` |
80
- | Clerk or DB fault | `500 auth_unavailable` (fails closed) |
81
+ | DB fault | `500 auth_unavailable` (fails closed) |
81
82
 
82
- ## Per-machine budgets
83
+ ## Per-customer budgets
83
84
 
84
- Pass `onFirstSeen` to set budget/scope from the verified token instead of a flat default:
85
+ Pass `onFirstSeen` to set budget/scope from the verified key for example, read a plan tier off the key's `claims`:
85
86
 
86
87
  ```typescript
87
- clerkAgentKeyMiddleware({
88
+ clerkApiKeyMiddleware({
88
89
  clerkClient,
89
90
  ak,
90
- onFirstSeen: (token) => ({
91
- accountId: (token.claims?.org_id as string) ?? token.subject,
91
+ onFirstSeen: (apiKey) => ({
92
+ accountId: apiKey.subject, // user_xxx or org_xxx
92
93
  scopes: ["proxy.chat"],
93
- budgetCents: 2000,
94
- budgetPeriod: "day",
94
+ budgetCents: apiKey.claims?.tier === "pro" ? 20000 : 2000,
95
+ budgetPeriod: "month",
95
96
  expiresIn: "30d",
96
97
  }),
97
98
  });
98
99
  ```
99
100
 
100
- `accountId` defaults to the machine subject if you don't set it.
101
+ `accountId` defaults to the key's `subject` if you don't set it.
101
102
 
102
103
  ## A note on scope
103
104
 
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
+ agentkey's `scopes` (what a key may *do* — `proxy.chat`, `usage.read`) are enforced by this package against the budget row. They're separate from Clerk's own API-key scopes. Set them in `defaults`/`onFirstSeen`.
106
+
107
+ ## Also: internal services (Clerk M2M)
108
+
109
+ If you also want to cap your *own* backend services (microservices, workers) that authenticate with [Clerk M2M tokens](https://clerk.com/docs/guides/development/machine-auth/m2m-tokens) — which are distinct from customer API keys — use `clerkAgentKeyMiddleware` + `trackByM2M`. Same shape, keyed on the M2M machine subject. That's internal cost control rather than customer spend caps.
105
110
 
106
111
  ## What it doesn't do
107
112
 
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.
113
+ Per-key dollar spend caps only. No hierarchical org > team > key budget composition. For non-Clerk apps, use [`@katrinalaszlo/agentkey`](https://github.com/katrinalaszlo/agentkey) directly with its own `ak_` keys.
109
114
 
110
115
  ## License
111
116
 
package/dist/index.d.ts CHANGED
@@ -27,9 +27,35 @@ declare global {
27
27
  namespace Express {
28
28
  interface Request {
29
29
  m2m?: VerifiedM2MToken;
30
+ clerkApiKey?: VerifiedApiKey;
30
31
  agentKey?: ValidateResult;
31
32
  }
32
33
  }
33
34
  }
34
35
  export declare function clerkAgentKeyMiddleware(opts: ClerkAgentKeyOptions): (req: Request, res: Response, next: NextFunction) => Promise<Response<any, Record<string, any>> | undefined>;
35
36
  export declare function trackByM2M(ak: AgentKey, subject: string, costCents: number): Promise<import("@katrinalaszlo/agentkey").TrackUsageResult>;
37
+ export interface VerifiedApiKey {
38
+ id: string;
39
+ name?: string;
40
+ description?: string | null;
41
+ subject: string;
42
+ scopes?: string[];
43
+ claims?: Record<string, unknown> | null;
44
+ revoked?: boolean;
45
+ expired?: boolean;
46
+ expiration?: number | null;
47
+ }
48
+ export interface ClerkApiKeyClient {
49
+ apiKeys: {
50
+ verify(secret: string): Promise<VerifiedApiKey>;
51
+ };
52
+ }
53
+ export interface ClerkApiKeyOptions {
54
+ clerkClient: ClerkApiKeyClient;
55
+ ak: AgentKey;
56
+ defaults?: EnsureSubjectOptions;
57
+ onFirstSeen?: (apiKey: VerifiedApiKey) => EnsureSubjectOptions;
58
+ scope?: string;
59
+ }
60
+ export declare function clerkApiKeyMiddleware(opts: ClerkApiKeyOptions): (req: Request, res: Response, next: NextFunction) => Promise<Response<any, Record<string, any>> | undefined>;
61
+ export declare function trackByApiKey(ak: AgentKey, subject: string, costCents: number): Promise<import("@katrinalaszlo/agentkey").TrackUsageResult>;
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.clerkAgentKeyMiddleware = clerkAgentKeyMiddleware;
4
4
  exports.trackByM2M = trackByM2M;
5
+ exports.clerkApiKeyMiddleware = clerkApiKeyMiddleware;
6
+ exports.trackByApiKey = trackByApiKey;
5
7
  // Express middleware: verify the incoming Clerk M2M token, then enforce
6
8
  // agentkey's budget/scope/expiry on the machine behind it. Clerk says *who* the
7
9
  // machine is; agentkey says *how much it may spend and do*. On first sight of a
@@ -59,3 +61,69 @@ function clerkAgentKeyMiddleware(opts) {
59
61
  function trackByM2M(ak, subject, costCents) {
60
62
  return ak.trackUsageBySubject(subject, { costCents });
61
63
  }
64
+ // Express middleware: verify the incoming Clerk API key, then enforce agentkey's
65
+ // budget/scope/expiry on the customer behind it. The customer keeps using their
66
+ // Clerk-issued key; agentkey caps what that key can spend.
67
+ function clerkApiKeyMiddleware(opts) {
68
+ const { clerkClient, ak, defaults, onFirstSeen, scope } = opts;
69
+ return async (req, res, next) => {
70
+ const authHeader = req.headers.authorization;
71
+ if (!authHeader?.startsWith("Bearer ")) {
72
+ return res.status(401).json({ error: "Missing API key" });
73
+ }
74
+ const secret = authHeader.slice(7);
75
+ let verified;
76
+ try {
77
+ // Clerk's verify throws on an invalid, revoked, or expired key. Treat any
78
+ // verify failure as a denied key (401). Failing closed is the safe default
79
+ // even if the cause was a transient Clerk fault — access is denied, not
80
+ // granted. (Same posture as the M2M middleware's 401 on a bad token.)
81
+ verified = await clerkClient.apiKeys.verify(secret);
82
+ }
83
+ catch {
84
+ return res.status(401).json({ error: "invalid_key" });
85
+ }
86
+ // Defensive: the SDK throws on these, but honor the flags if a future verify
87
+ // path returns them instead.
88
+ if (verified.revoked || verified.expired) {
89
+ return res.status(401).json({ error: "invalid_key" });
90
+ }
91
+ try {
92
+ const subject = verified.subject;
93
+ let result = await ak.validateBySubject(subject);
94
+ // First time we've seen this customer's key: provision its budget row,
95
+ // then re-validate. ensureSubject is idempotent, so concurrent first
96
+ // requests are safe.
97
+ if (!result.valid && result.reason === "invalid") {
98
+ await ak.ensureSubject(subject, onFirstSeen?.(verified) ?? defaults ?? {});
99
+ result = await ak.validateBySubject(subject);
100
+ }
101
+ if (!result.valid) {
102
+ const status = result.reason === "budget_exceeded" ? 429 : 401;
103
+ return res.status(status).json({ error: result.reason });
104
+ }
105
+ if (scope && !ak.hasScope(result, scope)) {
106
+ return res.status(403).json({
107
+ error: "insufficient_scope",
108
+ required: scope,
109
+ available: result.scopes,
110
+ });
111
+ }
112
+ req.clerkApiKey = verified;
113
+ req.agentKey = result;
114
+ next();
115
+ }
116
+ catch (err) {
117
+ // agentkey DB lookups reject on routine faults (pool exhaustion, timeout).
118
+ // Express 4 doesn't forward a rejected async-middleware promise, so fail
119
+ // closed with a 500.
120
+ console.error("clerkApiKeyMiddleware error:", err);
121
+ return res.status(500).json({ error: "auth_unavailable" });
122
+ }
123
+ };
124
+ }
125
+ // Charge a customer's budget after their billable work. Pass the verified key's
126
+ // subject (req.clerkApiKey.subject).
127
+ function trackByApiKey(ak, subject, costCents) {
128
+ return ak.trackUsageBySubject(subject, { costCents });
129
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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",
3
+ "version": "0.2.0",
4
+ "description": "Dollar spend caps for the Clerk API keys your users create: per-customer budget, scope, and expiry (also supports Clerk M2M)",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [