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 }; +}