Skip to content

Commit 860639e

Browse files
Digidaiclaude
andcommitted
feat: add D1-backed auth, tiered access, usage metering, and policy engine (Phase B)
Phase B of the auth/metering/pricing plan: 1. D1 auth middleware (src/middleware/auth-d1.ts): - resolveAuth(): Bearer header → SHA-256 hash → D1 lookup → AuthContext - LRU cache (1024 entries, 60s TTL) reduces D1 queries - D1 failure: stale LRU preserves paid tier for known keys - Unknown keys degrade to anonymous (not 500) 2. Policy engine (src/middleware/tier-gate.ts): - buildPolicy(): AuthContext → PolicyDecision (what resources are allowed) - checkPolicy(): validates request params against policy - policyHeaders(): X-RateLimit-Limit/Remaining, X-Request-Cost headers 3. Usage metering (src/handlers/usage.ts): - In-memory buffer with ctx.waitUntil() batch flush to D1 - ON CONFLICT UPDATE for atomic upsert of daily aggregates - Denormalized monthly_credits_used on accounts table - GET /api/usage endpoint for per-key usage data 4. D1 schema (schema.sql): accounts, api_keys, usage_daily, sessions, paddle_events 5. Type system: AuthContext, PolicyDecision, Tier, TIER_QUOTAS 6. Graceful degradation: quota exceeded serves cached content + X-Quota-Exceeded header 7. Dual auth path: D1 (when AUTH_DB configured) or legacy single-token (backward compatible) 8. All 566 tests pass with mock ExecutionContext Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e67c4b5 commit 860639e

21 files changed

+807
-230
lines changed

schema.sql

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
-- md-genedai Auth & Metering schema (D1)
2+
-- Run: wrangler d1 execute AUTH_DB --file=schema.sql
3+
4+
CREATE TABLE IF NOT EXISTS accounts (
5+
id TEXT PRIMARY KEY,
6+
email TEXT NOT NULL UNIQUE,
7+
github_id TEXT,
8+
tier TEXT NOT NULL DEFAULT 'free',
9+
paddle_customer_id TEXT,
10+
paddle_subscription_id TEXT,
11+
monthly_credits_used INTEGER NOT NULL DEFAULT 0,
12+
monthly_credits_reset_at TEXT NOT NULL,
13+
created_at TEXT NOT NULL,
14+
updated_at TEXT NOT NULL
15+
);
16+
17+
CREATE TABLE IF NOT EXISTS api_keys (
18+
id TEXT PRIMARY KEY,
19+
account_id TEXT NOT NULL REFERENCES accounts(id),
20+
prefix TEXT NOT NULL,
21+
key_hash TEXT NOT NULL,
22+
name TEXT,
23+
revoked_at TEXT,
24+
created_at TEXT NOT NULL
25+
);
26+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
27+
CREATE INDEX IF NOT EXISTS idx_api_keys_account ON api_keys(account_id);
28+
29+
CREATE TABLE IF NOT EXISTS usage_daily (
30+
key_id TEXT NOT NULL REFERENCES api_keys(id),
31+
date TEXT NOT NULL,
32+
requests INTEGER NOT NULL DEFAULT 0,
33+
credits INTEGER NOT NULL DEFAULT 0,
34+
browser_calls INTEGER NOT NULL DEFAULT 0,
35+
cache_hits INTEGER NOT NULL DEFAULT 0,
36+
PRIMARY KEY (key_id, date)
37+
);
38+
39+
CREATE TABLE IF NOT EXISTS sessions (
40+
id TEXT PRIMARY KEY,
41+
account_id TEXT NOT NULL REFERENCES accounts(id),
42+
token_hash TEXT NOT NULL,
43+
expires_at TEXT NOT NULL,
44+
created_at TEXT NOT NULL
45+
);
46+
CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_id);
47+
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
48+
49+
CREATE TABLE IF NOT EXISTS paddle_events (
50+
event_id TEXT PRIMARY KEY,
51+
processed_at TEXT NOT NULL
52+
);

src/__tests__/cf-integration.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2-
import { createMockEnv } from "./test-helpers";
2+
import { createMockEnv, mockCtx } from "./test-helpers";
33

44
const mocked = vi.hoisted(() => {
55
// Sentinel object used to verify genericAdapter identity checks
@@ -175,7 +175,7 @@ describe("CF REST API integration in convertUrl", () => {
175175
const req = new Request("https://md.example.com/https://example.com/page?raw=true&engine=cf", {
176176
headers: { Accept: "text/markdown", Authorization: "Bearer test-token" },
177177
});
178-
const res = await worker.fetch(req, env);
178+
const res = await worker.fetch(req, env, mockCtx());
179179

180180
expect(res.status).toBe(200);
181181
const text = await res.text();
@@ -189,7 +189,7 @@ describe("CF REST API integration in convertUrl", () => {
189189
const req = new Request("https://md.example.com/https://example.com/page?raw=true", {
190190
headers: { Accept: "text/markdown" },
191191
});
192-
const res = await worker.fetch(req, env);
192+
const res = await worker.fetch(req, env, mockCtx());
193193

194194
expect(res.status).toBe(200);
195195
expect(res.headers.get("X-Markdown-Method")).toBe("cf");
@@ -211,7 +211,7 @@ describe("CF REST API integration in convertUrl", () => {
211211
const req = new Request("https://md.example.com/https://mp.weixin.qq.com/s/abc?raw=true", {
212212
headers: { Accept: "text/markdown" },
213213
});
214-
const res = await worker.fetch(req, env);
214+
const res = await worker.fetch(req, env, mockCtx());
215215

216216
expect(res.status).toBe(200);
217217
expect(mocked.cfRest.fetchViaCfMarkdown).not.toHaveBeenCalled();
@@ -231,7 +231,7 @@ describe("CF REST API integration in convertUrl", () => {
231231
const req = new Request("https://md.example.com/https://example.com/paywalled?raw=true", {
232232
headers: { Accept: "text/markdown" },
233233
});
234-
const res = await worker.fetch(req, env);
234+
const res = await worker.fetch(req, env, mockCtx());
235235

236236
expect(res.status).toBe(200);
237237
expect(mocked.cfRest.fetchViaCfMarkdown).not.toHaveBeenCalled();
@@ -255,7 +255,7 @@ describe("CF REST API integration in convertUrl", () => {
255255
const req = new Request("https://md.example.com/https://example.com/blocked?raw=true", {
256256
headers: { Accept: "text/markdown" },
257257
});
258-
const res = await worker.fetch(req, env);
258+
const res = await worker.fetch(req, env, mockCtx());
259259

260260
expect(res.status).toBe(200);
261261
expect(mocked.cfRest.fetchViaCfMarkdown).not.toHaveBeenCalled();
@@ -279,7 +279,7 @@ describe("CF REST API integration in convertUrl", () => {
279279
const req = new Request("https://md.example.com/https://example.com/cf-empty?raw=true", {
280280
headers: { Accept: "text/markdown" },
281281
});
282-
const res = await worker.fetch(req, env);
282+
const res = await worker.fetch(req, env, mockCtx());
283283

284284
expect(res.status).toBe(200);
285285
// Should have attempted CF, then fallen back
@@ -304,7 +304,7 @@ describe("CF REST API integration in convertUrl", () => {
304304
const req = new Request("https://md.example.com/https://example.com/cf-error?raw=true", {
305305
headers: { Accept: "text/markdown" },
306306
});
307-
const res = await worker.fetch(req, env);
307+
const res = await worker.fetch(req, env, mockCtx());
308308

309309
expect(res.status).toBe(200);
310310
expect(mocked.cfRest.fetchViaCfMarkdown).toHaveBeenCalledTimes(1);
@@ -317,7 +317,7 @@ describe("CF REST API integration in convertUrl", () => {
317317
const req = new Request("https://md.example.com/https://example.com/cacheable?raw=true&engine=cf", {
318318
headers: { Accept: "text/markdown", Authorization: "Bearer test-token" },
319319
});
320-
await worker.fetch(req, env);
320+
await worker.fetch(req, env, mockCtx());
321321

322322
expect(mocked.cache.setCache).toHaveBeenCalledTimes(1);
323323
const setCacheArgs = mocked.cache.setCache.mock.calls[0];
@@ -337,7 +337,7 @@ describe("CF REST API integration in convertUrl", () => {
337337
const req = new Request("https://md.example.com/https://example.com/no-cf?raw=true", {
338338
headers: { Accept: "text/markdown" },
339339
});
340-
const res = await worker.fetch(req, env);
340+
const res = await worker.fetch(req, env, mockCtx());
341341

342342
expect(res.status).toBe(200);
343343
expect(mocked.cfRest.fetchViaCfMarkdown).not.toHaveBeenCalled();
@@ -358,7 +358,7 @@ describe("CF integration in batch handler", () => {
358358
urls: ["https://example.com/a", "https://example.com/b"],
359359
}),
360360
});
361-
const res = await worker.fetch(req, env);
361+
const res = await worker.fetch(req, env, mockCtx());
362362

363363
expect(res.status).toBe(200);
364364
const body = await res.json() as any;
@@ -398,7 +398,7 @@ describe("CF integration in batch handler", () => {
398398
],
399399
}),
400400
});
401-
const res = await worker.fetch(req, env);
401+
const res = await worker.fetch(req, env, mockCtx());
402402

403403
expect(res.status).toBe(200);
404404
const body = await res.json() as any;

src/__tests__/index-batch.test.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ vi.mock("cloudflare:sockets", () => ({
55
}));
66

77
import worker from "../index";
8-
import { createMockEnv } from "./test-helpers";
8+
import { createMockEnv, mockCtx } from "./test-helpers";
99

1010
afterEach(() => {
1111
vi.restoreAllMocks();
@@ -31,7 +31,7 @@ function batchRequest(
3131
describe("POST /api/batch", () => {
3232
it("returns 503 when API_TOKEN is missing", async () => {
3333
const req = batchRequest({ urls: ["https://example.com"] }, "token");
34-
const res = await worker.fetch(req, createMockEnv().env);
34+
const res = await worker.fetch(req, createMockEnv().env, mockCtx());
3535
const payload = await res.json() as { error?: string };
3636

3737
expect(res.status).toBe(503);
@@ -41,7 +41,7 @@ describe("POST /api/batch", () => {
4141
it("returns 401 for invalid bearer token", async () => {
4242
const { env } = createMockEnv({ API_TOKEN: "correct-token" });
4343
const req = batchRequest({ urls: ["https://example.com"] }, "wrong-token");
44-
const res = await worker.fetch(req, env);
44+
const res = await worker.fetch(req, env, mockCtx());
4545
const payload = await res.json() as { error?: string };
4646

4747
expect(res.status).toBe(401);
@@ -55,7 +55,7 @@ describe("POST /api/batch", () => {
5555
"token",
5656
{ "Content-Length": "100001" },
5757
);
58-
const res = await worker.fetch(req, env);
58+
const res = await worker.fetch(req, env, mockCtx());
5959
const payload = await res.json() as { error?: string };
6060

6161
expect(res.status).toBe(413);
@@ -66,7 +66,7 @@ describe("POST /api/batch", () => {
6666
const { env } = createMockEnv({ API_TOKEN: "token" });
6767
const oversizedBody = `{"urls":["https://example.com/${"a".repeat(110_000)}"]}`;
6868
const req = batchRequest(oversizedBody, "token", { "Content-Length": "0" });
69-
const res = await worker.fetch(req, env);
69+
const res = await worker.fetch(req, env, mockCtx());
7070
const payload = await res.json() as { error?: string };
7171

7272
expect(res.status).toBe(413);
@@ -77,7 +77,7 @@ describe("POST /api/batch", () => {
7777
const { env } = createMockEnv({ API_TOKEN: "token" });
7878
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
7979
const req = batchRequest("{invalid-json", "token");
80-
const res = await worker.fetch(req, env);
80+
const res = await worker.fetch(req, env, mockCtx());
8181
const payload = await res.json() as { error?: string };
8282

8383
expect(res.status).toBe(400);
@@ -99,7 +99,7 @@ describe("POST /api/batch", () => {
9999

100100
const { env } = createMockEnv({ API_TOKEN: "token" });
101101
const req = batchRequest({ urls: ["https://example.com/internal-error-case"] }, "token");
102-
const res = await worker.fetch(req, env);
102+
const res = await worker.fetch(req, env, mockCtx());
103103
const payload = await res.json() as { error?: string; message?: string };
104104

105105
expect(res.status).toBe(500);
@@ -110,7 +110,7 @@ describe("POST /api/batch", () => {
110110
it("returns 400 when urls is missing", async () => {
111111
const { env } = createMockEnv({ API_TOKEN: "token" });
112112
const req = batchRequest({ foo: "bar" }, "token");
113-
const res = await worker.fetch(req, env);
113+
const res = await worker.fetch(req, env, mockCtx());
114114
const payload = await res.json() as { error?: string };
115115

116116
expect(res.status).toBe(400);
@@ -121,7 +121,7 @@ describe("POST /api/batch", () => {
121121
const { env } = createMockEnv({ API_TOKEN: "token" });
122122
const urls = Array.from({ length: 11 }, (_, i) => `https://example.com/${i}`);
123123
const req = batchRequest({ urls }, "token");
124-
const res = await worker.fetch(req, env);
124+
const res = await worker.fetch(req, env, mockCtx());
125125
const payload = await res.json() as { error?: string };
126126

127127
expect(res.status).toBe(400);
@@ -136,7 +136,7 @@ describe("POST /api/batch", () => {
136136
const req = batchRequest({
137137
urls: ["https://example.com/a", 123, { bad: "item" }],
138138
}, "token");
139-
const res = await worker.fetch(req, env);
139+
const res = await worker.fetch(req, env, mockCtx());
140140
const payload = await res.json() as { error?: string };
141141

142142
expect(res.status).toBe(400);
@@ -152,7 +152,7 @@ describe("POST /api/batch", () => {
152152
const req = batchRequest({
153153
urls: [" ", { url: " ", format: "markdown" }],
154154
}, "token");
155-
const res = await worker.fetch(req, env);
155+
const res = await worker.fetch(req, env, mockCtx());
156156
const payload = await res.json() as { error?: string };
157157

158158
expect(res.status).toBe(400);
@@ -180,7 +180,7 @@ describe("POST /api/batch", () => {
180180
},
181181
],
182182
}, "token");
183-
const res = await worker.fetch(req, env);
183+
const res = await worker.fetch(req, env, mockCtx());
184184
const payload = await res.json() as {
185185
results?: Array<{
186186
url?: string;
@@ -215,7 +215,7 @@ describe("POST /api/batch", () => {
215215
},
216216
],
217217
}, "token");
218-
const res = await worker.fetch(req, env);
218+
const res = await worker.fetch(req, env, mockCtx());
219219
const payload = await res.json() as {
220220
results?: Array<{ error?: string; content?: string }>;
221221
};
@@ -233,7 +233,7 @@ describe("POST /api/batch", () => {
233233
const req = batchRequest({
234234
urls: ["not-a-url", "http://127.0.0.1/private"],
235235
}, "token");
236-
const res = await worker.fetch(req, env);
236+
const res = await worker.fetch(req, env, mockCtx());
237237
const payload = await res.json() as {
238238
results?: Array<{ url?: string; error?: string }>;
239239
};
@@ -254,7 +254,7 @@ describe("POST /api/batch", () => {
254254

255255
const { env } = createMockEnv({ API_TOKEN: "token" });
256256
const req = batchRequest({ urls: ["https://example.com/a"] }, "token");
257-
const res = await worker.fetch(req, env);
257+
const res = await worker.fetch(req, env, mockCtx());
258258
const payload = await res.json() as {
259259
results?: Array<{
260260
url?: string;
@@ -304,7 +304,7 @@ describe("POST /api/batch", () => {
304304
signal: controller.signal,
305305
});
306306

307-
const responsePromise = worker.fetch(req, env);
307+
const responsePromise = worker.fetch(req, env, mockCtx());
308308
setTimeout(() => controller.abort(), 20);
309309

310310
const race = await Promise.race([

0 commit comments

Comments
 (0)