From 9b909e85e6d2807e3e2cb56da1a446bfbd89db82 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 25 Aug 2026 12:30:50 -0400 Subject: [PATCH] refactor(arma-bridge): extract shared server guard and json coercion requireArmaServer (src/lib/arma-bridge/server.ts) wraps the shared bridge auth + game-servers lookup that every v1 route repeated; asJson (json.ts) narrows untrusted request values to what Payload's json field validation accepts (strings normalize to the fallback instead of failing the write). All four v1 routes now use both. Also document the ARMA_BRIDGE_API_KEY env var missed when the bridge routes landed. --- .env.example | 8 +- src/app/api/arma/v1/commands/ack/route.ts | 45 ++++++++--- src/app/api/arma/v1/commands/pull/route.ts | 43 +++++++--- src/app/api/arma/v1/events/route.ts | 93 +++++++++++++++++----- src/app/api/arma/v1/heartbeat/route.ts | 35 +++++++- src/lib/arma-bridge/json.ts | 28 +++++++ src/lib/arma-bridge/server.ts | 37 +++++++++ 7 files changed, 239 insertions(+), 50 deletions(-) create mode 100644 src/lib/arma-bridge/json.ts create mode 100644 src/lib/arma-bridge/server.ts diff --git a/.env.example b/.env.example index e9e6758..68f0917 100644 --- a/.env.example +++ b/.env.example @@ -57,4 +57,10 @@ APP_PORT=3000 # DISCORD_ANNOUNCE_CHANNEL_ID= # DISCORD_STAFF_ROLE_IDS= # DISCORD_ATTENDANCE_POLL_MS= -# DISCORD_NOTIFICATION_POLL_MS= \ No newline at end of file +# DISCORD_NOTIFICATION_POLL_MS= + +# --- Arma 3 bridge (Pythia HTTP integration) --- +# Shared secret required by the /api/arma/v1/* routes. The Arma server sends +# it as the `x-arma-bridge-key` header alongside `x-arma-server-id`. Generate +# with: openssl rand -hex 32. Leave unset to disable the bridge (routes 503). +# ARMA_BRIDGE_API_KEY= \ No newline at end of file diff --git a/src/app/api/arma/v1/commands/ack/route.ts b/src/app/api/arma/v1/commands/ack/route.ts index 50f2af5..bd3aff5 100644 --- a/src/app/api/arma/v1/commands/ack/route.ts +++ b/src/app/api/arma/v1/commands/ack/route.ts @@ -1,23 +1,42 @@ -import config from "@payload-config"; -import { getPayload } from "payload"; import { NextRequest, NextResponse } from "next/server"; -import { authenticateArmaBridge } from "@/lib/arma-bridge/auth"; +import { asJson } from "@/lib/arma-bridge/json"; +import { requireArmaServer } from "@/lib/arma-bridge/server"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(req: NextRequest) { - const serverId = authenticateArmaBridge(req); - if (serverId instanceof NextResponse) return serverId; - const body = await req.json().catch(() => null) as { id?: unknown; success?: unknown; result?: unknown; error?: unknown } | null; - if (!body || typeof body.id !== "string" || typeof body.success !== "boolean") return NextResponse.json({ error: "id and success are required." }, { status: 400 }); - const payload = await getPayload({ config }); - const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); - const server = servers.docs[0]; - if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); - const commands = await payload.find({ collection: "arma-commands", where: { and: [{ commandId: { equals: body.id } }, { server: { equals: server.id } }] }, limit: 1, overrideAccess: true }); + const auth = await requireArmaServer(req); + if (auth instanceof NextResponse) return auth; + const { payload, server } = auth; + + const body = (await req.json().catch(() => null)) as + | { id?: unknown; success?: unknown; result?: unknown; error?: unknown } + | null; + if (!body || typeof body.id !== "string" || typeof body.success !== "boolean") { + return NextResponse.json({ error: "id and success are required." }, { status: 400 }); + } + + const commands = await payload.find({ + collection: "arma-commands", + where: { and: [{ commandId: { equals: body.id } }, { server: { equals: server.id } }] }, + limit: 1, + overrideAccess: true, + }); const command = commands.docs[0]; if (!command) return NextResponse.json({ error: "Command not found." }, { status: 404 }); - await payload.update({ collection: "arma-commands", id: command.id, data: { status: body.success ? "succeeded" : "failed", result: body.result ?? {}, error: typeof body.error === "string" ? body.error : undefined, completedAt: new Date().toISOString() }, overrideAccess: true }); + + await payload.update({ + collection: "arma-commands", + id: command.id, + data: { + status: body.success ? "succeeded" : "failed", + result: asJson(body.result), + error: typeof body.error === "string" ? body.error : undefined, + completedAt: new Date().toISOString(), + }, + overrideAccess: true, + }); + return NextResponse.json({ ok: true }); } diff --git a/src/app/api/arma/v1/commands/pull/route.ts b/src/app/api/arma/v1/commands/pull/route.ts index 9eb0bec..4a372c5 100644 --- a/src/app/api/arma/v1/commands/pull/route.ts +++ b/src/app/api/arma/v1/commands/pull/route.ts @@ -1,20 +1,39 @@ -import config from "@payload-config"; -import { getPayload } from "payload"; import { NextRequest, NextResponse } from "next/server"; -import { authenticateArmaBridge } from "@/lib/arma-bridge/auth"; +import { requireArmaServer } from "@/lib/arma-bridge/server"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(req: NextRequest) { - const serverId = authenticateArmaBridge(req); - if (serverId instanceof NextResponse) return serverId; - const payload = await getPayload({ config }); - const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); - const server = servers.docs[0]; - if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); - const commands = await payload.find({ collection: "arma-commands", where: { and: [{ server: { equals: server.id } }, { status: { equals: "queued" } }] }, limit: 20, sort: "createdAt", overrideAccess: true }); + const auth = await requireArmaServer(req); + if (auth instanceof NextResponse) return auth; + const { payload, server } = auth; + + const commands = await payload.find({ + collection: "arma-commands", + where: { and: [{ server: { equals: server.id } }, { status: { equals: "queued" } }] }, + limit: 20, + sort: "createdAt", + overrideAccess: true, + }); + const deliveredAt = new Date().toISOString(); - await Promise.all(commands.docs.map((command) => payload.update({ collection: "arma-commands", id: command.id, data: { status: "delivered", deliveredAt }, overrideAccess: true }))); - return NextResponse.json({ commands: commands.docs.map((command) => ({ id: command.commandId, type: command.type, payload: command.payload })) }); + await Promise.all( + commands.docs.map((command) => + payload.update({ + collection: "arma-commands", + id: command.id, + data: { status: "delivered", deliveredAt }, + overrideAccess: true, + }), + ), + ); + + return NextResponse.json({ + commands: commands.docs.map((command) => ({ + id: command.commandId, + type: command.type, + payload: command.payload, + })), + }); } diff --git a/src/app/api/arma/v1/events/route.ts b/src/app/api/arma/v1/events/route.ts index 88c77e1..2bb4e2a 100644 --- a/src/app/api/arma/v1/events/route.ts +++ b/src/app/api/arma/v1/events/route.ts @@ -1,30 +1,83 @@ -import config from "@payload-config"; -import { getPayload } from "payload"; import { NextRequest, NextResponse } from "next/server"; -import { authenticateArmaBridge } from "@/lib/arma-bridge/auth"; +import { asJson } from "@/lib/arma-bridge/json"; +import { requireArmaServer } from "@/lib/arma-bridge/server"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +interface IncomingEvent { + eventId: string; + server: number; + type: string; + occurredAt: string; + payload: ReturnType; +} + export async function POST(req: NextRequest) { - const serverId = authenticateArmaBridge(req); - if (serverId instanceof NextResponse) return serverId; - const body = await req.json().catch(() => null) as { events?: unknown[] } | null; - if (!body?.events || !Array.isArray(body.events) || body.events.length > 100) return NextResponse.json({ error: "events must contain at most 100 entries." }, { status: 400 }); - const payload = await getPayload({ config }); - const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); - const server = servers.docs[0]; - if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); - let accepted = 0; - let duplicates = 0; + const auth = await requireArmaServer(req); + if (auth instanceof NextResponse) return auth; + const { payload, server } = auth; + + const body = (await req.json().catch(() => null)) as { events?: unknown[] } | null; + if (!body?.events || !Array.isArray(body.events) || body.events.length > 100) { + return NextResponse.json( + { error: "events must be an array containing at most 100 entries." }, + { status: 400 }, + ); + } + + // Validate + normalize; entries missing a string id or type are rejected. + const valid: IncomingEvent[] = []; + let rejected = 0; for (const rawEvent of body.events) { const event = rawEvent as Record; - if (!event || typeof event.id !== "string" || typeof event.type !== "string") continue; - const eventId = `${serverId}:${event.id}`; - const existing = await payload.find({ collection: "arma-sync-events", where: { eventId: { equals: eventId } }, limit: 1, overrideAccess: true }); - if (existing.docs.length) { duplicates++; continue; } - await payload.create({ collection: "arma-sync-events", data: { eventId, server: server.id, type: event.type, occurredAt: typeof event.occurredAt === "string" ? event.occurredAt : new Date().toISOString(), payload: event.payload ?? {} }, overrideAccess: true }); - accepted++; + if (!event || typeof event.id !== "string" || typeof event.type !== "string") { + rejected++; + continue; + } + valid.push({ + eventId: `${server.serverId}:${event.id}`, + server: server.id, + type: event.type, + occurredAt: + typeof event.occurredAt === "string" ? event.occurredAt : new Date().toISOString(), + payload: asJson(event.payload), + }); } - return NextResponse.json({ ok: true, accepted, duplicates }); + + // One batched lookup for events that already exist (idempotency by eventId). + const existing = valid.length + ? await payload.find({ + collection: "arma-sync-events", + where: { eventId: { in: valid.map((event) => event.eventId) } }, + limit: valid.length, + overrideAccess: true, + }) + : { docs: [] }; + const seen = new Set(existing.docs.map((doc) => doc.eventId)); + + let accepted = 0; + let duplicates = 0; + for (const event of valid) { + // Also catches the same id repeated within this batch. + if (seen.has(event.eventId)) { + duplicates++; + continue; + } + try { + await payload.create({ collection: "arma-sync-events", data: event, overrideAccess: true }); + seen.add(event.eventId); + accepted++; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/duplicate key|unique constraint/i.test(message)) { + // Unique-constraint race with a concurrent request — already stored. + duplicates++; + } else { + throw error; + } + } + } + + return NextResponse.json({ ok: true, accepted, duplicates, rejected }); } diff --git a/src/app/api/arma/v1/heartbeat/route.ts b/src/app/api/arma/v1/heartbeat/route.ts index 6fe6eeb..14a47ca 100644 --- a/src/app/api/arma/v1/heartbeat/route.ts +++ b/src/app/api/arma/v1/heartbeat/route.ts @@ -2,6 +2,7 @@ import config from "@payload-config"; import { getPayload } from "payload"; import { NextRequest, NextResponse } from "next/server"; import { authenticateArmaBridge } from "@/lib/arma-bridge/auth"; +import { asJson } from "@/lib/arma-bridge/json"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -9,10 +10,36 @@ export const dynamic = "force-dynamic"; export async function POST(req: NextRequest) { const serverId = authenticateArmaBridge(req); if (serverId instanceof NextResponse) return serverId; - const body = await req.json().catch(() => ({})); + + const body = (await req.json().catch(() => null)) as { name?: unknown; metadata?: unknown } | null; const payload = await getPayload({ config }); - const found = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); - const data = { name: typeof body.name === "string" ? body.name : serverId, status: "online" as const, lastSeenAt: new Date().toISOString(), metadata: body.metadata ?? {} }; - const server = found.docs[0] ? await payload.update({ collection: "game-servers", id: found.docs[0].id, data, overrideAccess: true }) : await payload.create({ collection: "game-servers", data: { serverId, ...data }, overrideAccess: true }); + + const found = await payload.find({ + collection: "game-servers", + where: { serverId: { equals: serverId } }, + limit: 1, + overrideAccess: true, + }); + + const data = { + name: typeof body?.name === "string" ? body.name : serverId, + status: "online" as const, + lastSeenAt: new Date().toISOString(), + metadata: asJson(body?.metadata), + }; + + const server = found.docs[0] + ? await payload.update({ + collection: "game-servers", + id: found.docs[0].id, + data, + overrideAccess: true, + }) + : await payload.create({ + collection: "game-servers", + data: { serverId, ...data }, + overrideAccess: true, + }); + return NextResponse.json({ ok: true, serverId: server.id }); } diff --git a/src/lib/arma-bridge/json.ts b/src/lib/arma-bridge/json.ts new file mode 100644 index 0000000..e0c4f9d --- /dev/null +++ b/src/lib/arma-bridge/json.ts @@ -0,0 +1,28 @@ +/** + * JSON value accepted by the bridge collections' `json` fields. + * Note: Payload 3's json field validation rejects plain string values, so + * strings are deliberately absent from this union — they normalize to the + * fallback instead of failing the write. + */ +export type BridgeJsonValue = + | { [k: string]: unknown } + | unknown[] + | number + | boolean + | null; + +/** + * Narrows an untrusted `unknown` value from a request body to a value that + * Payload's json field validation accepts. Anything else (undefined, + * strings, functions, symbols) falls back to the provided default. + */ +export function asJson(value: unknown, fallback: BridgeJsonValue = {}): BridgeJsonValue { + if (typeof value === "number" || typeof value === "boolean" || value === null) { + return value; + } + if (typeof value === "object") { + // Runtime-verified object or array — safe to store as JSON. + return value as BridgeJsonValue; + } + return fallback; +} diff --git a/src/lib/arma-bridge/server.ts b/src/lib/arma-bridge/server.ts new file mode 100644 index 0000000..531b8c9 --- /dev/null +++ b/src/lib/arma-bridge/server.ts @@ -0,0 +1,37 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { NextRequest, NextResponse } from "next/server"; +import type { GameServer } from "@/payload-types"; +import { authenticateArmaBridge } from "./auth"; + +/** + * Shared guard for bridge routes that operate on behalf of a known game + * server: verifies the bridge API key + server id headers, then resolves the + * matching `game-servers` doc. + * + * Returns the Payload instance and server doc on success, or a NextResponse + * (503/401/400/409) the route should return verbatim. + */ +export async function requireArmaServer( + req: NextRequest, +): Promise< + | { payload: Awaited>; server: GameServer } + | NextResponse +> { + const serverId = authenticateArmaBridge(req); + if (serverId instanceof NextResponse) return serverId; + + const payload = await getPayload({ config }); + const servers = await payload.find({ + collection: "game-servers", + where: { serverId: { equals: serverId } }, + limit: 1, + overrideAccess: true, + }); + const server = servers.docs[0]; + if (!server) { + return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); + } + + return { payload, server }; +}