From 40043163684d497281926c8c8d31382939664e50 Mon Sep 17 00:00:00 2001 From: Deyanju23 Date: Sun, 28 Jun 2026 14:41:20 +0100 Subject: [PATCH] feat: developer can list their own API keys (prefix only) --- docs/openapi.json | 151 +++++++++++++++++++++++++++ src/repositories/apiKeyRepository.ts | 61 ++++++++++- src/routes/developerRoutes.test.ts | 147 ++++++++++++++++++++++++++ src/routes/developerRoutes.ts | 75 ++++++++++++- 4 files changed, 432 insertions(+), 2 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index c130e7a..757a292 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -886,6 +886,90 @@ } } }, + "/api/developers/me/keys": { + "get": { + "summary": "List developer's own API keys", + "description": "Returns a paginated list of the authenticated developer's API keys using cursor-based pagination.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Number of API keys to return per page (max 100)", + "required": false, + "schema": { + "type": "integer", + "default": 20 + } + }, + { + "name": "cursor", + "in": "query", + "description": "Base64 encoded cursor from a previous response to paginate forward", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "API keys retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeveloperApiKeysResponse" + } + } + } + }, + "400": { + "description": "Invalid query parameters or cursor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Forbidden (no developer profile)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/apis/{id}/endpoints/bulk": { "post": { "summary": "Bulk register endpoints for an API", @@ -1688,6 +1772,73 @@ } } }, + "DeveloperApiKey": { + "type": "object", + "required": [ + "id", + "prefix", + "created_at", + "last_used_at", + "revoked_at" + ], + "properties": { + "id": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "last_used_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "DeveloperApiKeysResponse": { + "type": "object", + "required": [ + "data", + "meta" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeveloperApiKey" + } + }, + "meta": { + "type": "object", + "required": [ + "limit", + "nextCursor", + "hasMore" + ], + "properties": { + "limit": { + "type": "integer" + }, + "nextCursor": { + "type": "string", + "nullable": true + }, + "hasMore": { + "type": "boolean" + } + } + } + } + }, "GatewayHealthResponse": { "type": "object", "required": [ diff --git a/src/repositories/apiKeyRepository.ts b/src/repositories/apiKeyRepository.ts index 2227d8b..e633acb 100644 --- a/src/repositories/apiKeyRepository.ts +++ b/src/repositories/apiKeyRepository.ts @@ -1,6 +1,7 @@ import { randomBytes, timingSafeEqual } from "crypto"; import bcrypt from "bcryptjs"; import { config } from "../config/index.js"; +import { decodeCursor, encodeCursor } from "../lib/cursorPagination.js"; /** * Typed error returned when an API key prefix is found in the store but the @@ -27,6 +28,8 @@ export interface ApiKeyRecord { rateLimitPerMinute: number | null; createdAt: Date; revoked: boolean; + lastUsedAt?: Date | null; + revokedAt?: Date | null; } const apiKeys: ApiKeyRecord[] = []; @@ -85,7 +88,9 @@ export const apiKeyRepository = { scopes: p.scopes, rateLimitPerMinute: p.rateLimitPerMinute, createdAt, - revoked: false + revoked: false, + lastUsedAt: null, + revokedAt: null }); return { id, key, prefix, createdAt }; @@ -99,12 +104,64 @@ export const apiKeyRepository = { ) .map((record) => ({ ...record })); }, + listWithCursor(params: { + userId: string; + limit: number; + cursor?: string; + }): { keys: ApiKeyRecord[]; nextCursor: string | null; hasMore: boolean } { + const { userId, limit, cursor } = params; + + let filteredKeys = apiKeys.filter((record) => record.userId === userId); + + // Sort descending by createdAt, then descending by id + filteredKeys.sort((a, b) => { + const timeA = a.createdAt.getTime(); + const timeB = b.createdAt.getTime(); + if (timeB !== timeA) { + return timeB - timeA; + } + return b.id.localeCompare(a.id); + }); + + if (cursor) { + const decoded = decodeCursor(cursor); + if (decoded) { + const targetTime = decoded.timestamp.getTime(); + filteredKeys = filteredKeys.filter((k) => { + const kTime = k.createdAt.getTime(); + if (kTime < targetTime) { + return true; + } + if (kTime === targetTime) { + return k.id < decoded.id; + } + return false; + }); + } + } + + const hasMore = filteredKeys.length > limit; + const results = hasMore ? filteredKeys.slice(0, limit) : filteredKeys; + + let nextCursor: string | null = null; + if (hasMore && results.length > 0) { + const last = results[results.length - 1]; + nextCursor = encodeCursor(last.createdAt, last.id); + } + + return { + keys: results.map((record) => ({ ...record })), + nextCursor, + hasMore, + }; + }, revoke(id: string, userId: string): 'success' | 'not_found' | 'forbidden' { const key = apiKeys.find(k => k.id === id); if (!key) return 'not_found'; if (key.userId !== userId) return 'forbidden'; key.revoked = true; + key.revokedAt = new Date(); return 'success'; }, verify(key: string): ApiKeyRecord | null { @@ -136,6 +193,8 @@ export const apiKeyRepository = { rateLimitPerMinute: candidate.rateLimitPerMinute, createdAt: candidate.createdAt, revoked: candidate.revoked, + lastUsedAt: candidate.lastUsedAt, + revokedAt: candidate.revokedAt, }; } } diff --git a/src/routes/developerRoutes.test.ts b/src/routes/developerRoutes.test.ts index 849c955..dd43cc4 100644 --- a/src/routes/developerRoutes.test.ts +++ b/src/routes/developerRoutes.test.ts @@ -4,6 +4,7 @@ import { createDeveloperRouter } from './developerRoutes.js'; import { errorHandler } from '../middleware/errorHandler.js'; import type { Developer } from '../db/schema.js'; import type { UpdateDeveloperProfileInput } from '../types/developer.js'; +import { apiKeyRepository } from '../repositories/apiKeyRepository.js'; const mockSettlementStore = { create: jest.fn(), @@ -229,3 +230,149 @@ describe('PATCH /api/developers/me', () => { }); }); }); + +describe('GET /api/developers/me/keys', () => { + beforeEach(() => { + jest.clearAllMocks(); + apiKeyRepository.clear(); + // Default: findByUserId returns a developer profile for 'dev-1' + mockDeveloperRepository.findByUserId.mockImplementation((userId: string) => + userId === 'dev-1' + ? Promise.resolve(makeDeveloper({ user_id: 'dev-1' })) + : Promise.resolve(undefined), + ); + }); + + it('returns 401 when unauthenticated', async () => { + const res = await request(app).get('/api/developers/me/keys'); + expect(res.status).toBe(401); + }); + + it('returns 403 when the authenticated user has no developer profile', async () => { + mockDeveloperRepository.findByUserId.mockResolvedValue(undefined); + + const res = await request(app) + .get('/api/developers/me/keys') + .set('x-user-id', 'no-profile-user'); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('DEVELOPER_NOT_FOUND'); + }); + + it('retrieves only that developer\'s API keys and excludes sensitive fields', async () => { + // Create key for dev-1 + const key1 = apiKeyRepository.create({ + apiId: 'api-1', + userId: 'dev-1', + scopes: ['read'], + rateLimitPerMinute: null, + }); + + // Create key for dev-2 + apiKeyRepository.create({ + apiId: 'api-1', + userId: 'dev-2', + scopes: ['read'], + rateLimitPerMinute: null, + }); + + const res = await request(app) + .get('/api/developers/me/keys') + .set('x-user-id', 'dev-1'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].id).toBe(key1.id); + expect(res.body.data[0].prefix).toBe(key1.prefix); + expect(res.body.data[0].created_at).toBe(key1.createdAt.toISOString()); + expect(res.body.data[0].last_used_at).toBeNull(); + expect(res.body.data[0].revoked_at).toBeNull(); + + // Verify only public-safe fields are present + const keys = Object.keys(res.body.data[0]); + expect(keys).toEqual(expect.arrayContaining(['id', 'prefix', 'created_at', 'last_used_at', 'revoked_at'])); + expect(keys.length).toBe(5); + + // Verify secret fields are NOT returned + expect(res.body.data[0]).not.toHaveProperty('key'); + expect(res.body.data[0]).not.toHaveProperty('keyHash'); + expect(res.body.data[0]).not.toHaveProperty('key_hash'); + expect(res.body.data[0]).not.toHaveProperty('scopes'); + expect(res.body.data[0]).not.toHaveProperty('userId'); + expect(res.body.data[0]).not.toHaveProperty('user_id'); + expect(JSON.stringify(res.body)).not.toContain(key1.key); + }); + + it('supports cursor-based pagination and correctly updates nextCursor/hasMore', async () => { + const now = new Date(); + // Create 3 keys for dev-1 at distinct timestamps (or different IDs for sorting stability) + const key1 = apiKeyRepository.create({ apiId: 'api-1', userId: 'dev-1', scopes: ['*'], rateLimitPerMinute: null }); + const keysInRepo = apiKeyRepository.listForTesting(); + + // key1 created first (oldest) + keysInRepo[0].createdAt = new Date(now.getTime() - 3000); + + const key2 = apiKeyRepository.create({ apiId: 'api-1', userId: 'dev-1', scopes: ['*'], rateLimitPerMinute: null }); + keysInRepo[1].createdAt = new Date(now.getTime() - 2000); + + const key3 = apiKeyRepository.create({ apiId: 'api-1', userId: 'dev-1', scopes: ['*'], rateLimitPerMinute: null }); + keysInRepo[2].createdAt = new Date(now.getTime() - 1000); + + // Fetch page 1 (limit 2) -> should return key3, key2 (sorted by createdAt desc) + const page1 = await request(app) + .get('/api/developers/me/keys?limit=2') + .set('x-user-id', 'dev-1'); + + expect(page1.status).toBe(200); + expect(page1.body.data).toHaveLength(2); + expect(page1.body.data[0].id).toBe(key3.id); + expect(page1.body.data[1].id).toBe(key2.id); + expect(page1.body.meta.hasMore).toBe(true); + expect(page1.body.meta.nextCursor).toBeTruthy(); + + const nextCursor = page1.body.meta.nextCursor; + + // Fetch page 2 using the cursor + const page2 = await request(app) + .get(`/api/developers/me/keys?limit=2&cursor=${encodeURIComponent(nextCursor)}`) + .set('x-user-id', 'dev-1'); + + expect(page2.status).toBe(200); + expect(page2.body.data).toHaveLength(1); + expect(page2.body.data[0].id).toBe(key1.id); + expect(page2.body.meta.hasMore).toBe(false); + expect(page2.body.meta.nextCursor).toBeNull(); + }); + + it('rejects invalid cursor format', async () => { + const res = await request(app) + .get('/api/developers/me/keys?cursor=invalid-non-base64-json') + .set('x-user-id', 'dev-1'); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('BAD_REQUEST'); + expect(res.body.message).toBe('Invalid cursor'); + }); + + it('returns revoked keys with revoked_at correctly populated', async () => { + const key = apiKeyRepository.create({ + apiId: 'api-1', + userId: 'dev-1', + scopes: ['*'], + rateLimitPerMinute: null, + }); + + apiKeyRepository.revoke(key.id, 'dev-1'); + + const res = await request(app) + .get('/api/developers/me/keys') + .set('x-user-id', 'dev-1'); + + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].id).toBe(key.id); + expect(res.body.data[0].revoked_at).not.toBeNull(); + expect(new Date(res.body.data[0].revoked_at).getTime()).toBeCloseTo(Date.now().valueOf(), -3); // Within 1 second + }); +}); + diff --git a/src/routes/developerRoutes.ts b/src/routes/developerRoutes.ts index 3832fd2..0e1735b 100644 --- a/src/routes/developerRoutes.ts +++ b/src/routes/developerRoutes.ts @@ -8,8 +8,10 @@ import { SettlementStore, } from '../types/developer.js'; import { UsageStore } from '../types/gateway.js'; -import { ForbiddenError, UnauthorizedError } from '../errors/index.js'; +import { BadRequestError, ForbiddenError, UnauthorizedError } from '../errors/index.js'; import type { DeveloperRepository } from '../repositories/developerRepository.js'; +import { apiKeyRepository } from '../repositories/apiKeyRepository.js'; +import { parseCursor } from '../lib/cursorPagination.js'; /** * Wraps an async Express route handler so that any thrown error is forwarded @@ -204,5 +206,76 @@ export function createDeveloperRouter(deps: DeveloperRoutesDeps): Router { }), ); + // Validation schema for developer keys query parameters + const keysQuerySchema = z.object({ + limit: z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 20)) + .pipe(z.number().int()) + .transform((val) => Math.min(Math.max(val, 1), 100)), + cursor: z.string().optional(), + }); + + /** + * GET /api/developers/me/keys + * + * Returns a paginated list of the authenticated developer's API keys. + */ + router.get( + '/me/keys', + requireAuth, + validate({ query: keysQuerySchema }), + asyncHandler(async (req, res) => { + const user = res.locals.authenticatedUser; + if (!user) { + throw new UnauthorizedError(); + } + + // Check if developer profile exists to prevent cross-tenant enumeration. + const developer = await developerRepository.findByUserId(user.id); + if (!developer) { + throw new ForbiddenError( + 'No developer profile found for this account', + 'DEVELOPER_NOT_FOUND', + ); + } + + const parsedQuery = keysQuerySchema.parse(req.query); + const limit = parsedQuery.limit; + const cursor = parsedQuery.cursor; + + if (cursor) { + const decoded = parseCursor(cursor); + if (!decoded) { + throw new BadRequestError('Invalid cursor'); + } + } + + const { keys, nextCursor, hasMore } = apiKeyRepository.listWithCursor({ + userId: user.id, + limit, + cursor, + }); + + const data = keys.map((key) => ({ + id: key.id, + prefix: key.prefix, + created_at: key.createdAt.toISOString(), + last_used_at: key.lastUsedAt ? key.lastUsedAt.toISOString() : null, + revoked_at: key.revokedAt ? key.revokedAt.toISOString() : null, + })); + + res.json({ + data, + meta: { + limit, + nextCursor, + hasMore, + }, + }); + }), + ); + return router; }