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__<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], 'entity_label' => ['type' => 'string', 'nullable' => true, 'description' => 'Human-readable identifier for the target (name, slug, IP, CIDR, prefix). Frozen at write time.'], 'details' => ['type' => 'object', 'nullable' => true, 'additionalProperties' => true, 'description' => 'For update events, contains a `changes` map of `{field: {from, to}}` for every modified field.'], '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']]]]]]]]], ], '/api/v1/admin/maintenance/purge' => [ 'post' => [ 'tags' => ['Admin'], 'summary' => 'Wipe operational data (Admin)', '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.", 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'required' => ['confirm'], 'properties' => ['confirm' => ['type' => 'string', 'enum' => ['PURGE']]]]]]], 'responses' => [ '200' => ['description' => 'Purge succeeded', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['status' => ['type' => 'string', 'example' => 'purged'], 'deleted' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']]]]]]], '400' => ['description' => 'Missing or wrong `confirm` value'], ], ], ], '/api/v1/admin/maintenance/seed-demo' => [ 'post' => [ 'tags' => ['Admin'], 'summary' => 'Load demo dataset (Admin)', '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.\n\nRequires `confirm: \"SEED\"` in the body — any other value returns 400.", 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'required' => ['confirm'], 'properties' => ['confirm' => ['type' => 'string', 'enum' => ['SEED']]]]]]], 'responses' => [ '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']]]]]], '400' => ['description' => 'Missing or wrong `confirm` value'], '409' => ['description' => 'Demo data already present'], '412' => ['description' => 'No categories configured'], ], ], ], // ---------- 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\":\"\",\"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 ": " so we return "|\n". 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; }