diff --git a/apps/api/src/lib/jwt.ts b/apps/api/src/lib/jwt.ts index 7d5b2c6..152d3fc 100644 --- a/apps/api/src/lib/jwt.ts +++ b/apps/api/src/lib/jwt.ts @@ -14,8 +14,33 @@ export interface RefreshTokenPayload { const ACCESS_TOKEN_EXPIRY = '15m'; const REFRESH_TOKEN_EXPIRY = '7d'; +type JwtSign = (payload: object, options?: { expiresIn?: string }) => string; +type JwtVerify = (token: string) => unknown; + +/** + * The parts of the JWT decoration we actually call. + * + * @fastify/jwt decorates the instance at runtime and the refresh namespace is + * registered by our own auth plugin, so neither appears in FastifyInstance's + * type. Describing the shape here keeps the call sites type-checked instead of + * casting the instance to `any`, which switches checking off entirely. + */ +interface JwtDecoratedInstance { + jwt?: { + sign?: JwtSign; + verify?: JwtVerify; + refresh?: { sign?: JwtSign; verify?: JwtVerify }; + jwtRefresh?: { sign?: JwtSign; verify?: JwtVerify }; + }; +} + +/** The decorated JWT namespace, or undefined when the plugin is not loaded. */ +export function getJwt(app: FastifyInstance): JwtDecoratedInstance['jwt'] { + return (app as unknown as JwtDecoratedInstance).jwt; +} + export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string { - const signer = (app as any).jwt?.sign; + const signer = getJwt(app)?.sign; if (typeof signer !== 'function') { throw new Error('JWT signer is not configured'); } @@ -23,7 +48,8 @@ export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayloa } export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string { - const signer = (app as any).jwt?.refresh?.sign ?? (app as any).jwt?.jwtRefresh?.sign; + const jwt = getJwt(app); + const signer = jwt?.refresh?.sign ?? jwt?.jwtRefresh?.sign; if (typeof signer !== 'function') { throw new Error('Refresh JWT signer is not configured'); } @@ -31,7 +57,8 @@ export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayl } export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload { - const verifier = (app as any).jwt?.refresh?.verify ?? (app as any).jwt?.jwtRefresh?.verify; + const jwt = getJwt(app); + const verifier = jwt?.refresh?.verify ?? jwt?.jwtRefresh?.verify; if (typeof verifier !== 'function') { throw new Error('Refresh JWT verifier is not configured'); } diff --git a/apps/api/src/lib/pagination.ts b/apps/api/src/lib/pagination.ts index 092ff26..ab1fab0 100644 --- a/apps/api/src/lib/pagination.ts +++ b/apps/api/src/lib/pagination.ts @@ -5,7 +5,16 @@ export const PaginationQuerySchema = Type.Object({ perPage: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })), }); -export function paginate(query: { page?: number; perPage?: number }) { +/** + * The querystring shape PaginationQuerySchema validates. + * + * Route handlers receive `request.query` as `unknown`; the schema has already + * checked the values by then, so the cast at the call site is what tells + * TypeScript what Fastify handed over. + */ +export type PaginationQuery = { page?: number; perPage?: number }; + +export function paginate(query: PaginationQuery) { const page = query.page ?? 1; const perPage = query.perPage ?? 20; const offset = (page - 1) * perPage; diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index a2010e6..857a472 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -24,7 +24,7 @@ export async function getOrgMembership( return 'super_admin'; } - const member = await (request.server as any).db.query.organizationMembers.findFirst({ + const member = await request.server.db.query.organizationMembers.findFirst({ where: and( eq(organizationMembers.organizationId, orgId), eq(organizationMembers.userId, user.sub), diff --git a/apps/api/src/plugins/socket.ts b/apps/api/src/plugins/socket.ts index ccbd9b5..ddc8e01 100644 --- a/apps/api/src/plugins/socket.ts +++ b/apps/api/src/plugins/socket.ts @@ -5,6 +5,7 @@ import { Server as SocketIOServer } from 'socket.io'; import { nodes, organizationMembers, servers } from '@source/database'; import { ROLES } from '@source/shared'; import type { Role } from '@source/shared'; +import { getJwt } from '../lib/jwt.js'; import type { AccessTokenPayload } from '../lib/jwt.js'; import { daemonOpenConsoleStream, @@ -67,7 +68,7 @@ export default fp(async (app: FastifyInstance) => { return; } - const verifier = (app as any).jwt?.verify; + const verifier = getJwt(app)?.verify; if (typeof verifier !== 'function') { next(new Error('Authentication is not configured')); return; diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index d89295e..634533f 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -6,6 +6,7 @@ import { users, games, nodes, auditLogs, plugins, pluginReleases } from '@source import { AppError } from '../../lib/errors.js'; import { requireSuperAdmin } from '../../lib/permissions.js'; import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; +import type { PaginationQuery } from '../../lib/pagination.js'; import { uploadPluginArtifact } from '../../lib/cdn.js'; import * as yazl from 'yazl'; import { @@ -202,7 +203,7 @@ export default async function adminRoutes(app: FastifyInstance) { // GET /api/admin/users app.get('/users', { schema: { querystring: PaginationQuerySchema } }, async (request) => { - const { page, perPage, offset, limit } = paginate(request.query as any); + const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery); const [totalResult] = await app.db.select({ count: count() }).from(users); @@ -912,7 +913,7 @@ export default async function adminRoutes(app: FastifyInstance) { // GET /api/admin/audit-logs app.get('/audit-logs', { schema: { querystring: PaginationQuerySchema } }, async (request) => { - const { page, perPage, offset, limit } = paginate(request.query as any); + const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery); const [totalResult] = await app.db.select({ count: count() }).from(auditLogs); diff --git a/apps/api/src/routes/auth/index.ts b/apps/api/src/routes/auth/index.ts index f873a29..0a30f8d 100644 --- a/apps/api/src/routes/auth/index.ts +++ b/apps/api/src/routes/auth/index.ts @@ -3,7 +3,7 @@ import { eq } from 'drizzle-orm'; import { users } from '@source/database'; import { hashPassword, verifyPassword } from '../../lib/password.js'; import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.js'; -import type { AccessTokenPayload, RefreshTokenPayload } from '../../lib/jwt.js'; +import type { RefreshTokenPayload } from '../../lib/jwt.js'; import { AppError } from '../../lib/errors.js'; import { RegisterSchema, LoginSchema } from './schemas.js'; diff --git a/apps/api/src/routes/organizations/index.ts b/apps/api/src/routes/organizations/index.ts index 455921a..1a9ccb4 100644 --- a/apps/api/src/routes/organizations/index.ts +++ b/apps/api/src/routes/organizations/index.ts @@ -4,6 +4,7 @@ import { organizations, organizationMembers, users } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requirePermission, getOrgMembership } from '../../lib/permissions.js'; import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; +import type { PaginationQuery } from '../../lib/pagination.js'; import { createAuditLog } from '../../lib/audit.js'; import { CreateOrgSchema, @@ -20,7 +21,7 @@ export default async function organizationRoutes(app: FastifyInstance) { // GET /api/organizations — list user's organizations app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => { - const { page, perPage, offset, limit } = paginate(request.query as any); + const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery); const userId = request.user.sub; if (request.user.isSuperAdmin) { diff --git a/apps/api/src/routes/servers/index.ts b/apps/api/src/routes/servers/index.ts index 0cbe4a2..e65043c 100644 --- a/apps/api/src/routes/servers/index.ts +++ b/apps/api/src/routes/servers/index.ts @@ -8,6 +8,7 @@ import type { GameAutomationRule, PowerAction, ServerAutomationEvent } from '@so import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; +import type { PaginationQuery } from '../../lib/pagination.js'; import { createAuditLog } from '../../lib/audit.js'; import { deleteFivemQbCoreDatabase, @@ -637,7 +638,7 @@ export default async function serverRoutes(app: FastifyInstance) { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'server.read'); - const { page, perPage, offset, limit } = paginate(request.query as any); + const { page, perPage, offset, limit } = paginate(request.query as PaginationQuery); const [totalResult] = await app.db .select({ count: count() }) diff --git a/apps/api/src/routes/servers/plugins.ts b/apps/api/src/routes/servers/plugins.ts index 1d1b468..1490422 100644 --- a/apps/api/src/routes/servers/plugins.ts +++ b/apps/api/src/routes/servers/plugins.ts @@ -874,7 +874,9 @@ export default async function pluginRoutes(app: FastifyInstance) { app.get('/', { schema: ParamSchema }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'plugin.read'); - const context = await getServerPluginContext(app, orgId, serverId); + // Called for its validation of the server and the caller's access to it; + // this endpoint does not need the returned context. + await getServerPluginContext(app, orgId, serverId); const installed = await app.db .select({