1
0

openapi.php 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767
  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. 'details' => ['type' => 'object', 'nullable' => true, 'additionalProperties' => true],
  253. 'source_ip' => ['type' => 'string', 'nullable' => true],
  254. ],
  255. ],
  256. 'JobStatus' => [
  257. 'type' => 'object',
  258. 'properties' => [
  259. 'name' => ['type' => 'string'],
  260. 'default_interval_seconds' => ['type' => 'integer'],
  261. 'max_runtime_seconds' => ['type' => 'integer'],
  262. 'overdue' => ['type' => 'boolean'],
  263. 'last_run' => [
  264. 'type' => 'object',
  265. 'nullable' => true,
  266. 'properties' => [
  267. 'id' => ['type' => 'integer'],
  268. 'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
  269. 'items_processed' => ['type' => 'integer'],
  270. 'triggered_by' => ['type' => 'string', 'enum' => ['schedule', 'manual', 'api']],
  271. 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  272. 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
  273. 'error_message' => ['type' => 'string', 'nullable' => true],
  274. ],
  275. ],
  276. ],
  277. ],
  278. 'JobOutcome' => [
  279. 'type' => 'object',
  280. 'properties' => [
  281. 'job' => ['type' => 'string'],
  282. 'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
  283. 'items_processed' => ['type' => 'integer'],
  284. 'duration_ms' => ['type' => 'integer'],
  285. 'run_id' => ['type' => 'integer', 'nullable' => true],
  286. 'error' => ['type' => 'string', 'nullable' => true],
  287. ],
  288. ],
  289. 'User' => [
  290. 'type' => 'object',
  291. 'properties' => [
  292. 'id' => ['type' => 'integer'],
  293. 'email' => ['type' => 'string', 'nullable' => true],
  294. 'display_name' => ['type' => 'string'],
  295. 'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']],
  296. 'source' => ['type' => 'string', 'enum' => ['oidc', 'local', 'admin-token']],
  297. 'is_local' => ['type' => 'boolean'],
  298. ],
  299. ],
  300. 'Pagination' => [
  301. 'type' => 'object',
  302. 'properties' => $pageMeta,
  303. ],
  304. ],
  305. ];
  306. $paths = [
  307. // ---------- Public ----------
  308. '/api/v1/report' => [
  309. 'post' => [
  310. 'tags' => ['Public'],
  311. 'summary' => 'Submit an abuse report',
  312. 'description' => "Token kind: `reporter`. Rate limit: 60 req/s per token (configurable).\n"
  313. . 'Returns `202 Accepted` on success.',
  314. 'security' => [['BearerAuth' => []]],
  315. 'requestBody' => [
  316. 'required' => true,
  317. 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportRequest']]],
  318. ],
  319. 'responses' => [
  320. '202' => [
  321. 'description' => 'Report accepted',
  322. 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportResponse']]],
  323. ],
  324. '400' => ['description' => 'Validation failed', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
  325. '401' => ['description' => 'Bad / wrong-kind token', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
  326. '429' => [
  327. 'description' => 'Rate limited',
  328. 'headers' => ['Retry-After' => ['schema' => ['type' => 'integer'], 'description' => 'Seconds to wait before retrying.']],
  329. 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]],
  330. ],
  331. ],
  332. ],
  333. ],
  334. '/api/v1/blocklist' => [
  335. 'get' => [
  336. 'tags' => ['Public'],
  337. 'summary' => 'Pull a tailored blocklist',
  338. 'description' => "Token kind: `consumer`. The consumer's bound policy decides which IPs/CIDRs land in the output.\n"
  339. . "Cached internally for 30 s per consumer. Honour `If-None-Match` to skip retransfer.\n"
  340. . "`?format=json` returns structured rows; default is `text/plain`, one entry per line.",
  341. 'security' => [['BearerAuth' => []]],
  342. 'parameters' => [
  343. ['name' => 'format', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['text', 'json'], 'default' => 'text']],
  344. ['name' => 'If-None-Match', 'in' => 'header', 'schema' => ['type' => 'string']],
  345. ],
  346. 'responses' => [
  347. '200' => [
  348. 'description' => 'Current blocklist',
  349. 'headers' => [
  350. 'ETag' => ['schema' => ['type' => 'string']],
  351. 'X-Blocklist-Generated-At' => ['schema' => ['type' => 'string', 'format' => 'date-time']],
  352. 'X-Blocklist-Entries' => ['schema' => ['type' => 'integer']],
  353. 'X-Blocklist-Policy' => ['schema' => ['type' => 'string']],
  354. ],
  355. 'content' => [
  356. 'text/plain' => ['schema' => ['type' => 'string', 'example' => "203.0.113.42\n198.51.100.0/24\n"]],
  357. 'application/json' => ['schema' => ['$ref' => '#/components/schemas/BlocklistJson']],
  358. ],
  359. ],
  360. '304' => ['description' => 'Not modified — body matches `If-None-Match`'],
  361. '401' => ['description' => 'Bad / wrong-kind token'],
  362. ],
  363. ],
  364. ],
  365. // ---------- Admin: identity ----------
  366. '/api/v1/admin/me' => [
  367. 'get' => [
  368. 'tags' => ['Admin'],
  369. 'summary' => 'Current acting identity',
  370. 'security' => [['BearerAuth' => []]],
  371. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  372. 'responses' => [
  373. '200' => ['description' => 'Identity info', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]],
  374. ],
  375. ],
  376. ],
  377. // ---------- Admin: IPs ----------
  378. '/api/v1/admin/ips' => [
  379. 'get' => [
  380. 'tags' => ['Admin'],
  381. 'summary' => 'Search IPs',
  382. 'security' => [['BearerAuth' => []]],
  383. 'parameters' => [
  384. ['$ref' => '#/components/parameters/ActingUserId'],
  385. ['name' => 'q', 'in' => 'query', 'schema' => ['type' => 'string']],
  386. ['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']],
  387. ['name' => 'min_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
  388. ['name' => 'max_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
  389. ['name' => 'country', 'in' => 'query', 'schema' => ['type' => 'string']],
  390. ['name' => 'asn', 'in' => 'query', 'schema' => ['type' => 'integer']],
  391. ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['scored', 'manual', 'allowlisted', 'clean']]],
  392. ['$ref' => '#/components/parameters/Page'],
  393. ['$ref' => '#/components/parameters/PageSize'],
  394. ],
  395. 'responses' => ['200' => ['description' => 'Page of IPs', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['type' => 'object']]])]]]]],
  396. ],
  397. ],
  398. '/api/v1/admin/ips/countries' => [
  399. 'get' => [
  400. 'tags' => ['Admin'],
  401. 'summary' => 'Country code dropdown source',
  402. 'security' => [['BearerAuth' => []]],
  403. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  404. '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']]]]]]]]]],
  405. ],
  406. ],
  407. '/api/v1/admin/ips/{ip}' => [
  408. 'get' => [
  409. 'tags' => ['Admin'],
  410. 'summary' => 'IP detail',
  411. 'security' => [['BearerAuth' => []]],
  412. 'parameters' => [
  413. ['$ref' => '#/components/parameters/ActingUserId'],
  414. ['name' => 'ip', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']],
  415. ],
  416. 'responses' => [
  417. '200' => ['description' => 'IP detail', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/IpDetail']]]],
  418. '404' => ['description' => 'Invalid IP / not found'],
  419. ],
  420. ],
  421. ],
  422. // ---------- Admin: stats / dashboard ----------
  423. '/api/v1/admin/stats/dashboard' => [
  424. 'get' => [
  425. 'tags' => ['Admin'],
  426. 'summary' => 'Dashboard stats (30s cached)',
  427. 'security' => [['BearerAuth' => []]],
  428. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  429. 'responses' => ['200' => ['description' => 'Dashboard payload', 'content' => ['application/json' => ['schema' => ['type' => 'object']]]]],
  430. ],
  431. ],
  432. // ---------- Admin: manual blocks / allowlist ----------
  433. '/api/v1/admin/manual-blocks' => [
  434. 'get' => ['tags' => ['Admin'], 'summary' => 'List manual blocks', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  435. 'post' => [
  436. 'tags' => ['Admin'], 'summary' => 'Create manual block',
  437. 'description' => 'Operator+ role required. `kind=ip` requires `ip`; `kind=subnet` requires `cidr`.',
  438. 'security' => [['BearerAuth' => []]],
  439. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  440. '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']]]]]],
  441. 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]],
  442. ],
  443. ],
  444. '/api/v1/admin/manual-blocks/{id}' => [
  445. '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']]]]]],
  446. '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']]],
  447. ],
  448. '/api/v1/admin/allowlist' => [
  449. 'get' => ['tags' => ['Admin'], 'summary' => 'List allowlist', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  450. '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']]]]]],
  451. ],
  452. '/api/v1/admin/allowlist/{id}' => [
  453. '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']]]]]],
  454. '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']]],
  455. ],
  456. // ---------- Admin: reporters / consumers / tokens ----------
  457. '/api/v1/admin/reporters' => [
  458. 'get' => ['tags' => ['Admin'], 'summary' => 'List reporters', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  459. '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']]]]]],
  460. ],
  461. '/api/v1/admin/reporters/{id}' => [
  462. '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']]]]]],
  463. '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']]]]]],
  464. '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.']]],
  465. ],
  466. '/api/v1/admin/consumers' => [
  467. 'get' => ['tags' => ['Admin'], 'summary' => 'List consumers', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  468. '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']]]]]],
  469. ],
  470. '/api/v1/admin/consumers/{id}' => [
  471. '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']]]]]],
  472. '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']]],
  473. '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']]],
  474. ],
  475. '/api/v1/admin/tokens' => [
  476. 'get' => ['tags' => ['Admin'], 'summary' => 'List tokens', 'description' => 'Service tokens are filtered out unconditionally.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  477. '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']]]]]],
  478. ],
  479. '/api/v1/admin/tokens/{id}' => [
  480. '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']]],
  481. ],
  482. // ---------- Admin: categories / policies ----------
  483. '/api/v1/admin/categories' => [
  484. 'get' => ['tags' => ['Admin'], 'summary' => 'List categories', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  485. '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']]]]]],
  486. ],
  487. '/api/v1/admin/categories/{id}' => [
  488. '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']]]]]],
  489. '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']]],
  490. '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`.']]],
  491. ],
  492. '/api/v1/admin/policies' => [
  493. 'get' => ['tags' => ['Admin'], 'summary' => 'List policies', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
  494. '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']]]]]],
  495. ],
  496. '/api/v1/admin/policies/{id}' => [
  497. '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']]]]]],
  498. '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']]],
  499. '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']]],
  500. ],
  501. '/api/v1/admin/policies/{id}/preview' => [
  502. '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']]],
  503. ],
  504. // ---------- Admin: audit / jobs / config ----------
  505. '/api/v1/admin/audit-log' => [
  506. 'get' => [
  507. 'tags' => ['Admin'], 'summary' => 'Filtered audit log',
  508. 'security' => [['BearerAuth' => []]],
  509. 'parameters' => [
  510. ['$ref' => '#/components/parameters/ActingUserId'],
  511. ['name' => 'actor_kind', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']]],
  512. ['name' => 'actor_id', 'in' => 'query', 'schema' => ['type' => 'integer']],
  513. ['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string']],
  514. ['name' => 'entity_type', 'in' => 'query', 'schema' => ['type' => 'string']],
  515. ['name' => 'entity_id', 'in' => 'query', 'schema' => ['type' => 'string']],
  516. ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
  517. ['name' => 'to', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
  518. ['$ref' => '#/components/parameters/Page'],
  519. ['$ref' => '#/components/parameters/PageSize'],
  520. ],
  521. 'responses' => ['200' => ['description' => 'Audit page', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/AuditEntry']]])]]]]],
  522. ],
  523. ],
  524. '/api/v1/admin/jobs/status' => [
  525. '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']]]]]]]]],
  526. ],
  527. '/api/v1/admin/jobs/trigger/{name}' => [
  528. 'post' => [
  529. 'tags' => ['Admin'], 'summary' => 'Manually trigger a job (Admin)',
  530. 'description' => 'Whitelisted params: `full`, `max_rows`, `reenrich`. Other body fields are dropped.',
  531. 'security' => [['BearerAuth' => []]],
  532. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'name', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']]],
  533. 'requestBody' => ['content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['full' => ['type' => 'boolean'], 'max_rows' => ['type' => 'integer'], 'reenrich' => ['type' => 'boolean']]]]]],
  534. 'responses' => [
  535. '200' => ['description' => 'Job ran', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/JobOutcome']]]],
  536. '404' => ['description' => 'Unknown job'],
  537. '409' => ['description' => 'Lock held; status=`skipped_locked`'],
  538. '412' => ['description' => 'refresh-geoip without credential'],
  539. ],
  540. ],
  541. ],
  542. '/api/v1/admin/config' => [
  543. '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']]]]]]]]],
  544. ],
  545. '/api/v1/admin/maintenance/purge' => [
  546. 'post' => [
  547. 'tags' => ['Admin'],
  548. 'summary' => 'Wipe operational data (Admin)',
  549. '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.",
  550. 'security' => [['BearerAuth' => []]],
  551. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  552. 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'required' => ['confirm'], 'properties' => ['confirm' => ['type' => 'string', 'enum' => ['PURGE']]]]]]],
  553. 'responses' => [
  554. '200' => ['description' => 'Purge succeeded', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['status' => ['type' => 'string', 'example' => 'purged'], 'deleted' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']]]]]]],
  555. '400' => ['description' => 'Missing or wrong `confirm` value'],
  556. ],
  557. ],
  558. ],
  559. '/api/v1/admin/maintenance/seed-demo' => [
  560. 'post' => [
  561. 'tags' => ['Admin'],
  562. 'summary' => 'Load demo dataset (Admin)',
  563. '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.",
  564. 'security' => [['BearerAuth' => []]],
  565. 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
  566. 'responses' => [
  567. '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']]]]]],
  568. '409' => ['description' => 'Demo data already present'],
  569. '412' => ['description' => 'No categories configured'],
  570. ],
  571. ],
  572. ],
  573. // ---------- Auth (UI BFF only) ----------
  574. '/api/v1/auth/users/upsert-oidc' => [
  575. 'post' => [
  576. 'tags' => ['Auth'],
  577. 'summary' => 'Upsert an OIDC-authenticated user',
  578. 'description' => "**UI BFF only.** Service-token-required, no impersonation header.\n"
  579. . 'Resolves the user record + role (via `oidc_role_mappings`) for a freshly-validated ID token.',
  580. 'x-internal' => true,
  581. 'security' => [['BearerAuth' => []]],
  582. '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']]]]]]],
  583. 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
  584. ],
  585. ],
  586. '/api/v1/auth/users/upsert-local' => [
  587. 'post' => [
  588. 'tags' => ['Auth'],
  589. 'summary' => 'Upsert the local-admin user',
  590. 'description' => '**UI BFF only.** Called after the UI validates the local-admin password.',
  591. 'x-internal' => true,
  592. 'security' => [['BearerAuth' => []]],
  593. 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['username' => ['type' => 'string']]]]]],
  594. 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
  595. ],
  596. ],
  597. '/api/v1/auth/users/{id}' => [
  598. 'get' => [
  599. 'tags' => ['Auth'],
  600. 'summary' => 'Refetch a user record',
  601. 'description' => '**UI BFF only.** Used to refresh role / display_name during a session.',
  602. 'x-internal' => true,
  603. 'security' => [['BearerAuth' => []]],
  604. 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]],
  605. 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
  606. ],
  607. ],
  608. ];
  609. $spec = [
  610. 'openapi' => '3.0.3',
  611. 'info' => [
  612. 'title' => 'IRDB — IP Reputation Database',
  613. 'version' => '1.0.0',
  614. 'description' => "Self-hosted IP reputation service: ingest abuse reports, distribute tailored block lists.\n\n"
  615. . "## Versioning\n\n"
  616. . "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"
  617. . "## Endpoint groups\n\n"
  618. . "- **Public**: machine clients (reporters, consumers).\n"
  619. . "- **Admin**: UI BFF + admin-kind tokens. RBAC enforced server-side.\n"
  620. . "- **Auth**: UI BFF only — bridges browser auth to user records. Marked `x-internal: true`.\n"
  621. . "- **Internal jobs**: not in this spec. Scheduler-only, network-restricted.\n\n"
  622. . "## Authentication\n\n"
  623. . "Bearer token in the `Authorization` header. Four token kinds: `reporter`, `consumer`, `admin`, `service`. See `doc/auth-flows.md`.\n\n"
  624. . "## Errors\n\n"
  625. . "Uniform envelope: `{\"error\":\"<code>\",\"details\":{...}}`. Validation errors include `details`. Authentication failures return `401` `unauthorized`. Authorization failures return `403`.\n\n"
  626. . "## Rate limiting\n\n"
  627. . "Public endpoints: 60 req/s/token (configurable). On exhaustion: `429` with `Retry-After: 1`.",
  628. 'license' => ['name' => 'TBD'],
  629. ],
  630. 'servers' => [
  631. ['url' => 'http://localhost:8081', 'description' => 'Default compose deployment'],
  632. ['url' => 'https://reputation-api.example.com', 'description' => 'Production (replace hostname)'],
  633. ],
  634. 'tags' => [
  635. ['name' => 'Public', 'description' => 'Machine clients: reporters and blocklist consumers.'],
  636. ['name' => 'Admin', 'description' => 'UI BFF + admin-kind tokens.'],
  637. ['name' => 'Auth', 'description' => 'UI BFF only. Service token required, no impersonation.'],
  638. ],
  639. 'security' => [['BearerAuth' => []]],
  640. 'paths' => $paths,
  641. 'components' => $components,
  642. ];
  643. echo dumpYaml($spec);
  644. /**
  645. * Minimal YAML dumper that handles the subset we use here:
  646. * - assoc arrays become mappings
  647. * - lists become block sequences
  648. * - strings get quoted only when needed (newlines, leading specials, special chars)
  649. * - bools / ints / floats / null serialise plainly
  650. *
  651. * @param mixed $value
  652. */
  653. function dumpYaml(mixed $value, int $indent = 0): string
  654. {
  655. if (!is_array($value)) {
  656. return scalarYaml($value) . "\n";
  657. }
  658. if ($value === []) {
  659. // Caller decides whether `[]` is "empty list" or "empty object".
  660. // We always emit empty list — callers that need an empty object
  661. // pass a sentinel (we don't have any in this spec).
  662. return "[]\n";
  663. }
  664. $isList = array_is_list($value);
  665. $out = '';
  666. $pad = str_repeat(' ', $indent);
  667. foreach ($value as $k => $v) {
  668. if ($isList) {
  669. if (is_array($v) && $v !== []) {
  670. // Render the child at indent 0, then prefix the first line
  671. // with "- " and subsequent lines with two-space continuation.
  672. $rawSub = rtrim(dumpYaml($v, 0), "\n");
  673. $childPad = str_repeat(' ', $indent + 1);
  674. $lines = explode("\n", $rawSub);
  675. $first = true;
  676. foreach ($lines as $line) {
  677. if ($first) {
  678. $out .= $pad . '- ' . $line . "\n";
  679. $first = false;
  680. } else {
  681. $out .= $childPad . $line . "\n";
  682. }
  683. }
  684. } else {
  685. $out .= $pad . '- ' . scalarYaml($v, $indent) . "\n";
  686. }
  687. } else {
  688. $key = (string) $k;
  689. // Quote keys that contain special chars
  690. if (preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $key) !== 1 || $key === 'on' || $key === 'no' || $key === 'yes' || $key === 'off') {
  691. $key = "'" . str_replace("'", "''", $key) . "'";
  692. }
  693. if (is_array($v)) {
  694. if ($v === []) {
  695. $out .= $pad . $key . ": []\n";
  696. } else {
  697. $out .= $pad . $key . ":\n" . dumpYaml($v, $indent + 1);
  698. }
  699. } else {
  700. $out .= $pad . $key . ': ' . scalarYaml($v, $indent) . "\n";
  701. }
  702. }
  703. }
  704. return $out;
  705. }
  706. function scalarYaml(mixed $value, int $parentIndent = 0): string
  707. {
  708. if ($value === null) {
  709. return 'null';
  710. }
  711. if (is_bool($value)) {
  712. return $value ? 'true' : 'false';
  713. }
  714. if (is_int($value) || is_float($value)) {
  715. return (string) $value;
  716. }
  717. $s = (string) $value;
  718. if ($s === '') {
  719. return "''";
  720. }
  721. // Multi-line: literal block scalar. Content must be indented one level
  722. // *deeper* than the key — i.e. `parentIndent + 1`. The caller already
  723. // emitted "<key>: <this>" so we return "|\n<indented lines>".
  724. if (str_contains($s, "\n")) {
  725. $lines = explode("\n", rtrim($s, "\n"));
  726. $contentPad = str_repeat(' ', $parentIndent + 1);
  727. $block = '|';
  728. foreach ($lines as $line) {
  729. $block .= "\n" . ($line === '' ? '' : $contentPad . $line);
  730. }
  731. return $block;
  732. }
  733. // Quote if it could be misread as bool/null/number, contains special chars, or starts with sigils.
  734. $needsQuote = preg_match('/^(true|false|null|yes|no|on|off|~)$/i', $s) === 1
  735. || preg_match('/^[\\-?:,\\[\\]\\{\\}#&*!|>\'"%@`]/', $s) === 1
  736. || preg_match('/^[+\\-]?\\d/', $s) === 1
  737. || str_contains($s, ': ')
  738. || str_contains($s, ' #')
  739. || str_starts_with($s, ' ')
  740. || str_ends_with($s, ' ');
  741. if ($needsQuote) {
  742. return "'" . str_replace("'", "''", $s) . "'";
  743. }
  744. return $s;
  745. }