From dd337d30b586eb6996e4df22ad8339359e0c809d Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 7 Mar 2026 08:04:34 +0100 Subject: [PATCH] feat: add GET /v1/usage/me endpoint for user-facing usage stats --- src/__tests__/setup.ts | 1 + src/__tests__/usage-me.test.ts | 89 ++++++++++++++++++++++++++++++++++ src/index.ts | 48 +++++++++++++++++- src/middleware/usage.ts | 9 ++++ 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/usage-me.test.ts diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 90a9690..1629eab 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -91,4 +91,5 @@ vi.mock("../middleware/usage.js", () => ({ usageMiddleware: vi.fn((_req: any, _res: any, next: any) => next()), loadUsageData: vi.fn().mockResolvedValue(undefined), getUsageStats: vi.fn().mockReturnValue({ totalRequests: 0, keys: {} }), + getUsageForKey: vi.fn().mockReturnValue({ count: 0, monthKey: "2026-01" }), })); diff --git a/src/__tests__/usage-me.test.ts b/src/__tests__/usage-me.test.ts new file mode 100644 index 0000000..5e74fb0 --- /dev/null +++ b/src/__tests__/usage-me.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; + +import { isProKey, isValidKey, getKeyInfo } from "../services/keys.js"; +import { getUsageForKey } from "../middleware/usage.js"; +import { app } from "../index.js"; + +describe("GET /v1/usage/me", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 without auth", async () => { + const res = await request(app).get("/v1/usage/me"); + expect(res.status).toBe(401); + }); + + it("returns usage for authenticated Pro key", async () => { + vi.mocked(isProKey).mockReturnValue(true); + vi.mocked(isValidKey).mockReturnValue(true); + vi.mocked(getKeyInfo).mockReturnValue({ + key: "test-key", tier: "pro", email: "test@docfast.dev", createdAt: new Date().toISOString(), + } as any); + vi.mocked(getUsageForKey).mockReturnValue({ count: 142, monthKey: "2026-03" }); + + const res = await request(app) + .get("/v1/usage/me") + .set("X-API-Key", "test-key"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + used: 142, + limit: 5000, + plan: "pro", + month: "2026-03", + }); + }); + + it("returns count 0 for authenticated key with no usage this month", async () => { + vi.mocked(isProKey).mockReturnValue(true); + vi.mocked(isValidKey).mockReturnValue(true); + vi.mocked(getKeyInfo).mockReturnValue({ + key: "test-key", tier: "pro", email: "test@docfast.dev", createdAt: new Date().toISOString(), + } as any); + vi.mocked(getUsageForKey).mockReturnValue({ count: 0, monthKey: "2026-03" }); + + const res = await request(app) + .get("/v1/usage/me") + .set("X-API-Key", "test-key"); + + expect(res.status).toBe(200); + expect(res.body.used).toBe(0); + expect(res.body.limit).toBe(5000); + expect(res.body.plan).toBe("pro"); + }); + + it("returns correct month string", async () => { + vi.mocked(isValidKey).mockReturnValue(true); + vi.mocked(isProKey).mockReturnValue(true); + vi.mocked(getKeyInfo).mockReturnValue({ + key: "test-key", tier: "pro", email: "test@docfast.dev", createdAt: new Date().toISOString(), + } as any); + vi.mocked(getUsageForKey).mockReturnValue({ count: 5, monthKey: "2026-03" }); + + const res = await request(app) + .get("/v1/usage/me") + .set("X-API-Key", "test-key"); + + expect(res.body.month).toBe("2026-03"); + }); + + it("returns demo plan for non-pro keys", async () => { + vi.mocked(isProKey).mockReturnValue(false); + vi.mocked(isValidKey).mockReturnValue(true); + vi.mocked(getKeyInfo).mockReturnValue({ + key: "test-key", tier: "free", email: "test@docfast.dev", createdAt: new Date().toISOString(), + } as any); + vi.mocked(getUsageForKey).mockReturnValue({ count: 50, monthKey: "2026-03" }); + + const res = await request(app) + .get("/v1/usage/me") + .set("X-API-Key", "test-key"); + + expect(res.status).toBe(200); + expect(res.body.plan).toBe("demo"); + expect(res.body.limit).toBe(100); + expect(res.body.used).toBe(50); + }); +}); diff --git a/src/index.ts b/src/index.ts index 6bbb998..a39bfbe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,10 +19,10 @@ import { emailChangeRouter } from "./routes/email-change.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js"; -import { getUsageStats } from "./middleware/usage.js"; +import { getUsageStats, getUsageForKey } from "./middleware/usage.js"; import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; -import { loadKeys, getAllKeys } from "./services/keys.js"; +import { loadKeys, getAllKeys, isProKey } from "./services/keys.js"; import { verifyToken, loadVerifications } from "./services/verification.js"; import { initDatabase, pool, cleanupStaleData } from "./services/db.js"; import { swaggerSpec } from "./swagger.js"; @@ -142,6 +142,50 @@ const convertBodyLimit = express.json({ limit: "500kb" }); app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); app.use("/v1/templates", defaultJsonParser, authMiddleware, usageMiddleware, templatesRouter); +/** + * @openapi + * /v1/usage/me: + * get: + * summary: Get your current month's usage + * description: Returns the authenticated user's PDF generation usage for the current billing month. + * security: + * - ApiKeyAuth: [] + * responses: + * 200: + * description: Current usage statistics + * content: + * application/json: + * schema: + * type: object + * properties: + * used: + * type: integer + * description: Number of PDFs generated this month + * limit: + * type: integer + * description: Monthly PDF limit for your plan + * plan: + * type: string + * enum: [pro, demo] + * description: Your current plan + * month: + * type: string + * description: Current billing month (YYYY-MM) + * 401: + * description: Missing or invalid API key + */ +app.get("/v1/usage/me", authMiddleware, (req: any, res: any) => { + const key = req.apiKeyInfo.key; + const { count, monthKey } = getUsageForKey(key); + const pro = isProKey(key); + res.json({ + used: count, + limit: pro ? 5000 : 100, + plan: pro ? "pro" : "demo", + month: monthKey, + }); +}); + // Admin: usage stats (admin key required) const adminAuth = (req: any, res: any, next: any) => { const adminKey = process.env.ADMIN_API_KEY; diff --git a/src/middleware/usage.ts b/src/middleware/usage.ts index 040fc7a..c41083f 100644 --- a/src/middleware/usage.ts +++ b/src/middleware/usage.ts @@ -117,6 +117,15 @@ function trackUsage(key: string, monthKey: string): void { } } +export function getUsageForKey(key: string): { count: number; monthKey: string } { + const monthKey = getMonthKey(); + const record = usage.get(key); + if (record && record.monthKey === monthKey) { + return { count: record.count, monthKey }; + } + return { count: 0, monthKey }; +} + export function getUsageStats(apiKey?: string): Record { const stats: Record = {}; if (apiKey) {