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
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/app.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ describe('AppController', () => {
realtime: {
websocketTicket: false,
},
servers: {
requestableDiscovery: true,
},
},
});
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
Expand Down
6 changes: 6 additions & 0 deletions src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export interface PanelCapabilities {
realtime: {
websocketTicket: boolean;
};
servers: {
requestableDiscovery: boolean;
};
}

export interface PanelInfo {
Expand Down Expand Up @@ -59,6 +62,9 @@ export class AppController {
realtime: {
websocketTicket: false,
},
servers: {
requestableDiscovery: true,
},
},
};
}
Expand Down
8 changes: 8 additions & 0 deletions src/servers/server-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
34 changes: 34 additions & 0 deletions src/servers/servers.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,37 @@ describe('ServersController', () => {
}
});
});

describe('ServersController requestable discovery', () => {
let service: Pick<ServersService, 'listRequestableServers'>;
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();
});
});
17 changes: 16 additions & 1 deletion src/servers/servers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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')
Expand Down
85 changes: 83 additions & 2 deletions src/servers/servers.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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<ServerQueryRow[]>[];
let queryChains: QueryChain<(ServerQueryRow | RequestableRow)[]>[];
let calls: string[];
let configValues: TestConfigValues;

Expand Down Expand Up @@ -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()]);
Expand Down
54 changes: 53 additions & 1 deletion src/servers/servers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Parameters<DrizzleDB['transaction']>[0]>[0];

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading