| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739 |
- <?php
- declare(strict_types=1);
- /**
- * Hand-curated OpenAPI 3.0 spec for IRDB.
- *
- * Run: `composer openapi:build` (or `php api/openapi.php > api/public/openapi.yaml`).
- * The committed `api/public/openapi.yaml` MUST match this file's output —
- * the doc-accuracy CI guard (`scripts/check-doc-endpoints.sh`) treats the
- * generated spec as the source of truth for which endpoints exist.
- *
- * Why hand-curated and not annotation-based: keeps the API surface in one
- * place where it can be reviewed in PR diffs, avoids pulling
- * zircote/swagger-php into prod deps, and the static array fits in <500
- * lines with room for descriptions. Trade-off: drift between code and
- * spec is possible — the integration tests assert response shapes
- * separately, and CI runs `redocly lint` against the YAML to catch
- * malformed entries.
- *
- * Internal endpoints (`/internal/jobs/*`) are deliberately omitted per
- * SPEC §M13.1; they're scheduler-only and not part of the public
- * contract.
- */
- require_once __DIR__ . '/vendor/autoload.php';
- $errorEnvelope = [
- 'type' => 'object',
- 'required' => ['error'],
- 'properties' => [
- 'error' => ['type' => 'string', 'example' => 'unauthorized'],
- 'details' => [
- 'type' => 'object',
- 'description' => 'Field-level errors for `validation_failed`.',
- 'additionalProperties' => ['type' => 'string'],
- ],
- ],
- ];
- $pageMeta = [
- 'page' => ['type' => 'integer', 'minimum' => 1, 'example' => 1],
- 'page_size' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'example' => 50],
- 'total' => ['type' => 'integer', 'minimum' => 0, 'example' => 1284],
- ];
- $components = [
- 'securitySchemes' => [
- 'BearerAuth' => [
- 'type' => 'http',
- 'scheme' => 'bearer',
- 'description' => "Token in the form `irdb_<kind>_<32 base32 chars>`.\n"
- . 'See `doc/auth-flows.md` for the four token kinds.',
- ],
- ],
- 'parameters' => [
- 'Page' => [
- 'name' => 'page',
- 'in' => 'query',
- 'schema' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
- ],
- 'PageSize' => [
- 'name' => 'page_size',
- 'in' => 'query',
- 'schema' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'default' => 50],
- ],
- 'ActingUserId' => [
- 'name' => 'X-Acting-User-Id',
- 'in' => 'header',
- 'description' => "Required when authenticating with a `service` token.\n"
- . 'The api applies RBAC for the named user. Ignored on other token kinds.',
- 'schema' => ['type' => 'integer'],
- ],
- ],
- 'schemas' => [
- 'Error' => $errorEnvelope,
- 'ReportRequest' => [
- 'type' => 'object',
- 'required' => ['ip', 'category'],
- 'properties' => [
- 'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
- 'category' => ['type' => 'string', 'example' => 'brute_force'],
- 'metadata' => [
- 'type' => 'object',
- 'description' => 'Free-form per-report data, max 4 KB after json_encode.',
- 'additionalProperties' => true,
- ],
- ],
- ],
- 'ReportResponse' => [
- 'type' => 'object',
- 'properties' => [
- 'report_id' => ['type' => 'integer', 'example' => 12345],
- 'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
- 'received_at' => ['type' => 'string', 'format' => 'date-time'],
- ],
- ],
- 'BlocklistEntry' => [
- 'type' => 'object',
- 'properties' => [
- 'ip_or_cidr' => ['type' => 'string', 'example' => '203.0.113.42'],
- 'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
- 'score' => ['type' => 'number', 'format' => 'float', 'example' => 1.42],
- 'reason' => ['type' => 'string', 'enum' => ['scored', 'manual'], 'example' => 'scored'],
- ],
- ],
- 'BlocklistJson' => [
- 'type' => 'object',
- 'properties' => [
- 'count' => ['type' => 'integer', 'example' => 42],
- 'generated_at' => ['type' => 'string', 'format' => 'date-time'],
- 'policy' => ['type' => 'string', 'example' => 'moderate'],
- 'entries' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/BlocklistEntry']],
- ],
- ],
- 'Token' => [
- 'type' => 'object',
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'kind' => ['type' => 'string', 'enum' => ['reporter', 'consumer', 'admin']],
- 'prefix' => ['type' => 'string', 'example' => 'irdb_adm'],
- 'reporter_id' => ['type' => 'integer', 'nullable' => true],
- 'consumer_id' => ['type' => 'integer', 'nullable' => true],
- 'role' => ['type' => 'string', 'nullable' => true, 'enum' => ['viewer', 'operator', 'admin', null]],
- 'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
- 'revoked_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
- 'last_used_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
- ],
- ],
- 'TokenCreated' => [
- 'allOf' => [
- ['$ref' => '#/components/schemas/Token'],
- [
- 'type' => 'object',
- 'properties' => [
- 'raw_token' => [
- 'type' => 'string',
- 'description' => 'Returned ONCE on creation — copy it now, never displayed again.',
- 'example' => 'irdb_adm_AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD',
- ],
- ],
- ],
- ],
- ],
- 'Reporter' => [
- 'type' => 'object',
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'name' => ['type' => 'string', 'example' => 'web-prod-01'],
- 'description' => ['type' => 'string', 'nullable' => true],
- 'trust_weight' => ['type' => 'number', 'format' => 'float', 'minimum' => 0.0, 'maximum' => 2.0],
- 'is_active' => ['type' => 'boolean'],
- ],
- ],
- 'Consumer' => [
- 'type' => 'object',
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'name' => ['type' => 'string', 'example' => 'edge-fw-01'],
- 'description' => ['type' => 'string', 'nullable' => true],
- 'policy_id' => ['type' => 'integer'],
- 'is_active' => ['type' => 'boolean'],
- 'last_pulled_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
- ],
- ],
- 'Category' => [
- 'type' => 'object',
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'slug' => ['type' => 'string', 'example' => 'brute_force'],
- 'name' => ['type' => 'string'],
- 'description' => ['type' => 'string', 'nullable' => true],
- 'decay_function' => ['type' => 'string', 'enum' => ['linear', 'exponential']],
- 'decay_param' => ['type' => 'number', 'format' => 'float'],
- 'is_active' => ['type' => 'boolean'],
- ],
- ],
- 'Policy' => [
- 'type' => 'object',
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'name' => ['type' => 'string', 'example' => 'moderate'],
- 'description' => ['type' => 'string', 'nullable' => true],
- 'include_manual_blocks' => ['type' => 'boolean'],
- 'thresholds' => [
- 'type' => 'object',
- 'description' => '`{category_slug: threshold}`',
- 'additionalProperties' => ['type' => 'number', 'format' => 'float'],
- ],
- ],
- ],
- 'ManualBlock' => [
- 'type' => 'object',
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
- 'ip' => ['type' => 'string', 'nullable' => true],
- 'cidr' => ['type' => 'string', 'nullable' => true],
- 'reason' => ['type' => 'string', 'nullable' => true],
- 'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
- 'created_at' => ['type' => 'string', 'format' => 'date-time'],
- ],
- ],
- 'AllowlistEntry' => [
- 'type' => 'object',
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
- 'ip' => ['type' => 'string', 'nullable' => true],
- 'cidr' => ['type' => 'string', 'nullable' => true],
- 'reason' => ['type' => 'string', 'nullable' => true],
- 'created_at' => ['type' => 'string', 'format' => 'date-time'],
- ],
- ],
- 'IpDetail' => [
- 'type' => 'object',
- 'properties' => [
- 'ip' => ['type' => 'string'],
- 'is_ipv4' => ['type' => 'boolean'],
- 'scores' => [
- 'type' => 'array',
- 'items' => [
- 'type' => 'object',
- 'properties' => [
- 'category' => ['type' => 'string'],
- 'score' => ['type' => 'number', 'format' => 'float'],
- 'last_report_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
- 'report_count_30d' => ['type' => 'integer'],
- ],
- ],
- ],
- 'enrichment' => [
- 'type' => 'object',
- 'properties' => [
- 'country_code' => ['type' => 'string', 'nullable' => true],
- 'asn' => ['type' => 'integer', 'nullable' => true],
- 'as_org' => ['type' => 'string', 'nullable' => true],
- 'enriched_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
- ],
- ],
- 'status' => ['type' => 'string', 'enum' => ['scored', 'manually_blocked', 'allowlisted', 'clean']],
- 'manual_block' => ['type' => 'object', 'nullable' => true],
- 'allowlist' => ['type' => 'object', 'nullable' => true],
- 'history' => ['type' => 'array', 'items' => ['type' => 'object']],
- 'has_more' => ['type' => 'boolean'],
- ],
- ],
- 'AuditEntry' => [
- 'type' => 'object',
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'occurred_at' => ['type' => 'string', 'format' => 'date-time'],
- 'actor_kind' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']],
- 'actor_id' => ['type' => 'string', 'nullable' => true],
- 'action' => ['type' => 'string', 'example' => 'manual_block.created'],
- 'entity_type' => ['type' => 'string', 'nullable' => true],
- 'entity_id' => ['type' => 'string', 'nullable' => true],
- 'details' => ['type' => 'object', 'nullable' => true, 'additionalProperties' => true],
- 'source_ip' => ['type' => 'string', 'nullable' => true],
- ],
- ],
- 'JobStatus' => [
- 'type' => 'object',
- 'properties' => [
- 'name' => ['type' => 'string'],
- 'default_interval_seconds' => ['type' => 'integer'],
- 'max_runtime_seconds' => ['type' => 'integer'],
- 'overdue' => ['type' => 'boolean'],
- 'last_run' => [
- 'type' => 'object',
- 'nullable' => true,
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
- 'items_processed' => ['type' => 'integer'],
- 'triggered_by' => ['type' => 'string', 'enum' => ['schedule', 'manual', 'api']],
- 'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
- 'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
- 'error_message' => ['type' => 'string', 'nullable' => true],
- ],
- ],
- ],
- ],
- 'JobOutcome' => [
- 'type' => 'object',
- 'properties' => [
- 'job' => ['type' => 'string'],
- 'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
- 'items_processed' => ['type' => 'integer'],
- 'duration_ms' => ['type' => 'integer'],
- 'run_id' => ['type' => 'integer', 'nullable' => true],
- 'error' => ['type' => 'string', 'nullable' => true],
- ],
- ],
- 'User' => [
- 'type' => 'object',
- 'properties' => [
- 'id' => ['type' => 'integer'],
- 'email' => ['type' => 'string', 'nullable' => true],
- 'display_name' => ['type' => 'string'],
- 'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']],
- 'source' => ['type' => 'string', 'enum' => ['oidc', 'local', 'admin-token']],
- 'is_local' => ['type' => 'boolean'],
- ],
- ],
- 'Pagination' => [
- 'type' => 'object',
- 'properties' => $pageMeta,
- ],
- ],
- ];
- $paths = [
- // ---------- Public ----------
- '/api/v1/report' => [
- 'post' => [
- 'tags' => ['Public'],
- 'summary' => 'Submit an abuse report',
- 'description' => "Token kind: `reporter`. Rate limit: 60 req/s per token (configurable).\n"
- . 'Returns `202 Accepted` on success.',
- 'security' => [['BearerAuth' => []]],
- 'requestBody' => [
- 'required' => true,
- 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportRequest']]],
- ],
- 'responses' => [
- '202' => [
- 'description' => 'Report accepted',
- 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportResponse']]],
- ],
- '400' => ['description' => 'Validation failed', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
- '401' => ['description' => 'Bad / wrong-kind token', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
- '429' => [
- 'description' => 'Rate limited',
- 'headers' => ['Retry-After' => ['schema' => ['type' => 'integer'], 'description' => 'Seconds to wait before retrying.']],
- 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]],
- ],
- ],
- ],
- ],
- '/api/v1/blocklist' => [
- 'get' => [
- 'tags' => ['Public'],
- 'summary' => 'Pull a tailored blocklist',
- 'description' => "Token kind: `consumer`. The consumer's bound policy decides which IPs/CIDRs land in the output.\n"
- . "Cached internally for 30 s per consumer. Honour `If-None-Match` to skip retransfer.\n"
- . "`?format=json` returns structured rows; default is `text/plain`, one entry per line.",
- 'security' => [['BearerAuth' => []]],
- 'parameters' => [
- ['name' => 'format', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['text', 'json'], 'default' => 'text']],
- ['name' => 'If-None-Match', 'in' => 'header', 'schema' => ['type' => 'string']],
- ],
- 'responses' => [
- '200' => [
- 'description' => 'Current blocklist',
- 'headers' => [
- 'ETag' => ['schema' => ['type' => 'string']],
- 'X-Blocklist-Generated-At' => ['schema' => ['type' => 'string', 'format' => 'date-time']],
- 'X-Blocklist-Entries' => ['schema' => ['type' => 'integer']],
- 'X-Blocklist-Policy' => ['schema' => ['type' => 'string']],
- ],
- 'content' => [
- 'text/plain' => ['schema' => ['type' => 'string', 'example' => "203.0.113.42\n198.51.100.0/24\n"]],
- 'application/json' => ['schema' => ['$ref' => '#/components/schemas/BlocklistJson']],
- ],
- ],
- '304' => ['description' => 'Not modified — body matches `If-None-Match`'],
- '401' => ['description' => 'Bad / wrong-kind token'],
- ],
- ],
- ],
- // ---------- Admin: identity ----------
- '/api/v1/admin/me' => [
- 'get' => [
- 'tags' => ['Admin'],
- 'summary' => 'Current acting identity',
- 'security' => [['BearerAuth' => []]],
- 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
- 'responses' => [
- '200' => ['description' => 'Identity info', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]],
- ],
- ],
- ],
- // ---------- Admin: IPs ----------
- '/api/v1/admin/ips' => [
- 'get' => [
- 'tags' => ['Admin'],
- 'summary' => 'Search IPs',
- 'security' => [['BearerAuth' => []]],
- 'parameters' => [
- ['$ref' => '#/components/parameters/ActingUserId'],
- ['name' => 'q', 'in' => 'query', 'schema' => ['type' => 'string']],
- ['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']],
- ['name' => 'min_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
- ['name' => 'max_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
- ['name' => 'country', 'in' => 'query', 'schema' => ['type' => 'string']],
- ['name' => 'asn', 'in' => 'query', 'schema' => ['type' => 'integer']],
- ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['scored', 'manual', 'allowlisted', 'clean']]],
- ['$ref' => '#/components/parameters/Page'],
- ['$ref' => '#/components/parameters/PageSize'],
- ],
- 'responses' => ['200' => ['description' => 'Page of IPs', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['type' => 'object']]])]]]]],
- ],
- ],
- '/api/v1/admin/ips/countries' => [
- 'get' => [
- 'tags' => ['Admin'],
- 'summary' => 'Country code dropdown source',
- 'security' => [['BearerAuth' => []]],
- 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
- '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']]]]]]]]]],
- ],
- ],
- '/api/v1/admin/ips/{ip}' => [
- 'get' => [
- 'tags' => ['Admin'],
- 'summary' => 'IP detail',
- 'security' => [['BearerAuth' => []]],
- 'parameters' => [
- ['$ref' => '#/components/parameters/ActingUserId'],
- ['name' => 'ip', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']],
- ],
- 'responses' => [
- '200' => ['description' => 'IP detail', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/IpDetail']]]],
- '404' => ['description' => 'Invalid IP / not found'],
- ],
- ],
- ],
- // ---------- Admin: stats / dashboard ----------
- '/api/v1/admin/stats/dashboard' => [
- 'get' => [
- 'tags' => ['Admin'],
- 'summary' => 'Dashboard stats (30s cached)',
- 'security' => [['BearerAuth' => []]],
- 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
- 'responses' => ['200' => ['description' => 'Dashboard payload', 'content' => ['application/json' => ['schema' => ['type' => 'object']]]]],
- ],
- ],
- // ---------- Admin: manual blocks / allowlist ----------
- '/api/v1/admin/manual-blocks' => [
- 'get' => ['tags' => ['Admin'], 'summary' => 'List manual blocks', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
- 'post' => [
- 'tags' => ['Admin'], 'summary' => 'Create manual block',
- 'description' => 'Operator+ role required. `kind=ip` requires `ip`; `kind=subnet` requires `cidr`.',
- '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'], 'expires_at' => ['type' => 'string', 'format' => 'date-time']]]]]],
- 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]],
- ],
- ],
- '/api/v1/admin/manual-blocks/{id}' => [
- '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']]]]]],
- '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']]],
- ],
- '/api/v1/admin/allowlist' => [
- 'get' => ['tags' => ['Admin'], 'summary' => 'List allowlist', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
- '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']]]]]],
- ],
- '/api/v1/admin/allowlist/{id}' => [
- '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']]]]]],
- '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']]],
- ],
- // ---------- Admin: reporters / consumers / tokens ----------
- '/api/v1/admin/reporters' => [
- 'get' => ['tags' => ['Admin'], 'summary' => 'List reporters', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
- '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']]]]]],
- ],
- '/api/v1/admin/reporters/{id}' => [
- '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']]]]]],
- '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']]]]]],
- '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.']]],
- ],
- '/api/v1/admin/consumers' => [
- 'get' => ['tags' => ['Admin'], 'summary' => 'List consumers', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
- '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']]]]]],
- ],
- '/api/v1/admin/consumers/{id}' => [
- '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']]]]]],
- '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']]],
- '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']]],
- ],
- '/api/v1/admin/tokens' => [
- 'get' => ['tags' => ['Admin'], 'summary' => 'List tokens', 'description' => 'Service tokens are filtered out unconditionally.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
- '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']]]]]],
- ],
- '/api/v1/admin/tokens/{id}' => [
- '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']]],
- ],
- // ---------- Admin: categories / policies ----------
- '/api/v1/admin/categories' => [
- 'get' => ['tags' => ['Admin'], 'summary' => 'List categories', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
- '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']]]]]],
- ],
- '/api/v1/admin/categories/{id}' => [
- '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']]]]]],
- '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']]],
- '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`.']]],
- ],
- '/api/v1/admin/policies' => [
- 'get' => ['tags' => ['Admin'], 'summary' => 'List policies', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
- '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']]]]]],
- ],
- '/api/v1/admin/policies/{id}' => [
- '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']]]]]],
- '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']]],
- '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']]],
- ],
- '/api/v1/admin/policies/{id}/preview' => [
- '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']]],
- ],
- // ---------- Admin: audit / jobs / config ----------
- '/api/v1/admin/audit-log' => [
- 'get' => [
- 'tags' => ['Admin'], 'summary' => 'Filtered audit log',
- 'security' => [['BearerAuth' => []]],
- 'parameters' => [
- ['$ref' => '#/components/parameters/ActingUserId'],
- ['name' => 'actor_kind', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']]],
- ['name' => 'actor_id', 'in' => 'query', 'schema' => ['type' => 'integer']],
- ['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string']],
- ['name' => 'entity_type', 'in' => 'query', 'schema' => ['type' => 'string']],
- ['name' => 'entity_id', 'in' => 'query', 'schema' => ['type' => 'string']],
- ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
- ['name' => 'to', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
- ['$ref' => '#/components/parameters/Page'],
- ['$ref' => '#/components/parameters/PageSize'],
- ],
- 'responses' => ['200' => ['description' => 'Audit page', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/AuditEntry']]])]]]]],
- ],
- ],
- '/api/v1/admin/jobs/status' => [
- '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']]]]]]]]],
- ],
- '/api/v1/admin/jobs/trigger/{name}' => [
- 'post' => [
- 'tags' => ['Admin'], 'summary' => 'Manually trigger a job (Admin)',
- 'description' => 'Whitelisted params: `full`, `max_rows`, `reenrich`. Other body fields are dropped.',
- 'security' => [['BearerAuth' => []]],
- 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'name', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']]],
- 'requestBody' => ['content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['full' => ['type' => 'boolean'], 'max_rows' => ['type' => 'integer'], 'reenrich' => ['type' => 'boolean']]]]]],
- 'responses' => [
- '200' => ['description' => 'Job ran', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/JobOutcome']]]],
- '404' => ['description' => 'Unknown job'],
- '409' => ['description' => 'Lock held; status=`skipped_locked`'],
- '412' => ['description' => 'refresh-geoip without credential'],
- ],
- ],
- ],
- '/api/v1/admin/config' => [
- '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']]]]]]]]],
- ],
- // ---------- Auth (UI BFF only) ----------
- '/api/v1/auth/users/upsert-oidc' => [
- 'post' => [
- 'tags' => ['Auth'],
- 'summary' => 'Upsert an OIDC-authenticated user',
- 'description' => "**UI BFF only.** Service-token-required, no impersonation header.\n"
- . 'Resolves the user record + role (via `oidc_role_mappings`) for a freshly-validated ID token.',
- 'x-internal' => true,
- 'security' => [['BearerAuth' => []]],
- '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']]]]]]],
- 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
- ],
- ],
- '/api/v1/auth/users/upsert-local' => [
- 'post' => [
- 'tags' => ['Auth'],
- 'summary' => 'Upsert the local-admin user',
- 'description' => '**UI BFF only.** Called after the UI validates the local-admin password.',
- 'x-internal' => true,
- 'security' => [['BearerAuth' => []]],
- 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['username' => ['type' => 'string']]]]]],
- 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
- ],
- ],
- '/api/v1/auth/users/{id}' => [
- 'get' => [
- 'tags' => ['Auth'],
- 'summary' => 'Refetch a user record',
- 'description' => '**UI BFF only.** Used to refresh role / display_name during a session.',
- 'x-internal' => true,
- 'security' => [['BearerAuth' => []]],
- 'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]],
- 'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
- ],
- ],
- ];
- $spec = [
- 'openapi' => '3.0.3',
- 'info' => [
- 'title' => 'IRDB — IP Reputation Database',
- 'version' => '1.0.0',
- 'description' => "Self-hosted IP reputation service: ingest abuse reports, distribute tailored block lists.\n\n"
- . "## Versioning\n\n"
- . "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"
- . "## Endpoint groups\n\n"
- . "- **Public**: machine clients (reporters, consumers).\n"
- . "- **Admin**: UI BFF + admin-kind tokens. RBAC enforced server-side.\n"
- . "- **Auth**: UI BFF only — bridges browser auth to user records. Marked `x-internal: true`.\n"
- . "- **Internal jobs**: not in this spec. Scheduler-only, network-restricted.\n\n"
- . "## Authentication\n\n"
- . "Bearer token in the `Authorization` header. Four token kinds: `reporter`, `consumer`, `admin`, `service`. See `doc/auth-flows.md`.\n\n"
- . "## Errors\n\n"
- . "Uniform envelope: `{\"error\":\"<code>\",\"details\":{...}}`. Validation errors include `details`. Authentication failures return `401` `unauthorized`. Authorization failures return `403`.\n\n"
- . "## Rate limiting\n\n"
- . "Public endpoints: 60 req/s/token (configurable). On exhaustion: `429` with `Retry-After: 1`.",
- 'license' => ['name' => 'TBD'],
- ],
- 'servers' => [
- ['url' => 'http://localhost:8081', 'description' => 'Default compose deployment'],
- ['url' => 'https://reputation-api.example.com', 'description' => 'Production (replace hostname)'],
- ],
- 'tags' => [
- ['name' => 'Public', 'description' => 'Machine clients: reporters and blocklist consumers.'],
- ['name' => 'Admin', 'description' => 'UI BFF + admin-kind tokens.'],
- ['name' => 'Auth', 'description' => 'UI BFF only. Service token required, no impersonation.'],
- ],
- 'security' => [['BearerAuth' => []]],
- 'paths' => $paths,
- 'components' => $components,
- ];
- echo dumpYaml($spec);
- /**
- * Minimal YAML dumper that handles the subset we use here:
- * - assoc arrays become mappings
- * - lists become block sequences
- * - strings get quoted only when needed (newlines, leading specials, special chars)
- * - bools / ints / floats / null serialise plainly
- *
- * @param mixed $value
- */
- function dumpYaml(mixed $value, int $indent = 0): string
- {
- if (!is_array($value)) {
- return scalarYaml($value) . "\n";
- }
- if ($value === []) {
- // Caller decides whether `[]` is "empty list" or "empty object".
- // We always emit empty list — callers that need an empty object
- // pass a sentinel (we don't have any in this spec).
- return "[]\n";
- }
- $isList = array_is_list($value);
- $out = '';
- $pad = str_repeat(' ', $indent);
- foreach ($value as $k => $v) {
- if ($isList) {
- if (is_array($v) && $v !== []) {
- // Render the child at indent 0, then prefix the first line
- // with "- " and subsequent lines with two-space continuation.
- $rawSub = rtrim(dumpYaml($v, 0), "\n");
- $childPad = str_repeat(' ', $indent + 1);
- $lines = explode("\n", $rawSub);
- $first = true;
- foreach ($lines as $line) {
- if ($first) {
- $out .= $pad . '- ' . $line . "\n";
- $first = false;
- } else {
- $out .= $childPad . $line . "\n";
- }
- }
- } else {
- $out .= $pad . '- ' . scalarYaml($v, $indent) . "\n";
- }
- } else {
- $key = (string) $k;
- // Quote keys that contain special chars
- if (preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $key) !== 1 || $key === 'on' || $key === 'no' || $key === 'yes' || $key === 'off') {
- $key = "'" . str_replace("'", "''", $key) . "'";
- }
- if (is_array($v)) {
- if ($v === []) {
- $out .= $pad . $key . ": []\n";
- } else {
- $out .= $pad . $key . ":\n" . dumpYaml($v, $indent + 1);
- }
- } else {
- $out .= $pad . $key . ': ' . scalarYaml($v, $indent) . "\n";
- }
- }
- }
- return $out;
- }
- function scalarYaml(mixed $value, int $parentIndent = 0): string
- {
- if ($value === null) {
- return 'null';
- }
- if (is_bool($value)) {
- return $value ? 'true' : 'false';
- }
- if (is_int($value) || is_float($value)) {
- return (string) $value;
- }
- $s = (string) $value;
- if ($s === '') {
- return "''";
- }
- // Multi-line: literal block scalar. Content must be indented one level
- // *deeper* than the key — i.e. `parentIndent + 1`. The caller already
- // emitted "<key>: <this>" so we return "|\n<indented lines>".
- if (str_contains($s, "\n")) {
- $lines = explode("\n", rtrim($s, "\n"));
- $contentPad = str_repeat(' ', $parentIndent + 1);
- $block = '|';
- foreach ($lines as $line) {
- $block .= "\n" . ($line === '' ? '' : $contentPad . $line);
- }
- return $block;
- }
- // Quote if it could be misread as bool/null/number, contains special chars, or starts with sigils.
- $needsQuote = preg_match('/^(true|false|null|yes|no|on|off|~)$/i', $s) === 1
- || preg_match('/^[\\-?:,\\[\\]\\{\\}#&*!|>\'"%@`]/', $s) === 1
- || preg_match('/^[+\\-]?\\d/', $s) === 1
- || str_contains($s, ': ')
- || str_contains($s, ' #')
- || str_starts_with($s, ' ')
- || str_ends_with($s, ' ');
- if ($needsQuote) {
- return "'" . str_replace("'", "''", $s) . "'";
- }
- return $s;
- }
|