From d61620d4b9d7f4875cd73bb5e4ca147a7f7b1990 Mon Sep 17 00:00:00 2001 From: okazakee Date: Wed, 26 Aug 2026 17:38:08 +0200 Subject: [PATCH 1/3] feat(servers): add requestable server discovery --- src/app.controller.spec.ts | 3 + src/app.controller.ts | 6 ++ src/servers/server-access.ts | 8 ++ src/servers/servers.controller.spec.ts | 34 +++++++ src/servers/servers.controller.ts | 17 +++- src/servers/servers.service.spec.ts | 85 +++++++++++++++- src/servers/servers.service.ts | 54 ++++++++++- test/server-authorization.e2e-spec.ts | 129 +++++++++++++++++++++++++ 8 files changed, 332 insertions(+), 4 deletions(-) diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index a3f06f1..f235545 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -51,6 +51,9 @@ describe('AppController', () => { realtime: { websocketTicket: false, }, + servers: { + requestableDiscovery: true, + }, }, }); expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); diff --git a/src/app.controller.ts b/src/app.controller.ts index b602120..f9f946a 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -18,6 +18,9 @@ export interface PanelCapabilities { realtime: { websocketTicket: boolean; }; + servers: { + requestableDiscovery: boolean; + }; } export interface PanelInfo { @@ -59,6 +62,9 @@ export class AppController { realtime: { websocketTicket: false, }, + servers: { + requestableDiscovery: true, + }, }, }; } diff --git a/src/servers/server-access.ts b/src/servers/server-access.ts index a84cef6..a41c17a 100644 --- a/src/servers/server-access.ts +++ b/src/servers/server-access.ts @@ -25,3 +25,11 @@ export const toMyAccessRequest = (row: ServerAccess): MyAccessRequestProjection requestedAt: row.createdAt, approvedAt: row.approvedAt ?? null, }); + +/** Minimal discoverable REQUEST-server projection (requestable discovery API). */ +export type RequestableServerProjection = { + id: string; + name: string; + accessType: 'REQUEST'; + requestStatus: 'PENDING' | null; +}; diff --git a/src/servers/servers.controller.spec.ts b/src/servers/servers.controller.spec.ts index 4d0dcad..403b7bd 100644 --- a/src/servers/servers.controller.spec.ts +++ b/src/servers/servers.controller.spec.ts @@ -191,3 +191,37 @@ describe('ServersController', () => { } }); }); + +describe('ServersController requestable discovery', () => { + let service: Pick; + let controller: ServersController; + + beforeEach(() => { + service = { listRequestableServers: jest.fn().mockResolvedValue({ data: [], total: 0 }) }; + // SAFETY: the controller only uses listRequestableServers from its service for this route. + controller = new ServersController(service as never); + }); + + it('delegates to the service with the authenticated principal (no roles metadata => any authenticated user)', async () => { + const request = makeRequest({ id: 'user-1', role: 'USER' }); + const query = { limit: 20, offset: 0 }; + + // SAFETY: NestJS validation would produce ListServersQueryDto; the raw literal + // satisfies the controller's read-only usage of limit/offset. + await expect(controller.listRequestable(query as never, request)).resolves.toEqual({ + data: [], + total: 0, + }); + expect(service.listRequestableServers).toHaveBeenCalledWith(query, { + id: 'user-1', + role: 'USER', + }); + }); + + it('carries no @Public decorator, so JwtAuthGuard rejects unauthenticated callers before the handler', () => { + const isPublic = Reflect.getMetadata('isPublic', ServersController.prototype.listRequestable); + expect(isPublic).toBeUndefined(); + const roles = Reflect.getMetadata('roles', ServersController.prototype.listRequestable); + expect(roles).toBeUndefined(); + }); +}); diff --git a/src/servers/servers.controller.ts b/src/servers/servers.controller.ts index 696403a..ddea7a0 100644 --- a/src/servers/servers.controller.ts +++ b/src/servers/servers.controller.ts @@ -19,7 +19,7 @@ import { CreateServerDto } from './dto/create-server.dto'; import { ListServersQueryDto } from './dto/list-servers-query.dto'; import { ServerParamDto } from './dto/server-param.dto'; import { type PublicServer } from './public-server'; -import { type ServerPrincipal } from './server-access'; +import { type RequestableServerProjection, type ServerPrincipal } from './server-access'; import { ServersService } from './servers.service'; type UserPayload = { id: string; username: string; role: string; temporaryAuth?: boolean }; @@ -48,6 +48,21 @@ export class ServersController { return this.serversService.listServers(query, this.toPrincipal(this.extractUser(req))); } + @ApiOperation({ + summary: 'List REQUEST servers discoverable for access requests (requestable discovery)', + }) + @HttpCode(HttpStatus.OK) + @Get('requestable') + async listRequestable( + @Query() query: ListServersQueryDto, + @Req() req: Request, + ): Promise<{ data: RequestableServerProjection[]; total: number }> { + return this.serversService.listRequestableServers( + query, + this.toPrincipal(this.extractUser(req)), + ); + } + @ApiOperation({ summary: 'Get a single visible server' }) @HttpCode(HttpStatus.OK) @Get(':id') diff --git a/src/servers/servers.service.spec.ts b/src/servers/servers.service.spec.ts index 9edd0bf..e754471 100644 --- a/src/servers/servers.service.spec.ts +++ b/src/servers/servers.service.spec.ts @@ -6,6 +6,14 @@ type ServerCountRow = { total: number }; type ServerQueryRow = Server | ServerResourceRow | ServerCountRow; +/** Minimal projection returned by listRequestableServers (requestable discovery). */ +type RequestableRow = { + id: string; + name: string; + accessType: 'REQUEST'; + requestStatus: 'PENDING' | null; +}; + type TestConfig = { get: jest.Mock; }; @@ -133,14 +141,14 @@ describe('ServersService', () => { | 'getHostDiskInfo' >; let config: TestConfig; - let selectResults: ServerQueryRow[][]; + let selectResults: (ServerQueryRow | RequestableRow)[][]; let insertResults: Server[]; let updateResults: Server[][]; let updatedValues: ServerMutation[]; let insertedValues: ServerMutation[]; let successfulUpdatedValues: ServerMutation[]; let deletedRows: boolean[]; - let queryChains: QueryChain[]; + let queryChains: QueryChain<(ServerQueryRow | RequestableRow)[]>[]; let calls: string[]; let configValues: TestConfigValues; @@ -1355,6 +1363,79 @@ describe('ServersService', () => { }); }); + describe('requestable discovery (owner-approved slice)', () => { + it('returns only the minimal REQUEST projection with request status and never sensitive fields', async () => { + selectResults.push( + [ + { + id: 'req-1', + name: 'Requestable', + accessType: 'REQUEST', + requestStatus: 'PENDING' as const, + }, + { id: 'req-2', name: 'Another', accessType: 'REQUEST', requestStatus: null }, + ], + [{ total: 2 }], + ); + + const result = await service.listRequestableServers(new ListServersQueryDto(), { + id: 'user-1', + role: 'USER', + }); + + expect(result.total).toBe(2); + expect(result.data).toEqual([ + { id: 'req-1', name: 'Requestable', accessType: 'REQUEST', requestStatus: 'PENDING' }, + { id: 'req-2', name: 'Another', accessType: 'REQUEST', requestStatus: null }, + ]); + for (const row of result.data) { + expect(row).not.toHaveProperty('port'); + expect(row).not.toHaveProperty('levelSeed'); + expect(row).not.toHaveProperty('memoryLimitMb'); + expect(row).not.toHaveProperty('containerId'); + expect(row).not.toHaveProperty('rconPassword'); + expect(row).not.toHaveProperty('worldPath'); + expect(row).not.toHaveProperty('ownerId'); + } + }); + + it('applies pagination bounds to the rows query', async () => { + selectResults.push([], [{ total: 0 }]); + + await service.listRequestableServers( + { limit: 7, offset: 14 }, + { id: 'user-1', role: 'USER' }, + ); + + const rowsQuery = queryChains[queryChains.length - 2]; + expect(rowsQuery.limit).toHaveBeenCalledWith(7); + expect(rowsQuery.offset).toHaveBeenCalledWith(14); + expect(db.select).toHaveBeenCalledTimes(2); + }); + + it('applies the same REQUEST-only predicate for USER and ADMIN callers and always sends a where clause', async () => { + selectResults.push([], [{ total: 0 }]); + await service.listRequestableServers(new ListServersQueryDto(), { + id: 'user-1', + role: 'USER', + }); + selectResults.push([], [{ total: 0 }]); + await service.listRequestableServers(new ListServersQueryDto(), { + id: 'admin-1', + role: 'ADMIN', + }); + + const userRowsQuery = queryChains[0]; + const adminRowsQuery = queryChains[2]; + const userWhere = userRowsQuery.where.mock.calls[0]?.[0]; + const adminWhere = adminRowsQuery.where.mock.calls[0]?.[0]; + expect(userWhere).toBeDefined(); + expect(adminWhere).toBeDefined(); + // Both roles run the identical discovery predicate (no role branching). + expect(String(userWhere)).toBe(String(adminWhere)); + expect(db.select).toHaveBeenCalledTimes(4); + }); + }); describe('concurrency claims', () => { it('allows only one same-server start claimant and one Docker start', async () => { selectResults.push([makeServer()], [makeServer()]); diff --git a/src/servers/servers.service.ts b/src/servers/servers.service.ts index 2062fa3..ee9cc41 100644 --- a/src/servers/servers.service.ts +++ b/src/servers/servers.service.ts @@ -25,7 +25,7 @@ import { DockerService } from 'src/docker/docker.service'; import { CreateServerDto } from './dto/create-server.dto'; import { ListServersQueryDto } from './dto/list-servers-query.dto'; import { type PublicServer, toPublicServer } from './public-server'; -import { type ServerPrincipal } from './server-access'; +import { type RequestableServerProjection, type ServerPrincipal } from './server-access'; type Tx = Parameters[0]>[0]; @@ -229,6 +229,58 @@ export class ServersService implements OnModuleInit { return toPublicServer(running); } + async listRequestableServers( + query: ListServersQueryDto, + principal: ServerPrincipal, + ): Promise<{ data: RequestableServerProjection[]; total: number }> { + // REQUEST servers the caller may discover purely to request access: + // PRIVATE is never disclosed here (absent from the predicate), OPEN does + // not belong (no request flow), CREATING is excluded like the normal list, + // and servers the caller already has APPROVED access on are omitted because + // they already appear in the normal GET /servers collection. A caller's own + // PENDING row is surfaced as requestStatus so the UI can show "Pending". + const hasAccessState = (status: 'PENDING' | 'APPROVED') => + sql`EXISTS (SELECT 1 FROM server_access WHERE server_access.server_id = ${servers.id} AND server_access.user_id = ${principal.id} AND server_access.status = ${status})`; + + // SAFETY: and() is called with three non-empty operands, so drizzle's AND + // result is defined; the cast satisfies the typed predicate contract. + const where = and( + eq(servers.accessType, 'REQUEST'), + ne(servers.status, 'CREATING'), + sql`NOT ${hasAccessState('APPROVED')}`, + ) as SQL; + + const [rows, [{ total }]] = await Promise.all([ + this.db + .select({ + id: servers.id, + name: servers.name, + accessType: servers.accessType, + requestStatus: sql< + RequestableServerProjection['requestStatus'] + >`CASE WHEN ${hasAccessState('PENDING')} THEN 'PENDING' END`, + }) + .from(servers) + .where(where) + .orderBy(asc(servers.createdAt), asc(servers.id)) + .limit(query.limit) + .offset(query.offset), + this.db.select({ total: count() }).from(servers).where(where), + ]); + + const data = rows.map( + (row): RequestableServerProjection => ({ + id: row.id, + name: row.name, + // SAFETY: the SQL predicate restricts this projection exclusively to REQUEST servers. + accessType: 'REQUEST', + requestStatus: row.requestStatus, + }), + ); + + return { data, total: Number(total) }; + } + async listServers( query: ListServersQueryDto, principal: ServerPrincipal, diff --git a/test/server-authorization.e2e-spec.ts b/test/server-authorization.e2e-spec.ts index 7c146e3..84871c0 100644 --- a/test/server-authorization.e2e-spec.ts +++ b/test/server-authorization.e2e-spec.ts @@ -36,6 +36,14 @@ type TestServer = schema.Server; type Role = 'ADMIN' | 'MOD' | 'USER'; type TestAgent = ReturnType; +/** HTTP response projection for GET /api/servers/requestable (owner-approved slice). */ +type RequestableResponseRow = { + id: string; + name: string; + accessType: 'REQUEST'; + requestStatus: 'PENDING' | null; +}; + type DockerMock = { ping: jest.Mock; createContainer: jest.Mock; @@ -799,4 +807,125 @@ describe('Server authorization (PostgreSQL e2e)', () => { expect(response.body.status).toBe('PENDING'); }); }); + describe('requestable server discovery (owner-approved slice, real PostgreSQL)', () => { + const assertMinimalRow = ( + row: RequestableResponseRow, + serverId: string, + requestStatus: 'PENDING' | null, + ): void => { + expect(row.id).toBe(serverId); + expect(row.name.length).toBeGreaterThan(0); + expect(row.accessType).toBe('REQUEST'); + expect(row.requestStatus).toBe(requestStatus); + // response minimization: no sensitive or internal fields may appear + for (const forbidden of [ + 'port', + 'memoryLimitMb', + 'levelSeed', + 'ownerId', + 'containerId', + 'rconPassword', + 'worldPath', + 'maxPlayers', + 'status', + 'provider', + 'version', + 'pvp', + 'onlineMode', + ]) { + expect(row).not.toHaveProperty(forbidden); + } + }; + + it('returns 401 for an unauthenticated caller', async () => { + await request(app.getHttpServer()).get('/api/servers/requestable').expect(401); + }); + + it('returns a minimal REQUEST row with requestStatus null when the user has no access', async () => { + const server = await createServer('REQUEST'); + const response = await userAgent.get('/api/servers/requestable').expect(200); + + expect(response.body.total).toBe(1); + expect(response.body.data).toHaveLength(1); + assertMinimalRow(response.body.data[0], server.id, null); + }); + + it('returns the same REQUEST server with requestStatus PENDING while a request is pending', async () => { + const server = await createServer('REQUEST'); + await createPendingRequest(server.id, user.id); + + const response = await userAgent.get('/api/servers/requestable').expect(200); + + expect(response.body.total).toBe(1); + assertMinimalRow(response.body.data[0], server.id, 'PENDING'); + }); + + it('omits an APPROVED REQUEST server from requestable while it appears in the normal list', async () => { + const server = await createServer('REQUEST'); + await approveAccess(server.id, user.id); + + const requestable = await userAgent.get('/api/servers/requestable').expect(200); + expect(requestable.body.total).toBe(0); + expect(requestable.body.data).toHaveLength(0); + + const normal = await userAgent.get('/api/servers').expect(200); + expect(normal.body.total).toBe(1); + expect(normal.body.data[0].id).toBe(server.id); + }); + + it('never returns PRIVATE or OPEN servers', async () => { + await createServer('PRIVATE'); + await createServer('OPEN'); + await createServer('REQUEST'); + + const response = await userAgent.get('/api/servers/requestable').expect(200); + + expect(response.body.total).toBe(1); + const ids = response.body.data.map((row: { id: string }) => row.id); + const privateRow = await db + .select({ id: schema.servers.id }) + .from(schema.servers) + .where(eq(schema.servers.accessType, 'PRIVATE')); + const openRow = await db + .select({ id: schema.servers.id }) + .from(schema.servers) + .where(eq(schema.servers.accessType, 'OPEN')); + expect(ids).not.toContain(privateRow[0]?.id); + expect(ids).not.toContain(openRow[0]?.id); + }); + + it('excludes CREATING REQUEST servers from discovery', async () => { + await createServer('REQUEST', 'CREATING'); + const created = await createServer('REQUEST'); + await createPendingRequest(created.id, user.id); + + const response = await userAgent.get('/api/servers/requestable').expect(200); + + expect(response.body.total).toBe(1); + expect(response.body.data[0].id).toBe(created.id); + }); + + it('paginates against PostgreSQL and reports the true matching total', async () => { + const serverA = await createServer('REQUEST'); + const serverB = await createServer('REQUEST'); + await createServer('REQUEST'); + + const page1 = await userAgent.get('/api/servers/requestable?limit=2&offset=0').expect(200); + const page2 = await userAgent.get('/api/servers/requestable?limit=2&offset=2').expect(200); + + expect(page1.body.total).toBe(3); + expect(page1.body.data).toHaveLength(2); + expect(page2.body.total).toBe(3); + expect(page2.body.data).toHaveLength(1); + + const pageOneIds = page1.body.data.map((row: { id: string }) => row.id); + const pageTwoIds = page2.body.data.map((row: { id: string }) => row.id); + expect(pageOneIds).toContain(serverA.id); + expect(pageOneIds).toContain(serverB.id); + expect([...pageOneIds, ...pageTwoIds]).toHaveLength(3); + expect([...pageOneIds, ...pageTwoIds]).toEqual( + expect.arrayContaining([serverA.id, serverB.id]), + ); + }); + }); }); From 90a7c6df01d5e7b86a69e0dc8c8ebc95e1144cbd Mon Sep 17 00:00:00 2001 From: okazakee Date: Wed, 26 Aug 2026 18:32:57 +0200 Subject: [PATCH 2/3] fix(docker): pin openssl >= 3.5.8-r0 and bust apk cache The gha build cache served a stale `apk upgrade` layer pinning openssl 3.5.7-r0, failing the HIGH-fix scan (CVE-2026-14456). Explicit version constraint invalidates the layer and fails loudly if the mirror lags. --- Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 613cd43..289dd86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,11 @@ FROM oven/bun:1.3.14-alpine AS final WORKDIR /app -RUN apk upgrade --no-cache && apk add --no-cache curl +# Cache-busted apk resolve with explicit openssl guard: the plain `apk upgrade` +# RUN layer is served from gha build cache and can pin a stale openssl +# (CVE-2026-14456, fixed 3.5.8-r0). The version constraint fails the build if +# the mirror lags. +RUN apk upgrade --no-cache && apk add --no-cache curl 'libcrypto3>=3.5.8' 'libssl3>=3.5.8' COPY --from=production /app/node_modules ./node_modules COPY --from=build /app/dist ./dist From ac4963f1c4b193c28d8d1b6ac58adf3dbde78ba5 Mon Sep 17 00:00:00 2001 From: okazakee Date: Wed, 26 Aug 2026 18:43:40 +0200 Subject: [PATCH 3/3] ci: re-trigger workflow after GitHub Actions incident