Secure actor-owned game API routes
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user