From 78143afadeae6ff44ad4f6a4f95f674095e42e31 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:12:02 +0800 Subject: [PATCH 1/6] refactor: rename gateway /peer/* routes to resource-oriented paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /peer/ping → /ping - /peer/peers removed (redundant with GET /agents) - /peer/announce → POST /agents (agent online/re-announce) - /peer/heartbeat → POST /agents/:agentId/heartbeat - /peer/message → POST /messages - /world/:worldId → /worlds/:worldId (fix singular/plural) - DELETE /world/:worldId → DELETE /worlds/:worldId - Add GET /agents/:agentId, DELETE /agents/:agentId, POST /worlds/:worldId/heartbeat Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- gateway/schemas.mjs | 3 +- gateway/server.mjs | 255 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 197 insertions(+), 61 deletions(-) diff --git a/gateway/schemas.mjs b/gateway/schemas.mjs index bc60991..c6b66f5 100644 --- a/gateway/schemas.mjs +++ b/gateway/schemas.mjs @@ -86,9 +86,8 @@ export const AnnounceRequestSchema = { export const HeartbeatRequestSchema = { $id: "HeartbeatRequest", type: "object", - required: ["agentId", "ts", "signature"], + required: ["ts", "signature"], properties: { - agentId: { type: "string", description: "aw:sha256:{hex} agent identifier" }, ts: { type: "integer", description: "Unix timestamp (ms)" }, signature: { type: "string", description: "Domain-separated Ed25519 signature (HEARTBEAT context)" }, }, diff --git a/gateway/server.mjs b/gateway/server.mjs index bf083b2..2cd6d8b 100644 --- a/gateway/server.mjs +++ b/gateway/server.mjs @@ -2,19 +2,22 @@ * AWN Gateway — stateless portal + WebSocket bridge. * No OpenClaw dependency. Runs on plain HTTP/TCP. * - * World Servers announce directly to this Gateway via POST /peer/announce. + * World Servers register with this Gateway via POST /agents (with a world: capability). * The Gateway maintains a peer DB and exposes discovered worlds via /worlds. * * HTTP Endpoints: - * 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 /peer/ping — peer liveness - * GET /peer/peers — known peers exchange - * POST /peer/announce — world server registration - * POST /peer/heartbeat — lightweight liveness heartbeat - * POST /peer/message — inbound signed message (world.state broadcasts) + * GET /health — health check + * GET /ping — peer liveness + * GET /worlds — list discovered world:* agents on AWN network + * GET /worlds/:worldId — info about a specific world + * DELETE /worlds/:worldId — deregister a world (admin, requires GATEWAY_ADMIN_KEY bearer token if set) + * GET /agents — list all known AWN agents + * GET /agents/:agentId — get a specific agent record + * DELETE /agents/:agentId — deregister an agent (admin, requires GATEWAY_ADMIN_KEY bearer token if set) + * POST /agents — register or re-announce an agent (online) + * POST /agents/:agentId/heartbeat — agent liveness heartbeat + * POST /worlds/:worldId/heartbeat — world server liveness heartbeat + * POST /messages — inbound signed message (world.state broadcasts) * * WebSocket: * WS /ws?world= — subscribe to a world's real-time events @@ -335,8 +338,8 @@ export async function createGatewayApp(opts = {}) { title: "AWN Gateway", description: "Agent World Network Gateway — stateless portal + WebSocket bridge.\n" + - "World Servers announce directly via POST /peer/announce and stay alive\n" + - "with periodic POST /peer/heartbeat signals.\n\n" + + "World Servers register via POST /agents (with a `world:` capability) and stay alive\n" + + "with periodic POST /agents/:agentId/heartbeat signals.\n\n" + "**WebSocket** — `ws://{host}/ws?world={worldId}` subscribes to a world's\n" + "real-time events (world.state broadcasts, join/leave/action messages).", version: "0.5.0", @@ -394,6 +397,25 @@ export async function createGatewayApp(opts = {}) { } }); + app.get("/ping", { + schema: { + summary: "Peer liveness check", + operationId: "getPing", + tags: ["gateway"], + response: { + 200: { + type: "object", + required: ["ok", "ts", "role"], + properties: { + ok: { type: "boolean" }, + ts: { type: "integer" }, + role: { type: "string", enum: ["gateway"] }, + }, + }, + }, + }, + }, async () => ({ ok: true, ts: Date.now(), role: "gateway" })); + let _cachedCardJson = null app.get("/.well-known/agent.json", { schema: { @@ -466,7 +488,7 @@ export async function createGatewayApp(opts = {}) { }; }); - app.get("/world/:worldId", { + app.get("/worlds/:worldId", { schema: { summary: "Get info about a specific world", operationId: "getWorld", @@ -498,6 +520,105 @@ export async function createGatewayApp(opts = {}) { }; }); + app.delete("/worlds/: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("/agents/:agentId", { + schema: { + summary: "Get a specific agent record", + operationId: "getAgent", + tags: ["gateway"], + params: { + type: "object", + required: ["agentId"], + properties: { agentId: { type: "string" } }, + }, + response: { + 200: { $ref: "PeerRecord#" }, + 404: { $ref: "Error#" }, + }, + }, + }, async (req, reply) => { + const { agentId } = req.params; + const agent = registry.get(agentId); + if (!agent) return reply.code(404).send({ error: "Agent not found" }); + return agent; + }); + + app.delete("/agents/:agentId", { + schema: { + summary: "Deregister an agent (admin)", + operationId: "deleteAgent", + tags: ["gateway"], + params: { + type: "object", + required: ["agentId"], + properties: { agentId: { type: "string" } }, + }, + response: { + 200: { + type: "object", + required: ["ok"], + properties: { ok: { type: "boolean" } }, + }, + 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 { agentId } = req.params; + if (!registry.has(agentId)) return reply.code(404).send({ error: "Agent not found" }); + registry.delete(agentId); + _registryModifiedAt = Date.now(); + console.log(`[gateway] Deregistered agent:${agentId}`); + return { ok: true }; + }); + app.get("/ws", { websocket: true }, (socket, req) => { const worldId = new URL(req.url, "http://x").searchParams.get("world"); if (!worldId) { @@ -580,46 +701,12 @@ export async function createGatewayApp(opts = {}) { const noValidate = () => () => true; peer.setValidatorCompiler(noValidate); - peer.get("/peer/ping", { + peer.post("/agents", { schema: { - summary: "Peer liveness check", - operationId: "getPeerPing", - tags: ["peer"], - response: { - 200: { - type: "object", - required: ["ok", "ts", "role"], - properties: { - ok: { type: "boolean" }, - ts: { type: "integer" }, - role: { type: "string", enum: ["gateway"] }, - }, - }, - }, - }, - }, async () => ({ ok: true, ts: Date.now(), role: "gateway" })); - - peer.get("/peer/peers", { - schema: { - summary: "Exchange known peers", - operationId: "getPeerPeers", - tags: ["peer"], - response: { - 200: { - type: "object", - required: ["peers"], - properties: { peers: { type: "array", items: { $ref: "PeerRecord#" } } }, - }, - }, - }, - }, async () => ({ peers: getAgentsForExchange() })); - - peer.post("/peer/announce", { - schema: { - summary: "Register or re-announce a world server", - operationId: "postAnnounce", - tags: ["peer"], - description: "Ed25519-signed announcement from a world server.", + summary: "Register or re-announce an agent (online)", + operationId: "postAgents", + tags: ["gateway"], + description: "Ed25519-signed agent registration. World servers include a `world:` capability.", body: { $ref: "AnnounceRequest#" }, response: { 200: { @@ -660,12 +747,17 @@ export async function createGatewayApp(opts = {}) { return { ok: true, peers: getAgentsForExchange(20) }; }); - peer.post("/peer/heartbeat", { + peer.post("/agents/:agentId/heartbeat", { schema: { summary: "Lightweight liveness heartbeat", operationId: "postHeartbeat", - tags: ["peer"], + tags: ["gateway"], description: "Updates an agent's lastSeen without a full re-announce.", + params: { + type: "object", + required: ["agentId"], + properties: { agentId: { type: "string" } }, + }, body: { $ref: "HeartbeatRequest#" }, response: { 200: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, @@ -675,8 +767,9 @@ export async function createGatewayApp(opts = {}) { }, }, }, async (req, reply) => { - const { agentId, ts, signature } = req.body ?? {}; - if (!agentId || !ts || !signature) return reply.code(400).send({ error: "Invalid heartbeat" }); + const { agentId } = req.params; + const { ts, signature } = req.body ?? {}; + if (!ts || !signature) return reply.code(400).send({ error: "Invalid heartbeat" }); const skew = Math.abs(Date.now() - ts); if (skew > 5 * 60 * 1000) return reply.code(400).send({ error: "Timestamp out of range" }); @@ -697,11 +790,55 @@ export async function createGatewayApp(opts = {}) { return { ok: true }; }); - peer.post("/peer/message", { + peer.post("/worlds/:worldId/heartbeat", { + schema: { + summary: "World server liveness heartbeat", + operationId: "postWorldHeartbeat", + tags: ["gateway"], + description: "Updates a world server's lastSeen without a full re-announce.", + params: { + type: "object", + required: ["worldId"], + properties: { worldId: { type: "string" } }, + }, + body: { $ref: "HeartbeatRequest#" }, + response: { + 200: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, + 400: { $ref: "Error#" }, + 403: { $ref: "Error#" }, + 404: { $ref: "Error#" }, + }, + }, + }, async (req, reply) => { + const { worldId } = req.params; + const { ts, signature } = req.body ?? {}; + if (!ts || !signature) return reply.code(400).send({ error: "Invalid heartbeat" }); + + const skew = Math.abs(Date.now() - ts); + if (skew > 5 * 60 * 1000) return reply.code(400).send({ error: "Timestamp out of range" }); + + const worlds = findByCapability(`world:${worldId}`); + if (!worlds.length) return reply.code(404).send({ error: "World not found" }); + const existing = worlds[0]; + + const ok = verifyWithDomainSeparator( + DOMAIN_SEPARATORS.HEARTBEAT, + existing.publicKey, + { worldId, ts }, + signature + ); + if (!ok) return reply.code(403).send({ error: "Invalid signature" }); + + existing.lastSeen = Date.now(); + _registryModifiedAt = existing.lastSeen; + return { ok: true }; + }); + + peer.post("/messages", { schema: { summary: "Inbound signed message (world.state broadcasts)", - operationId: "postMessage", - tags: ["peer"], + operationId: "postMessages", + tags: ["gateway"], description: "Receives Ed25519-signed messages from world servers.", body: { $ref: "SignedMessage#" }, response: { From 4fff9cf331eedbf75249b90a10a335ab8f2d3043 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:12:09 +0800 Subject: [PATCH 2/6] refactor: update SDK gateway-announce to use new /agents and heartbeat endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - announceToGateway: /peer/announce → /agents - sendHeartbeat: /peer/heartbeat → /agents/:agentId/heartbeat for regular agents and /worlds/:worldId/heartbeat for world servers (detected via capabilities) - heartbeat body no longer includes agentId/worldId (moved to path) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../agent-world-sdk/src/gateway-announce.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/agent-world-sdk/src/gateway-announce.ts b/packages/agent-world-sdk/src/gateway-announce.ts index cac6741..e1a921a 100644 --- a/packages/agent-world-sdk/src/gateway-announce.ts +++ b/packages/agent-world-sdk/src/gateway-announce.ts @@ -33,7 +33,7 @@ export async function announceToGateway( peerDb, } = opts; - const url = `${gatewayUrl.replace(/\/+$/, "")}/peer/announce`; + const url = `${gatewayUrl.replace(/\/+$/, "")}/agents`; const endpoints = publicAddr ? [ @@ -106,27 +106,40 @@ export async function announceToGateway( /** * Send a lightweight heartbeat to a gateway. + * World servers use POST /worlds/:worldId/heartbeat (signature covers { worldId, ts }). + * Regular agents use POST /agents/:agentId/heartbeat (signature covers { agentId, ts }). * Returns true if the gateway accepted it, false if it responded with - * 404/403 (agent unknown or key mismatch — caller should re-announce). + * 404/403 (unknown or key mismatch — caller should re-announce). * Network errors return true (no re-announce needed, gateway is just unreachable). */ export async function sendHeartbeat( gatewayUrl: string, - identity: Identity + identity: Identity, + opts: { worldId?: string } = {} ): Promise { - const url = `${gatewayUrl.replace(/\/+$/, "")}/peer/heartbeat`; + const base = gatewayUrl.replace(/\/+$/, ""); const ts = Date.now(); - const payload = { agentId: identity.agentId, ts }; + let url: string; + let signable: Record; + + if (opts.worldId) { + url = `${base}/worlds/${encodeURIComponent(opts.worldId)}/heartbeat`; + signable = { worldId: opts.worldId, ts }; + } else { + url = `${base}/agents/${encodeURIComponent(identity.agentId)}/heartbeat`; + signable = { agentId: identity.agentId, ts }; + } + const signature = signWithDomainSeparator( DOMAIN_SEPARATORS.HEARTBEAT, - payload, + signable, identity.secretKey ); try { const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...payload, signature }), + body: JSON.stringify({ ts, signature }), signal: AbortSignal.timeout(5_000), }); if (resp.status === 404 || resp.status === 403) return false; @@ -167,9 +180,12 @@ export async function startGatewayAnnounce(opts: GatewayAnnounceOpts): Promise<( onDiscovery?.(opts.peerDb.size); } + const worldCap = opts.capabilities.find((c) => c.startsWith("world:")); + const worldId = worldCap ? worldCap.slice("world:".length) : undefined; + async function runHeartbeat() { const results = await Promise.allSettled( - urls.map(async (u) => ({ url: u, ok: await sendHeartbeat(u, opts.identity) })) + urls.map(async (u) => ({ url: u, ok: await sendHeartbeat(u, opts.identity, { worldId }) })) ); // Re-announce to any gateway that rejected the heartbeat (404/403) const reannounce = results From e21b7bb8254ccc2fceed479727ccd5ad7618d645 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:12:13 +0800 Subject: [PATCH 3/6] fix: update plugin to use /worlds/:worldId gateway endpoint Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index c8e6c9d..e8f59df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -323,7 +323,7 @@ async function fetchGatewayWorldRecord(worldId: string): Promise<{ publicKey?: string } | null> { try { - const resp = await fetch(`${getGatewayUrl()}/world/${encodeURIComponent(worldId)}`, { + const resp = await fetch(`${getGatewayUrl()}/worlds/${encodeURIComponent(worldId)}`, { signal: AbortSignal.timeout(10_000), }) if (!resp.ok) return null From bed8793537e29d318c0ef989f4c814e3d54fe465 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:12:19 +0800 Subject: [PATCH 4/6] test: update gateway tests for renamed endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gateway-world-record: /world/ → /worlds/, /peer/announce → /agents - gateway-heartbeat: new /agents/:agentId/heartbeat and /worlds/:worldId/heartbeat tests - gateway-worlds: update OpenAPI path assertions - gateway-announce-default: update expected announce URL - index-lifecycle: update /world/arena → /worlds/arena mock URL Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- test/gateway-announce-default.test.mjs | 2 +- test/gateway-heartbeat.test.mjs | 115 +++++++++++++++++++++---- test/gateway-world-record.test.mjs | 82 +++++++++++++++--- test/gateway-worlds.test.mjs | 9 +- test/index-lifecycle.test.mjs | 4 +- 5 files changed, 176 insertions(+), 36 deletions(-) diff --git a/test/gateway-announce-default.test.mjs b/test/gateway-announce-default.test.mjs index 98b9867..4c45794 100644 --- a/test/gateway-announce-default.test.mjs +++ b/test/gateway-announce-default.test.mjs @@ -65,7 +65,7 @@ test("startGatewayAnnounce defaults to the local gateway HTTP port", async () => startupTimers[0]() assert.equal(fetchCalls.length, 1) - assert.equal(fetchCalls[0].url, "http://localhost:8100/peer/announce") + assert.equal(fetchCalls[0].url, "http://localhost:8100/agents") stop() } finally { diff --git a/test/gateway-heartbeat.test.mjs b/test/gateway-heartbeat.test.mjs index 8e5ce31..d2da8d1 100644 --- a/test/gateway-heartbeat.test.mjs +++ b/test/gateway-heartbeat.test.mjs @@ -33,10 +33,17 @@ function signHeartbeat(kp) { const ts = Date.now() const payload = { agentId: kp.agentId, ts } const signature = signWithDomainSeparator(DOMAIN_SEPARATORS.HEARTBEAT, payload, kp.secretKey) - return { ...payload, signature } + return { ts, signature } } -describe("Gateway /peer/heartbeat", () => { +function signWorldHeartbeat(kp, worldId) { + const ts = Date.now() + const payload = { worldId, ts } + const signature = signWithDomainSeparator(DOMAIN_SEPARATORS.HEARTBEAT, payload, kp.secretKey) + return { ts, signature } +} + +describe("Gateway /agents/:agentId/heartbeat", () => { let tmpDir let app let stop @@ -54,7 +61,7 @@ describe("Gateway /peer/heartbeat", () => { it("returns 404 for unknown agent", async () => { const kp = makeKeypair() const hb = signHeartbeat(kp) - const resp = await app.inject({ method: "POST", url: "/peer/heartbeat", payload: hb }) + const resp = await app.inject({ method: "POST", url: `/agents/${kp.agentId}/heartbeat`, payload: hb }) assert.equal(resp.statusCode, 404) const body = JSON.parse(resp.body) assert.equal(body.error, "Unknown agent") @@ -65,7 +72,7 @@ describe("Gateway /peer/heartbeat", () => { // First announce so the agent exists const ann = signAnnounce(kp, "hb-sig-test") - const annResp = await app.inject({ method: "POST", url: "/peer/announce", payload: ann }) + const annResp = await app.inject({ method: "POST", url: "/agents", payload: ann }) assert.equal(annResp.statusCode, 200) // Send heartbeat with wrong signature (sign with ANNOUNCE separator instead of HEARTBEAT) @@ -75,8 +82,8 @@ describe("Gateway /peer/heartbeat", () => { const resp = await app.inject({ method: "POST", - url: "/peer/heartbeat", - payload: { ...payload, signature: wrongSig }, + url: `/agents/${kp.agentId}/heartbeat`, + payload: { ts, signature: wrongSig }, }) assert.equal(resp.statusCode, 403) const body = JSON.parse(resp.body) @@ -84,7 +91,8 @@ describe("Gateway /peer/heartbeat", () => { }) it("returns 400 for missing fields", async () => { - const resp = await app.inject({ method: "POST", url: "/peer/heartbeat", payload: {} }) + const kp = makeKeypair() + const resp = await app.inject({ method: "POST", url: `/agents/${kp.agentId}/heartbeat`, payload: {} }) assert.equal(resp.statusCode, 400) }) @@ -98,8 +106,8 @@ describe("Gateway /peer/heartbeat", () => { const resp = await app.inject({ method: "POST", - url: "/peer/heartbeat", - payload: { ...payload, signature }, + url: `/agents/${kp.agentId}/heartbeat`, + payload: { ts: staleTs, signature }, }) assert.equal(resp.statusCode, 400) const body = JSON.parse(resp.body) @@ -111,11 +119,11 @@ describe("Gateway /peer/heartbeat", () => { // Announce const ann = signAnnounce(kp, "hb-lastseen") - await app.inject({ method: "POST", url: "/peer/announce", payload: ann }) + await app.inject({ method: "POST", url: "/agents", payload: ann }) // Record initial lastSeen const before = JSON.parse( - (await app.inject({ method: "GET", url: "/world/hb-lastseen" })).body + (await app.inject({ method: "GET", url: "/worlds/hb-lastseen" })).body ) const initialLastSeen = before.lastSeen @@ -124,19 +132,90 @@ describe("Gateway /peer/heartbeat", () => { // Heartbeat const hb = signHeartbeat(kp) - const resp = await app.inject({ method: "POST", url: "/peer/heartbeat", payload: hb }) + const resp = await app.inject({ method: "POST", url: `/agents/${kp.agentId}/heartbeat`, payload: hb }) assert.equal(resp.statusCode, 200) const body = JSON.parse(resp.body) assert.equal(body.ok, true) // Verify lastSeen updated const afterResp = JSON.parse( - (await app.inject({ method: "GET", url: "/world/hb-lastseen" })).body + (await app.inject({ method: "GET", url: "/worlds/hb-lastseen" })).body ) assert.ok(afterResp.lastSeen >= initialLastSeen, "lastSeen should be updated after heartbeat") }) }) +describe("Gateway /worlds/:worldId/heartbeat", () => { + let tmpDir + let app + let stop + + before(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gateway-world-hb-")) + ;({ app, stop } = await createGatewayApp({ dataDir: tmpDir, staleTtlMs: 90_000 })) + }) + + after(async () => { + await stop() + fs.rmSync(tmpDir, { recursive: true }) + }) + + it("returns 404 for unknown world", async () => { + const kp = makeKeypair() + const hb = signWorldHeartbeat(kp, "unknown-world") + const resp = await app.inject({ method: "POST", url: "/worlds/unknown-world/heartbeat", payload: hb }) + assert.equal(resp.statusCode, 404) + const body = JSON.parse(resp.body) + assert.equal(body.error, "World not found") + }) + + it("returns 403 for invalid signature", async () => { + const kp = makeKeypair() + const worldId = "world-hb-sig-test" + + const ann = signAnnounce(kp, worldId) + const annResp = await app.inject({ method: "POST", url: "/agents", payload: ann }) + assert.equal(annResp.statusCode, 200) + + const ts = Date.now() + const wrongSig = signWithDomainSeparator(DOMAIN_SEPARATORS.ANNOUNCE, { worldId, ts }, kp.secretKey) + + const resp = await app.inject({ + method: "POST", + url: `/worlds/${worldId}/heartbeat`, + payload: { ts, signature: wrongSig }, + }) + assert.equal(resp.statusCode, 403) + const body = JSON.parse(resp.body) + assert.equal(body.error, "Invalid signature") + }) + + it("updates lastSeen in registry", async () => { + const kp = makeKeypair() + const worldId = "world-hb-lastseen" + + const ann = signAnnounce(kp, worldId) + await app.inject({ method: "POST", url: "/agents", payload: ann }) + + const before = JSON.parse( + (await app.inject({ method: "GET", url: `/worlds/${worldId}` })).body + ) + const initialLastSeen = before.lastSeen + + await new Promise((r) => setTimeout(r, 10)) + + const hb = signWorldHeartbeat(kp, worldId) + const resp = await app.inject({ method: "POST", url: `/worlds/${worldId}/heartbeat`, payload: hb }) + assert.equal(resp.statusCode, 200) + assert.equal(JSON.parse(resp.body).ok, true) + + const after = JSON.parse( + (await app.inject({ method: "GET", url: `/worlds/${worldId}` })).body + ) + assert.ok(after.lastSeen >= initialLastSeen, "lastSeen should be updated after world heartbeat") + }) +}) + describe("Gateway stale TTL at 90s", () => { let tmpDir let app @@ -156,10 +235,10 @@ describe("Gateway stale TTL at 90s", () => { it("prunes agents after stale TTL", async () => { const kp = makeKeypair() const ann = signAnnounce(kp, "ttl-test") - await app.inject({ method: "POST", url: "/peer/announce", payload: ann }) + await app.inject({ method: "POST", url: "/agents", payload: ann }) // Agent should be visible - let resp = await app.inject({ method: "GET", url: "/world/ttl-test" }) + let resp = await app.inject({ method: "GET", url: "/worlds/ttl-test" }) assert.equal(resp.statusCode, 200) // Wait for TTL to expire @@ -183,19 +262,19 @@ describe("Gateway stale TTL at 90s", () => { try { const kp = makeKeypair() const ann = signAnnounce(kp, "keep-alive") - await app2.inject({ method: "POST", url: "/peer/announce", payload: ann }) + await app2.inject({ method: "POST", url: "/agents", payload: ann }) // Send heartbeat before TTL expires await new Promise((r) => setTimeout(r, 100)) const hb = signHeartbeat(kp) - const resp = await app2.inject({ method: "POST", url: "/peer/heartbeat", payload: hb }) + const resp = await app2.inject({ method: "POST", url: `/agents/${kp.agentId}/heartbeat`, payload: hb }) assert.equal(resp.statusCode, 200) // Wait a bit more but not past TTL from last heartbeat await new Promise((r) => setTimeout(r, 50)) // Agent should still be visible - const worldResp = await app2.inject({ method: "GET", url: "/world/keep-alive" }) + const worldResp = await app2.inject({ method: "GET", url: "/worlds/keep-alive" }) assert.equal(worldResp.statusCode, 200, "Agent should still be visible after heartbeat") } finally { await stop2() diff --git a/test/gateway-world-record.test.mjs b/test/gateway-world-record.test.mjs index 9066def..98ace15 100644 --- a/test/gateway-world-record.test.mjs +++ b/test/gateway-world-record.test.mjs @@ -16,7 +16,7 @@ function makeKeypair() { return { publicKey: pubB64, secretKey: kp.secretKey, agentId: agentIdFromPublicKey(pubB64) } } -describe("Gateway /world/:worldId", () => { +describe("Gateway /worlds/:worldId", () => { let tmpDir let app let stop @@ -43,44 +43,106 @@ describe("Gateway /world/:worldId", () => { const signature = signWithDomainSeparator(DOMAIN_SEPARATORS.ANNOUNCE, payload, kp.secretKey) return app.inject({ method: "POST", - url: "/peer/announce", + url: "/agents", payload: { ...payload, signature }, }) } - it("GET /world/:worldId returns 404 for unknown world", async () => { - const resp = await app.inject({ method: "GET", url: "/world/nonexistent" }) + it("GET /worlds/:worldId returns 404 for unknown world", async () => { + const resp = await app.inject({ method: "GET", url: "/worlds/nonexistent" }) assert.equal(resp.statusCode, 404) }) - it("GET /world/:worldId includes publicKey after announce", async () => { + it("GET /worlds/:worldId includes publicKey after announce", async () => { const kp = makeKeypair() const worldId = "pixel-city" const annResp = await announce(kp, worldId) assert.equal(annResp.statusCode, 200, `announce failed: ${annResp.body}`) - const resp = await app.inject({ method: "GET", url: `/world/${worldId}` }) + const resp = await app.inject({ method: "GET", url: `/worlds/${worldId}` }) assert.equal(resp.statusCode, 200) const body = JSON.parse(resp.body) assert.equal(body.worldId, worldId) - assert.equal(body.publicKey, kp.publicKey, "publicKey must be present in /world/:worldId response") + assert.equal(body.publicKey, kp.publicKey, "publicKey must be present in /worlds/:worldId response") assert.equal(body.agentId, kp.agentId) }) - it("GET /world/:worldId publicKey matches the announcing agent", async () => { + it("GET /worlds/:worldId publicKey matches the announcing agent", async () => { const kp1 = makeKeypair() const kp2 = makeKeypair() await announce(kp1, "arena-alpha") await announce(kp2, "arena-beta") - const r1 = JSON.parse((await app.inject({ method: "GET", url: "/world/arena-alpha" })).body) - const r2 = JSON.parse((await app.inject({ method: "GET", url: "/world/arena-beta" })).body) + const r1 = JSON.parse((await app.inject({ method: "GET", url: "/worlds/arena-alpha" })).body) + const r2 = JSON.parse((await app.inject({ method: "GET", url: "/worlds/arena-beta" })).body) assert.equal(r1.publicKey, kp1.publicKey) assert.equal(r2.publicKey, kp2.publicKey) assert.notEqual(r1.publicKey, r2.publicKey) }) + + it("DELETE /worlds/:worldId returns 404 for unknown world", async () => { + const resp = await app.inject({ method: "DELETE", url: "/worlds/nonexistent-delete" }) + assert.equal(resp.statusCode, 404) + }) + + it("DELETE /worlds/: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: `/worlds/${worldId}` }) + assert.equal(before.statusCode, 200) + + const del = await app.inject({ method: "DELETE", url: `/worlds/${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: `/worlds/${worldId}` }) + assert.equal(after.statusCode, 404) + }) + + it("DELETE /worlds/: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: `/worlds/${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 /worlds/: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: `/worlds/${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 + } + }) }) diff --git a/test/gateway-worlds.test.mjs b/test/gateway-worlds.test.mjs index c29c08c..4e4c6aa 100644 --- a/test/gateway-worlds.test.mjs +++ b/test/gateway-worlds.test.mjs @@ -78,8 +78,8 @@ describe("Gateway GET /worlds", () => { const paths = Object.keys(spec.paths).sort() assert.ok(paths.includes("/worlds"), "must include /worlds") assert.ok(paths.includes("/health"), "must include /health") - assert.ok(paths.includes("/peer/announce"), "must include /peer/announce") - assert.ok(paths.includes("/peer/heartbeat"), "must include /peer/heartbeat") + assert.ok(paths.includes("/agents"), "must include /agents") + assert.ok(paths.includes("/messages"), "must include /messages") const schemas = Object.keys(spec.components?.schemas ?? {}).sort() assert.ok(schemas.includes("WorldSummary"), "must include WorldSummary schema") @@ -87,9 +87,8 @@ describe("Gateway GET /worlds", () => { assert.ok(schemas.includes("PeerRecord"), "must include PeerRecord schema") for (const [route, schemaName] of [ - ["/peer/announce", "AnnounceRequest"], - ["/peer/heartbeat", "HeartbeatRequest"], - ["/peer/message", "SignedMessage"], + ["/agents", "AnnounceRequest"], + ["/messages", "SignedMessage"], ]) { const post = spec.paths[route]?.post assert.ok(post, `${route} POST must exist`) diff --git a/test/index-lifecycle.test.mjs b/test/index-lifecycle.test.mjs index fcef2be..7a8265c 100644 --- a/test/index-lifecycle.test.mjs +++ b/test/index-lifecycle.test.mjs @@ -336,7 +336,7 @@ describe("plugin lifecycle", () => { }), } } - if (requestUrl.endsWith("/world/arena")) { + if (requestUrl.endsWith("/worlds/arena")) { return { ok: true, status: 200, @@ -371,7 +371,7 @@ describe("plugin lifecycle", () => { const joinCall = harness.sendCalls.find((call) => call.event === "world.join") assert.equal(joinCall?.targetAddr, "203.0.113.10") - assert.ok(harness.fetchCalls.some(([requestUrl]) => String(requestUrl).endsWith("/world/arena"))) + assert.ok(harness.fetchCalls.some(([requestUrl]) => String(requestUrl).endsWith("/worlds/arena"))) const worldPeer = harness.peers.get(worldAgentId) assert.ok(worldPeer) From 76a7e5130797c0605ff74b74f93435b053118136 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:12:25 +0800 Subject: [PATCH 5/6] chore: add changeset for gateway API rename Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .changeset/gateway-api-rename.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gateway-api-rename.md diff --git a/.changeset/gateway-api-rename.md b/.changeset/gateway-api-rename.md new file mode 100644 index 0000000..4fd5f62 --- /dev/null +++ b/.changeset/gateway-api-rename.md @@ -0,0 +1,5 @@ +--- +"@resciencelab/agent-world-network": minor +--- + +Rename gateway HTTP endpoints to resource-oriented paths: /peer/* routes replaced by /agents, /messages, /ping; /world/:worldId corrected to /worlds/:worldId; added GET /agents/:agentId, DELETE /agents/:agentId, and separate POST /worlds/:worldId/heartbeat for world servers From 50a73b2e8845f1cec694f5ad1307b374137d1d22 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 16:25:42 +0800 Subject: [PATCH 6/6] fix: persist registry on delete + update web/docs to /worlds/:worldId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /worlds/:worldId and DELETE /agents/:agentId now call flushRegistry() so deregistrations survive restarts - web/client.js: /world/ → /worlds/ (viewWorldInfo was hitting removed route) - docs/index.html: /world/ → /worlds/ (API docs text) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/index.html | 2 +- gateway/server.mjs | 2 ++ web/client.js | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/index.html b/docs/index.html index ed475c6..7712a6e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -37,7 +37,7 @@

Gateway Worlds

API

Machine-readable world list: GET /worlds on your configured Gateway.

-

World details: GET /world/<worldId> on the same Gateway.

+

World details: GET /worlds/<worldId> on the same Gateway.

Quick Start

# Install AWN
diff --git a/gateway/server.mjs b/gateway/server.mjs
index 2cd6d8b..df2adbb 100644
--- a/gateway/server.mjs
+++ b/gateway/server.mjs
@@ -557,6 +557,7 @@ export async function createGatewayApp(opts = {}) {
       removed++;
     }
     _registryModifiedAt = Date.now();
+    flushRegistry();
     console.log(`[gateway] Deregistered world:${worldId} (${removed} agent(s) removed)`);
     return { ok: true, removed };
   });
@@ -615,6 +616,7 @@ export async function createGatewayApp(opts = {}) {
     if (!registry.has(agentId)) return reply.code(404).send({ error: "Agent not found" });
     registry.delete(agentId);
     _registryModifiedAt = Date.now();
+    flushRegistry();
     console.log(`[gateway] Deregistered agent:${agentId}`);
     return { ok: true };
   });
diff --git a/web/client.js b/web/client.js
index 15cd45b..648dfe7 100644
--- a/web/client.js
+++ b/web/client.js
@@ -127,7 +127,7 @@ window.connectToWorld = function(worldId) {
 
 window.viewWorldInfo = async function(worldId) {
   try {
-    const resp = await fetch(`${GATEWAY}/world/${worldId}`, { signal: AbortSignal.timeout(5_000) });
+    const resp = await fetch(`${GATEWAY}/worlds/${worldId}`, { signal: AbortSignal.timeout(5_000) });
     const data = await resp.json();
     alert(JSON.stringify(data, null, 2));
   } catch (e) {