Skip to content
Open
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
133 changes: 133 additions & 0 deletions docs/seed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Sample Market Seeding (non-production)

`POST /api/admin/seed` inserts a small, fixed batch of **sample markets** so that
E2E suites and demos have predictable data to work against. It is intended for
**development, staging, and test** environments only.

- **Source:** [`src/routes/admin/seed.ts`](../src/routes/admin/seed.ts),
[`src/services/seedService.ts`](../src/services/seedService.ts)
- **Related:** [errors.md](./errors.md), [log-events.md](./log-events.md),
[rate-limiting.md](./rate-limiting.md)

## Endpoint

```
POST /api/admin/seed
Authorization: Bearer <admin-jwt>
Content-Type: application/json

{} # empty body — the endpoint takes no parameters
```

### Success — `200 OK`

```json
{
"data": {
"requested": 5,
"inserted": 5,
"skipped": 0,
"batchVersion": 1,
"insertedIds": [
"seed-market-001",
"seed-market-002",
"seed-market-003",
"seed-market-004",
"seed-market-005"
],
"markets": [
{
"id": "seed-market-001",
"question": "Will BTC close above $100k by year end?",
"status": "open",
"resolutionTime": "2026-07-30T10:00:00.000Z"
}
]
}
}
```

| Field | Meaning |
| ------------- | ------------------------------------------------------------------- |
| `requested` | Number of sample markets the batch defines (currently 5). |
| `inserted` | Rows **this call** actually created. |
| `skipped` | Rows that already existed and were left untouched (idempotent skip). |
| `batchVersion`| Version of the sample batch that produced the seed. |
| `insertedIds` | Ids created by this call (empty on a repeat run). |
| `markets` | Every seeded market currently tracked in the database. |

## Behaviour

### Non-production only

The endpoint is unavailable in production:

- In production the router responds **`404 not_found`** *before* authentication,
so the route is not even probeable.
- As defense-in-depth, `seedSampleMarkets()` itself throws `SeedNotAllowedError`
(→ `403 seed_not_allowed`) if invoked when `NODE_ENV=production`, so sample
data can never be written to a production database even via a direct call.

### Idempotent

Each sample market has a **stable primary key** (`seed-market-001` …). Inserts
use `ON CONFLICT (id) DO NOTHING`, so:

- The **first** call inserts the full batch (`inserted: 5, skipped: 0`).
- Every **subsequent** call inserts nothing (`inserted: 0, skipped: 5`) and
returns `200`. No duplicates are ever created.

### Tracked

Every seeded row is tagged in its `metadata` column:

```json
{ "seeded": true, "seedBatchVersion": 1, "outcomes": ["yes", "no"] }
```

Seeded markets can therefore be listed and distinguished from real
(indexed / admin-created) markets — `seedService.listSeeded()` returns exactly
the rows where `metadata->>'seeded' = 'true'`.

## Security

| Layer | Behaviour |
| ---------------- | ------------------------------------------------------------ |
| Production guard | `404` in production (route hidden, runs before auth). |
| Rate limit | `30 req/min` per admin token (IP fallback) → `429`. |
| Admin auth | Valid admin JWT required; otherwise `403 forbidden`. |
| Input validation | Body validated with a strict schema; extra fields → `400 validation_error`. |

## Observability

- Emits a `market.created` structured log event per inserted row, carrying the
`correlationId`, the admin `actor`, and `seeded: true`.
- Writes an `admin.seed_markets` entry to `audit_logs` (actor address, IP,
correlation id).
- Responses and logs propagate the request id via the standard
`X-Request-Id` correlation header.

## Error envelope

All errors use the standard envelope:

```json
{ "error": { "code": "validation_error", "details": [], "requestId": "<id>" } }
```

| Status | `code` | When |
| ------ | --------------------- | ----------------------------------------------- |
| 400 | `validation_error` | Unexpected fields in the request body. |
| 403 | `forbidden` | Missing / invalid / non-admin JWT. |
| 403 | `seed_not_allowed` | Service invoked directly in production. |
| 404 | `not_found` | Endpoint called in production. |
| 429 | `rate_limit_exceeded` | Per-token rate limit exceeded. |

## Example

```bash
curl -X POST http://localhost:3001/api/admin/seed \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{}'
```
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { notificationsRouter } from "./routes/notifications";
import { socialRouter } from "./routes/social";
import { adminAuditRouter } from "./routes/admin/audit";
import { adminMarketsRouter } from "./routes/admin/markets";
import { adminSeedRouter } from "./routes/admin/seed";
import { errorHandler } from "./middleware/errorHandler";
import { requestContextStorage } from "./lib/requestContext";
import { REQUEST_ID_HEADER } from "./lib/http";
Expand Down Expand Up @@ -105,6 +106,7 @@ export function createApp(_options?: unknown): express.Express {
app.use("/api/me/devices", devicesRouter);
app.use("/api/admin/audit", adminAuditRouter);
app.use("/api/admin/markets", adminMarketsRouter);
app.use("/api/admin/seed", adminSeedRouter);

app.get("/metrics", async (req, res) => {
const metricsAuthToken = process.env.METRICS_AUTH_TOKEN;
Expand Down
125 changes: 125 additions & 0 deletions src/routes/admin/seed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Admin sample-market seed router — NON-PRODUCTION ONLY.
*
* POST /api/admin/seed → insert a small, fixed batch of sample markets
* (idempotent) for E2E tests and demos.
*
* Security layers (outermost first):
* 1. Production guard — in production the endpoint behaves as if it does not
* exist (404). This runs BEFORE auth so the route is not even probeable.
* 2. Rate limit — 30 requests/min, keyed per admin token (IP fallback).
* 3. requireAdmin — valid admin JWT required (403 otherwise).
*
* The seed itself is idempotent (see src/services/seedService.ts): repeat calls
* insert nothing and report the existing rows as "skipped".
*/

import { Router, type Request, type Response } from "express";
import { rateLimit } from "express-rate-limit";
import { z } from "zod";
import { env } from "../../config/env";
import { requireAdmin } from "../../middleware/requireAdmin";
import { seedSampleMarkets, SeedNotAllowedError } from "../../services/seedService";

/** Pulls the first valid IP from X-Forwarded-For or falls back to socket/ip. */
function extractClientIp(req: Request): string {
const forwarded = req.headers["x-forwarded-for"];
if (typeof forwarded === "string") {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
if (Array.isArray(forwarded) && forwarded.length > 0) {
return forwarded[0]!;
}
return req.ip ?? req.socket.remoteAddress ?? "unknown";
}

function requestIdOf(req: { id?: unknown }): string {
return typeof req.id === "string" ? req.id : "";
}

// The seed endpoint takes no parameters. `.strict()` rejects any unexpected
// fields at the boundary so callers get a clear validation error rather than a
// silently-ignored payload.
const bodySchema = z.object({}).strict();

export interface AdminSeedRouterOptions {
/** Requests per minute per admin token. Default: 30 */
rateLimitPerMinute?: number;
}

export function createAdminSeedRouter(opts: AdminSeedRouterOptions = {}): Router {
const router = Router();
const limit = opts.rateLimitPerMinute ?? 30;

// 1. Production guard — hide the endpoint entirely outside non-prod.
router.use((req, res, next) => {
if (env.NODE_ENV === "production") {
res
.status(404)
.json({ error: { code: "not_found", requestId: requestIdOf({ id: req.id }) } });
return;
}
next();
});

// 2. Per-admin-token rate limit (IP fallback for unauthenticated callers).
router.use(
rateLimit({
windowMs: 60_000,
limit,
keyGenerator: (req) =>
(req.headers.authorization as string | undefined) ?? req.ip ?? "unknown",
standardHeaders: "draft-6",
legacyHeaders: false,
message: { error: { code: "rate_limit_exceeded" } },
}),
);

// 3. Admin guard.
router.use(requireAdmin);

router.post("/", async (req: Request, res: Response, next) => {
const requestId = requestIdOf({ id: req.id });

const parsed = bodySchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
error: {
code: "validation_error",
details: parsed.error.issues,
requestId,
},
});
return;
}

if (!req.adminAddress) {
// requireAdmin guarantees this; narrow defensively for direct callers.
res.status(401).json({ error: { code: "unauthorized", requestId } });
return;
}

try {
const result = await seedSampleMarkets({
adminAddress: req.adminAddress,
ip: extractClientIp(req),
correlationId: requestId,
});
res.status(200).json({ data: result });
} catch (err) {
if (err instanceof SeedNotAllowedError) {
res.status(err.status).json({
error: { code: err.code, message: err.message, requestId },
});
return;
}
next(err);
}
});

return router;
}

// Default export wired into src/index.ts.
export const adminSeedRouter = createAdminSeedRouter();
Loading