From 68833981bf19b982004af3f78aa2fc637792a069 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 14:17:44 +0800 Subject: [PATCH 1/2] feat: auto-generate OpenAPI spec from route schemas Replace manual gateway/openapi.yaml with @fastify/swagger auto-generation. All 10 routes now have inline JSON Schema definitions that Fastify uses for both response validation and OpenAPI spec generation. - Add @fastify/swagger + @fastify/swagger-ui dependencies - Extract reusable schemas into gateway/schemas.mjs - Add inline schema to all gateway and peer routes - Serve Swagger UI at /docs and JSON spec at /docs/json - Remove hand-maintained openapi.yaml - Update Dockerfile to include schemas.mjs - Add /docs/json spec test --- .changeset/gateway-auto-openapi.md | 5 + gateway/Dockerfile | 2 +- gateway/openapi.yaml | 446 ----------------------------- gateway/package-lock.json | 363 ++++++++++++++++++++++- gateway/package.json | 2 + gateway/schemas.mjs | 120 ++++++++ gateway/server.mjs | 194 ++++++++++++- test/gateway-worlds.test.mjs | 20 ++ 8 files changed, 684 insertions(+), 468 deletions(-) create mode 100644 .changeset/gateway-auto-openapi.md delete mode 100644 gateway/openapi.yaml create mode 100644 gateway/schemas.mjs diff --git a/.changeset/gateway-auto-openapi.md b/.changeset/gateway-auto-openapi.md new file mode 100644 index 0000000..0496e8c --- /dev/null +++ b/.changeset/gateway-auto-openapi.md @@ -0,0 +1,5 @@ +--- +"@resciencelab/agent-world-network": patch +--- + +Auto-generate OpenAPI spec from route schemas using @fastify/swagger, serve Swagger UI at /docs diff --git a/gateway/Dockerfile b/gateway/Dockerfile index 259585b..e69fb73 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -15,7 +15,7 @@ COPY gateway/package*.json ./ # Pre-built SDK at the path file:../packages/agent-world-sdk resolves to COPY --from=sdk-builder /app/packages/agent-world-sdk /app/packages/agent-world-sdk RUN npm install --production -COPY gateway/server.mjs ./ +COPY gateway/server.mjs gateway/schemas.mjs ./ RUN mkdir -p /data ENV HTTP_PORT=8100 diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml deleted file mode 100644 index 3859a67..0000000 --- a/gateway/openapi.yaml +++ /dev/null @@ -1,446 +0,0 @@ -openapi: 3.1.0 -info: - title: AWN Gateway - description: | - Agent World Network Gateway — stateless portal + WebSocket bridge. - World Servers announce directly via POST /peer/announce and stay alive - with periodic POST /peer/heartbeat signals. - - **WebSocket** — `ws://{host}/ws?world={worldId}` subscribes to a world's - real-time events (world.state broadcasts, join/leave/action messages). - version: 0.5.0 - license: - name: MIT - -servers: - - url: http://localhost:8100 - description: Local development - -paths: - /health: - get: - summary: Health check - operationId: getHealth - responses: - "200": - description: Gateway health status - content: - application/json: - schema: - type: object - required: [ok, ts, agentId, agents, worlds, status] - properties: - ok: - type: boolean - ts: - type: integer - description: Unix timestamp (ms) - agentId: - type: string - agents: - type: integer - description: Number of known agents - worlds: - type: integer - description: Number of discovered worlds - registryAge: - type: ["integer", "null"] - description: Milliseconds since last registry modification - status: - type: string - enum: [ready, warming, empty] - - /worlds: - get: - summary: List discovered worlds - operationId: getWorlds - responses: - "200": - description: Array of discovered worlds - content: - application/json: - schema: - type: object - required: [worlds] - properties: - worlds: - type: array - items: - $ref: "#/components/schemas/WorldSummary" - - /world/{worldId}: - get: - summary: Get info about a specific world - operationId: getWorld - parameters: - - name: worldId - in: path - required: true - schema: - type: string - responses: - "200": - description: World details - content: - application/json: - schema: - $ref: "#/components/schemas/WorldDetail" - "404": - description: World not found - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /agents: - get: - summary: List all known AWN agents - operationId: getAgents - responses: - "200": - description: Array of known agents - content: - application/json: - schema: - type: object - required: [agents] - properties: - agents: - type: array - items: - $ref: "#/components/schemas/PeerRecord" - - /peer/announce: - post: - summary: Register or re-announce a world server - operationId: postAnnounce - description: | - Ed25519-signed announcement from a world server. The gateway verifies - the domain-separated signature (DOMAIN_SEPARATORS.ANNOUNCE) or - AgentWorld HTTP request headers before upserting the agent record. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AnnounceRequest" - responses: - "200": - description: Announce accepted - content: - application/json: - schema: - type: object - required: [ok, peers] - properties: - ok: - type: boolean - peers: - type: array - items: - $ref: "#/components/schemas/PeerRecord" - "400": - description: Invalid announce payload or agentId mismatch - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Signature verification failed - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /peer/heartbeat: - post: - summary: Lightweight liveness heartbeat - operationId: postHeartbeat - description: | - Lightweight signal that updates an agent's `lastSeen` timestamp - without a full re-announce. Signed with DOMAIN_SEPARATORS.HEARTBEAT. - Does not persist to disk — memory-only update. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/HeartbeatRequest" - responses: - "200": - description: Heartbeat accepted - content: - application/json: - schema: - type: object - required: [ok] - properties: - ok: - type: boolean - "400": - description: Invalid heartbeat payload - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Signature verification failed - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "404": - description: Agent not found in registry - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /peer/ping: - get: - summary: Peer liveness check - operationId: getPeerPing - responses: - "200": - description: Pong - content: - application/json: - schema: - type: object - required: [ok, ts, role] - properties: - ok: - type: boolean - ts: - type: integer - role: - type: string - enum: [gateway] - - /peer/peers: - get: - summary: Exchange known peers - operationId: getPeerPeers - responses: - "200": - description: Known peers - content: - application/json: - schema: - type: object - required: [peers] - properties: - peers: - type: array - items: - $ref: "#/components/schemas/PeerRecord" - - /peer/message: - post: - summary: Inbound signed message (world.state broadcasts) - operationId: postMessage - description: | - Receives Ed25519-signed messages from world servers. Currently handles - `world.state` events by broadcasting them to WebSocket subscribers. - Verified via AgentWorld HTTP request headers or legacy body signature. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SignedMessage" - responses: - "200": - description: Message accepted - content: - application/json: - schema: - type: object - required: [ok] - properties: - ok: - type: boolean - "400": - description: Invalid message payload - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - "403": - description: Signature verification failed - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /.well-known/agent.json: - get: - summary: AgentWorld agent card - operationId: getAgentCard - description: | - Returns a JWS-signed Agent Card for the gateway. Only available - when PUBLIC_URL is configured. - responses: - "200": - description: Signed agent card - content: - application/json: - schema: - type: object - -components: - schemas: - Error: - type: object - required: [error] - properties: - error: - type: string - - Endpoint: - type: object - required: [transport, address, port, priority] - properties: - transport: - type: string - enum: [tcp] - address: - type: string - port: - type: integer - priority: - type: integer - ttl: - type: integer - - PeerRecord: - type: object - required: [agentId, publicKey, alias, endpoints, capabilities, lastSeen] - properties: - agentId: - type: string - description: "aw:sha256:{hex} agent identifier" - publicKey: - type: string - description: Base64-encoded Ed25519 public key - alias: - type: string - endpoints: - type: array - items: - $ref: "#/components/schemas/Endpoint" - capabilities: - type: array - items: - type: string - lastSeen: - type: integer - description: Unix timestamp (ms) of last announce or heartbeat - - WorldSummary: - type: object - required: [worldId, agentId, name, endpoints, reachable, lastSeen] - properties: - worldId: - type: string - agentId: - type: string - name: - type: string - endpoints: - type: array - items: - $ref: "#/components/schemas/Endpoint" - reachable: - type: boolean - lastSeen: - type: integer - - WorldDetail: - type: object - required: [worldId, agentId, publicKey, name, endpoints, reachable, subscribers, lastSeen] - properties: - worldId: - type: string - agentId: - type: string - publicKey: - type: string - name: - type: string - endpoints: - type: array - items: - $ref: "#/components/schemas/Endpoint" - reachable: - type: boolean - subscribers: - type: integer - description: Number of active WebSocket subscribers - lastSeen: - type: integer - - AnnounceRequest: - type: object - required: [from, publicKey, alias, endpoints, capabilities, timestamp, signature] - properties: - from: - type: string - description: "aw:sha256:{hex} agent identifier" - publicKey: - type: string - description: Base64-encoded Ed25519 public key - alias: - type: string - version: - type: string - default: "1.0.0" - endpoints: - type: array - items: - $ref: "#/components/schemas/Endpoint" - capabilities: - type: array - items: - type: string - timestamp: - type: integer - signature: - type: string - description: Domain-separated Ed25519 signature (ANNOUNCE context) - - HeartbeatRequest: - type: object - required: [agentId, 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) - - SignedMessage: - type: object - required: [from, publicKey, event, content, timestamp, signature] - properties: - from: - type: string - description: "aw:sha256:{hex} sender agent identifier" - publicKey: - type: string - description: Base64-encoded Ed25519 public key - event: - type: string - description: Message event type (e.g. "world.state") - content: - type: string - description: JSON-encoded message content - timestamp: - type: integer - signature: - type: string - description: Ed25519 signature over canonical payload diff --git a/gateway/package-lock.json b/gateway/package-lock.json index c2979f3..9850f58 100644 --- a/gateway/package-lock.json +++ b/gateway/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@fastify/cors": "^10.0.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", "@fastify/websocket": "^11.0.0", "@resciencelab/agent-world-sdk": "file:../packages/agent-world-sdk", "fastify": "^5.0.0", @@ -17,7 +19,7 @@ }, "../packages/agent-world-sdk": { "name": "@resciencelab/agent-world-sdk", - "version": "0.1.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "fastify": "^5.0.0", @@ -29,6 +31,22 @@ "typescript": "^5.3.3" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", @@ -160,6 +178,99 @@ "ipaddr.js": "^2.1.0" } }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz", + "integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/swagger": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz", + "integrity": "sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, + "node_modules/@fastify/swagger-ui": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-5.2.5.tgz", + "integrity": "sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/static": "^9.0.0", + "fastify-plugin": "^5.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.1" + } + }, "node_modules/@fastify/websocket": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz", @@ -181,6 +292,15 @@ "ws": "^8.16.0" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -259,6 +379,40 @@ "fastq": "^1.17.1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -272,6 +426,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -302,6 +482,12 @@ "once": "^1.4.0" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -435,6 +621,43 @@ "node": ">=20" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -450,15 +673,6 @@ "node": ">= 10" } }, - "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -478,6 +692,23 @@ "dequal": "^2.0.3" } }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -521,6 +752,51 @@ ], "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mnemonist": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", @@ -530,6 +806,12 @@ "obliterator": "^2.0.4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", @@ -554,6 +836,28 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -752,6 +1056,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -770,6 +1080,15 @@ "node": ">= 10.x" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -806,6 +1125,15 @@ "node": ">=12" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -844,6 +1172,21 @@ "optional": true } } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } } } } diff --git a/gateway/package.json b/gateway/package.json index 2c3c4d5..85df0be 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -9,6 +9,8 @@ }, "dependencies": { "@fastify/cors": "^10.0.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", "@fastify/websocket": "^11.0.0", "@resciencelab/agent-world-sdk": "file:../packages/agent-world-sdk", "fastify": "^5.0.0", diff --git a/gateway/schemas.mjs b/gateway/schemas.mjs new file mode 100644 index 0000000..bc60991 --- /dev/null +++ b/gateway/schemas.mjs @@ -0,0 +1,120 @@ +// Reusable JSON Schema definitions for the AWN Gateway OpenAPI spec. +// Used by @fastify/swagger to auto-generate the spec at /docs. + +export const ErrorSchema = { + $id: "Error", + type: "object", + required: ["error"], + properties: { + error: { type: "string" }, + }, +} + +export const EndpointSchema = { + $id: "Endpoint", + type: "object", + required: ["transport", "address", "port", "priority"], + properties: { + transport: { type: "string", enum: ["tcp"] }, + address: { type: "string" }, + port: { type: "integer" }, + priority: { type: "integer" }, + ttl: { type: "integer" }, + }, +} + +export const PeerRecordSchema = { + $id: "PeerRecord", + type: "object", + required: ["agentId", "publicKey", "alias", "endpoints", "capabilities", "lastSeen"], + properties: { + agentId: { type: "string", description: "aw:sha256:{hex} agent identifier" }, + publicKey: { type: "string", description: "Base64-encoded Ed25519 public key" }, + alias: { type: "string" }, + endpoints: { type: "array", items: { $ref: "Endpoint#" } }, + capabilities: { type: "array", items: { type: "string" } }, + lastSeen: { type: "integer", description: "Unix timestamp (ms) of last announce or heartbeat" }, + }, +} + +export const WorldSummarySchema = { + $id: "WorldSummary", + type: "object", + required: ["worldId", "agentId", "name", "endpoints", "reachable", "lastSeen"], + properties: { + worldId: { type: "string" }, + agentId: { type: "string" }, + name: { type: "string" }, + endpoints: { type: "array", items: { $ref: "Endpoint#" } }, + reachable: { type: "boolean" }, + lastSeen: { type: "integer" }, + }, +} + +export const WorldDetailSchema = { + $id: "WorldDetail", + type: "object", + required: ["worldId", "agentId", "publicKey", "name", "endpoints", "reachable", "subscribers", "lastSeen"], + properties: { + worldId: { type: "string" }, + agentId: { type: "string" }, + publicKey: { type: "string" }, + name: { type: "string" }, + endpoints: { type: "array", items: { $ref: "Endpoint#" } }, + reachable: { type: "boolean" }, + subscribers: { type: "integer", description: "Number of active WebSocket subscribers" }, + lastSeen: { type: "integer" }, + }, +} + +export const AnnounceRequestSchema = { + $id: "AnnounceRequest", + type: "object", + required: ["from", "publicKey", "alias", "endpoints", "capabilities", "timestamp", "signature"], + properties: { + from: { type: "string", description: "aw:sha256:{hex} agent identifier" }, + publicKey: { type: "string", description: "Base64-encoded Ed25519 public key" }, + alias: { type: "string" }, + version: { type: "string", default: "1.0.0" }, + endpoints: { type: "array", items: { $ref: "Endpoint#" } }, + capabilities: { type: "array", items: { type: "string" } }, + timestamp: { type: "integer" }, + signature: { type: "string", description: "Domain-separated Ed25519 signature (ANNOUNCE context)" }, + }, +} + +export const HeartbeatRequestSchema = { + $id: "HeartbeatRequest", + type: "object", + required: ["agentId", "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)" }, + }, +} + +export const SignedMessageSchema = { + $id: "SignedMessage", + type: "object", + required: ["from", "publicKey", "event", "content", "timestamp", "signature"], + properties: { + from: { type: "string", description: "aw:sha256:{hex} sender agent identifier" }, + publicKey: { type: "string", description: "Base64-encoded Ed25519 public key" }, + event: { type: "string", description: 'Message event type (e.g. "world.state")' }, + content: { type: "string", description: "JSON-encoded message content" }, + timestamp: { type: "integer" }, + signature: { type: "string", description: "Ed25519 signature over canonical payload" }, + }, +} + +export const allSchemas = [ + ErrorSchema, + EndpointSchema, + PeerRecordSchema, + WorldSummarySchema, + WorldDetailSchema, + AnnounceRequestSchema, + HeartbeatRequestSchema, + SignedMessageSchema, +] diff --git a/gateway/server.mjs b/gateway/server.mjs index d691fe2..84109e2 100644 --- a/gateway/server.mjs +++ b/gateway/server.mjs @@ -38,6 +38,8 @@ import { fileURLToPath } from "node:url" import Fastify from "fastify" import websocketPlugin from "@fastify/websocket" import cors from "@fastify/cors" +import swagger from "@fastify/swagger" +import swaggerUi from "@fastify/swagger-ui" import nacl from "tweetnacl" import { agentIdFromPublicKey, @@ -326,7 +328,53 @@ export async function createGatewayApp(opts = {}) { await app.register(cors, { origin: true }); await app.register(websocketPlugin); - app.get("/health", async () => { + const { allSchemas } = await import("./schemas.mjs"); + await app.register(swagger, { + openapi: { + info: { + 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" + + "**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", + license: { name: "MIT" }, + }, + servers: [{ url: "http://localhost:8100", description: "Local development" }], + }, + refResolver: { + buildLocalReference(json) { + return json.$id || json.title || `def-${json.$id}` + }, + }, + }); + for (const s of allSchemas) app.addSchema(s); + await app.register(swaggerUi, { routePrefix: "/docs" }); + + app.get("/health", { + schema: { + summary: "Health check", + operationId: "getHealth", + tags: ["gateway"], + response: { + 200: { + type: "object", + required: ["ok", "ts", "agentId", "agents", "worlds", "status"], + properties: { + ok: { type: "boolean" }, + ts: { type: "integer", description: "Unix timestamp (ms)" }, + agentId: { type: "string" }, + agents: { type: "integer", description: "Number of known agents" }, + worlds: { type: "integer", description: "Number of discovered worlds" }, + registryAge: { type: ["integer", "null"], description: "Milliseconds since last registry modification" }, + status: { type: "string", enum: ["ready", "warming", "empty"] }, + }, + }, + }, + }, + }, async () => { const ts = Date.now() const worlds = findByCapability("world:").length const agents = registry.size @@ -347,7 +395,15 @@ export async function createGatewayApp(opts = {}) { }); let _cachedCardJson = null - app.get("/.well-known/agent.json", async (_req, reply) => { + app.get("/.well-known/agent.json", { + schema: { + summary: "AgentWorld agent card", + operationId: "getAgentCard", + tags: ["gateway"], + description: "Returns a JWS-signed Agent Card for the gateway.", + response: { 200: { type: "object" } }, + }, + }, async (_req, reply) => { if (!_cachedCardJson) { const cardUrl = publicUrl ? `${publicUrl.replace(/\/$/, "")}/.well-known/agent.json` @@ -362,11 +418,37 @@ export async function createGatewayApp(opts = {}) { reply.send(_cachedCardJson); }); - app.get("/agents", async () => ({ + app.get("/agents", { + schema: { + summary: "List all known AWN agents", + operationId: "getAgents", + tags: ["gateway"], + response: { + 200: { + type: "object", + required: ["agents"], + properties: { agents: { type: "array", items: { $ref: "PeerRecord#" } } }, + }, + }, + }, + }, async () => ({ agents: getAgentsForExchange(100), })); - app.get("/worlds", async () => { + app.get("/worlds", { + schema: { + summary: "List discovered worlds", + operationId: "getWorlds", + tags: ["gateway"], + response: { + 200: { + type: "object", + required: ["worlds"], + properties: { worlds: { type: "array", items: { $ref: "WorldSummary#" } } }, + }, + }, + }, + }, async () => { const worlds = findByCapability("world:"); return { worlds: worlds.map((w) => { @@ -384,7 +466,22 @@ export async function createGatewayApp(opts = {}) { }; }); - app.get("/world/:worldId", async (req, reply) => { + app.get("/world/:worldId", { + schema: { + summary: "Get info about a specific world", + operationId: "getWorld", + tags: ["gateway"], + params: { + type: "object", + required: ["worldId"], + properties: { worldId: { type: "string" } }, + }, + response: { + 200: { $ref: "WorldDetail#" }, + 404: { $ref: "Error#" }, + }, + }, + }, async (req, reply) => { const { worldId } = req.params; const worlds = findByCapability(`world:${worldId}`); if (!worlds.length) return reply.code(404).send({ error: "World not found" }); @@ -477,10 +574,60 @@ export async function createGatewayApp(opts = {}) { } }); - peer.get("/peer/ping", async () => ({ ok: true, ts: Date.now(), role: "gateway" })); - peer.get("/peer/peers", async () => ({ peers: getAgentsForExchange() })); - - peer.post("/peer/announce", async (req, reply) => { + peer.get("/peer/ping", { + 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.", + response: { + 200: { + type: "object", + required: ["ok", "peers"], + properties: { + ok: { type: "boolean" }, + peers: { type: "array", items: { $ref: "PeerRecord#" } }, + }, + }, + 400: { $ref: "Error#" }, + 403: { $ref: "Error#" }, + }, + }, + }, async (req, reply) => { const ann = req.body; if (!ann?.publicKey || !ann?.from) return reply.code(400).send({ error: "Invalid announce" }); @@ -506,7 +653,20 @@ export async function createGatewayApp(opts = {}) { return { ok: true, peers: getAgentsForExchange(20) }; }); - peer.post("/peer/heartbeat", async (req, reply) => { + peer.post("/peer/heartbeat", { + schema: { + summary: "Lightweight liveness heartbeat", + operationId: "postHeartbeat", + tags: ["peer"], + description: "Updates an agent's lastSeen without a full re-announce.", + response: { + 200: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, + 400: { $ref: "Error#" }, + 403: { $ref: "Error#" }, + 404: { $ref: "Error#" }, + }, + }, + }, async (req, reply) => { const { agentId, ts, signature } = req.body ?? {}; if (!agentId || !ts || !signature) return reply.code(400).send({ error: "Invalid heartbeat" }); @@ -529,7 +689,19 @@ export async function createGatewayApp(opts = {}) { return { ok: true }; }); - peer.post("/peer/message", async (req, reply) => { + peer.post("/peer/message", { + schema: { + summary: "Inbound signed message (world.state broadcasts)", + operationId: "postMessage", + tags: ["peer"], + description: "Receives Ed25519-signed messages from world servers.", + response: { + 200: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, + 400: { $ref: "Error#" }, + 403: { $ref: "Error#" }, + }, + }, + }, async (req, reply) => { const msg = req.body; if (!msg?.publicKey || !msg?.from) return reply.code(400).send({ error: "Invalid message" }); diff --git a/test/gateway-worlds.test.mjs b/test/gateway-worlds.test.mjs index ad46833..f3d6e1f 100644 --- a/test/gateway-worlds.test.mjs +++ b/test/gateway-worlds.test.mjs @@ -66,4 +66,24 @@ describe("Gateway GET /worlds", () => { assert.ok(Array.isArray(world.endpoints), `world ${world.worldId} must have endpoints array`) } }) + + it("GET /docs/json returns auto-generated OpenAPI spec with all routes", async () => { + const resp = await app.inject({ method: "GET", url: "/docs/json" }) + assert.equal(resp.statusCode, 200) + + const spec = JSON.parse(resp.body) + assert.equal(spec.info.title, "AWN Gateway") + assert.ok(spec.openapi.startsWith("3.")) + + 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") + + const schemas = Object.keys(spec.components?.schemas ?? {}).sort() + assert.ok(schemas.includes("WorldSummary"), "must include WorldSummary schema") + assert.ok(schemas.includes("Endpoint"), "must include Endpoint schema") + assert.ok(schemas.includes("PeerRecord"), "must include PeerRecord schema") + }) }) From a3e1bfbf45059a397ae03ec66d20703d035305e2 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 24 Mar 2026 14:28:22 +0800 Subject: [PATCH 2/2] fix: add requestBody schemas to POST peer routes Add body schema refs for /peer/announce, /peer/heartbeat, /peer/message so the auto-generated OpenAPI spec includes requestBody documentation. Disable body validation in the peer plugin scope to avoid interfering with the custom rawBody content parser used for signature verification. Add test assertion that POST operations reference their request schemas. --- gateway/server.mjs | 9 +++++++++ test/gateway-worlds.test.mjs | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/gateway/server.mjs b/gateway/server.mjs index 84109e2..bf083b2 100644 --- a/gateway/server.mjs +++ b/gateway/server.mjs @@ -574,6 +574,12 @@ export async function createGatewayApp(opts = {}) { } }); + // Skip body validation for peer routes — signature verification is the + // validation layer, and Fastify's schema validation would interfere with + // the custom content parser that preserves rawBody for signature checks. + const noValidate = () => () => true; + peer.setValidatorCompiler(noValidate); + peer.get("/peer/ping", { schema: { summary: "Peer liveness check", @@ -614,6 +620,7 @@ export async function createGatewayApp(opts = {}) { operationId: "postAnnounce", tags: ["peer"], description: "Ed25519-signed announcement from a world server.", + body: { $ref: "AnnounceRequest#" }, response: { 200: { type: "object", @@ -659,6 +666,7 @@ export async function createGatewayApp(opts = {}) { operationId: "postHeartbeat", tags: ["peer"], description: "Updates an agent's lastSeen without a full re-announce.", + body: { $ref: "HeartbeatRequest#" }, response: { 200: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, 400: { $ref: "Error#" }, @@ -695,6 +703,7 @@ export async function createGatewayApp(opts = {}) { operationId: "postMessage", tags: ["peer"], description: "Receives Ed25519-signed messages from world servers.", + body: { $ref: "SignedMessage#" }, response: { 200: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, 400: { $ref: "Error#" }, diff --git a/test/gateway-worlds.test.mjs b/test/gateway-worlds.test.mjs index f3d6e1f..c29c08c 100644 --- a/test/gateway-worlds.test.mjs +++ b/test/gateway-worlds.test.mjs @@ -85,5 +85,16 @@ describe("Gateway GET /worlds", () => { assert.ok(schemas.includes("WorldSummary"), "must include WorldSummary schema") assert.ok(schemas.includes("Endpoint"), "must include Endpoint schema") assert.ok(schemas.includes("PeerRecord"), "must include PeerRecord schema") + + for (const [route, schemaName] of [ + ["/peer/announce", "AnnounceRequest"], + ["/peer/heartbeat", "HeartbeatRequest"], + ["/peer/message", "SignedMessage"], + ]) { + const post = spec.paths[route]?.post + assert.ok(post, `${route} POST must exist`) + const ref = post.requestBody?.content?.["application/json"]?.schema?.$ref + assert.ok(ref && ref.includes(schemaName), `${route} requestBody must reference ${schemaName}`) + } }) })