@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 +37 -32
- package/dist/index.d.ts +26 -0
- package/dist/index.js +68 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
# agentkey-clerk
|
|
2
2
|
|
|
3
|
-
Spend caps for [Clerk](https://clerk.com)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
| Budget | How much this
|
|
15
|
-
| Scope | What this
|
|
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 {
|
|
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
|
|
47
|
-
|
|
48
|
+
"/api",
|
|
49
|
+
clerkApiKeyMiddleware({
|
|
48
50
|
clerkClient,
|
|
49
51
|
ak,
|
|
50
|
-
// Applied the first time each
|
|
51
|
-
defaults: {
|
|
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
|
|
57
|
-
app.post("/api/
|
|
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
|
|
60
|
+
await trackByApiKey(ak, req.clerkApiKey!.subject, cost.cents);
|
|
60
61
|
res.json(cost.result);
|
|
61
62
|
});
|
|
62
63
|
```
|
|
63
64
|
|
|
64
|
-
The
|
|
65
|
+
The customer calls your API with their Clerk API key in `Authorization: Bearer <key>`. The middleware:
|
|
65
66
|
|
|
66
|
-
1. verifies the
|
|
67
|
-
2. provisions a budget row for the
|
|
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.
|
|
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`
|
|
77
|
-
|
|
|
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
|
-
|
|
|
81
|
+
| DB fault | `500 auth_unavailable` (fails closed) |
|
|
81
82
|
|
|
82
|
-
## Per-
|
|
83
|
+
## Per-customer budgets
|
|
83
84
|
|
|
84
|
-
Pass `onFirstSeen` to set budget/scope from the verified
|
|
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
|
-
|
|
88
|
+
clerkApiKeyMiddleware({
|
|
88
89
|
clerkClient,
|
|
89
90
|
ak,
|
|
90
|
-
onFirstSeen: (
|
|
91
|
-
accountId:
|
|
91
|
+
onFirstSeen: (apiKey) => ({
|
|
92
|
+
accountId: apiKey.subject, // user_xxx or org_xxx
|
|
92
93
|
scopes: ["proxy.chat"],
|
|
93
|
-
budgetCents: 2000,
|
|
94
|
-
budgetPeriod: "
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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": [
|