Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
219 changes: 219 additions & 0 deletions docs/comments-api.md
Original file line number Diff line number Diff line change
@@ -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()`).
134 changes: 133 additions & 1 deletion src/openapi/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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",
Expand Down
Loading