Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
61 changes: 60 additions & 1 deletion src/repositories/apiKeyRepository.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -27,6 +28,8 @@ export interface ApiKeyRecord {
rateLimitPerMinute: number | null;
createdAt: Date;
revoked: boolean;
lastUsedAt?: Date | null;
revokedAt?: Date | null;
}

const apiKeys: ApiKeyRecord[] = [];
Expand Down Expand Up @@ -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 };
Expand All @@ -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 {
Expand Down Expand Up @@ -136,6 +193,8 @@ export const apiKeyRepository = {
rateLimitPerMinute: candidate.rateLimitPerMinute,
createdAt: candidate.createdAt,
revoked: candidate.revoked,
lastUsedAt: candidate.lastUsedAt,
revokedAt: candidate.revokedAt,
};
}
}
Expand Down
Loading