Skip to content

Commit 9735241

Browse files
authored
Merge pull request #778 from TaloDev/migrate-api-key-routes
Migrate api key routes to new router
2 parents c10118d + cd17e8c commit 9735241

File tree

13 files changed

+266
-218
lines changed

13 files changed

+266
-218
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ Three route helpers provide type safety for route configurations:
282282

283283
Route configurations support:
284284
- `method`: HTTP method ('get', 'post', 'put', 'patch', 'delete')
285-
- `path`: Route path (relative to router basePath)
285+
- `path`: Route path (relative to router basePath, can be omitted for root routes)
286286
- `handler`: Async function receiving typed context
287287
- `middleware`: Optional middleware array (use `withMiddleware()` wrapper)
288288
- `validation`: Optional Zod schema for request body validation

src/config/protected-routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import GameStatService from '../services/game-stat.service'
77
import { gameActivityRouter } from '../routes/protected/game-activity'
88
import LeaderboardService from '../services/leaderboard.service'
99
import DataExportService from '../services/data-export.service'
10-
import APIKeyService from '../services/api-key.service'
10+
import { apiKeyRouter } from '../routes/protected/api-key'
1111
import EventService from '../services/event.service'
1212
import { headlineRouter } from '../routes/protected/headline'
1313
import PlayerService from '../services/player.service'
@@ -54,7 +54,6 @@ export default function protectedRoutes(app: Koa) {
5454
app.use(service('/games/:gameId/game-stats', new GameStatService(), serviceOpts))
5555
app.use(service('/games/:gameId/leaderboards', new LeaderboardService(), serviceOpts))
5656
app.use(service('/games/:gameId/data-exports', new DataExportService(), serviceOpts))
57-
app.use(service('/games/:gameId/api-keys', new APIKeyService(), serviceOpts))
5857
app.use(service('/games/:gameId/events', new EventService(), serviceOpts))
5958
app.use(service('/games/:gameId/players', new PlayerService(), serviceOpts))
6059
app.use(service('/games/:gameId/integrations', new IntegrationService(), serviceOpts))
@@ -63,6 +62,7 @@ export default function protectedRoutes(app: Koa) {
6362
app.use(service('/games/:gameId/game-channels', new GameChannelService(), serviceOpts))
6463

6564
// new router-based routes
65+
app.use(apiKeyRouter().routes())
6666
app.use(billingRouter().routes())
6767
app.use(gameActivityRouter().routes())
6868
app.use(gameRouter().routes())

src/middleware/game-middleware.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { Next } from 'koa'
22
import Game from '../entities/game'
33
import { ProtectedRouteContext } from '../lib/routing/context'
44

5-
type GameRouteContext = ProtectedRouteContext<{ game: Game }>
5+
export type GameRouteState = { game: Game }
6+
type GameRouteContext = ProtectedRouteContext<GameRouteState>
67

78
export const loadGame = async (ctx: GameRouteContext, next: Next) => {
89
const { gameId } = ctx.params as { gameId: string }

src/policies/api-key.policy.ts

Lines changed: 0 additions & 40 deletions
This file was deleted.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Next } from 'koa'
2+
import { EntityManager } from '@mikro-orm/mysql'
3+
import APIKey from '../../../entities/api-key'
4+
import { ProtectedRouteContext } from '../../../lib/routing/context'
5+
import { GameRouteState } from '../../../middleware/game-middleware'
6+
import { sign } from '../../../lib/auth/jwt'
7+
8+
type APIKeyRouteContext = ProtectedRouteContext<GameRouteState & { apiKey: APIKey }>
9+
10+
export const loadAPIKey = async (ctx: APIKeyRouteContext, next: Next) => {
11+
const { id } = ctx.params as { id: string }
12+
const em = ctx.em
13+
14+
const apiKey = await em.repo(APIKey).findOne({
15+
id: Number(id),
16+
game: ctx.state.game
17+
})
18+
19+
if (!apiKey) {
20+
ctx.throw(404, 'API key not found')
21+
}
22+
23+
ctx.state.apiKey = apiKey
24+
await next()
25+
}
26+
27+
export async function createToken(em: EntityManager, apiKey: APIKey): Promise<string> {
28+
await em.populate(apiKey, ['game.apiSecret'])
29+
30+
const payload = {
31+
sub: apiKey.id,
32+
api: true,
33+
iat: Math.floor(new Date(apiKey.createdAt).getTime() / 1000)
34+
}
35+
36+
const token = await sign(payload, apiKey.game.apiSecret.getPlainSecret()!)
37+
return token
38+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { protectedRoute, withMiddleware } from '../../../lib/routing/router'
2+
import { loadGame } from '../../../middleware/game-middleware'
3+
import { userTypeGate, requireEmailConfirmed } from '../../../middleware/policy-middleware'
4+
import { UserType } from '../../../entities/user'
5+
import APIKey, { APIKeyScope } from '../../../entities/api-key'
6+
import { GameActivityType } from '../../../entities/game-activity'
7+
import createGameActivity from '../../../lib/logging/createGameActivity'
8+
import { createToken } from './common'
9+
10+
export const createRoute = protectedRoute({
11+
method: 'post',
12+
schema: (z) => ({
13+
body: z.object({
14+
scopes: z.array(z.nativeEnum(APIKeyScope))
15+
})
16+
}),
17+
middleware: withMiddleware(
18+
userTypeGate([UserType.ADMIN], 'create API keys'),
19+
requireEmailConfirmed('create API keys'),
20+
loadGame
21+
),
22+
handler: async (ctx) => {
23+
const { scopes } = ctx.state.validated.body
24+
const em = ctx.em
25+
26+
const apiKey = new APIKey(ctx.state.game, ctx.state.authenticatedUser)
27+
apiKey.scopes = scopes
28+
29+
createGameActivity(em, {
30+
user: ctx.state.authenticatedUser,
31+
game: ctx.state.game,
32+
type: GameActivityType.API_KEY_CREATED,
33+
extra: {
34+
keyId: apiKey.id,
35+
display: {
36+
'Scopes': scopes.join(', ')
37+
}
38+
}
39+
})
40+
41+
await em.persist(apiKey).flush()
42+
43+
const token = await createToken(em, apiKey)
44+
45+
return {
46+
status: 200,
47+
body: {
48+
token,
49+
apiKey
50+
}
51+
}
52+
}
53+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { protectedRouter } from '../../../lib/routing/router'
2+
import { createRoute } from './create'
3+
import { listRoute } from './list'
4+
import { scopesRoute } from './scopes'
5+
import { revokeRoute } from './revoke'
6+
import { updateRoute } from './update'
7+
8+
export function apiKeyRouter() {
9+
return protectedRouter('/games/:gameId/api-keys', ({ route }) => {
10+
route(createRoute)
11+
route(listRoute)
12+
route(scopesRoute)
13+
route(revokeRoute)
14+
route(updateRoute)
15+
})
16+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { protectedRoute, withMiddleware } from '../../../lib/routing/router'
2+
import { loadGame } from '../../../middleware/game-middleware'
3+
import APIKey from '../../../entities/api-key'
4+
5+
export const listRoute = protectedRoute({
6+
method: 'get',
7+
middleware: withMiddleware(loadGame),
8+
handler: async (ctx) => {
9+
const em = ctx.em
10+
const apiKeys = await em.repo(APIKey).find(
11+
{ game: ctx.state.game, revokedAt: null },
12+
{ populate: ['createdByUser'] }
13+
)
14+
15+
return {
16+
status: 200,
17+
body: {
18+
apiKeys
19+
}
20+
}
21+
}
22+
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { protectedRoute, withMiddleware } from '../../../lib/routing/router'
2+
import { loadGame } from '../../../middleware/game-middleware'
3+
import { userTypeGate, requireEmailConfirmed } from '../../../middleware/policy-middleware'
4+
import { UserType } from '../../../entities/user'
5+
import { GameActivityType } from '../../../entities/game-activity'
6+
import createGameActivity from '../../../lib/logging/createGameActivity'
7+
import { getTokenCacheKey } from '../../../lib/auth/getAPIKeyFromToken'
8+
import { loadAPIKey, createToken } from './common'
9+
10+
export const revokeRoute = protectedRoute({
11+
method: 'delete',
12+
path: '/:id',
13+
middleware: withMiddleware(
14+
userTypeGate([UserType.ADMIN], 'revoke API keys'),
15+
requireEmailConfirmed('revoke API keys'),
16+
loadGame,
17+
loadAPIKey
18+
),
19+
handler: async (ctx) => {
20+
const em = ctx.em
21+
const apiKey = ctx.state.apiKey
22+
23+
apiKey.revokedAt = new Date()
24+
await em.clearCache(getTokenCacheKey(apiKey.id))
25+
26+
const token = await createToken(em, apiKey)
27+
28+
createGameActivity(em, {
29+
user: ctx.state.authenticatedUser,
30+
game: ctx.state.game,
31+
type: GameActivityType.API_KEY_REVOKED,
32+
extra: {
33+
keyId: apiKey.id,
34+
display: {
35+
'Key ending in': token.substring(token.length - 5, token.length)
36+
}
37+
}
38+
})
39+
40+
const socket = ctx.wss
41+
const conns = socket.findConnections((conn) => conn.getAPIKeyId() === apiKey.id)
42+
await Promise.all(conns.map((conn) => socket.closeConnection(conn.getSocket())))
43+
44+
await em.flush()
45+
46+
return {
47+
status: 204
48+
}
49+
}
50+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { protectedRoute } from '../../../lib/routing/router'
2+
import { APIKeyScope } from '../../../entities/api-key'
3+
import { groupBy } from 'lodash'
4+
5+
type ScopeKey = keyof typeof APIKeyScope
6+
7+
export const scopesRoute = protectedRoute({
8+
method: 'get',
9+
path: '/scopes',
10+
handler: () => {
11+
const scopes = Object.keys(APIKeyScope)
12+
.filter((key) => APIKeyScope[key as ScopeKey] !== APIKeyScope.FULL_ACCESS)
13+
.map((key) => APIKeyScope[key as ScopeKey])
14+
15+
return {
16+
status: 200,
17+
body: {
18+
scopes: groupBy(scopes, (scope) => scope.split(':')[1])
19+
}
20+
}
21+
}
22+
})

0 commit comments

Comments
 (0)