1
0

openapi.php 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * Hand-curated OpenAPI 3.0 spec for IRDB.
  5. *
  6. * Run: `composer openapi:build` (or `php api/openapi.php > api/public/openapi.yaml`).
  7. * The committed `api/public/openapi.yaml` MUST match this file's output —
  8. * the doc-accuracy CI guard (`scripts/check-doc-endpoints.sh`) treats the
  9. * generated spec as the source of truth for which endpoints exist.
  10. *
  11. * Why hand-curated and not annotation-based: keeps the API surface in one
  12. * place where it can be reviewed in PR diffs, avoids pulling
  13. * zircote/swagger-php into prod deps, and the static array fits in <500
  14. * lines with room for descriptions. Trade-off: drift between code and
  15. * spec is possible — the integration tests assert response shapes
  16. * separately, and CI runs `redocly lint` against the YAML to catch
  17. * malformed entries.
  18. *
  19. * Internal endpoints (`/internal/jobs/*`) are deliberately omitted per
  20. * SPEC §M13.1; they're scheduler-only and not part of the public
  21. * contract.
  22. */
  23. require_once __DIR__ . '/vendor/autoload.php';
  24. $errorEnvelope = [
  25. 'type' => 'object',
  26. 'required' => ['error'],
  27. 'properties' => [
  28. 'error' => ['type' => 'string', 'example' => 'unauthorized'],
  29. 'details' => [
  30. 'type' => 'object',
  31. 'description' => 'Field-level errors for `validation_failed`.',
  32. 'additionalProperties' => ['type' => 'string'],
  33. ],
  34. ],
  35. ];
  36. $pageMeta = [
  37. 'page' => ['type' => 'integer', 'minimum' => 1, 'example' => 1],
  38. 'page_size' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'example' => 50],
  39. 'total' => ['type' => 'integer', 'minimum' => 0, 'example' => 1284],
  40. ];
  41. $components = [
  42. 'securitySchemes' => [
  43. 'BearerAuth' => [
  44. 'type' => 'http',
  45. 'scheme' => 'bearer',
  46. 'description' => "Token in the form `irdb_<kind>_<32 base32 chars>`.\n"
  47. . 'See `doc/auth-flows.md` for the four token kinds.',
  48. ],
  49. ],
  50. 'parameters' => [
  51. 'Page' => [
  52. 'name' => 'page',
  53. 'in' => 'query',
  54. 'schema' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
  55. ],
  56. 'PageSize' => [
  57. 'name' => 'page_size',
  58. 'in' => 'query',
  59. 'schema' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'default' => 50],
  60. ],
  61. 'ActingUserId' => [
  62. 'name' => 'X-Acting-User-Id',
  63. 'in' => 'header',
  64. 'description' => "Required when authenticating with a `service` token.\n"
  65. . 'The api applies RBAC for the named user. Ignored on other token kinds.',
  66. 'schema' => ['type' => 'integer'],
  67. ],
  68. ],
  69. 'schemas' => [
  70. 'Error' => $errorEnvelope,
  71. 'ReportRequest' => [
  72. 'type' => 'object',
  73. 'required' => ['ip', 'category'],
  74. 'properties' => [
  75. 'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
  76. 'category' => ['type' => 'string', 'example' => 'brute_force'],
  77. 'metadata' => [
  78. 'type' => 'object',
  79. 'description' => 'Free-form per-report data, max 4 KB after json_encode.',
  80. 'additionalProperties' => true,
  81. ],
  82. ],
  83. ],
  84. 'ReportResponse' => [
  85. 'type' => 'object',
  86. 'properties' => [
  87. 'report_id' => ['type' => 'integer', 'example' => 12345],
  88. 'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
  89. 'received_at' => ['type' => 'string', 'format' => 'date-time'],
  90. ],
  91. ],
  92. 'BlocklistEntry' => [
  93. 'type' => 'object',
  94. 'properties' => [
  95. 'ip_or_cidr' => ['type' => 'string', 'example' => '203.0.113.42'],
  96. 'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
  97. 'score' => ['type' => 'number', 'format' => 'float', 'example' => 1.42],
  98. 'reason' => ['type' => 'string', 'enum' => ['scored', 'manual'], 'example' => 'scored'],
  99. ],
  100. ],
  101. 'BlocklistJson' => [
  102. 'type' => 'object',
  103. 'properties' => [
  104. 'count' => ['type' => 'integer', 'example' => 42],
  105. 'generated_at' => ['type' => 'string', 'format' => 'date-time'],
  106. 'policy' => ['type' => 'string', 'example' => 'moderate'],
  107. 'entries' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/BlocklistEntry']],
  108. ],
  109. ],
  110. 'Token' => [
  111. 'type' => 'object',
  112. 'properties' => [
  113. 'id' => ['type' => 'integer'],
  114. 'kind' => ['type' => 'string', 'enum' => ['reporter', 'consumer', 'admin']],
  115. 'prefix' => ['type' => 'string', 'example' => 'irdb_adm'],
  116. 'reporter_id' => ['type' => 'integer', 'nullable' => true],
  117. 'consumer_id' => ['type' => 'integer', 'nullable' => true],
  118. 'role' => ['type' => 'string', 'nullable' => true, 'enum' => ['viewer', 'operator', 'admin', null]],
  119. 'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  120. 'revoked_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  121. 'last_used_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  122. ],
  123. ],
  124. 'TokenCreated' => [
  125. 'allOf' => [
  126. ['$ref' => '#/components/schemas/Token'],
  127. [
  128. 'type' => 'object',
  129. 'properties' => [
  130. 'raw_token' => [
  131. 'type' => 'string',
  132. 'description' => 'Returned ONCE on creation — copy it now, never displayed again.',
  133. 'example' => 'irdb_adm_AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD',
  134. ],
  135. ],
  136. ],
  137. ],
  138. ],
  139. 'Reporter' => [
  140. 'type' => 'object',
  141. 'properties' => [
  142. 'id' => ['type' => 'integer'],
  143. 'name' => ['type' => 'string', 'example' => 'web-prod-01'],
  144. 'description' => ['type' => 'string', 'nullable' => true],
  145. 'trust_weight' => ['type' => 'number', 'format' => 'float', 'minimum' => 0.0, 'maximum' => 2.0],
  146. 'is_active' => ['type' => 'boolean'],
  147. ],
  148. ],
  149. 'Consumer' => [
  150. 'type' => 'object',
  151. 'properties' => [
  152. 'id' => ['type' => 'integer'],
  153. 'name' => ['type' => 'string', 'example' => 'edge-fw-01'],
  154. 'description' => ['type' => 'string', 'nullable' => true],
  155. 'policy_id' => ['type' => 'integer'],
  156. 'is_active' => ['type' => 'boolean'],
  157. 'last_pulled_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  158. ],
  159. ],
  160. 'Category' => [
  161. 'type' => 'object',
  162. 'properties' => [
  163. 'id' => ['type' => 'integer'],
  164. 'slug' => ['type' => 'string', 'example' => 'brute_force'],
  165. 'name' => ['type' => 'string'],
  166. 'description' => ['type' => 'string', 'nullable' => true],
  167. 'decay_function' => ['type' => 'string', 'enum' => ['linear', 'exponential']],
  168. 'decay_param' => ['type' => 'number', 'format' => 'float'],
  169. 'is_active' => ['type' => 'boolean'],
  170. ],
  171. ],
  172. 'Policy' => [
  173. 'type' => 'object',
  174. 'properties' => [
  175. 'id' => ['type' => 'integer'],
  176. 'name' => ['type' => 'string', 'example' => 'moderate'],
  177. 'description' => ['type' => 'string', 'nullable' => true],
  178. 'include_manual_blocks' => ['type' => 'boolean'],
  179. 'thresholds' => [
  180. 'type' => 'object',
  181. 'description' => '`{category_slug: threshold}`',
  182. 'additionalProperties' => ['type' => 'number', 'format' => 'float'],
  183. ],
  184. ],
  185. ],
  186. 'ManualBlock' => [
  187. 'type' => 'object',
  188. 'properties' => [
  189. 'id' => ['type' => 'integer'],
  190. 'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
  191. 'ip' => ['type' => 'string', 'nullable' => true],
  192. 'cidr' => ['type' => 'string', 'nullable' => true],
  193. 'reason' => ['type' => 'string', 'nullable' => true],
  194. 'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  195. 'created_at' => ['type' => 'string', 'format' => 'date-time'],
  196. ],
  197. ],
  198. 'AllowlistEntry' => [
  199. 'type' => 'object',
  200. 'properties' => [
  201. 'id' => ['type' => 'integer'],
  202. 'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
  203. 'ip' => ['type' => 'string', 'nullable' => true],
  204. 'cidr' => ['type' => 'string', 'nullable' => true],
  205. 'reason' => ['type' => 'string', 'nullable' => true],
  206. 'created_at' => ['type' => 'string', 'format' => 'date-time'],
  207. ],
  208. ],
  209. 'IpDetail' => [
  210. 'type' => 'object',
  211. 'properties' => [
  212. 'ip' => ['type' => 'string'],
  213. 'is_ipv4' => ['type' => 'boolean'],
  214. 'scores' => [
  215. 'type' => 'array',
  216. 'items' => [
  217. 'type' => 'object',
  218. 'properties' => [
  219. 'category' => ['type' => 'string'],
  220. 'score' => ['type' => 'number', 'format' => 'float'],
  221. 'last_report_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  222. 'report_count_30d' => ['type' => 'integer'],
  223. ],
  224. ],
  225. ],
  226. 'enrichment' => [
  227. 'type' => 'object',
  228. 'properties' => [
  229. 'country_code' => ['type' => 'string', 'nullable' => true],
  230. 'asn' => ['type' => 'integer', 'nullable' => true],
  231. 'as_org' => ['type' => 'string', 'nullable' => true],
  232. 'enriched_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  233. ],
  234. ],
  235. 'status' => ['type' => 'string', 'enum' => ['scored', 'manually_blocked', 'allowlisted', 'clean']],
  236. 'manual_block' => ['type' => 'object', 'nullable' => true],
  237. 'allowlist' => ['type' => 'object', 'nullable' => true],
  238. 'history' => ['type' => 'array', 'items' => ['type' => 'object']],
  239. 'has_more' => ['type' => 'boolean'],
  240. ],
  241. ],
  242. 'AuditEntry' => [
  243. 'type' => 'object',
  244. 'properties' => [
  245. 'id' => ['type' => 'integer'],
  246. 'occurred_at' => ['type' => 'string', 'format' => 'date-time'],
  247. 'actor_kind' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']],
  248. 'actor_id' => ['type' => 'string', 'nullable' => true],
  249. 'action' => ['type' => 'string', 'example' => 'manual_block.created'],
  250. 'entity_type' => ['type' => 'string', 'nullable' => true],
  251. 'entity_id' => ['type' => 'string', 'nullable' => true],
  252. 'entity_label' => ['type' => 'string', 'nullable' => true, 'description' => 'Human-readable identifier for the target (name, slug, IP, CIDR, prefix). Frozen at write time.'],
  253. 'details' => ['type' => 'object', 'nullable' => true, 'additionalProperties' => true, 'description' => 'For update events, contains a `changes` map of `{field: {from, to}}` for every modified field.'],
  254. 'source_ip' => ['type' => 'string', 'nullable' => true],
  255. ],
  256. ],
  257. 'JobStatus' => [
  258. 'type' => 'object',
  259. 'properties' => [
  260. 'name' => ['type' => 'string'],
  261. 'default_interval_seconds' => ['type' => 'integer'],
  262. 'max_runtime_seconds' => ['type' => 'integer'],
  263. 'overdue' => ['type' => 'boolean'],
  264. 'last_run' => [
  265. 'type' => 'object',
  266. 'nullable' => true,
  267. 'properties' => [
  268. 'id' => ['type' => 'integer'],
  269. 'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
  270. 'items_processed' => ['type' => 'integer'],
  271. 'triggered_by' => ['type' => 'string', 'enum' => ['schedule', 'manual', 'api']],
  272. 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  273. 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  274. 'error_message' => ['type' => 'string', 'nullable' => true],
  275. ],
  276. ],
  277. ],
  278. ],
  279. 'JobOutcome' => [
  280. 'type' => 'object',
  281. 'properties' => [
  282. 'job' => ['type' => 'string'],
  283. 'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
  284. 'items_processed' => ['type' => 'integer'],
  285. 'duration_ms' => ['type' => 'integer'],
  286. 'run_id' => ['type' => 'integer', 'nullable' => true],
  287. 'error' => ['type' => 'string', 'nullable' => true],
  288. ],
  289. ],
  290. 'User' => [
  291. 'type' => 'object',
  292. 'properties' => [
  293. 'id' => ['type' => 'integer'],
  294. 'email' => ['type' => 'string', 'nullable' => true],
  295. 'display_name' => ['type' => 'string'],
  296. 'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']],
  297. 'source' => ['type' => 'string', 'enum' => ['oidc', 'local', 'admin-token']],
  298. 'is_local' => ['type' => 'boolean'],
  299. ],
  300. ],
  301. 'Pagination' => [
  302. 'type' => 'object',
  303. 'properties' => $pageMeta,
  304. ],
  305. ],
  306. ];
  307. $paths = [
  308. // ---------- Public ----------
  309. '/api/v1/report' => [
  310. 'post' => [
  311. 'tags' => ['Public'],
  312. 'summary' => 'Submit an abuse report',
  313. 'description' => "Token kind: `reporter`. Rate limit: 60 req/s per token (configurable).\n"
  314. . 'Returns `202 Accepted` on success.',
  315. 'security' => [['BearerAuth' => []]],
  316. 'requestBody' => [
  317. 'required' => true,
  318. 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportRequest']]],
  319. ],
  320. 'responses' => [
  321. '202' => [
  322. 'description' => 'Report accepted',
  323. 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportResponse']]],
  324. ],
  325. '400' => ['description' => 'Validation failed', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
  326. '401' => ['description' => 'Bad / wrong-kind token', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
  327. '429' => [
  328. 'description' => 'Rate limited',
  329. 'headers' => ['Retry-After' => ['schema' => ['type' => 'integer'], 'description' => 'Seconds to wait before retrying.']],
  330. 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]],
  331. ],
  332. ],
  333. ],
  334. ],
  335. '/api/v1/blocklist' => [
  336. 'get' => [
  337. 'tags' => ['Public'],
  338. 'summary' => 'Pull a tailored blocklist',
  339. 'description' => "Token kind: `consumer`. The consumer's bound policy decides which IPs/CIDRs land in the output.\n"
  340. . "Cached internally for 30 s per consumer. Honour `If-None-Match` to skip retransfer.\n"
  341. . "`?format=json` returns structured rows; default is `text/plain`, one entry per line.",
  342. 'security' => [['BearerAuth' => []]],
  343. 'parameters' => [
  344. ['name' => 'format', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['text', 'json'], 'default' => 'text']],
  345. ['name' => 'If-None-Match', 'in' => 'header', 'schema' => ['type' => 'string']],
  346. ],
  347. 'responses' => [
  348. '200' => [
  349. 'description' => 'Current blocklist',
  350. 'headers' => [
  351. 'ETag' => ['schema' => ['type' => 'string']],
  352. 'X-Blocklist-Generated-At' => ['schema' => ['type' => 'string', 'format' => 'date-time']],
  353. 'X-Blocklist-Entries' => ['schema' => ['type' => 'integer']],
  354. 'X-Blocklist-Policy' => ['schema' => ['type' => 'string']],
  355. ],
  356. 'content' => [
  357. 'text/plain' => ['schema' => ['type' => 'string', 'example' => "203.0.113.42\n198.51.100.0/24\n"]],
  358. 'application/json' => ['schema' => ['$ref' => '#/components/schemas/BlocklistJson']],
  359. ],
  360. ],
  361. '304' => ['description' => 'Not modified — body matches `If-None-Match`'],
  362. '401' => ['description' => 'Bad / wrong-kind token'],
  363. ],
  364. ],
  365. ],
  366. // ---------- Admin: identity ----------
  367. '/api/v1/admin/me' => [
  368. 'get' => [
  369. 'tags' => ['Admin'],
  370. 'summary' => 'Current acting identity',
  371. 'security' => [['BearerAuth' => []]],
  372. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  373. 'responses' => [
  374. '200' => ['description' => 'Identity info', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]],
  375. ],
  376. ],
  377. ],
  378. // ---------- Admin: IPs ----------
  379. '/api/v1/admin/ips' => [
  380. 'get' => [
  381. 'tags' => ['Admin'],
  382. 'summary' => 'Search IPs',
  383. 'security' => [['BearerAuth' => []]],
  384. 'parameters' => [
  385. ['$ref' => '#/components/parameters/ActingUserId'],
  386. ['name' => 'q', 'in' => 'query', 'schema' => ['type' => 'string']],
  387. ['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']],
  388. ['name' => 'min_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
  389. ['name' => 'max_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
  390. ['name' => 'country', 'in' => 'query', 'schema' => ['type' => 'string']],
  391. ['name' => 'asn', 'in' => 'query', 'schema' => ['type' => 'integer']],
  392. ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['scored', 'manual', 'allowlisted', 'clean']]],
  393. ['$ref' => '#/components/parameters/Page'],
  394. ['$ref' => '#/components/parameters/PageSize'],
  395. ],
  396. 'responses' => ['200' => ['description' => 'Page of IPs', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['type' => 'object']]])]]]]],
  397. ],
  398. ],
  399. '/api/v1/admin/ips/countries' => [
  400. 'get' => [
  401. 'tags' => ['Admin'],
  402. 'summary' => 'Country code dropdown source',
  403. 'security' => [['BearerAuth' => []]],
  404. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  405. 'responses' => ['200' => ['description' => '`[{code, count}]`', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['items' => ['type' => 'array', 'items' => ['type' => 'object', 'properties' => ['code' => ['type' => 'string'], 'count' => ['type' => 'integer']]]]]]]]]],
  406. ],
  407. ],
  408. '/api/v1/admin/ips/{ip}' => [
  409. 'get' => [
  410. 'tags' => ['Admin'],
  411. 'summary' => 'IP detail',
  412. 'security' => [['BearerAuth' => []]],
  413. 'parameters' => [
  414. ['$ref' => '#/components/parameters/ActingUserId'],
  415. ['name' => 'ip', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']],
  416. ],
  417. 'responses' => [
  418. '200' => ['description' => 'IP detail', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/IpDetail']]]],
  419. '404' => ['description' => 'Invalid IP / not found'],
  420. ],
  421. ],
  422. ],
  423. // ---------- Admin: stats / dashboard ----------
  424. '/api/v1/admin/stats/dashboard' => [
  425. 'get' => [
  426. 'tags' => ['Admin'],
  427. 'summary' => 'Dashboard stats (30s cached)',
  428. 'security' => [['BearerAuth' => []]],
  429. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  430. 'responses' => ['200' => ['description' => 'Dashboard payload', 'content' => ['application/json' => ['schema' => ['type' => 'object']]]]],
  431. ],
  432. ],
  433. // ---------- Admin: manual blocks / allowlist ----------
  434. '/api/v1/admin/manual-blocks' => [
  435. 'get' => ['tags' => ['Admin'], 'summary' => 'List manual blocks', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  436. 'post' => [
  437. 'tags' => ['Admin'], 'summary' => 'Create manual block',
  438. 'description' => 'Operator+ role required. `kind=ip` requires `ip`; `kind=subnet` requires `cidr`.',
  439. 'security' => [['BearerAuth' => []]],
  440. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  441. 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']], 'ip' => ['type' => 'string'], 'cidr' => ['type' => 'string'], 'reason' => ['type' => 'string'], 'expires_at' => ['type' => 'string', 'format' => 'date-time']]]]]],
  442. 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]],
  443. ],
  444. ],
  445. '/api/v1/admin/manual-blocks/{id}' => [
  446. 'get' => ['tags' => ['Admin'], 'summary' => 'Show manual block', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'manual block', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]]],
  447. 'delete' => ['tags' => ['Admin'], 'summary' => 'Delete manual block', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
  448. ],
  449. '/api/v1/admin/allowlist' => [
  450. 'get' => ['tags' => ['Admin'], 'summary' => 'List allowlist', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  451. 'post' => ['tags' => ['Admin'], 'summary' => 'Create allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']], 'ip' => ['type' => 'string'], 'cidr' => ['type' => 'string'], 'reason' => ['type' => 'string']]]]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/AllowlistEntry']]]]]],
  452. ],
  453. '/api/v1/admin/allowlist/{id}' => [
  454. 'get' => ['tags' => ['Admin'], 'summary' => 'Show allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'allowlist entry', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/AllowlistEntry']]]]]],
  455. 'delete' => ['tags' => ['Admin'], 'summary' => 'Delete allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
  456. ],
  457. // ---------- Admin: reporters / consumers / tokens ----------
  458. '/api/v1/admin/reporters' => [
  459. 'get' => ['tags' => ['Admin'], 'summary' => 'List reporters', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  460. 'post' => ['tags' => ['Admin'], 'summary' => 'Create reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
  461. ],
  462. '/api/v1/admin/reporters/{id}' => [
  463. 'get' => ['tags' => ['Admin'], 'summary' => 'Show reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'reporter', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
  464. 'patch' => ['tags' => ['Admin'], 'summary' => 'Update reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]], 'responses' => ['200' => ['description' => 'Updated', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
  465. 'delete' => ['tags' => ['Admin'], 'summary' => 'Soft-delete reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Has reports — flagged inactive instead.']]],
  466. ],
  467. '/api/v1/admin/consumers' => [
  468. 'get' => ['tags' => ['Admin'], 'summary' => 'List consumers', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  469. 'post' => ['tags' => ['Admin'], 'summary' => 'Create consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]]]],
  470. ],
  471. '/api/v1/admin/consumers/{id}' => [
  472. 'get' => ['tags' => ['Admin'], 'summary' => 'Show consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'consumer', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]]]],
  473. 'patch' => ['tags' => ['Admin'], 'summary' => 'Update consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]], 'responses' => ['200' => ['description' => 'Updated']]],
  474. 'delete' => ['tags' => ['Admin'], 'summary' => 'Soft-delete consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
  475. ],
  476. '/api/v1/admin/tokens' => [
  477. 'get' => ['tags' => ['Admin'], 'summary' => 'List tokens', 'description' => 'Service tokens are filtered out unconditionally.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  478. 'post' => ['tags' => ['Admin'], 'summary' => 'Create token', 'description' => 'Returns the raw token ONCE in `raw_token`. Service tokens are not creatable here.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['reporter', 'consumer', 'admin']], 'reporter_id' => ['type' => 'integer'], 'consumer_id' => ['type' => 'integer'], 'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']], 'expires_at' => ['type' => 'string', 'format' => 'date-time']]]]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/TokenCreated']]]]]],
  479. ],
  480. '/api/v1/admin/tokens/{id}' => [
  481. 'delete' => ['tags' => ['Admin'], 'summary' => 'Revoke token', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Revoked']]],
  482. ],
  483. // ---------- Admin: categories / policies ----------
  484. '/api/v1/admin/categories' => [
  485. 'get' => ['tags' => ['Admin'], 'summary' => 'List categories', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  486. 'post' => ['tags' => ['Admin'], 'summary' => 'Create category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]]]],
  487. ],
  488. '/api/v1/admin/categories/{id}' => [
  489. 'get' => ['tags' => ['Admin'], 'summary' => 'Show category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'category', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]]]],
  490. 'patch' => ['tags' => ['Admin'], 'summary' => 'Update category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]], 'responses' => ['200' => ['description' => 'Updated']]],
  491. 'delete' => ['tags' => ['Admin'], 'summary' => 'Hard-delete category (refused if in use)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Category in use; soft-delete via PATCH `is_active=false`.']]],
  492. ],
  493. '/api/v1/admin/policies' => [
  494. 'get' => ['tags' => ['Admin'], 'summary' => 'List policies', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  495. 'post' => ['tags' => ['Admin'], 'summary' => 'Create policy', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]]]],
  496. ],
  497. '/api/v1/admin/policies/{id}' => [
  498. 'get' => ['tags' => ['Admin'], 'summary' => 'Show policy', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'policy', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]]]],
  499. 'patch' => ['tags' => ['Admin'], 'summary' => 'Update policy (replaces thresholds wholesale when present)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]], 'responses' => ['200' => ['description' => 'Updated']]],
  500. 'delete' => ['tags' => ['Admin'], 'summary' => 'Delete policy (refused if used by consumers)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Policy in use']]],
  501. ],
  502. '/api/v1/admin/policies/{id}/preview' => [
  503. 'get' => ['tags' => ['Admin'], 'summary' => 'Preview policy (count + sample)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'Preview']]],
  504. ],
  505. // ---------- Admin: audit / jobs / config ----------
  506. '/api/v1/admin/audit-log' => [
  507. 'get' => [
  508. 'tags' => ['Admin'], 'summary' => 'Filtered audit log',
  509. 'security' => [['BearerAuth' => []]],
  510. 'parameters' => [
  511. ['$ref' => '#/components/parameters/ActingUserId'],
  512. ['name' => 'actor_kind', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']]],
  513. ['name' => 'actor_id', 'in' => 'query', 'schema' => ['type' => 'integer']],
  514. ['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string']],
  515. ['name' => 'entity_type', 'in' => 'query', 'schema' => ['type' => 'string']],
  516. ['name' => 'entity_id', 'in' => 'query', 'schema' => ['type' => 'string']],
  517. ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
  518. ['name' => 'to', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
  519. ['$ref' => '#/components/parameters/Page'],
  520. ['$ref' => '#/components/parameters/PageSize'],
  521. ],
  522. 'responses' => ['200' => ['description' => 'Audit page', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/AuditEntry']]])]]]]],
  523. ],
  524. ],
  525. '/api/v1/admin/jobs/status' => [
  526. 'get' => ['tags' => ['Admin'], 'summary' => 'Jobs status (Viewer)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'Jobs status', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['now' => ['type' => 'string', 'format' => 'date-time'], 'jobs' => ['type' => 'object', 'additionalProperties' => ['$ref' => '#/components/schemas/JobStatus']]]]]]]]],
  527. ],
  528. '/api/v1/admin/jobs/trigger/{name}' => [
  529. 'post' => [
  530. 'tags' => ['Admin'], 'summary' => 'Manually trigger a job (Admin)',
  531. 'description' => 'Whitelisted params: `full`, `max_rows`, `reenrich`. Other body fields are dropped.',
  532. 'security' => [['BearerAuth' => []]],
  533. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'name', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']]],
  534. 'requestBody' => ['content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['full' => ['type' => 'boolean'], 'max_rows' => ['type' => 'integer'], 'reenrich' => ['type' => 'boolean']]]]]],
  535. 'responses' => [
  536. '200' => ['description' => 'Job ran', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/JobOutcome']]]],
  537. '404' => ['description' => 'Unknown job'],
  538. '409' => ['description' => 'Lock held; status=`skipped_locked`'],
  539. '412' => ['description' => 'refresh-geoip without credential'],
  540. ],
  541. ],
  542. ],
  543. '/api/v1/admin/config' => [
  544. 'get' => ['tags' => ['Admin'], 'summary' => 'Effective config (secrets masked)', 'description' => 'Admin only.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'Config sections', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['sections' => ['type' => 'object', 'additionalProperties' => ['type' => 'object']]]]]]]]],
  545. ],
  546. '/api/v1/admin/maintenance/purge' => [
  547. 'post' => [
  548. 'tags' => ['Admin'],
  549. 'summary' => 'Wipe operational data (Admin)',
  550. 'description' => "Deletes reports, scores, enrichment, manual blocks, allowlist, audit log, job history, reporters, consumers, policies, and non-service tokens. Preserves users, OIDC role mappings, abuse categories, and the `service`-kind token.\n\nRequires `confirm: \"PURGE\"` in the body — any other value returns 400.",
  551. 'security' => [['BearerAuth' => []]],
  552. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  553. 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'required' => ['confirm'], 'properties' => ['confirm' => ['type' => 'string', 'enum' => ['PURGE']]]]]]],
  554. 'responses' => [
  555. '200' => ['description' => 'Purge succeeded', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['status' => ['type' => 'string', 'example' => 'purged'], 'deleted' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']]]]]]],
  556. '400' => ['description' => 'Missing or wrong `confirm` value'],
  557. ],
  558. ],
  559. ],
  560. '/api/v1/admin/maintenance/seed-demo' => [
  561. 'post' => [
  562. 'tags' => ['Admin'],
  563. 'summary' => 'Load demo dataset (Admin)',
  564. 'description' => "Populates reporters, consumers, IPs, reports, manual blocks, allowlist, and synthetic GeoIP for demos and screenshots. Triggers a full score recompute on completion. Idempotent: returns 409 if demo data is already present.",
  565. 'security' => [['BearerAuth' => []]],
  566. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  567. 'responses' => [
  568. '200' => ['description' => 'Demo data inserted', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['status' => ['type' => 'string', 'example' => 'seeded'], 'summary' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], 'recompute' => ['$ref' => '#/components/schemas/JobOutcome']]]]]],
  569. '409' => ['description' => 'Demo data already present'],
  570. '412' => ['description' => 'No categories configured'],
  571. ],
  572. ],
  573. ],
  574. // ---------- Auth (UI BFF only) ----------
  575. '/api/v1/auth/users/upsert-oidc' => [
  576. 'post' => [
  577. 'tags' => ['Auth'],
  578. 'summary' => 'Upsert an OIDC-authenticated user',
  579. 'description' => "**UI BFF only.** Service-token-required, no impersonation header.\n"
  580. . 'Resolves the user record + role (via `oidc_role_mappings`) for a freshly-validated ID token.',
  581. 'x-internal' => true,
  582. 'security' => [['BearerAuth' => []]],
  583. 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['subject' => ['type' => 'string'], 'email' => ['type' => 'string'], 'display_name' => ['type' => 'string'], 'groups' => ['type' => 'array', 'items' => ['type' => 'string']]]]]]],
  584. 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
  585. ],
  586. ],
  587. '/api/v1/auth/users/upsert-local' => [
  588. 'post' => [
  589. 'tags' => ['Auth'],
  590. 'summary' => 'Upsert the local-admin user',
  591. 'description' => '**UI BFF only.** Called after the UI validates the local-admin password.',
  592. 'x-internal' => true,
  593. 'security' => [['BearerAuth' => []]],
  594. 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['username' => ['type' => 'string']]]]]],
  595. 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
  596. ],
  597. ],
  598. '/api/v1/auth/users/{id}' => [
  599. 'get' => [
  600. 'tags' => ['Auth'],
  601. 'summary' => 'Refetch a user record',
  602. 'description' => '**UI BFF only.** Used to refresh role / display_name during a session.',
  603. 'x-internal' => true,
  604. 'security' => [['BearerAuth' => []]],
  605. 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]],
  606. 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
  607. ],
  608. ],
  609. ];
  610. $spec = [
  611. 'openapi' => '3.0.3',
  612. 'info' => [
  613. 'title' => 'IRDB — IP Reputation Database',
  614. 'version' => '1.0.0',
  615. 'description' => "Self-hosted IP reputation service: ingest abuse reports, distribute tailored block lists.\n\n"
  616. . "## Versioning\n\n"
  617. . "Single major version `v1`. Changes within `v1` are additive only — new endpoints, new optional fields, new optional query params. Breaking changes ship as `v2`.\n\n"
  618. . "## Endpoint groups\n\n"
  619. . "- **Public**: machine clients (reporters, consumers).\n"
  620. . "- **Admin**: UI BFF + admin-kind tokens. RBAC enforced server-side.\n"
  621. . "- **Auth**: UI BFF only — bridges browser auth to user records. Marked `x-internal: true`.\n"
  622. . "- **Internal jobs**: not in this spec. Scheduler-only, network-restricted.\n\n"
  623. . "## Authentication\n\n"
  624. . "Bearer token in the `Authorization` header. Four token kinds: `reporter`, `consumer`, `admin`, `service`. See `doc/auth-flows.md`.\n\n"
  625. . "## Errors\n\n"
  626. . "Uniform envelope: `{\"error\":\"<code>\",\"details\":{...}}`. Validation errors include `details`. Authentication failures return `401` `unauthorized`. Authorization failures return `403`.\n\n"
  627. . "## Rate limiting\n\n"
  628. . "Public endpoints: 60 req/s/token (configurable). On exhaustion: `429` with `Retry-After: 1`.",
  629. 'license' => ['name' => 'TBD'],
  630. ],
  631. 'servers' => [
  632. ['url' => 'http://localhost:8081', 'description' => 'Default compose deployment'],
  633. ['url' => 'https://reputation-api.example.com', 'description' => 'Production (replace hostname)'],
  634. ],
  635. 'tags' => [
  636. ['name' => 'Public', 'description' => 'Machine clients: reporters and blocklist consumers.'],
  637. ['name' => 'Admin', 'description' => 'UI BFF + admin-kind tokens.'],
  638. ['name' => 'Auth', 'description' => 'UI BFF only. Service token required, no impersonation.'],
  639. ],
  640. 'security' => [['BearerAuth' => []]],
  641. 'paths' => $paths,
  642. 'components' => $components,
  643. ];
  644. echo dumpYaml($spec);
  645. /**
  646. * Minimal YAML dumper that handles the subset we use here:
  647. * - assoc arrays become mappings
  648. * - lists become block sequences
  649. * - strings get quoted only when needed (newlines, leading specials, special chars)
  650. * - bools / ints / floats / null serialise plainly
  651. *
  652. * @param mixed $value
  653. */
  654. function dumpYaml(mixed $value, int $indent = 0): string
  655. {
  656. if (!is_array($value)) {
  657. return scalarYaml($value) . "\n";
  658. }
  659. if ($value === []) {
  660. // Caller decides whether `[]` is "empty list" or "empty object".
  661. // We always emit empty list — callers that need an empty object
  662. // pass a sentinel (we don't have any in this spec).
  663. return "[]\n";
  664. }
  665. $isList = array_is_list($value);
  666. $out = '';
  667. $pad = str_repeat(' ', $indent);
  668. foreach ($value as $k => $v) {
  669. if ($isList) {
  670. if (is_array($v) && $v !== []) {
  671. // Render the child at indent 0, then prefix the first line
  672. // with "- " and subsequent lines with two-space continuation.
  673. $rawSub = rtrim(dumpYaml($v, 0), "\n");
  674. $childPad = str_repeat(' ', $indent + 1);
  675. $lines = explode("\n", $rawSub);
  676. $first = true;
  677. foreach ($lines as $line) {
  678. if ($first) {
  679. $out .= $pad . '- ' . $line . "\n";
  680. $first = false;
  681. } else {
  682. $out .= $childPad . $line . "\n";
  683. }
  684. }
  685. } else {
  686. $out .= $pad . '- ' . scalarYaml($v, $indent) . "\n";
  687. }
  688. } else {
  689. $key = (string) $k;
  690. // Quote keys that contain special chars
  691. if (preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $key) !== 1 || $key === 'on' || $key === 'no' || $key === 'yes' || $key === 'off') {
  692. $key = "'" . str_replace("'", "''", $key) . "'";
  693. }
  694. if (is_array($v)) {
  695. if ($v === []) {
  696. $out .= $pad . $key . ": []\n";
  697. } else {
  698. $out .= $pad . $key . ":\n" . dumpYaml($v, $indent + 1);
  699. }
  700. } else {
  701. $out .= $pad . $key . ': ' . scalarYaml($v, $indent) . "\n";
  702. }
  703. }
  704. }
  705. return $out;
  706. }
  707. function scalarYaml(mixed $value, int $parentIndent = 0): string
  708. {
  709. if ($value === null) {
  710. return 'null';
  711. }
  712. if (is_bool($value)) {
  713. return $value ? 'true' : 'false';
  714. }
  715. if (is_int($value) || is_float($value)) {
  716. return (string) $value;
  717. }
  718. $s = (string) $value;
  719. if ($s === '') {
  720. return "''";
  721. }
  722. // Multi-line: literal block scalar. Content must be indented one level
  723. // *deeper* than the key — i.e. `parentIndent + 1`. The caller already
  724. // emitted "<key>: <this>" so we return "|\n<indented lines>".
  725. if (str_contains($s, "\n")) {
  726. $lines = explode("\n", rtrim($s, "\n"));
  727. $contentPad = str_repeat(' ', $parentIndent + 1);
  728. $block = '|';
  729. foreach ($lines as $line) {
  730. $block .= "\n" . ($line === '' ? '' : $contentPad . $line);
  731. }
  732. return $block;
  733. }
  734. // Quote if it could be misread as bool/null/number, contains special chars, or starts with sigils.
  735. $needsQuote = preg_match('/^(true|false|null|yes|no|on|off|~)$/i', $s) === 1
  736. || preg_match('/^[\\-?:,\\[\\]\\{\\}#&*!|>\'"%@`]/', $s) === 1
  737. || preg_match('/^[+\\-]?\\d/', $s) === 1
  738. || str_contains($s, ': ')
  739. || str_contains($s, ' #')
  740. || str_starts_with($s, ' ')
  741. || str_ends_with($s, ' ');
  742. if ($needsQuote) {
  743. return "'" . str_replace("'", "''", $s) . "'";
  744. }
  745. return $s;
  746. }