Secure actor-owned game API routes

This commit is contained in:
2026-07-25 08:50:41 +00:00
parent 6889c463fd
commit a9d6541c18
13 changed files with 301 additions and 118 deletions
+4 -27
View File
@@ -18,6 +18,7 @@ import {
type MessageView,
} from '../../messages/store.js';
import { publishRealtimeEvent } from '../../realtime/publisher.js';
import { getOwnedGeneral } from '../shared/general.js';
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
@@ -30,15 +31,7 @@ export const messagesRouter = router({
})
)
.query(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
const general = await getOwnedGeneral(ctx, input.generalId);
const sequence = input.sequence ?? -1;
const nationId = general.nationId;
@@ -138,15 +131,7 @@ export const messagesRouter = router({
})
)
.query(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
const general = await getOwnedGeneral(ctx, input.generalId);
const nationId = general.nationId;
const mailboxes = {
@@ -190,15 +175,7 @@ export const messagesRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
const general = await getOwnedGeneral(ctx, input.generalId);
const src = await buildTargetFromGeneral(ctx.db, general);
const now = new Date();
+22
View File
@@ -13,3 +13,25 @@ export const getMyGeneral = async (ctx: Pick<GameApiContext, 'db' | 'auth'>) =>
}
return general;
};
export const getOwnedGeneral = async (
ctx: Pick<GameApiContext, 'db' | 'auth'>,
generalId: number
) => {
if (!ctx.auth?.user.id) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const general = await ctx.db.general.findUnique({
where: { id: generalId },
});
if (!general) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found.' });
}
if (general.userId !== ctx.auth.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'General is not owned by the authenticated user.',
});
}
return general;
};
+5 -2
View File
@@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js';
import { getOwnedGeneral } from '../shared/general.js';
export const troopRouter = router({
join: authedProcedure
@@ -12,9 +13,10 @@ export const troopRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const result = await ctx.turnDaemon.requestCommand({
type: 'troopJoin',
generalId: input.generalId,
generalId: general.id,
troopId: input.troopId,
});
if (!result) {
@@ -45,9 +47,10 @@ export const troopRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const result = await ctx.turnDaemon.requestCommand({
type: 'troopExit',
generalId: input.generalId,
generalId: general.id,
});
if (!result) {
throw new TRPCError({
+25 -5
View File
@@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { procedure, router } from '../../trpc.js';
import { authedProcedure, router } from '../../trpc.js';
const zRunReason = z.enum(['schedule', 'manual', 'poke']);
@@ -10,8 +11,27 @@ const zTurnRunBudget = z.object({
catchUpCap: z.number().int().positive(),
});
const turnDaemonAdminProcedure = authedProcedure.use(({ ctx, next }) => {
const roles = ctx.auth?.user.roles ?? [];
const profileName = ctx.profile.name;
const canManageProfile =
roles.includes('superuser') ||
roles.includes('admin') ||
roles.includes('admin.superuser') ||
roles.includes('admin.profiles.manage') ||
roles.includes('admin.profiles.manage:*') ||
roles.includes(`admin.profiles.manage:${profileName}`);
if (!canManageProfile) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Profile administration permission is required.',
});
}
return next();
});
export const turnDaemonRouter = router({
run: procedure
run: turnDaemonAdminProcedure
.input(
z.object({
reason: zRunReason,
@@ -28,7 +48,7 @@ export const turnDaemonRouter = router({
});
return { accepted: true, requestId };
}),
pause: procedure
pause: turnDaemonAdminProcedure
.input(
z
.object({
@@ -43,7 +63,7 @@ export const turnDaemonRouter = router({
});
return { accepted: true, requestId };
}),
resume: procedure
resume: turnDaemonAdminProcedure
.input(
z
.object({
@@ -58,7 +78,7 @@ export const turnDaemonRouter = router({
});
return { accepted: true, requestId };
}),
status: procedure
status: turnDaemonAdminProcedure
.input(
z
.object({
+8 -62
View File
@@ -13,6 +13,7 @@ import {
shiftGeneralTurns,
shiftNationTurns,
} from '../../turns/reservedTurns.js';
import { getOwnedGeneral } from '../shared/general.js';
const buildShiftAmountSchema = (maxTurns: number) =>
z
@@ -34,7 +35,7 @@ export const turnsRouter = router({
.query(async ({ ctx, input }) => {
const [worldState, general] = await Promise.all([
ctx.db.worldState.findFirst(),
ctx.db.general.findUnique({ where: { id: input.generalId } }),
getOwnedGeneral(ctx, input.generalId),
]);
if (!worldState) {
@@ -44,13 +45,6 @@ export const turnsRouter = router({
});
}
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
const [city, nation, nationGenerals] = await Promise.all([
general.cityId > 0
? ctx.db.city.findUnique({
@@ -85,15 +79,7 @@ export const turnsRouter = router({
})
)
.query(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
await getOwnedGeneral(ctx, input.generalId);
return listGeneralTurns(ctx.db, input.generalId);
}),
@@ -104,15 +90,7 @@ export const turnsRouter = router({
})
)
.query(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
const general = await getOwnedGeneral(ctx, input.generalId);
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
@@ -142,15 +120,7 @@ export const turnsRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
await getOwnedGeneral(ctx, input.generalId);
const turns = await setGeneralTurn(
ctx.db,
@@ -169,15 +139,7 @@ export const turnsRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
await getOwnedGeneral(ctx, input.generalId);
const turns = await shiftGeneralTurns(ctx.db, input.generalId, input.amount);
return { ok: true, turns };
@@ -196,15 +158,7 @@ export const turnsRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
const general = await getOwnedGeneral(ctx, input.generalId);
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
@@ -236,15 +190,7 @@ export const turnsRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
});
if (!general) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
});
}
const general = await getOwnedGeneral(ctx, input.generalId);
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
+11 -3
View File
@@ -1,18 +1,23 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { WorldStateRow } from '../../context.js';
import {
type WorldStateRow,
zWorldStateConfig,
zWorldStateMeta,
} from '../../context.js';
import { procedure, router } from '../../trpc.js';
import { loadWorldMap } from '../../maps/worldMap.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { getOwnedGeneral } from '../shared/general.js';
const toWorldStateSnapshot = (row: WorldStateRow) => ({
scenarioCode: row.scenarioCode,
currentYear: row.currentYear,
currentMonth: row.currentMonth,
tickSeconds: row.tickSeconds,
config: row.config,
meta: row.meta,
config: zWorldStateConfig.parse(row.config),
meta: zWorldStateMeta.parse(row.meta),
updatedAt: row.updatedAt.toISOString(),
});
@@ -34,6 +39,9 @@ export const worldRouter = router({
})
)
.query(async ({ ctx, input }) => {
if (input.generalId !== undefined) {
await getOwnedGeneral(ctx, input.generalId);
}
const map = await loadWorldMap(ctx, input);
if (!map) {
throw new TRPCError({
+142 -5
View File
@@ -26,7 +26,7 @@ const profile: GameProfile = {
const buildGeneralRow = (overrides?: Partial<GeneralRow>): GeneralRow => {
const base: GeneralRow = {
id: 1,
userId: null,
userId: 'user-1',
name: '테스트',
nationId: 0,
cityId: 1,
@@ -123,7 +123,7 @@ const buildContext = (options?: {
},
profile.name
);
const auth: GameSessionTokenPayload = options?.auth ?? {
const defaultAuth: GameSessionTokenPayload = {
version: 1,
profile: profile.name,
issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(),
@@ -133,10 +133,11 @@ const buildContext = (options?: {
id: 'user-1',
username: 'tester',
displayName: 'Tester',
roles: [],
roles: ['admin'],
},
sanctions: {},
};
const auth = options && 'auth' in options ? (options.auth ?? null) : defaultAuth;
return {
db: db as unknown as DatabaseClient,
turnDaemon: transport,
@@ -172,8 +173,8 @@ describe('appRouter', () => {
currentYear: 1,
currentMonth: 2,
tickSeconds: 600,
config: { seed: 123 },
meta: { label: 'sample' },
config: { maxUserCnt: 500, hiddenSeed: 'config-secret' },
meta: { otherTextInfo: 'sample', hiddenSeed: 'meta-secret' },
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
@@ -182,9 +183,50 @@ describe('appRouter', () => {
expect(response?.scenarioCode).toBe('default');
expect(response?.currentYear).toBe(1);
expect(response?.config).toEqual({ maxUserCnt: 500 });
expect(response?.meta).toEqual({ otherTextInfo: 'sample' });
expect(response?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
});
it('requires profile administration permission for turn daemon control', async () => {
const auth: GameSessionTokenPayload = {
version: 1,
profile: profile.name,
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-01-02T00:00:00.000Z',
sessionId: 'session-user',
user: {
id: 'user-1',
username: 'tester',
displayName: 'Tester',
roles: ['user'],
},
sanctions: {},
};
const caller = appRouter.createCaller(buildContext({ auth }));
await expect(caller.turnDaemon.run({ reason: 'manual' })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.turnDaemon.pause()).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.turnDaemon.resume()).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.turnDaemon.status()).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
it('rejects unauthenticated turn daemon control', async () => {
const caller = appRouter.createCaller(buildContext({ auth: null }));
await expect(caller.turnDaemon.pause()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
it('returns status from transport', async () => {
const transport = new InMemoryTurnDaemonTransport({
state: 'paused',
@@ -238,4 +280,99 @@ describe('appRouter', () => {
expect(response[0]?.action).toBe('che_포상');
expect(response[0]?.index).toBe(0);
});
it('rejects another user general across actor-owned routers', async () => {
const general = buildGeneralRow({ id: 15, userId: 'user-2' });
const transport = new InMemoryTurnDaemonTransport();
const caller = appRouter.createCaller(buildContext({ general, transport }));
await expect(caller.turns.getCommandTable({ generalId: general.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.turns.reserved.getGeneral({ generalId: general.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.turns.reserved.getNation({ generalId: general.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.turns.reserved.setGeneral({
generalId: general.id,
turnIndex: 0,
action: '휴식',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.turns.reserved.shiftGeneral({
generalId: general.id,
amount: 1,
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.turns.reserved.setNation({
generalId: general.id,
turnIndex: 0,
action: '휴식',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.turns.reserved.shiftNation({
generalId: general.id,
amount: 1,
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.messages.getRecent({ generalId: general.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.messages.getOld({
generalId: general.id,
to: 1,
type: 'private',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.messages.send({
generalId: general.id,
mailbox: 0,
text: 'test',
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.troop.join({ generalId: general.id, troopId: 3 })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(caller.troop.exit({ generalId: general.id })).rejects.toMatchObject({
code: 'FORBIDDEN',
});
await expect(
caller.world.getMap({
generalId: general.id,
showMe: true,
})
).rejects.toMatchObject({
code: 'FORBIDDEN',
});
expect(transport.commands).toHaveLength(0);
});
it('rejects unauthenticated general-scoped map views', async () => {
const general = buildGeneralRow({ id: 16 });
const caller = appRouter.createCaller(buildContext({ general, auth: null }));
await expect(caller.world.getMap({ generalId: general.id })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
});
});
+7 -3
View File
@@ -99,10 +99,14 @@ export const appRouter = router({
password: input.password,
displayName: input.displayName,
});
await ctx.users.updateRoles(created.id, ['superuser']);
const session = await ctx.sessions.createSession(created);
const bootstrappedUser = {
...created,
roles: ['superuser'],
};
await ctx.users.updateRoles(created.id, bootstrappedUser.roles);
const session = await ctx.sessions.createSession(bootstrappedUser);
return {
user: toPublicUser(created),
user: toPublicUser(bootstrappedUser),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
+36 -1
View File
@@ -8,6 +8,7 @@ import { createGatewayApiContext } from '../src/context.js';
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
import { appRouter } from '../src/router.js';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
const buildCaller = () => {
const users = createInMemoryUserRepository();
@@ -87,13 +88,47 @@ const buildCaller = () => {
orchestrator,
profileStatus,
requestHeaders: {},
prisma: {} as unknown as GatewayPrismaClient,
prisma: {
appUser: {
findFirst: async () => null,
},
} as unknown as GatewayPrismaClient,
})
);
return { caller, oauthSessions };
};
describe('gateway auth flow', () => {
it('carries the bootstrap superuser role into game sessions', async () => {
const previousToken = process.env.GATEWAY_BOOTSTRAP_TOKEN;
process.env.GATEWAY_BOOTSTRAP_TOKEN = 'bootstrap-test-token';
try {
const { caller } = buildCaller();
const bootstrap = await caller.auth.bootstrapLocal({
token: 'bootstrap-test-token',
username: 'admin',
password: 'secretpass',
displayName: 'Admin',
});
expect(bootstrap.user.roles).toEqual(['superuser']);
const issued = await caller.auth.issueGameSession({
sessionToken: bootstrap.sessionToken,
profile: 'che:default',
});
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
expect(payload?.user.roles).toEqual(['superuser']);
} finally {
if (previousToken === undefined) {
delete process.env.GATEWAY_BOOTSTRAP_TOKEN;
} else {
process.env.GATEWAY_BOOTSTRAP_TOKEN = previousToken;
}
}
});
it('registers and issues a game session', async () => {
const { caller, oauthSessions } = buildCaller();
const oauthSession = await oauthSessions.createSession({
+8 -3
View File
@@ -75,12 +75,15 @@ For profile alignment:
- Wait for PM2 to report the processes as `online`.
5) **Verify API <-> daemon**
- Call `turnDaemon.status` over tRPC and expect a non-null status.
- Exchange a gateway session carrying `superuser`, `admin`, or
`admin.profiles.manage[:<profile>]` for a game access token.
- Call `turnDaemon.status` with that administrator token and expect a
non-null status.
- Send a mutation command that expects a result (e.g., `troop.join`) and
verify `commandResult` is received.
6) **Verify realtime events**
- Trigger a run via `turnDaemon.run`.
- Trigger a run via `turnDaemon.run` with the administrator token.
- Subscribe to `sammo:${profileName}:realtime:events` and wait for
`turnCompleted`.
@@ -94,7 +97,9 @@ For profile alignment:
Mandatory checks:
- PM2 reports both processes as online.
- `turnDaemon.status` responds within the timeout.
- Authenticated profile administration is required for every
`turnDaemon.run/pause/resume/status` call.
- `turnDaemon.status` responds within the timeout for the administrator token.
- A command with `commandResult` returns a success response.
- A `turnCompleted` realtime event is observed after a `run` command.
+2 -2
View File
@@ -69,5 +69,5 @@ These are loaded from `.env.ci` and can be overridden per run.
- `auth.bootstrapLocal` only works when no users exist; the test resets the DB
to satisfy this precondition.
- `profiles.installNow` seeds the scenario and auto-creates the admin general.
- The test runs turn processing via `turnDaemon.run` and validates founding
rules at the third turn.
- The test runs turn processing via administrator-authorized
`turnDaemon.run` and validates founding rules at the third turn.
@@ -293,6 +293,15 @@ describe('auction integration flow', () => {
},
});
const adminGatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: adminSessionRef.value ?? '',
profile: 'che:2',
});
const adminAccess = await gameClient.auth.exchangeGatewayToken.mutate({
gatewayToken: adminGatewayToken.gameToken,
});
gameAccessRef.value = adminAccess.accessToken;
for (const user of demoUsers) {
const login = await gatewayClient.auth.login.mutate({
username: user.username,
@@ -192,10 +192,14 @@ const cleanupPm2 = async (manager: Pm2ProcessManager, names: string[]) => {
for (const name of names) {
try {
await manager.stop(name);
} catch {}
} catch {
// Cleanup is best-effort when the process is already stopped.
}
try {
await manager.delete(name);
} catch {}
} catch {
// Cleanup is best-effort when the process is already deleted.
}
}
};
@@ -237,7 +241,9 @@ const waitForHttpOk = async (url: string, timeoutMs = 15_000) => {
if (status >= 200 && status < 300) {
return;
}
} catch {}
} catch {
// The service can refuse connections while PM2 is still starting it.
}
await sleep(250);
}
throw new Error(`health check timeout: ${url}`);
@@ -445,10 +451,12 @@ describe('pm2 orchestrator e2e', () => {
const gatewayUrl = `http://localhost:${gatewayServer.config.port}`;
const gameUrl = `http://localhost:${apiPort}`;
const adminSessionRef: { value?: string } = {};
const adminGameAccessRef: { value?: string } = {};
const accessTokenRef: { value?: string } = {};
const gatewayClient = createGatewayClient(gatewayUrl, gatewayServer.config.trpcPath, adminSessionRef);
const gameTrpcPath = process.env.GAME_TRPC_PATH ?? process.env.TRPC_PATH ?? '/trpc';
const gameClientPublic = createGameClient(gameUrl, gameTrpcPath, { value: undefined });
const gameClientAdmin = createGameClient(gameUrl, gameTrpcPath, adminGameAccessRef);
const gameClientAuthed = createGameClient(gameUrl, gameTrpcPath, accessTokenRef);
const bootstrap = await gatewayClient.auth.bootstrapLocal.mutate({
@@ -496,7 +504,16 @@ describe('pm2 orchestrator e2e', () => {
await waitForHttpOk(`${gameUrl}/healthz`);
await waitForTurnDaemonStatus(gameClientPublic);
const adminGameSession = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: adminSessionRef.value ?? '',
profile: profileName,
});
const adminAccess = await gameClientPublic.auth.exchangeGatewayToken.mutate({
gatewayToken: adminGameSession.gameToken,
});
adminGameAccessRef.value = adminAccess.accessToken;
await waitForTurnDaemonStatus(gameClientAdmin);
const login = await gatewayClient.auth.login.mutate({
username: 'e2e-user',
@@ -547,7 +564,7 @@ describe('pm2 orchestrator e2e', () => {
timeoutMs: 30_000,
});
const runStatus = await runTurn(gameClientPublic);
const runStatus = await runTurn(gameClientAdmin);
expect(runStatus.lastRunAt).toBeTruthy();
const realtimeEvent = await ssePromise;