diff --git a/README.md b/README.md index 1fb5607e..f29b04d5 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,16 @@ npm test -- tests/refreshToken.test.ts The refresh-token test suite covers rotation, expiry handling, reuse detection, logout family revocation, and hash-only storage. +## Comments API + +Market comments are exposed at: + +- **`GET /api/markets/:id/comments`** — cursor-paginated comments for a market (`limit`, `cursor` params) +- **`GET /api/comments`** — root list endpoint +- **`POST /api/comments`** — create a comment; supply `outboundUrl` to dispatch a webhook + +Every handler generates or preserves an `X-Correlation-Id`, sanitises it (strips characters outside `[A-Za-z0-9\-_]`, max 128 chars), stores it in AsyncLocalStorage, echoes it in the response header, and propagates it to any outbound HTTP call via `fetchWithCorrelationId`. See [docs/comments-api.md](docs/comments-api.md) for the full runbook. + ## Social graph Follow graph mutations are exposed at: diff --git a/docs/comments-api.md b/docs/comments-api.md new file mode 100644 index 00000000..343e7d3e --- /dev/null +++ b/docs/comments-api.md @@ -0,0 +1,219 @@ +# Comments API + +The comments API exposes read/write access to market comments and propagates +`X-Correlation-Id` through every handler and any outbound HTTP call made within +the same request lifecycle. + +## Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/api/comments` | None | Root list endpoint | +| `POST` | `/api/comments` | None | Create a comment | +| `GET` | `/api/markets/:id/comments` | None | List comments for a market (cursor-paginated) | + +--- + +### `GET /api/comments` + +Returns a paginated list of comments. + +**Query parameters** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `limit` | integer 1–100 | No | Page size (default: 20) | +| `cursor` | string | No | Opaque cursor returned by the previous page | + +**Request headers** + +| Header | Description | +|--------|-------------| +| `X-Correlation-Id` | Optional. Alphanumeric + hyphens + underscores, max 128 chars. A UUID v4 is generated when absent. Unsafe characters are silently stripped. | + +**Response headers** + +| Header | Description | +|--------|-------------| +| `X-Correlation-Id` | The resolved correlation ID (passed-through or freshly generated). | + +**200 response** + +```json +{ + "data": [], + "nextCursor": null, + "message": "Comments fetched securely" +} +``` + +--- + +### `POST /api/comments` + +Creates a new comment. If `outboundUrl` is supplied the service dispatches a +`POST` to that URL containing the comment payload; `X-Correlation-Id` is +forwarded automatically via `fetchWithCorrelationId`. + +Outbound call failures are logged as warnings — they do **not** affect the +`201` response returned to the caller. + +**Request headers** + +| Header | Description | +|--------|-------------| +| `X-Correlation-Id` | Optional. Propagated to the outbound webhook call when `outboundUrl` is set. | + +**Request body** + +```json +{ + "marketId": "market-abc", + "body": "Great prediction!", + "authorAddress": "GABC...", + "outboundUrl": "https://notifications.example.com/webhook" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `marketId` | string | **Yes** | Target market ID | +| `body` | string 1–2000 chars | **Yes** | Comment text | +| `authorAddress` | string | No | Stellar address of the author | +| `outboundUrl` | URL string | No | Outbound webhook URL | + +**Response headers** + +| Header | Description | +|--------|-------------| +| `X-Correlation-Id` | Resolved correlation ID, echoed back. | + +**201 response** + +```json +{ + "data": { + "id": "c-1753712345678", + "marketId": "market-abc", + "body": "Great prediction!", + "authorAddress": "GABC...", + "createdAt": "2026-07-28T16:00:00.000Z" + }, + "message": "Comment created successfully" +} +``` + +--- + +### `GET /api/markets/:id/comments` + +Returns cursor-paginated comments for a single market, ordered newest-first. + +**Path parameters** + +| Parameter | Description | +|-----------|-------------| +| `id` | Market ID | + +**Query parameters** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `limit` | integer 1–100 | No | Page size (default: 20) | +| `cursor` | string | No | Opaque cursor from the previous page | + +**Request headers** + +| Header | Description | +|--------|-------------| +| `X-Correlation-Id` | Optional. See rules above. | + +**Response headers** + +| Header | Description | +|--------|-------------| +| `X-Correlation-Id` | Resolved correlation ID. | + +**200 response** + +```json +{ + "data": [ + { + "id": "c-abc", + "marketId": "m-123", + "authorId": null, + "authorAddress": null, + "body": "I think YES wins.", + "moderationFlagged": false, + "moderationReason": null, + "createdAt": "2026-07-28T16:00:00.000Z" + } + ], + "nextCursor": "eyJzb3J0VmFsdWUiOi..." +} +``` + +--- + +## X-Correlation-Id behaviour + +All three endpoints share the same correlation ID resolution logic (implemented +in [`src/middleware/correlation.ts`](../src/middleware/correlation.ts)): + +1. **Accept** — the value from the incoming `X-Correlation-Id` request header + (priority 1), then `X-Request-Id` (priority 2), then the pino-http `req.id` + (priority 3). +2. **Sanitise** — strip characters outside `[A-Za-z0-9\-_]` and truncate to + 128 characters to prevent log-injection attacks. +3. **Generate** — if the sanitised value is empty, generate a fresh RFC 4122 + UUID v4. +4. **Store** — write the resolved ID into the + [AsyncLocalStorage](../src/lib/requestContext.ts) context so downstream + service code can call `getCorrelationId()` without prop-drilling. +5. **Echo** — set `X-Correlation-Id` on the HTTP response so callers can + correlate their own traces. +6. **Propagate** — outbound HTTP calls made via + `fetchWithCorrelationId(url, init)` (exported from + `src/middleware/correlation.ts`) automatically inject the correlation ID + from the current ALS context into the outgoing request's + `X-Correlation-Id` header. + +### Structured log fields + +Every handler logs `correlationId` and `reqId` together so a single grep or +log query can reconstruct the full request chain: + +```json +{ + "level": 30, + "correlationId": "client-corr-id-12345", + "reqId": "client-corr-id-12345", + "marketId": "m-123", + "msg": "market comments listed" +} +``` + +--- + +## Error envelope + +Validation failures return a standard error envelope: + +```json +{ + "error": { + "code": "validation_error", + "details": [...] + } +} +``` + +HTTP status codes: `400` (validation), `404` (not found), `500` (unexpected). + +--- + +## Rate limiting & CORS + +All comments routes inherit the global anonymous rate limit +(`rateLimitAnon`) and the markets CORS allowlist (`marketsCors()`). diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts index 19b30042..c37ba9f0 100644 --- a/src/openapi/registry.ts +++ b/src/openapi/registry.ts @@ -1006,14 +1006,67 @@ const MarketComment = z registry.registerPath({ method: "get", path: "/api/markets/{id}/comments", - tags: ["Markets"], + tags: ["Markets", "Comments"], summary: "List comments for a market with cursor pagination", + description: + "Returns a cursor-paginated list of comments for the given market. The resolved `X-Correlation-Id` is echoed back in the response header.", request: { params: z.object({ id: z.string() }), query: z.object({ limit: z.coerce.number().int().positive().optional().default(20), cursor: z.string().optional(), }), + headers: z.object({ + "x-correlation-id": z.string().optional().openapi({ + description: + "Client-supplied correlation ID. Alphanumeric, hyphens, and underscores only (max 128 chars). A UUID v4 is generated when absent.", + }), + }), + }, + responses: { + 200: { + description: "Paginated comments list", + content: { + "application/json": { + schema: z.object({ + data: z.array(MarketComment), + nextCursor: z.string().nullable(), + }), + }, + }, + headers: z.object({ + "x-correlation-id": z.string().openapi({ + description: "Resolved correlation ID, echoed back to the caller.", + }), + }), + }, + 400: { + description: "Validation error", + content: { "application/json": { schema: ValidationErrorBody } }, + }, + }, +}); + +// ── /api/comments (root) ───────────────────────────────────────────────────── + +registry.registerPath({ + method: "get", + path: "/api/comments", + tags: ["Comments"], + summary: "List comments (root endpoint)", + description: + "Returns a paginated list of comments. The resolved `X-Correlation-Id` is echoed back in the response header.", + request: { + query: z.object({ + limit: z.coerce.number().int().positive().max(100).optional(), + cursor: z.string().optional(), + }), + headers: z.object({ + "x-correlation-id": z.string().optional().openapi({ + description: + "Client-supplied correlation ID. Alphanumeric, hyphens, and underscores only (max 128 chars). A UUID v4 is generated when absent.", + }), + }), }, responses: { 200: { @@ -1023,9 +1076,88 @@ registry.registerPath({ schema: z.object({ data: z.array(MarketComment), nextCursor: z.string().nullable(), + message: z.string(), }), }, }, + headers: z.object({ + "x-correlation-id": z.string().openapi({ + description: "Resolved correlation ID, echoed back to the caller.", + }), + }), + }, + 400: { + description: "Validation error", + content: { "application/json": { schema: ValidationErrorBody } }, + }, + }, +}); + +const CreateCommentRequest = z + .object({ + marketId: z.string().min(1).openapi({ description: "Target market ID" }), + body: z + .string() + .min(1) + .max(2000) + .openapi({ description: "Comment body (max 2 000 chars)" }), + authorAddress: z + .string() + .optional() + .openapi({ description: "Stellar address of the author" }), + outboundUrl: z + .string() + .url() + .optional() + .openapi({ + description: + "Optional webhook URL that receives a POST with the comment payload. X-Correlation-Id is forwarded automatically.", + }), + }) + .openapi("CreateCommentRequest"); + +const CreateCommentResponse = z + .object({ + data: z.object({ + id: z.string(), + marketId: z.string(), + body: z.string(), + authorAddress: z.string().nullable(), + createdAt: z.string().datetime(), + }), + message: z.string(), + }) + .openapi("CreateCommentResponse"); + +registry.registerPath({ + method: "post", + path: "/api/comments", + tags: ["Comments"], + summary: "Create a comment", + description: + "Creates a new comment. When `outboundUrl` is provided the service posts the comment payload to that URL, forwarding `X-Correlation-Id` so the receiving system can correlate the call.", + request: { + headers: z.object({ + "x-correlation-id": z.string().optional().openapi({ + description: + "Client-supplied correlation ID propagated to the outbound webhook call.", + }), + }), + body: { + content: { "application/json": { schema: CreateCommentRequest } }, + }, + }, + responses: { + 201: { + description: "Comment created", + content: { + "application/json": { schema: CreateCommentResponse }, + }, + headers: z.object({ + "x-correlation-id": z.string().openapi({ + description: "Resolved correlation ID, echoed back to the caller.", + }), + }), }, 400: { description: "Validation error",