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({