From 43e68597d71d1cd8a4ad298d04e72c83614bd1b2 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 15:53:49 +0800 Subject: [PATCH] feat: add DELETE /world/:worldId admin endpoint to gateway Adds a DELETE /world/:worldId endpoint for deregistering worlds from the gateway registry. Supports optional GATEWAY_ADMIN_KEY bearer token auth when the env var is set, and returns the count of removed agents. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .changeset/gateway-delete-world.md | 5 +++ gateway/server.mjs | 44 ++++++++++++++++++++- test/gateway-world-record.test.mjs | 62 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 .changeset/gateway-delete-world.md diff --git a/.changeset/gateway-delete-world.md b/.changeset/gateway-delete-world.md new file mode 100644 index 0000000..4dd93f1 --- /dev/null +++ b/.changeset/gateway-delete-world.md @@ -0,0 +1,5 @@ +--- +"@resciencelab/agent-world-network": minor +--- + +Add DELETE /world/:worldId admin endpoint to the gateway for deregistering worlds, with optional GATEWAY_ADMIN_KEY bearer token auth diff --git a/gateway/server.mjs b/gateway/server.mjs index bf083b2..a559867 100644 --- a/gateway/server.mjs +++ b/gateway/server.mjs @@ -9,7 +9,8 @@ * GET /health — health check * GET /worlds — list discovered world:* agents on AWN network * GET /agents — list all known AWN agents - * GET /world/:worldId — info about a specific world + * GET /world/:worldId — info about a specific world + * DELETE /world/:worldId — deregister a world (admin, requires GATEWAY_ADMIN_KEY bearer token if set) * GET /peer/ping — peer liveness * GET /peer/peers — known peers exchange * POST /peer/announce — world server registration @@ -498,6 +499,47 @@ export async function createGatewayApp(opts = {}) { }; }); + app.delete("/world/:worldId", { + schema: { + summary: "Deregister a world (admin)", + operationId: "deleteWorld", + tags: ["gateway"], + params: { + type: "object", + required: ["worldId"], + properties: { worldId: { type: "string" } }, + }, + response: { + 200: { + type: "object", + required: ["ok", "removed"], + properties: { ok: { type: "boolean" }, removed: { type: "integer" } }, + }, + 403: { $ref: "Error#" }, + 404: { $ref: "Error#" }, + }, + }, + }, async (req, reply) => { + const adminKey = process.env.GATEWAY_ADMIN_KEY; + if (adminKey) { + const auth = req.headers["authorization"] ?? ""; + if (auth !== `Bearer ${adminKey}`) { + return reply.code(403).send({ error: "Forbidden" }); + } + } + const { worldId } = req.params; + const worlds = findByCapability(`world:${worldId}`); + if (!worlds.length) return reply.code(404).send({ error: "World not found" }); + let removed = 0; + for (const w of worlds) { + registry.delete(w.agentId); + removed++; + } + _registryModifiedAt = Date.now(); + console.log(`[gateway] Deregistered world:${worldId} (${removed} agent(s) removed)`); + return { ok: true, removed }; + }); + app.get("/ws", { websocket: true }, (socket, req) => { const worldId = new URL(req.url, "http://x").searchParams.get("world"); if (!worldId) { diff --git a/test/gateway-world-record.test.mjs b/test/gateway-world-record.test.mjs index 9066def..59a4b33 100644 --- a/test/gateway-world-record.test.mjs +++ b/test/gateway-world-record.test.mjs @@ -83,4 +83,66 @@ describe("Gateway /world/:worldId", () => { assert.equal(r2.publicKey, kp2.publicKey) assert.notEqual(r1.publicKey, r2.publicKey) }) + + it("DELETE /world/:worldId returns 404 for unknown world", async () => { + const resp = await app.inject({ method: "DELETE", url: "/world/nonexistent-delete" }) + assert.equal(resp.statusCode, 404) + }) + + it("DELETE /world/:worldId removes a known world", async () => { + const kp = makeKeypair() + const worldId = "delete-me" + + await announce(kp, worldId) + const before = await app.inject({ method: "GET", url: `/world/${worldId}` }) + assert.equal(before.statusCode, 200) + + const del = await app.inject({ method: "DELETE", url: `/world/${worldId}` }) + assert.equal(del.statusCode, 200) + const body = JSON.parse(del.body) + assert.equal(body.ok, true) + assert.equal(body.removed, 1) + + const after = await app.inject({ method: "GET", url: `/world/${worldId}` }) + assert.equal(after.statusCode, 404) + }) + + it("DELETE /world/:worldId returns 403 when GATEWAY_ADMIN_KEY is set and token is missing", async () => { + const kp = makeKeypair() + const worldId = "protected-world" + await announce(kp, worldId) + + const prev = process.env.GATEWAY_ADMIN_KEY + process.env.GATEWAY_ADMIN_KEY = "secret-test-key" + try { + const resp = await app.inject({ method: "DELETE", url: `/world/${worldId}` }) + assert.equal(resp.statusCode, 403) + } finally { + if (prev === undefined) delete process.env.GATEWAY_ADMIN_KEY + else process.env.GATEWAY_ADMIN_KEY = prev + } + }) + + it("DELETE /world/:worldId succeeds with correct GATEWAY_ADMIN_KEY bearer token", async () => { + const kp = makeKeypair() + const worldId = "protected-world-2" + await announce(kp, worldId) + + const prev = process.env.GATEWAY_ADMIN_KEY + process.env.GATEWAY_ADMIN_KEY = "secret-test-key" + try { + const resp = await app.inject({ + method: "DELETE", + url: `/world/${worldId}`, + headers: { authorization: "Bearer secret-test-key" }, + }) + assert.equal(resp.statusCode, 200) + const body = JSON.parse(resp.body) + assert.equal(body.ok, true) + assert.equal(body.removed, 1) + } finally { + if (prev === undefined) delete process.env.GATEWAY_ADMIN_KEY + else process.env.GATEWAY_ADMIN_KEY = prev + } + }) })