File size: 8,771 Bytes
d705bb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
---
title: "Scenarios API"
description: "Run pre-defined supply-chain disruption scenarios against a scoped country or the v1 seeded reporter set, then poll for worker-computed results."
---

The **scenarios** API is a PRO-only, job-queued surface on top of the WorldMonitor chokepoint + trade dataset. Callers enqueue a named scenario template against an optional country, then poll a job-id until the worker completes. If no country is supplied, v1 computes across the seeded reporter set only: `US`, `CN`, `RU`, `IR`, `IN`, and `TW`.

<Info>
This service is proto-backed and included in the published OpenAPI bundle β€” see `proto/worldmonitor/scenario/v1/service.proto` and `/api/ScenarioService.openapi.yaml`. This page adds migration notes and examples on top of the generated reference.
</Info>

<Note>
**Legacy v1 URL aliases** β€” the sebuf migration (#3207) renamed the three v1 endpoints to align with the proto RPC names. The old URLs are preserved as thin aliases so existing integrations keep working:

| Legacy URL | Canonical URL |
|---|---|
| `POST /api/scenario/v1/run` | `POST /api/scenario/v1/run-scenario` |
| `GET /api/scenario/v1/status` | `GET /api/scenario/v1/get-scenario-status` |
| `GET /api/scenario/v1/templates` | `GET /api/scenario/v1/list-scenario-templates` |

Prefer the canonical URLs in new code β€” the aliases will retire at the next v1β†’v2 break (tracked in [#3282](https://github.com/koala73/worldmonitor/issues/3282)).
</Note>

## List templates

### `GET /api/scenario/v1/list-scenario-templates`

Returns the catalog of pre-defined scenario templates. Cached `public, max-age=3600`.

**Response** β€” abbreviated example using one of the live shipped templates (`server/worldmonitor/supply-chain/v1/scenario-templates.ts`):
```json
{
  "templates": [
    {
      "id": "hormuz-tanker-blockade",
      "name": "Hormuz Strait Tanker Blockade",
      "affectedChokepointIds": ["hormuz_strait"],
      "disruptionPct": 100,
      "durationDays": 14,
      "affectedHs2": ["27", "29"],
      "costShockMultiplier": 2.10
    }
  ]
}
```

Other shipped templates at the time of writing: `taiwan-strait-full-closure`, `suez-bab-simultaneous`, `panama-drought-50pct`, `russia-baltic-grain-suspension`, `us-tariff-escalation-electronics`. Use the live `/list-scenario-templates` response as the source of truth β€” the set grows over time. `affectedHs2: []` on the wire means the scenario affects ALL sectors (the registry's `null` sentinel, which `repeated string` cannot carry directly).

## Run a scenario

### `POST /api/scenario/v1/run-scenario`

Enqueues a job. Returns the assigned `jobId` the caller must poll.

- **Auth**: PRO entitlement required. Granted by either (a) a valid `X-WorldMonitor-Key` (env key from `WORLDMONITOR_VALID_KEYS`, or a user-owned `wm_`-prefixed key whose owner has the `apiAccess` entitlement), **or** (b) a Clerk bearer token whose user has role `pro` or Dodo entitlement tier β‰₯ 1. A trusted browser Origin alone is **not** sufficient β€” `isCallerPremium()` in `server/_shared/premium-check.ts` only counts explicit credentials. Browser calls work because `premiumFetch()` (`src/services/premium-fetch.ts`) injects one of the two credential forms on the caller's behalf.
- **Rate limits**:
  - 10 jobs / minute / IP (enforced at the gateway via `ENDPOINT_RATE_POLICIES` in `server/_shared/rate-limit.ts`)
  - Queue backpressure checks the pending Redis list before enqueue; depth `> 100` is rejected with `429`, so depth `100` can still accept one more job.

**Request**:
```json
{
  "scenarioId": "hormuz-tanker-blockade",
  "iso2": "US"
}
```

- `scenarioId` β€” id from `/list-scenario-templates`. Required.
- `iso2` β€” optional ISO-3166-1 alpha-2 (uppercase). Scopes the scenario to one country. Empty string means the worker uses the v1 seeded reporter set: `US`, `CN`, `RU`, `IR`, `IN`, and `TW`.

**Response (`202 Accepted`)**:
```json
{
  "jobId": "scenario:1713456789012:a1b2c3d4",
  "status": "pending",
  "statusUrl": "/api/scenario/v1/get-scenario-status?jobId=scenario%3A1713456789012%3Aa1b2c3d4"
}
```

- `statusUrl` β€” server-computed convenience URL. Callers that don't want to hardcode the status path can follow this directly (it URL-encodes the `jobId`).
- `Location` response header β€” carries the same poll URL as `statusUrl`, per the standard REST async-job pattern (`202` + `Location` β†’ poll until terminal).

<Note>
**Status-code history (v1 β†’ v1 β†’ v1)** β€” the pre-sebuf-migration endpoint returned `202 Accepted` on successful enqueue; the sebuf migration shifted it to `200 OK` (no per-RPC status-code configuration exists in sebuf's HTTP annotations). The original `202 Accepted` contract has since been **restored** β€” the gateway upgrades the generated 200 via a status-override side-channel and adds the `Location` header.

Treat any `2xx` as enqueue success. The interim guidance to branch on response body shape (`response.body.status === "pending"`) instead of the status code remains valid, and `statusUrl` is preserved exactly as before.
</Note>

**Errors**:

| Status | `message` | Cause |
|--------|-----------|-------|
| 400 | `Validation failed` (violations include `scenarioId`) | Missing or unknown `scenarioId` |
| 400 | `Validation failed` (violations include `iso2`) | Malformed `iso2` |
| 403 | `PRO subscription required` | Not PRO |
| 405 | β€” | Method other than `POST` (enforced by sebuf service-config) |
| 429 | `Too many requests` | Per-IP 10/min gateway rate limit |
| 429 | `Scenario queue is at capacity, please try again later` | Pending queue depth is greater than 100 before enqueue |
| 502 | `Failed to enqueue scenario job` | Redis enqueue failure |

## Poll job status

### `GET /api/scenario/v1/get-scenario-status?jobId=<jobId>`

Returns the job's current state as written by the worker, or a synthesised `pending` stub while the job is still queued.

- **Auth**: same as `/run-scenario`
- **jobId format**: `scenario:{unix-ms}:{8-char-suffix}` β€” strictly validated to guard against path traversal

**Status lifecycle**:

| `status` | When |
|---|---|
| `pending` | Job enqueued but worker has not picked it up yet. Synthesised by the status handler when no Redis record exists. |
| `processing` | Worker dequeued the job and started computing. |
| `done` | Worker completed successfully; `result` is populated. |
| `failed` | Worker hit a computation error; `error` is populated. |

**Pending response (`200`)**:
```json
{ "status": "pending", "error": "" }
```

**Processing response (`200`)**:
```json
{ "status": "processing", "error": "" }
```

**Done response (`200`)** β€” `result` carries the worker's computed payload:

```json
{
  "status": "done",
  "error": "",
  "result": {
    "affectedChokepointIds": ["hormuz_strait"],
    "topImpactCountries": [
      { "iso2": "US", "totalImpact": 150.0, "impactPct": 100 }
    ],
    "template": {
      "name": "hormuz_strait",
      "disruptionPct": 100,
      "durationDays": 14,
      "costShockMultiplier": 2.10
    }
  }
}
```

In the status payload, `template.name` is the worker-derived key: physical
scenarios join affected chokepoint ids with `+`, while tariff-shock scenarios
with no physical chokepoint use `tariff_shock`. It is not the catalog label.

`totalImpact` is a relative weighted score, not a currency amount or USD import
value. For physical chokepoint scenarios, the worker computes
`exposureScore * (disruptionPct / 100) * costShockMultiplier` for each matching
exposure entry, then sums by country. For tariff-shock scenarios with no
affected chokepoint ids, it uses
`vulnerabilityIndex * costShockMultiplier`. `impactPct` is each returned
country's share of `max(maxReturnedTotalImpact, 1)`, capped at 100. That
denominator floor means the top returned country can be below 100 when every
returned `totalImpact` is below `1`.

**Failed response (`200`)**:

```json
{ "status": "failed", "error": "computation_error" }
```

Poll loop: treat `pending` and `processing` as non-terminal; only `done` and `failed` are terminal. Both pending and processing can legitimately persist for several seconds under load.

**Errors**:

| Status | `message` | Cause |
|--------|-----------|-------|
| 400 | `Validation failed` (violations include `jobId`) | Missing or malformed `jobId` |
| 403 | `PRO subscription required` | Not PRO |
| 405 | β€” | Method other than `GET` (enforced by sebuf service-config) |
| 502 | `Failed to fetch job status` | Redis read failure |

## Polling strategy

- First poll: ~1s after enqueue.
- Subsequent polls: exponential backoff (1s β†’ 2s β†’ 4s, cap 10s).
- Workers typically complete in 5-30 seconds depending on scenario complexity.
- If still pending after 2 minutes, the job is probably dead β€” re-enqueue.