Explorar el Código

docs(examples): add Postman collection covering every API endpoint

Drop a v2.1.0 Postman collection plus a local environment file under
examples/postman/. Folders mirror the four endpoint groups (Public,
Auth, Admin, Internal) with the right token kind wired per folder, and
test scripts capture raw_token / created ids back to environment vars
so the report-and-distribute golden path can be driven end to end
without manual copy/paste.
chiappa hace 1 semana
padre
commit
d9cb118062

+ 75 - 0
examples/postman/README.md

@@ -0,0 +1,75 @@
+# IRDB Postman collection
+
+Import these two files into Postman (or [Bruno](https://www.usebruno.com/)/[Insomnia](https://insomnia.rest/), which read the v2.1.0 format) to drive the API by hand:
+
+- `irdb.postman_collection.json` — every endpoint exposed by the `api` container.
+- `irdb-local.postman_environment.json` — variable slots for `baseUrl`, the four token kinds, and chained ids.
+
+The collection mirrors the spec at `/api/v1/openapi.yaml` and the routes registered in `api/src/App/AppFactory.php`.
+
+## Quickstart
+
+1. Bring the stack up: `docker compose up -d` from the repo root. The api listens on `http://localhost:8081`.
+2. Import `irdb.postman_collection.json` and `irdb-local.postman_environment.json`. Select the **IRDB Local** environment.
+3. Mint an admin token from the host (no Postman variable needed yet):
+
+   ```bash
+   docker compose exec api php bin/console tokens:create admin --role=admin
+   ```
+
+   Paste the printed `irdb_adm_…` value into the environment's `adminToken` variable.
+4. Run **Health & Docs → GET /healthz**. You should see `{ "status": "ok", … }`.
+5. Run **Admin — Identity → GET /api/v1/admin/me**. You should get back your token's role.
+
+The collection's default auth is `Bearer {{adminToken}}`. Per-folder overrides apply for the public, auth and internal-job endpoints.
+
+## Token kinds at a glance
+
+| Folder                  | Auth used                                   | Variable          |
+| ----------------------- | ------------------------------------------- | ----------------- |
+| Health & Docs           | none                                        | —                 |
+| Public — Reporter       | `Bearer {{reporterToken}}`                  | `reporterToken`   |
+| Public — Consumer       | `Bearer {{consumerToken}}`                  | `consumerToken`   |
+| Auth API (UI BFF)       | `Bearer {{serviceToken}}`                   | `serviceToken`    |
+| Admin — *               | `Bearer {{adminToken}}` (collection-level)  | `adminToken`      |
+| Internal Jobs           | `Bearer {{internalJobToken}}`               | `internalJobToken`|
+
+### Service-token impersonation against the Admin API
+
+Admin endpoints also accept a service token plus an `X-Acting-User-Id` header. To use that flow with the collection:
+
+1. Set the `serviceToken` variable to the value of the `UI_SERVICE_TOKEN` env var.
+2. Set `actingUserId` to the user id you want to act as (`POST /api/v1/auth/users/upsert-local` returns one).
+3. On a request, override **Authorization** to `Bearer {{serviceToken}}` and tick the disabled `X-Acting-User-Id: {{actingUserId}}` header (it is included on every Admin request, just disabled by default).
+
+The audit log will attribute these calls to `actor_kind=user` with `actor_id=<userId>`, exactly as the UI does in production.
+
+## Suggested run order on a fresh database
+
+Tests in the collection capture useful ids back to environment variables, so this sequence works without manual copy/paste:
+
+1. **Admin — Categories → GET /api/v1/admin/categories** (sanity check; defaults are seeded).
+2. **Admin — Policies → GET /api/v1/admin/policies** — populates `policyId` (prefers `moderate`).
+3. **Admin — Reporters → POST /api/v1/admin/reporters** — populates `reporterId`.
+4. **Admin — Consumers → POST /api/v1/admin/consumers** — uses `policyId`, populates `consumerId`.
+5. **Admin — Tokens → POST /api/v1/admin/tokens (reporter)** — populates `reporterToken` directly.
+6. **Admin — Tokens → POST /api/v1/admin/tokens (consumer)** — populates `consumerToken` directly.
+7. **Public — Reporter → POST /api/v1/report** — submits a report with the freshly-minted reporter token.
+8. **Internal Jobs → POST /internal/jobs/recompute-scores** (or **Admin — Jobs → trigger/recompute-scores**) to fold the report into `ip_scores`.
+9. **Public — Consumer → GET /api/v1/blocklist** — pulls the resulting blocklist.
+
+Run that loop end-to-end and you have exercised the report-and-distribute golden path.
+
+## Raw-token capture
+
+Token creation responses include `raw_token` once and only once. The collection's test scripts mirror it into:
+
+- `lastRawToken` (always)
+- `reporterToken` (on `kind=reporter`)
+- `consumerToken` (on `kind=consumer`)
+
+If you create an admin-kind token via the UI / collection, copy `lastRawToken` into `adminToken` to swap auth.
+
+## Internal jobs caveat
+
+`/internal/*` endpoints are bound to loopback and RFC1918 by the api's Caddyfile and return `404` from any other source. Run Postman on the same host as the api container (or inside the Docker network), not from a workstation calling a public hostname.

+ 103 - 0
examples/postman/irdb-local.postman_environment.json

@@ -0,0 +1,103 @@
+{
+	"id": "f1e2d3c4-b5a6-4798-8a9b-0c1d2e3f4a5b",
+	"name": "IRDB Local",
+	"values": [
+		{
+			"key": "baseUrl",
+			"value": "http://localhost:8081",
+			"type": "default",
+			"enabled": true
+		},
+		{
+			"key": "adminToken",
+			"value": "",
+			"type": "secret",
+			"enabled": true
+		},
+		{
+			"key": "serviceToken",
+			"value": "",
+			"type": "secret",
+			"enabled": true
+		},
+		{
+			"key": "actingUserId",
+			"value": "1",
+			"type": "default",
+			"enabled": true
+		},
+		{
+			"key": "reporterToken",
+			"value": "",
+			"type": "secret",
+			"enabled": true
+		},
+		{
+			"key": "consumerToken",
+			"value": "",
+			"type": "secret",
+			"enabled": true
+		},
+		{
+			"key": "internalJobToken",
+			"value": "",
+			"type": "secret",
+			"enabled": true
+		},
+		{
+			"key": "reporterId",
+			"value": "",
+			"type": "default",
+			"enabled": true
+		},
+		{
+			"key": "consumerId",
+			"value": "",
+			"type": "default",
+			"enabled": true
+		},
+		{
+			"key": "policyId",
+			"value": "",
+			"type": "default",
+			"enabled": true
+		},
+		{
+			"key": "categoryId",
+			"value": "",
+			"type": "default",
+			"enabled": true
+		},
+		{
+			"key": "manualBlockId",
+			"value": "",
+			"type": "default",
+			"enabled": true
+		},
+		{
+			"key": "allowlistEntryId",
+			"value": "",
+			"type": "default",
+			"enabled": true
+		},
+		{
+			"key": "tokenId",
+			"value": "",
+			"type": "default",
+			"enabled": true
+		},
+		{
+			"key": "lastRawToken",
+			"value": "",
+			"type": "secret",
+			"enabled": true
+		},
+		{
+			"key": "userId",
+			"value": "",
+			"type": "default",
+			"enabled": true
+		}
+	],
+	"_postman_variable_scope": "environment"
+}

+ 2534 - 0
examples/postman/irdb.postman_collection.json

@@ -0,0 +1,2534 @@
+{
+	"info": {
+		"_postman_id": "8c4d2b1f-3e1a-4a7d-9c2b-1d2a3b4c5d6e",
+		"name": "IRDB — IP Reputation Database",
+		"description": "Postman collection for the IRDB API. Mirrors the OpenAPI spec at `/api/v1/openapi.yaml` and the routes registered in `api/src/App/AppFactory.php`.\n\n## Variables\n\nUse the matching `IRDB Local` environment file or set these manually:\n\n- `baseUrl`         — e.g. `http://localhost:8081`.\n- `adminToken`      — `irdb_adm_…` token (kind=admin, role=admin). Default `Authorization` for the **Admin API** folder.\n- `serviceToken`    — `irdb_svc_…` token. Used for the **Auth API** folder and for impersonated calls into the **Admin API** folder.\n- `actingUserId`    — integer user id sent in the `X-Acting-User-Id` header when using the service token.\n- `reporterToken`   — `irdb_rep_…` for posting reports.\n- `consumerToken`   — `irdb_con_…` for pulling blocklists.\n- `internalJobToken` — value of the `INTERNAL_JOB_TOKEN` env var. Internal jobs are loopback / RFC1918 only; run Postman from the host that runs the api container.\n\n## Auth model\n\n- **Admin API** endpoints accept either `Authorization: Bearer <admin-kind-token>` OR `Authorization: Bearer <service-token>` plus `X-Acting-User-Id: <user_id>`. The collection defaults to the admin-kind token. To switch to service-token impersonation, override the request-level auth and add the header.\n- **Auth API** endpoints accept ONLY a `service` token; no impersonation.\n- **Internal Jobs** endpoints require `Authorization: Bearer <INTERNAL_JOB_TOKEN>` AND the request must arrive from loopback / RFC1918.\n\n## Chaining\n\nMost write requests save useful ids back to collection variables (`reporterId`, `consumerId`, `policyId`, `categoryId`, `manualBlockId`, `allowlistEntryId`, `tokenId`, `lastRawToken`) so a follow-up request can use them without manual copy/paste. Run requests in folder order on a fresh database for a smooth flow.\n",
+		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
+	},
+	"auth": {
+		"type": "bearer",
+		"bearer": [
+			{
+				"key": "token",
+				"value": "{{adminToken}}",
+				"type": "string"
+			}
+		]
+	},
+	"event": [
+		{
+			"listen": "prerequest",
+			"script": {
+				"type": "text/javascript",
+				"exec": [
+					""
+				]
+			}
+		},
+		{
+			"listen": "test",
+			"script": {
+				"type": "text/javascript",
+				"exec": [
+					""
+				]
+			}
+		}
+	],
+	"variable": [
+		{
+			"key": "baseUrl",
+			"value": "http://localhost:8081",
+			"type": "string"
+		},
+		{
+			"key": "adminToken",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "serviceToken",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "actingUserId",
+			"value": "1",
+			"type": "string"
+		},
+		{
+			"key": "reporterToken",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "consumerToken",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "internalJobToken",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "reporterId",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "consumerId",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "policyId",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "categoryId",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "manualBlockId",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "allowlistEntryId",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "tokenId",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "lastRawToken",
+			"value": "",
+			"type": "string"
+		},
+		{
+			"key": "userId",
+			"value": "",
+			"type": "string"
+		}
+	],
+	"item": [
+		{
+			"name": "Health & Docs",
+			"description": "Unauthenticated endpoints for liveness checks and the API spec.",
+			"auth": {
+				"type": "noauth"
+			},
+			"item": [
+				{
+					"name": "GET /healthz",
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/healthz",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"healthz"
+							]
+						},
+						"description": "Liveness probe. Returns `{status, db: {connected, driver}, geoip: {…}}`."
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"pm.test('200 OK', () => pm.response.to.have.status(200));",
+									"pm.test('returns ok status', () => pm.expect(pm.response.json().status).to.eql('ok'));"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "GET /api/v1/openapi.yaml",
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/openapi.yaml",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"openapi.yaml"
+							]
+						},
+						"description": "OpenAPI 3.0.3 document — canonical source of request/response schemas."
+					}
+				},
+				{
+					"name": "GET /api/docs",
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/docs",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"docs"
+							]
+						},
+						"description": "Stoplight Elements / RapiDoc viewer (HTML)."
+					}
+				}
+			]
+		},
+		{
+			"name": "Public — Reporter",
+			"description": "`POST /api/v1/report`. Token kind must be `reporter`. Rate limited at 60 req/s/token.",
+			"auth": {
+				"type": "bearer",
+				"bearer": [
+					{
+						"key": "token",
+						"value": "{{reporterToken}}",
+						"type": "string"
+					}
+				]
+			},
+			"item": [
+				{
+					"name": "POST /api/v1/report",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"ip\": \"203.0.113.42\",\n  \"category\": \"brute_force\",\n  \"metadata\": {\n    \"url\": \"/wp-login.php\",\n    \"ua\": \"curl/8.0\"\n  }\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/report",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"report"
+							]
+						},
+						"description": "Submit a single abuse report. The category must match an active `categories.slug`."
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"pm.test('202 Accepted', () => pm.response.to.have.status(202));",
+									"const body = pm.response.json();",
+									"pm.test('body has report_id', () => pm.expect(body.report_id).to.be.a('number'));"
+								]
+							}
+						}
+					]
+				}
+			]
+		},
+		{
+			"name": "Public — Consumer",
+			"description": "`GET /api/v1/blocklist`. Token kind must be `consumer`. Default response is `text/plain`, one entry per line. Pass `?format=json` for structured rows. Cached internally for 30 s per consumer; honour `If-None-Match` for `304`.",
+			"auth": {
+				"type": "bearer",
+				"bearer": [
+					{
+						"key": "token",
+						"value": "{{consumerToken}}",
+						"type": "string"
+					}
+				]
+			},
+			"item": [
+				{
+					"name": "GET /api/v1/blocklist (text)",
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/blocklist",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"blocklist"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"pm.test('200 OK', () => pm.response.to.have.status(200));",
+									"const etag = pm.response.headers.get('ETag');",
+									"if (etag) pm.collectionVariables.set('lastBlocklistETag', etag);"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "GET /api/v1/blocklist?format=json",
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/blocklist?format=json",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"blocklist"
+							],
+							"query": [
+								{
+									"key": "format",
+									"value": "json"
+								}
+							]
+						}
+					}
+				},
+				{
+					"name": "GET /api/v1/blocklist (If-None-Match)",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "If-None-Match",
+								"value": "{{lastBlocklistETag}}",
+								"description": "Set automatically by the previous text-format request."
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/blocklist",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"blocklist"
+							]
+						},
+						"description": "Re-pulls the blocklist with the previously captured ETag. Expects `304 Not Modified` when the body is unchanged."
+					}
+				}
+			]
+		},
+		{
+			"name": "Auth API (UI BFF)",
+			"description": "Service-token-only. No `X-Acting-User-Id` header here — these endpoints exist to *produce* user records the UI can later impersonate.",
+			"auth": {
+				"type": "bearer",
+				"bearer": [
+					{
+						"key": "token",
+						"value": "{{serviceToken}}",
+						"type": "string"
+					}
+				]
+			},
+			"item": [
+				{
+					"name": "POST /api/v1/auth/users/upsert-oidc",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"subject\": \"00000000-0000-0000-0000-000000000001\",\n  \"email\": \"alice@example.com\",\n  \"display_name\": \"Alice Example\",\n  \"groups\": [\"00000000-aaaa-bbbb-cccc-000000000001\"]\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/auth/users/upsert-oidc",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"auth",
+								"users",
+								"upsert-oidc"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 200) {",
+									"  const u = pm.response.json();",
+									"  if (u && u.id) pm.collectionVariables.set('userId', String(u.id));",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "POST /api/v1/auth/users/upsert-local",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"username\": \"admin\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/auth/users/upsert-local",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"auth",
+								"users",
+								"upsert-local"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 200) {",
+									"  const u = pm.response.json();",
+									"  if (u && u.id) pm.collectionVariables.set('userId', String(u.id));",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "GET /api/v1/auth/users/:id",
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/auth/users/{{userId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"auth",
+								"users",
+								"{{userId}}"
+							]
+						}
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Identity",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/me",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"description": "Required when authenticating with the service token. Ignored otherwise.",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/me",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"me"
+							]
+						},
+						"description": "Returns the current acting identity: `{id, email, display_name, role, source, is_local}`."
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — IPs & Stats",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/ips (search)",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/ips?q=&category=&min_score=&max_score=&country=&asn=&status=&page=1&page_size=50",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"ips"
+							],
+							"query": [
+								{
+									"key": "q",
+									"value": "",
+									"description": "Free-text search (IP / CIDR / country / ASN substring)."
+								},
+								{
+									"key": "category",
+									"value": "",
+									"description": "Category slug, e.g. `brute_force`."
+								},
+								{
+									"key": "min_score",
+									"value": "",
+									"description": "Minimum aggregate score."
+								},
+								{
+									"key": "max_score",
+									"value": "",
+									"description": "Maximum aggregate score."
+								},
+								{
+									"key": "country",
+									"value": "",
+									"description": "ISO 3166-1 alpha-2 country code."
+								},
+								{
+									"key": "asn",
+									"value": "",
+									"description": "ASN integer."
+								},
+								{
+									"key": "status",
+									"value": "",
+									"description": "scored | manual | allowlisted | clean"
+								},
+								{
+									"key": "page",
+									"value": "1"
+								},
+								{
+									"key": "page_size",
+									"value": "50"
+								}
+							]
+						}
+					}
+				},
+				{
+					"name": "GET /api/v1/admin/ips/countries",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/ips/countries",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"ips",
+								"countries"
+							]
+						},
+						"description": "Country code dropdown source — `[{code, count}]`."
+					}
+				},
+				{
+					"name": "GET /api/v1/admin/ips/:ip",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/ips/203.0.113.42",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"ips",
+								"203.0.113.42"
+							]
+						},
+						"description": "Full per-IP detail: scores, enrichment, manual/allowlist status, history."
+					}
+				},
+				{
+					"name": "GET /api/v1/admin/stats/dashboard",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/stats/dashboard",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"stats",
+								"dashboard"
+							]
+						},
+						"description": "Pre-built dashboard payload (~30 s cached)."
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Manual Blocks",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/manual-blocks",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/manual-blocks",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"manual-blocks"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/manual-blocks (ip)",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"kind\": \"ip\",\n  \"ip\": \"198.51.100.7\",\n  \"reason\": \"persistent SSH brute force\",\n  \"expires_at\": null\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/manual-blocks",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"manual-blocks"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 201) {",
+									"  const b = pm.response.json();",
+									"  if (b && b.id) pm.collectionVariables.set('manualBlockId', String(b.id));",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "POST /api/v1/admin/manual-blocks (subnet)",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"kind\": \"subnet\",\n  \"cidr\": \"198.51.100.0/24\",\n  \"reason\": \"abuse cluster\",\n  \"expires_at\": null\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/manual-blocks",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"manual-blocks"
+							]
+						}
+					}
+				},
+				{
+					"name": "GET /api/v1/admin/manual-blocks/:id",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/manual-blocks/{{manualBlockId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"manual-blocks",
+								"{{manualBlockId}}"
+							]
+						}
+					}
+				},
+				{
+					"name": "DELETE /api/v1/admin/manual-blocks/:id",
+					"request": {
+						"method": "DELETE",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/manual-blocks/{{manualBlockId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"manual-blocks",
+								"{{manualBlockId}}"
+							]
+						}
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Allowlist",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/allowlist",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/allowlist",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"allowlist"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/allowlist (ip)",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"kind\": \"ip\",\n  \"ip\": \"192.0.2.1\",\n  \"reason\": \"office egress\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/allowlist",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"allowlist"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 201) {",
+									"  const b = pm.response.json();",
+									"  if (b && b.id) pm.collectionVariables.set('allowlistEntryId', String(b.id));",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "POST /api/v1/admin/allowlist (subnet)",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"kind\": \"subnet\",\n  \"cidr\": \"192.0.2.0/24\",\n  \"reason\": \"corporate range\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/allowlist",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"allowlist"
+							]
+						}
+					}
+				},
+				{
+					"name": "GET /api/v1/admin/allowlist/:id",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/allowlist/{{allowlistEntryId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"allowlist",
+								"{{allowlistEntryId}}"
+							]
+						}
+					}
+				},
+				{
+					"name": "DELETE /api/v1/admin/allowlist/:id",
+					"request": {
+						"method": "DELETE",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/allowlist/{{allowlistEntryId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"allowlist",
+								"{{allowlistEntryId}}"
+							]
+						}
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Reporters",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/reporters",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/reporters",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"reporters"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/reporters",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"name\": \"web-prod-01\",\n  \"description\": \"front-end webserver\",\n  \"trust_weight\": 1.0,\n  \"is_active\": true\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/reporters",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"reporters"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 201) {",
+									"  const r = pm.response.json();",
+									"  if (r && r.id) pm.collectionVariables.set('reporterId', String(r.id));",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "GET /api/v1/admin/reporters/:id",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/reporters/{{reporterId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"reporters",
+								"{{reporterId}}"
+							]
+						}
+					}
+				},
+				{
+					"name": "PATCH /api/v1/admin/reporters/:id",
+					"request": {
+						"method": "PATCH",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"trust_weight\": 1.25,\n  \"description\": \"front-end webserver (raised trust)\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/reporters/{{reporterId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"reporters",
+								"{{reporterId}}"
+							]
+						}
+					}
+				},
+				{
+					"name": "DELETE /api/v1/admin/reporters/:id",
+					"request": {
+						"method": "DELETE",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/reporters/{{reporterId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"reporters",
+								"{{reporterId}}"
+							]
+						},
+						"description": "Hard-deletes if no reports reference it; returns `409` and flags inactive otherwise."
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Consumers",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/consumers",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/consumers",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"consumers"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/consumers",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"name\": \"edge-fw-01\",\n  \"description\": \"edge firewall\",\n  \"policy_id\": {{policyId}},\n  \"is_active\": true\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/consumers",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"consumers"
+							]
+						},
+						"description": "`policy_id` is required. Set the `policyId` collection var first by listing policies or creating one."
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 201) {",
+									"  const c = pm.response.json();",
+									"  if (c && c.id) pm.collectionVariables.set('consumerId', String(c.id));",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "GET /api/v1/admin/consumers/:id",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/consumers/{{consumerId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"consumers",
+								"{{consumerId}}"
+							]
+						}
+					}
+				},
+				{
+					"name": "PATCH /api/v1/admin/consumers/:id",
+					"request": {
+						"method": "PATCH",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"is_active\": false\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/consumers/{{consumerId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"consumers",
+								"{{consumerId}}"
+							]
+						}
+					}
+				},
+				{
+					"name": "DELETE /api/v1/admin/consumers/:id",
+					"request": {
+						"method": "DELETE",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/consumers/{{consumerId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"consumers",
+								"{{consumerId}}"
+							]
+						}
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Tokens",
+			"description": "Service tokens are filtered out of the list and cannot be created here.",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/tokens",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/tokens",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"tokens"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/tokens (reporter)",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"kind\": \"reporter\",\n  \"reporter_id\": {{reporterId}}\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/tokens",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"tokens"
+							]
+						},
+						"description": "Returns the raw token ONCE in `raw_token`. The response is also captured into the `lastRawToken` and `reporterToken` collection vars for chained tests."
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 201) {",
+									"  const t = pm.response.json();",
+									"  if (t && t.id) pm.collectionVariables.set('tokenId', String(t.id));",
+									"  if (t && t.raw_token) {",
+									"    pm.collectionVariables.set('lastRawToken', t.raw_token);",
+									"    pm.collectionVariables.set('reporterToken', t.raw_token);",
+									"  }",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "POST /api/v1/admin/tokens (consumer)",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"kind\": \"consumer\",\n  \"consumer_id\": {{consumerId}}\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/tokens",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"tokens"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 201) {",
+									"  const t = pm.response.json();",
+									"  if (t && t.id) pm.collectionVariables.set('tokenId', String(t.id));",
+									"  if (t && t.raw_token) {",
+									"    pm.collectionVariables.set('lastRawToken', t.raw_token);",
+									"    pm.collectionVariables.set('consumerToken', t.raw_token);",
+									"  }",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "POST /api/v1/admin/tokens (admin)",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"kind\": \"admin\",\n  \"role\": \"admin\",\n  \"expires_at\": null\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/tokens",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"tokens"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 201) {",
+									"  const t = pm.response.json();",
+									"  if (t && t.id) pm.collectionVariables.set('tokenId', String(t.id));",
+									"  if (t && t.raw_token) pm.collectionVariables.set('lastRawToken', t.raw_token);",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "DELETE /api/v1/admin/tokens/:id",
+					"request": {
+						"method": "DELETE",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/tokens/{{tokenId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"tokens",
+								"{{tokenId}}"
+							]
+						}
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Categories",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/categories",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/categories",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"categories"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/categories",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"slug\": \"crypto_mining\",\n  \"name\": \"Crypto Mining\",\n  \"description\": \"Unauthorised crypto-mining traffic\",\n  \"decay_function\": \"exponential\",\n  \"decay_param\": 14.0,\n  \"is_active\": true\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/categories",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"categories"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 201) {",
+									"  const c = pm.response.json();",
+									"  if (c && c.id) pm.collectionVariables.set('categoryId', String(c.id));",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "GET /api/v1/admin/categories/:id",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/categories/{{categoryId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"categories",
+								"{{categoryId}}"
+							]
+						}
+					}
+				},
+				{
+					"name": "PATCH /api/v1/admin/categories/:id",
+					"request": {
+						"method": "PATCH",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"decay_param\": 7.0\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/categories/{{categoryId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"categories",
+								"{{categoryId}}"
+							]
+						}
+					}
+				},
+				{
+					"name": "DELETE /api/v1/admin/categories/:id",
+					"request": {
+						"method": "DELETE",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/categories/{{categoryId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"categories",
+								"{{categoryId}}"
+							]
+						},
+						"description": "`409 Conflict` if the category is referenced by reports — soft-delete via PATCH `is_active=false` instead."
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Policies",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/policies",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/policies",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"policies"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 200) {",
+									"  const list = pm.response.json();",
+									"  const items = Array.isArray(list) ? list : (list.items || []);",
+									"  const moderate = items.find(p => p.name === 'moderate');",
+									"  if (moderate && moderate.id) pm.collectionVariables.set('policyId', String(moderate.id));",
+									"  else if (items[0] && items[0].id) pm.collectionVariables.set('policyId', String(items[0].id));",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "POST /api/v1/admin/policies",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"name\": \"edge-default\",\n  \"description\": \"sample policy\",\n  \"include_manual_blocks\": true,\n  \"thresholds\": {\n    \"brute_force\": 1.0,\n    \"scanner\": 2.0,\n    \"web_attack\": 1.5\n  }\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/policies",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"policies"
+							]
+						}
+					},
+					"event": [
+						{
+							"listen": "test",
+							"script": {
+								"type": "text/javascript",
+								"exec": [
+									"if (pm.response.code === 201) {",
+									"  const p = pm.response.json();",
+									"  if (p && p.id) pm.collectionVariables.set('policyId', String(p.id));",
+									"}"
+								]
+							}
+						}
+					]
+				},
+				{
+					"name": "GET /api/v1/admin/policies/:id",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/policies/{{policyId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"policies",
+								"{{policyId}}"
+							]
+						}
+					}
+				},
+				{
+					"name": "GET /api/v1/admin/policies/:id/preview",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/policies/{{policyId}}/preview",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"policies",
+								"{{policyId}}",
+								"preview"
+							]
+						},
+						"description": "Returns the count and a sample of IPs that would land in the blocklist for this policy. Used by the UI policy editor."
+					}
+				},
+				{
+					"name": "GET /api/v1/admin/policies/:id/score-distribution",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/policies/{{policyId}}/score-distribution",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"policies",
+								"{{policyId}}",
+								"score-distribution"
+							]
+						}
+					}
+				},
+				{
+					"name": "PATCH /api/v1/admin/policies/:id",
+					"request": {
+						"method": "PATCH",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"description\": \"sample policy (raised thresholds)\",\n  \"thresholds\": {\n    \"brute_force\": 1.5,\n    \"scanner\": 2.5\n  }\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/policies/{{policyId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"policies",
+								"{{policyId}}"
+							]
+						},
+						"description": "When `thresholds` is present, the API replaces the entire threshold set wholesale."
+					}
+				},
+				{
+					"name": "DELETE /api/v1/admin/policies/:id",
+					"request": {
+						"method": "DELETE",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/policies/{{policyId}}",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"policies",
+								"{{policyId}}"
+							]
+						},
+						"description": "`409 Conflict` if any consumer still references the policy."
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Audit Log",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/audit-log",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/audit-log?actor_kind=&actor_id=&action=&entity_type=&entity_id=&from=&to=&page=1&page_size=50",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"audit-log"
+							],
+							"query": [
+								{
+									"key": "actor_kind",
+									"value": "",
+									"description": "user | admin-token | reporter | consumer | system"
+								},
+								{
+									"key": "actor_id",
+									"value": ""
+								},
+								{
+									"key": "action",
+									"value": "",
+									"description": "e.g. `manual_block.created`"
+								},
+								{
+									"key": "entity_type",
+									"value": ""
+								},
+								{
+									"key": "entity_id",
+									"value": ""
+								},
+								{
+									"key": "from",
+									"value": "",
+									"description": "ISO 8601 UTC"
+								},
+								{
+									"key": "to",
+									"value": ""
+								},
+								{
+									"key": "page",
+									"value": "1"
+								},
+								{
+									"key": "page_size",
+									"value": "50"
+								}
+							]
+						}
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Jobs",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/jobs/status",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/jobs/status",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"jobs",
+								"status"
+							]
+						},
+						"description": "Per-job last-run + overdue flag. Viewer-readable."
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/jobs/trigger/recompute-scores",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"full\": false,\n  \"max_rows\": 5000\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/jobs/trigger/recompute-scores",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"jobs",
+								"trigger",
+								"recompute-scores"
+							]
+						},
+						"description": "Whitelisted body keys: `full`, `max_rows`, `reenrich`. Other keys are dropped."
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/jobs/trigger/cleanup-audit",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/jobs/trigger/cleanup-audit",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"jobs",
+								"trigger",
+								"cleanup-audit"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/jobs/trigger/cleanup-expired-manual-blocks",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/jobs/trigger/cleanup-expired-manual-blocks",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"jobs",
+								"trigger",
+								"cleanup-expired-manual-blocks"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/jobs/trigger/enrich-pending",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"reenrich\": false\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/jobs/trigger/enrich-pending",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"jobs",
+								"trigger",
+								"enrich-pending"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/jobs/trigger/refresh-geoip",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/jobs/trigger/refresh-geoip",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"jobs",
+								"trigger",
+								"refresh-geoip"
+							]
+						},
+						"description": "Returns `412 Precondition Failed` when the GeoIP provider isn't configured."
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/jobs/trigger/tick",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/jobs/trigger/tick",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"jobs",
+								"trigger",
+								"tick"
+							]
+						},
+						"description": "Convenience: invokes any due job."
+					}
+				}
+			]
+		},
+		{
+			"name": "Admin — Config & Maintenance",
+			"item": [
+				{
+					"name": "GET /api/v1/admin/config",
+					"request": {
+						"method": "GET",
+						"header": [
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/config",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"config"
+							]
+						},
+						"description": "Effective config grouped by section. Secrets are masked."
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/maintenance/purge",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"confirm\": \"PURGE\"\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/maintenance/purge",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"maintenance",
+								"purge"
+							]
+						},
+						"description": "DESTRUCTIVE. Deletes operational data (reports, scores, manual blocks, allowlist, audit log, jobs history, reporters, consumers, policies, non-service tokens). Requires literal `confirm: \"PURGE\"`."
+					}
+				},
+				{
+					"name": "POST /api/v1/admin/maintenance/seed-demo",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							},
+							{
+								"key": "X-Acting-User-Id",
+								"value": "{{actingUserId}}",
+								"disabled": true
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/api/v1/admin/maintenance/seed-demo",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"api",
+								"v1",
+								"admin",
+								"maintenance",
+								"seed-demo"
+							]
+						},
+						"description": "Loads a demo dataset and triggers a full recompute. Returns `409` if demo data is already present, `412` if no categories are configured."
+					}
+				}
+			]
+		},
+		{
+			"name": "Internal Jobs",
+			"description": "Loopback / RFC1918-only. Run Postman from the host running the api container, and target the host port (`http://localhost:8081`). External callers receive `404`.",
+			"auth": {
+				"type": "bearer",
+				"bearer": [
+					{
+						"key": "token",
+						"value": "{{internalJobToken}}",
+						"type": "string"
+					}
+				]
+			},
+			"item": [
+				{
+					"name": "POST /internal/jobs/tick",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/internal/jobs/tick",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"internal",
+								"jobs",
+								"tick"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /internal/jobs/recompute-scores",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"full\": false,\n  \"max_rows\": 5000\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/internal/jobs/recompute-scores",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"internal",
+								"jobs",
+								"recompute-scores"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /internal/jobs/cleanup-audit",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/internal/jobs/cleanup-audit",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"internal",
+								"jobs",
+								"cleanup-audit"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /internal/jobs/cleanup-expired-manual-blocks",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/internal/jobs/cleanup-expired-manual-blocks",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"internal",
+								"jobs",
+								"cleanup-expired-manual-blocks"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /internal/jobs/enrich-pending",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{\n  \"reenrich\": false\n}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/internal/jobs/enrich-pending",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"internal",
+								"jobs",
+								"enrich-pending"
+							]
+						}
+					}
+				},
+				{
+					"name": "POST /internal/jobs/refresh-geoip",
+					"request": {
+						"method": "POST",
+						"header": [
+							{
+								"key": "Content-Type",
+								"value": "application/json"
+							}
+						],
+						"body": {
+							"mode": "raw",
+							"raw": "{}"
+						},
+						"url": {
+							"raw": "{{baseUrl}}/internal/jobs/refresh-geoip",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"internal",
+								"jobs",
+								"refresh-geoip"
+							]
+						}
+					}
+				},
+				{
+					"name": "GET /internal/jobs/status",
+					"request": {
+						"method": "GET",
+						"header": [],
+						"url": {
+							"raw": "{{baseUrl}}/internal/jobs/status",
+							"host": [
+								"{{baseUrl}}"
+							],
+							"path": [
+								"internal",
+								"jobs",
+								"status"
+							]
+						}
+					}
+				}
+			]
+		}
+	]
+}