Skip to content

Commit 9619e3c

Browse files
authored
Merge branch 'staging' into fix/key-management-improvements
2 parents 95aebaf + 5fb31f6 commit 9619e3c

17 files changed

Lines changed: 1079 additions & 31 deletions

src/api-keys/api-key.controller.ts

Lines changed: 206 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,26 @@ import {
44
Get,
55
Body,
66
Param,
7-
Delete,
87
Query,
98
HttpCode,
109
HttpStatus,
1110
} from '@nestjs/common';
11+
import {
12+
ApiTags,
13+
ApiOperation,
14+
ApiResponse,
15+
ApiParam,
16+
ApiQuery,
17+
ApiBody,
18+
} from '@nestjs/swagger';
1219
import {
1320
ApiKeyService,
1421
CreateApiKeyRequest,
1522
ListApiKeysRequest,
1623
} from './api-key.service';
1724
import { CreateApiKeyDto } from './dto/create-api-key.dto';
1825

26+
@ApiTags('api-keys')
1927
@Controller('api-keys')
2028
export class ApiKeyController {
2129
constructor(private readonly apiKeyService: ApiKeyService) {}
@@ -25,6 +33,79 @@ export class ApiKeyController {
2533
*/
2634
@Post()
2735
@HttpCode(HttpStatus.CREATED)
36+
@ApiOperation({
37+
summary: 'Create a new API key',
38+
description:
39+
'Generates a new API key for a project. The plain-text key is returned **only once** — store it securely.',
40+
})
41+
@ApiBody({
42+
type: CreateApiKeyDto,
43+
examples: {
44+
basic: {
45+
summary: 'Basic key creation',
46+
value: { name: 'production-key', projectId: 'project-abc123' },
47+
},
48+
withExpiry: {
49+
summary: 'Key with expiration date',
50+
value: {
51+
name: 'temporary-key',
52+
projectId: 'project-abc123',
53+
expiresAt: '2027-01-01T00:00:00.000Z',
54+
},
55+
},
56+
},
57+
})
58+
@ApiResponse({
59+
status: 201,
60+
description: 'API key created — plain-text key returned only here.',
61+
schema: {
62+
type: 'object',
63+
properties: {
64+
message: {
65+
type: 'string',
66+
example: 'Store this key securely — it will not be shown again',
67+
},
68+
apiKey: {
69+
type: 'object',
70+
properties: {
71+
id: { type: 'string', example: 'apikey-uuid-here' },
72+
name: { type: 'string', example: 'production-key' },
73+
keyPrefix: { type: 'string', example: 'mux_live_' },
74+
lastFour: { type: 'string', example: 'Ab1C' },
75+
status: { type: 'string', example: 'ACTIVE' },
76+
createdAt: { type: 'string', format: 'date-time' },
77+
},
78+
},
79+
plainTextKey: {
80+
type: 'string',
81+
example: 'mux_live_AbCdEfGhIjKlMnOpQrStUvWx',
82+
},
83+
},
84+
},
85+
})
86+
@ApiResponse({
87+
status: 400,
88+
description: 'Bad request — missing or invalid fields.',
89+
schema: {
90+
type: 'object',
91+
properties: {
92+
statusCode: { type: 'number', example: 400 },
93+
message: { type: 'array', items: { type: 'string' } },
94+
error: { type: 'string', example: 'Bad Request' },
95+
},
96+
},
97+
})
98+
@ApiResponse({
99+
status: 404,
100+
description: 'Project not found.',
101+
schema: {
102+
type: 'object',
103+
properties: {
104+
statusCode: { type: 'number', example: 404 },
105+
message: { type: 'string', example: 'Project project-abc123 not found' },
106+
},
107+
},
108+
})
28109
async createApiKey(@Body() request: CreateApiKeyDto) {
29110
const result = await this.apiKeyService.createApiKey(
30111
request as CreateApiKeyRequest,
@@ -49,6 +130,53 @@ export class ApiKeyController {
49130
* Lists all API keys for a project with pagination
50131
*/
51132
@Get()
133+
@ApiOperation({
134+
summary: 'List API keys for a project',
135+
description: 'Returns paginated API key metadata. Plain-text keys are never exposed here.',
136+
})
137+
@ApiQuery({ name: 'projectId', required: true, description: 'Project ID to list keys for', example: 'project-abc123' })
138+
@ApiQuery({ name: 'page', required: false, description: 'Page number (1-based)', example: 1 })
139+
@ApiQuery({ name: 'pageSize', required: false, description: 'Number of results per page', example: 10 })
140+
@ApiQuery({ name: 'developerId', required: false, description: 'Optional developer ID for ownership check' })
141+
@ApiResponse({
142+
status: 200,
143+
description: 'Paginated list of API key metadata.',
144+
schema: {
145+
type: 'object',
146+
properties: {
147+
keys: {
148+
type: 'array',
149+
items: {
150+
type: 'object',
151+
properties: {
152+
id: { type: 'string', example: 'apikey-uuid-here' },
153+
name: { type: 'string', example: 'production-key' },
154+
keyPrefix: { type: 'string', example: 'mux_live_' },
155+
lastFour: { type: 'string', example: 'Ab1C' },
156+
status: { type: 'string', example: 'ACTIVE' },
157+
lastUsedAt: { type: 'string', format: 'date-time', nullable: true },
158+
createdAt: { type: 'string', format: 'date-time' },
159+
expiresAt: { type: 'string', format: 'date-time', nullable: true },
160+
projectId: { type: 'string', example: 'project-abc123' },
161+
},
162+
},
163+
},
164+
pagination: {
165+
type: 'object',
166+
properties: {
167+
page: { type: 'number', example: 1 },
168+
pageSize: { type: 'number', example: 10 },
169+
total: { type: 'number', example: 42 },
170+
totalPages: { type: 'number', example: 5 },
171+
},
172+
},
173+
},
174+
},
175+
})
176+
@ApiResponse({
177+
status: 401,
178+
description: 'Unauthorized — developer does not own this project.',
179+
})
52180
async listApiKeys(
53181
@Query('projectId') projectId: string,
54182
@Query('page') page?: string,
@@ -88,6 +216,39 @@ export class ApiKeyController {
88216
*/
89217
@Post(':apiKeyId/revoke')
90218
@HttpCode(HttpStatus.OK)
219+
@ApiOperation({
220+
summary: 'Revoke an API key',
221+
description: 'Marks the key as REVOKED. Idempotent — revoking an already-revoked key succeeds.',
222+
})
223+
@ApiParam({ name: 'apiKeyId', description: 'ID of the API key to revoke', example: 'apikey-uuid-here' })
224+
@ApiBody({
225+
schema: {
226+
type: 'object',
227+
properties: {
228+
reason: { type: 'string', example: 'Key compromised', description: 'Optional revocation reason' },
229+
developerId: { type: 'string', example: 'dev-uuid', description: 'Optional: verify ownership before revoking' },
230+
},
231+
},
232+
examples: {
233+
basic: { summary: 'Revoke without reason', value: {} },
234+
withReason: { summary: 'Revoke with reason', value: { reason: 'Key compromised during security incident' } },
235+
},
236+
})
237+
@ApiResponse({
238+
status: 200,
239+
description: 'API key revoked successfully.',
240+
schema: {
241+
type: 'object',
242+
properties: {
243+
id: { type: 'string', example: 'apikey-uuid-here' },
244+
status: { type: 'string', example: 'REVOKED' },
245+
revokedAt: { type: 'string', format: 'date-time' },
246+
revokedReason: { type: 'string', nullable: true, example: 'Key compromised' },
247+
},
248+
},
249+
})
250+
@ApiResponse({ status: 401, description: 'Not authorized to revoke this key.' })
251+
@ApiResponse({ status: 404, description: 'API key not found.' })
91252
async revokeApiKey(
92253
@Param('apiKeyId') apiKeyId: string,
93254
@Body() body: { reason?: string; developerId?: string },
@@ -111,6 +272,50 @@ export class ApiKeyController {
111272
*/
112273
@Post(':apiKeyId/rotate')
113274
@HttpCode(HttpStatus.OK)
275+
@ApiOperation({
276+
summary: 'Rotate an API key',
277+
description:
278+
'Creates a new API key and sets the old key into a grace-period window (configurable via `API_KEY_ROTATION_GRACE_SECONDS`). ' +
279+
'Both keys remain valid during the grace period so clients can migrate without downtime.',
280+
})
281+
@ApiParam({ name: 'apiKeyId', description: 'ID of the API key to rotate', example: 'apikey-uuid-here' })
282+
@ApiBody({
283+
schema: {
284+
type: 'object',
285+
properties: {
286+
name: { type: 'string', example: 'production-key-v2', description: 'Optional name for the new key' },
287+
developerId: { type: 'string', example: 'dev-uuid', description: 'Optional: verify ownership before rotating' },
288+
},
289+
},
290+
examples: {
291+
basic: { summary: 'Rotate without renaming', value: {} },
292+
withName: { summary: 'Rotate with new name', value: { name: 'production-key-v2' } },
293+
},
294+
})
295+
@ApiResponse({
296+
status: 200,
297+
description: 'New API key returned. Old key stays valid during the grace period.',
298+
schema: {
299+
type: 'object',
300+
properties: {
301+
message: { type: 'string', example: 'Store this key securely — it will not be shown again' },
302+
apiKey: {
303+
type: 'object',
304+
properties: {
305+
id: { type: 'string', example: 'apikey-new-uuid' },
306+
name: { type: 'string', example: 'production-key-v2' },
307+
keyPrefix: { type: 'string', example: 'mux_live_' },
308+
lastFour: { type: 'string', example: 'Xy9Z' },
309+
status: { type: 'string', example: 'ACTIVE' },
310+
createdAt: { type: 'string', format: 'date-time' },
311+
},
312+
},
313+
plainTextKey: { type: 'string', example: 'mux_live_XyZaBcDeFgHiJkLmNoPqRsTuV' },
314+
},
315+
},
316+
})
317+
@ApiResponse({ status: 401, description: 'Not authorized to rotate this key.' })
318+
@ApiResponse({ status: 404, description: 'API key not found.' })
114319
async rotateApiKey(
115320
@Param('apiKeyId') apiKeyId: string,
116321
@Body() body: { name?: string; developerId?: string },

0 commit comments

Comments
 (0)