1
0
Fork 0

feat(arma-bridge): add authenticated v1 bridge API routes

Add key-and-server-id authenticated endpoints for server heartbeat, event ingestion, and command pull/ack.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-08-23 18:51:33 -04:00
parent 347cbdf956
commit fc3b961a43
5 changed files with 101 additions and 0 deletions

View file

@ -0,0 +1,23 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server";
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
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 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 });
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,20 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server";
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
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 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 })) });
}

View file

@ -0,0 +1,30 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server";
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
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 { 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;
for (const rawEvent of body.events) {
const event = rawEvent as Record<string, unknown>;
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++;
}
return NextResponse.json({ ok: true, accepted, duplicates });
}

View file

@ -0,0 +1,18 @@
import config from "@payload-config";
import { getPayload } from "payload";
import { NextRequest, NextResponse } from "next/server";
import { authenticateArmaBridge } from "@/lib/arma-bridge/auth";
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(() => ({}));
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 });
return NextResponse.json({ ok: true, serverId: server.id });
}

View file

@ -0,0 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
export function authenticateArmaBridge(req: NextRequest): string | NextResponse {
const secret = process.env.ARMA_BRIDGE_API_KEY;
if (!secret) return NextResponse.json({ error: "Arma bridge is not configured." }, { status: 503 });
if (req.headers.get("x-arma-bridge-key") !== secret) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
const serverId = req.headers.get("x-arma-server-id");
if (!serverId || !/^[a-zA-Z0-9_-]{3,64}$/.test(serverId)) return NextResponse.json({ error: "Invalid server id." }, { status: 400 });
return serverId;
}