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 @@
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.
# 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) {