diff --git a/openapi.yaml b/openapi.yaml index 83c0174..bbdba3e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -223,6 +223,29 @@ components: minimum: 0 required: - expectedVersion + PredictionCountResponse: + type: object + properties: + data: + type: object + properties: + marketId: + type: string + count: + type: integer + minimum: 0 + computedAt: + type: string + format: date-time + cached: + type: boolean + required: + - marketId + - count + - computedAt + - cached + required: + - data LeaderboardEntry: type: object properties: @@ -236,6 +259,64 @@ components: - rank - stellarAddress - score + AnonRateLimitStatus: + type: object + properties: + data: + type: object + properties: + type: + type: string + enum: + - anonymous + clientIp: + type: string + limit: + type: integer + used: + type: integer + remaining: + type: integer + windowMs: + type: integer + resetAt: + type: string + format: date-time + required: + - type + - clientIp + - limit + - used + - remaining + - windowMs + - resetAt + required: + - data + AuthRateLimitStatus: + type: object + properties: + data: + type: object + properties: + type: + type: string + enum: + - authenticated + limit: + type: integer + windowMs: + type: integer + bypasses: + type: boolean + enum: + - true + required: + - type + - limit + - windowMs + - bypasses + required: + - data FeaturedMarket: type: object properties: @@ -636,6 +717,109 @@ components: - id - action - createdAt + PluginView: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + description: + type: string + nullable: true + enabled: + type: boolean + config: + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - id + - name + - description + - enabled + - createdAt + - updatedAt + CreatePluginRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 255 + description: + type: string + maxLength: 1000 + enabled: + type: boolean + config: + type: object + additionalProperties: + nullable: true + required: + - name + UpdatePluginRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 255 + description: + type: string + nullable: true + maxLength: 1000 + enabled: + type: boolean + config: + type: object + additionalProperties: + nullable: true + DeletePluginResult: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + required: + - id + - name + AdminRateLimitInspect: + type: object + properties: + address: + type: string + description: Target Stellar address + limit: + type: integer + description: Configured request cap in the window + used: + type: integer + description: Requests currently active in the window + remaining: + type: integer + description: Requests remaining in the current window + windowMs: + type: integer + description: Sliding-window length in milliseconds + resetAt: + type: string + format: date-time + description: ISO-8601 timestamp when the window resets + required: + - address + - limit + - used + - remaining + - windowMs + - resetAt CheckStatus: type: string enum: @@ -731,7 +915,6 @@ components: - rpc - checkedAt parameters: {} - paths: /health: get: @@ -949,6 +1132,26 @@ paths: $ref: '#/components/schemas/Market' required: - data + examples: + default: + value: + data: + - id: market-001 + question: Will the US win the 2026 FIFA World Cup? + status: active + metadata: + category: sports + resolutionSource: official + version: 1 + createdAt: '2026-01-10T12:00:00.000Z' + - id: market-002 + question: Will Stellar launch a new protocol upgrade in 2026? + status: active + metadata: + category: technology + resolutionSource: community + version: 2 + createdAt: '2026-02-14T07:30:00.000Z' /api/markets/search: get: operationId: searchMarkets @@ -998,6 +1201,34 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + /api/markets/tags: + get: + operationId: getMarketTags + tags: + - Markets + summary: Get market tags with counts + responses: + '200': + description: Market tags with counts + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + tag: + type: string + count: + type: number + required: + - tag + - count + required: + - data /api/markets/{id}: get: operationId: getMarketById @@ -1022,6 +1253,18 @@ paths: $ref: '#/components/schemas/Market' required: - data + examples: + default: + value: + data: + id: market-001 + question: Will the US win the 2026 FIFA World Cup? + status: active + metadata: + category: sports + resolutionSource: official + version: 1 + createdAt: '2026-01-10T12:00:00.000Z' '404': description: Not found content: @@ -1076,6 +1319,40 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + /api/markets/{id}/prediction-count: + get: + operationId: getMarketPredictionCount + tags: + - Markets + summary: Get total prediction count for a market + description: >- + Returns the total number of predictions placed on the given market. Results are cached in Redis for 60 seconds. + The `cached` field indicates whether the value came from the cache. + parameters: + - schema: + type: string + required: true + name: id + in: path + responses: + '200': + description: Prediction count + content: + application/json: + schema: + $ref: '#/components/schemas/PredictionCountResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '404': + description: Market not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' /api/leaderboard: get: operationId: getLeaderboard @@ -1168,6 +1445,21 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + /api/rate-limit/status: + get: + operationId: getRateLimitStatus + tags: + - Rate Limiting + summary: Get the current anonymous rate-limit status for the caller + responses: + '200': + description: Rate-limit status (type differs for anonymous vs authenticated callers) + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/AnonRateLimitStatus' + - $ref: '#/components/schemas/AuthRateLimitStatus' /api/markets/featured: get: operationId: getFeaturedMarkets @@ -1669,45 +1961,23 @@ paths: tags: - Users summary: List predictions for a Stellar address - description: | - Returns a cursor-paginated list of predictions for the given Stellar - address, ordered by `createdAt DESC, id DESC`. - - **Cursor pagination** - - Pass the opaque `nextCursor` value from a previous response back as the - `?cursor=` query parameter to fetch the next page. When `nextCursor` is - `null` you have reached the last page. - - Cursors are versioned base64url tokens. A cursor minted under a - different schema version (e.g. from before a migration) is silently - ignored and the response restarts from the first page rather than - returning an error or a wrong offset. parameters: - schema: type: string - pattern: '^G[A-Z2-7]{55}$' required: true name: address in: path - description: 56-character Stellar public key (G…) - schema: type: string enum: *ref_0 required: false name: status in: query - description: Filter by prediction status. - schema: type: string required: false name: cursor in: query - description: > - Opaque, versioned base64url pagination cursor returned as - `nextCursor` from the previous page. Omit or leave empty to start - from the first page. An invalid or version-mismatched cursor is - treated as absent (restarts from page one). - schema: type: integer minimum: 1 @@ -1716,7 +1986,6 @@ paths: required: false name: limit in: query - description: Maximum number of predictions to return (1–100, default 20). responses: '200': description: Paginated predictions @@ -1729,19 +1998,14 @@ paths: type: array items: $ref: '#/components/schemas/Prediction' - description: Predictions for this page, ordered by createdAt DESC. nextCursor: type: string nullable: true - description: > - Opaque cursor for the next page, or null when this is the - last page. Pass verbatim as `?cursor=` on the next - request. required: - data - nextCursor '400': - description: Invalid address or query parameters + description: Invalid address content: application/json: schema: @@ -2054,6 +2318,315 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + /api/admin/plugins: + get: + operationId: listAdminPlugins + tags: + - Admin + summary: List all plugins (admin only) + security: + - bearerAuth: [] + parameters: + - schema: + type: string + enum: + - 'true' + - 'false' + required: false + name: enabled + in: query + - schema: + type: integer + minimum: 1 + maximum: 200 + required: false + name: limit + in: query + - schema: + type: integer + nullable: true + minimum: 0 + required: false + name: offset + in: query + responses: + '200': + description: Paginated list of plugins + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/PluginView' + total: + type: integer + required: + - data + - total + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '403': + description: Forbidden — missing or non-admin JWT + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + post: + operationId: createAdminPlugin + tags: + - Admin + summary: Create a new plugin (admin only) + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePluginRequest' + responses: + '201': + description: Plugin created + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/PluginView' + required: + - data + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '403': + description: Forbidden — missing or non-admin JWT + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: Plugin name already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + /api/admin/plugins/{id}: + get: + operationId: getAdminPlugin + tags: + - Admin + summary: Get a single plugin by ID (admin only) + security: + - bearerAuth: [] + parameters: + - schema: + type: string + format: uuid + required: true + name: id + in: path + responses: + '200': + description: Plugin details + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/PluginView' + required: + - data + '400': + description: Invalid ID + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '403': + description: Forbidden — missing or non-admin JWT + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Plugin not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + patch: + operationId: updateAdminPlugin + tags: + - Admin + summary: Update a plugin (admin only) + security: + - bearerAuth: [] + parameters: + - schema: + type: string + format: uuid + required: true + name: id + in: path + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePluginRequest' + responses: + '200': + description: Plugin updated + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/PluginView' + required: + - data + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '403': + description: Forbidden — missing or non-admin JWT + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Plugin not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + delete: + operationId: deleteAdminPlugin + tags: + - Admin + summary: Delete a plugin (admin only) + security: + - bearerAuth: [] + parameters: + - schema: + type: string + format: uuid + required: true + name: id + in: path + responses: + '200': + description: Plugin deleted + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/DeletePluginResult' + required: + - data + '400': + description: Invalid ID + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '403': + description: Forbidden — missing or non-admin JWT + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Plugin not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + /api/admin/rate-limit/inspect/{address}: + get: + operationId: inspectAdminRateLimit + tags: + - Admin + summary: Inspect current rate-limit state for an address (admin only) + description: Returns the current sliding-window rate-limit usage for a target Stellar address. Admin-only and read-only. + security: + - bearerAuth: [] + responses: + '200': + description: Current rate-limit state for the requested address + content: + application/json: + schema: + type: object + properties: + data: + $ref: '#/components/schemas/AdminRateLimitInspect' + required: + - data + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '403': + description: Forbidden — missing or non-admin JWT + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' /api/admin/health/detail: get: operationId: getAdminHealthDetail diff --git a/package-lock.json b/package-lock.json index e8380bf..a0b3d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2916,7 +2916,7 @@ "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2926,7 +2926,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -3923,7 +3923,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/bare-semver": { @@ -3965,7 +3965,7 @@ "version": "2.4.6", "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "bare-path": "^3.0.0" @@ -10742,7 +10742,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts index 079ee1b..a55f5b6 100644 --- a/src/openapi/registry.ts +++ b/src/openapi/registry.ts @@ -296,7 +296,39 @@ registry.registerPath({ 200: { description: "Array of markets", content: { - "application/json": { schema: z.object({ data: z.array(Market) }) }, + "application/json": { + schema: z.object({ data: z.array(Market) }), + examples: { + default: { + value: { + data: [ + { + id: "market-001", + question: "Will the US win the 2026 FIFA World Cup?", + status: "active", + metadata: { + category: "sports", + resolutionSource: "official", + }, + version: 1, + createdAt: "2026-01-10T12:00:00.000Z", + }, + { + id: "market-002", + question: "Will Stellar launch a new protocol upgrade in 2026?", + status: "active", + metadata: { + category: "technology", + resolutionSource: "community", + }, + version: 2, + createdAt: "2026-02-14T07:30:00.000Z", + }, + ], + }, + }, + }, + }, }, }, }, @@ -365,7 +397,28 @@ registry.registerPath({ responses: { 200: { description: "Market", - content: { "application/json": { schema: z.object({ data: Market }) } }, + content: { + "application/json": { + schema: z.object({ data: Market }), + examples: { + default: { + value: { + data: { + id: "market-001", + question: "Will the US win the 2026 FIFA World Cup?", + status: "active", + metadata: { + category: "sports", + resolutionSource: "official", + }, + version: 1, + createdAt: "2026-01-10T12:00:00.000Z", + }, + }, + }, + }, + }, + }, }, 404: { description: "Not found", diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts index c11db2a..e30b1b8 100644 --- a/tests/openapi.test.ts +++ b/tests/openapi.test.ts @@ -38,6 +38,47 @@ describe("OpenAPI spec generation", () => { expect(paths).toContain("/api/admin/markets/{id}/feature"); }); + it("includes example responses for the markets endpoints", () => { + const paths = spec.paths ?? {}; + const listMarketsOp = (paths["/api/markets"] as Record)?.get as Record; + const detailMarketsOp = (paths["/api/markets/{id}"] as Record)?.get as Record; + + const listResponse = (listMarketsOp?.responses as Record)?.["200"] as Record; + const detailResponse = (detailMarketsOp?.responses as Record)?.["200"] as Record; + const listJsonContent = (listResponse?.content as Record)?.["application/json"] as Record; + const detailJsonContent = (detailResponse?.content as Record)?.["application/json"] as Record; + + expect(listJsonContent?.examples).toBeDefined(); + expect(detailJsonContent?.examples).toBeDefined(); + + const listExample = (listJsonContent?.examples as Record)?.default as Record; + const detailExample = (detailJsonContent?.examples as Record)?.default as Record; + + expect(listExample?.value).toEqual( + expect.objectContaining({ + data: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + question: expect.any(String), + status: expect.any(String), + version: expect.any(Number), + }), + ]), + }), + ); + + expect(detailExample?.value).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + id: expect.any(String), + question: expect.any(String), + status: expect.any(String), + version: expect.any(Number), + }), + }), + ); + }); + it("defines reusable component schemas", () => { const schemas = spec.components?.schemas ?? {}; expect(schemas["ErrorBody"]).toBeDefined();