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 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/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..df2adbb 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,107 @@ 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();
+    flushRegistry();
+    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();
+    flushRegistry();
+    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 +703,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 +749,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 +769,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 +792,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: {
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
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
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)
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) {