1
0
Fork 0

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.
This commit is contained in:
Jason Fraley 2026-08-25 12:30:50 -04:00
parent c628f8a90b
commit 9b909e85e6
7 changed files with 239 additions and 50 deletions

View file

@ -58,3 +58,9 @@ APP_PORT=3000
# DISCORD_STAFF_ROLE_IDS= # DISCORD_STAFF_ROLE_IDS=
# DISCORD_ATTENDANCE_POLL_MS= # DISCORD_ATTENDANCE_POLL_MS=
# DISCORD_NOTIFICATION_POLL_MS= # 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=

View file

@ -1,23 +1,42 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server"; 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 runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const serverId = authenticateArmaBridge(req); const auth = await requireArmaServer(req);
if (serverId instanceof NextResponse) return serverId; if (auth instanceof NextResponse) return auth;
const body = await req.json().catch(() => null) as { id?: unknown; success?: unknown; result?: unknown; error?: unknown } | null; const { payload, server } = auth;
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 body = (await req.json().catch(() => null)) as
const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); | { id?: unknown; success?: unknown; result?: unknown; error?: unknown }
const server = servers.docs[0]; | null;
if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); if (!body || typeof body.id !== "string" || typeof body.success !== "boolean") {
const commands = await payload.find({ collection: "arma-commands", where: { and: [{ commandId: { equals: body.id } }, { server: { equals: server.id } }] }, limit: 1, overrideAccess: true }); 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]; const command = commands.docs[0];
if (!command) return NextResponse.json({ error: "Command not found." }, { status: 404 }); 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 }); return NextResponse.json({ ok: true });
} }

View file

@ -1,20 +1,39 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server"; 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 runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const serverId = authenticateArmaBridge(req); const auth = await requireArmaServer(req);
if (serverId instanceof NextResponse) return serverId; if (auth instanceof NextResponse) return auth;
const payload = await getPayload({ config }); const { payload, server } = auth;
const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true });
const server = servers.docs[0]; const commands = await payload.find({
if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); collection: "arma-commands",
const commands = await payload.find({ collection: "arma-commands", where: { and: [{ server: { equals: server.id } }, { status: { equals: "queued" } }] }, limit: 20, sort: "createdAt", overrideAccess: true }); where: { and: [{ server: { equals: server.id } }, { status: { equals: "queued" } }] },
limit: 20,
sort: "createdAt",
overrideAccess: true,
});
const deliveredAt = new Date().toISOString(); 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 }))); await Promise.all(
return NextResponse.json({ commands: commands.docs.map((command) => ({ id: command.commandId, type: command.type, payload: command.payload })) }); 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,
})),
});
} }

View file

@ -1,30 +1,83 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server"; 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 runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
interface IncomingEvent {
eventId: string;
server: number;
type: string;
occurredAt: string;
payload: ReturnType<typeof asJson>;
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const serverId = authenticateArmaBridge(req); const auth = await requireArmaServer(req);
if (serverId instanceof NextResponse) return serverId; if (auth instanceof NextResponse) return auth;
const body = await req.json().catch(() => null) as { events?: unknown[] } | null; const { payload, server } = auth;
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 body = (await req.json().catch(() => null)) as { events?: unknown[] } | null;
const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); if (!body?.events || !Array.isArray(body.events) || body.events.length > 100) {
const server = servers.docs[0]; return NextResponse.json(
if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); { error: "events must be an array containing at most 100 entries." },
let accepted = 0; { status: 400 },
let duplicates = 0; );
}
// Validate + normalize; entries missing a string id or type are rejected.
const valid: IncomingEvent[] = [];
let rejected = 0;
for (const rawEvent of body.events) { for (const rawEvent of body.events) {
const event = rawEvent as Record<string, unknown>; const event = rawEvent as Record<string, unknown>;
if (!event || typeof event.id !== "string" || typeof event.type !== "string") continue; if (!event || typeof event.id !== "string" || typeof event.type !== "string") {
const eventId = `${serverId}:${event.id}`; rejected++;
const existing = await payload.find({ collection: "arma-sync-events", where: { eventId: { equals: eventId } }, limit: 1, overrideAccess: true }); continue;
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 }); 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),
});
}
// 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++; 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 }); }
}
return NextResponse.json({ ok: true, accepted, duplicates, rejected });
} }

View file

@ -2,6 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth"; import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
import { asJson } from "@/lib/arma-bridge/json";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@ -9,10 +10,36 @@ export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const serverId = authenticateArmaBridge(req); const serverId = authenticateArmaBridge(req);
if (serverId instanceof NextResponse) return serverId; 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 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 found = await payload.find({
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 }); 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 }); return NextResponse.json({ ok: true, serverId: server.id });
} }

View file

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

View file

@ -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<ReturnType<typeof getPayload>>; 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 };
}