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:
parent
347cbdf956
commit
fc3b961a43
5 changed files with 101 additions and 0 deletions
23
src/app/api/arma/v1/commands/ack/route.ts
Normal file
23
src/app/api/arma/v1/commands/ack/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
20
src/app/api/arma/v1/commands/pull/route.ts
Normal file
20
src/app/api/arma/v1/commands/pull/route.ts
Normal 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 })) });
|
||||||
|
}
|
||||||
30
src/app/api/arma/v1/events/route.ts
Normal file
30
src/app/api/arma/v1/events/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
18
src/app/api/arma/v1/heartbeat/route.ts
Normal file
18
src/app/api/arma/v1/heartbeat/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
10
src/lib/arma-bridge/auth.ts
Normal file
10
src/lib/arma-bridge/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue