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..bf083b2 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,67 @@ 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) => { + // 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", + 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.", + body: { $ref: "AnnounceRequest#" }, + 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 +660,21 @@ 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.", + 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 { agentId, ts, signature } = req.body ?? {}; if (!agentId || !ts || !signature) return reply.code(400).send({ error: "Invalid heartbeat" }); @@ -529,7 +697,20 @@ 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.", + body: { $ref: "SignedMessage#" }, + 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..c29c08c 100644 --- a/test/gateway-worlds.test.mjs +++ b/test/gateway-worlds.test.mjs @@ -66,4 +66,35 @@ 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") + + 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}`) + } + }) })