From b001f7bc3d0213fa073d8576e98afd63e53dda79 Mon Sep 17 00:00:00 2001 From: Michael Tarassov Date: Sun, 23 Aug 2026 14:44:50 +0500 Subject: [PATCH 1/2] test(e2e): validate real responses against the OpenAPI schemas --- CLAUDE.md | 8 +- docs/openapi.yaml | 113 +- scripts/gen-openapi-json.sh | 78 + tests/e2e/openapi.gen.json | 4384 +++++++++++++++++++++++++++++++++++ tests/e2e/openapi_check.hpp | 319 +++ tests/e2e/test_http_e2e.cpp | 60 +- 6 files changed, 4945 insertions(+), 17 deletions(-) create mode 100755 scripts/gen-openapi-json.sh create mode 100644 tests/e2e/openapi.gen.json create mode 100644 tests/e2e/openapi_check.hpp diff --git a/CLAUDE.md b/CLAUDE.md index 6ab6ebc..b8468fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,13 @@ gates by construction. Hand-rolled versions usually don't. 1. **Route triple-sync:** every `ADD_METHOD_TO` in a controller must also appear in `Api::get_endpoints()` (`src/api/Endpoints.hpp`) **and** in `docs/openapi.yaml`. `scripts/check-openapi-drift.sh` and - `scripts/check-routes-registered.sh` fail CI on any mismatch. + `scripts/check-routes-registered.sh` fail CI on any mismatch. Response + BODIES are checked too: the e2e bucket validates real responses against + the spec's schemas (`tests/e2e/openapi_check.hpp`, subset validator). + It reads `tests/e2e/openapi.gen.json` — a committed conversion of + `docs/openapi.yaml`; after editing the spec run + `./scripts/gen-openapi-json.sh` and commit both, or the e2e test + `OpenApiSpec.GenJsonIsFreshAndLoadable` fails on the stale hash stamp. 2. **API versioning (ADR 0006):** business routes live under `/api/v1`; `new-endpoint.sh` rejects unversioned paths. Probe routes (`/healthz`, `/ready`, `/health`, `/metrics`) stay unversioned. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 6c3dd0c..646c234 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -52,21 +52,27 @@ components: pattern: '^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$' schemas: + # Mirrors ErrorResponse::make in src/utils/ErrorResponse.cpp — every + # error body carries `error` AND the numeric `status` (message/extras + # optional). The schema used to omit `status`; the e2e schema validation + # surfaced the drift. Error: type: object - required: [error] + required: [error, status] properties: error: { type: string } + status: { type: integer, description: "HTTP status code, duplicated into the body" } message: { type: string } code: { type: string } ValidationError: type: object - required: [error, errors] + required: [error, status, errors] properties: error: type: string enum: [validation_failed] + status: { type: integer } errors: type: array items: @@ -237,6 +243,42 @@ components: properties: message: { type: string } + # POST /api/v1/auth/register. Mirrors AuthController::registerUser — + # Response::created({{"user", user}, {"message", ...}}). + RegisterResponse: + type: object + required: [user, message] + properties: + user: { $ref: '#/components/schemas/User' } + message: { type: string } + + # Mirrors Domain::to_json(Post) in src/domain/Post.hpp — the full admin + # shape (includes the raw Markdown body). The public index serves the + # lighter PostCard projection documented inline on /api/v1/public/posts. + Post: + type: object + required: [id, slug, title, summary, body, status, topic, tags, published_at, created_at, updated_at] + properties: + id: { type: string, format: uuid } + slug: { type: string } + title: { type: string } + summary: { type: string } + body: { type: string } + status: { type: string, enum: [draft, published] } + topic: { type: string } + tags: { type: array, items: { type: string } } + published_at: { type: ['string', 'null'], format: date-time } + created_at: { type: string } + updated_at: { type: string } + + # GET/POST/PATCH /api/v1/posts[/{id}]. Mirrors PostsController — + # Response::ok/created({{"data", post}}). + PostDetailResponse: + type: object + required: [data] + properties: + data: { $ref: '#/components/schemas/Post' } + # Mirrors Domain::to_json(Package) in src/domain/Billing.hpp. BillingPackage: type: object @@ -560,6 +602,14 @@ paths: responses: '200': description: Process is alive + content: + application/json: + schema: + type: object + required: [status, timestamp] + properties: + status: { type: string, enum: [alive] } + timestamp: { type: integer, format: int64, description: Epoch seconds } /ready: get: summary: Readiness probe @@ -595,8 +645,16 @@ paths: responses: '201': description: User created — confirmation email queued + content: + application/json: + schema: { $ref: '#/components/schemas/RegisterResponse' } '400': { description: Validation failed } '409': { description: Email already registered } + '422': + description: Idempotency-Key conflict — same key, different body (idempotency middleware) + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/auth/login: post: summary: Log in @@ -620,7 +678,16 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/MeResponse' } - '401': { description: Invalid email or password } + '401': + description: Invalid email or password + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Body content type is not application/json (content-type middleware — applies to every JSON API endpoint) + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/auth/logout: post: summary: Log out (clears cookies + revokes refresh token) @@ -645,7 +712,11 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/MeResponse' } - '401': { description: Refresh token missing / expired / revoked } + '401': + description: Refresh token missing / expired / revoked + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/auth/me: get: summary: Get the authenticated user @@ -695,7 +766,7 @@ paths: properties: email: { type: string, format: email } responses: - '200': { description: If the email is registered, a reset link is on its way } + '200': { description: "If the email is registered, a reset link is on its way" } /api/v1/account/reset-password/{token}: post: summary: Apply a password reset using an email-link token @@ -716,7 +787,11 @@ paths: new_password: { type: string, minLength: 8, maxLength: 128 } responses: '200': { description: Password updated } - '400': { description: Invalid or expired token } + '400': + description: Invalid or expired token + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/account/change-email-request: post: summary: Start an email-change flow (verifies password, mails a link to new address) @@ -840,7 +915,11 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/UserListResponse' } - '403': { description: Not an admin } + '403': + description: Not an admin + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } post: summary: Create a confirmed user (admin) tags: [admin] @@ -1034,9 +1113,9 @@ paths: parameters: - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } } - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 } } - - { name: action, in: query, schema: { type: string }, description: Exact action filter, e.g. user.create } + - { name: action, in: query, schema: { type: string }, description: "Exact action filter, e.g. user.create" } - { name: actor_id, in: query, schema: { type: string }, description: Filter by acting principal subject } - - { name: target_type, in: query, schema: { type: string }, description: Filter by target kind, e.g. user / role } + - { name: target_type, in: query, schema: { type: string }, description: "Filter by target kind, e.g. user / role" } - { name: from, in: query, schema: { type: string, format: date-time }, description: created_at lower bound } - { name: to, in: query, schema: { type: string, format: date-time }, description: created_at upper bound } responses: @@ -1062,6 +1141,11 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/JobListResponse' } + '401': + description: Not authenticated (auth middleware — the route is not in api.public_paths) + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '403': { description: Not an admin } post: summary: Submit a background job @@ -1166,7 +1250,11 @@ paths: description: "Keyword tags driving the index tag cloud. No commas or line breaks per tag." items: { type: string, maxLength: 40 } responses: - '201': { description: Created } + '201': + description: Created + content: + application/json: + schema: { $ref: '#/components/schemas/PostDetailResponse' } '400': { description: Validation failed } '403': { description: Not an admin } '409': { description: Slug already exists } @@ -1364,6 +1452,11 @@ paths: responses: '201': { description: "Stored — { data: { key, url } }" } '400': { description: "no_file | unsupported_type (raster only — SVG is rejected) | bad_size (1 byte – 5 MB) | bad_content (magic bytes do not match the extension)" } + '401': + description: Not authenticated (auth middleware — multipart passes the content-type gate but still needs a session) + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '403': { description: Not an admin } '404': { description: Content module disabled } '503': { description: Storage backend not configured } diff --git a/scripts/gen-openapi-json.sh b/scripts/gen-openapi-json.sh new file mode 100755 index 0000000..4eee56e --- /dev/null +++ b/scripts/gen-openapi-json.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Convert docs/openapi.yaml into tests/e2e/openapi.gen.json — the machine- +# readable spec the e2e binary validates real HTTP response bodies against +# (tests/e2e/openapi_check.hpp). +# +# Why a COMMITTED artifact instead of parsing YAML at test time: +# * the C++ test binary links no YAML parser, and adding one via vcpkg +# would rebuild the entire dependency world (vetoed — see CLAUDE.md on +# vcpkg.json); +# * the docker test-runner image has python3 but NOT pyyaml, and the +# drift gates deliberately avoid python+yaml so they run anywhere +# (see scripts/check-openapi-drift.sh). +# So the conversion runs here, on a dev machine with pyyaml, and the JSON is +# committed next to the e2e sources (COPY . . puts it in the test image). +# +# Staleness cannot slip through: the JSON embeds an FNV-1a-64 hash of the +# raw YAML bytes under x-generated.source_fnv1a64, and the e2e test +# OpenApiSpec.GenJsonIsFreshAndLoadable re-hashes docs/openapi.yaml with the +# same function (mirrored in tests/e2e/openapi_check.hpp) and fails the +# suite when the stamp no longer matches. Edit docs/openapi.yaml → re-run +# this script → commit both. +# +# Usage: ./scripts/gen-openapi-json.sh +set -euo pipefail + +REPO="${REPO_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +SPEC="$REPO/docs/openapi.yaml" +OUT="$REPO/tests/e2e/openapi.gen.json" + +if [[ ! -f "$SPEC" ]]; then + echo "gen-openapi-json: $SPEC not found" >&2 + exit 2 +fi + +python3 - "$SPEC" "$OUT" <<'PY' +import json +import sys + +try: + import yaml +except ImportError: + sys.exit("gen-openapi-json: the python3 'yaml' module (pyyaml) is required " + "on the machine running this generator — pip install pyyaml") + +spec_path, out_path = sys.argv[1], sys.argv[2] +with open(spec_path, "rb") as fh: + raw = fh.read() + +# FNV-1a 64 over the raw YAML bytes. Mirrored byte-for-byte in +# tests/e2e/openapi_check.hpp (fnv1a64) — keep the two in sync. +h = 0xCBF29CE484222325 +for b in raw: + h = ((h ^ b) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF + +doc = yaml.safe_load(raw) + +# Sanity: an empty/garbled conversion committed in good faith would turn the +# e2e schema validation into a rubber stamp. Refuse to write one. +paths = doc.get("paths") or {} +schemas = (doc.get("components") or {}).get("schemas") or {} +if not paths or not schemas: + sys.exit("gen-openapi-json: conversion produced no paths or no " + "components.schemas — refusing to write %s" % out_path) + +doc["x-generated"] = { + "by": "scripts/gen-openapi-json.sh", + "source": "docs/openapi.yaml", + "source_bytes": len(raw), + "source_fnv1a64": "%016x" % h, +} + +with open(out_path, "w", encoding="utf-8") as fh: + json.dump(doc, fh, ensure_ascii=False, indent=1, sort_keys=False) + fh.write("\n") + +print("gen-openapi-json: wrote %s (%d paths, %d schemas, source fnv1a64 %016x)" + % (out_path, len(paths), len(schemas), h)) +PY diff --git a/tests/e2e/openapi.gen.json b/tests/e2e/openapi.gen.json new file mode 100644 index 0000000..188ea73 --- /dev/null +++ b/tests/e2e/openapi.gen.json @@ -0,0 +1,4384 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "C++ REST API Template", + "description": "Infrastructure endpoints for the C++ REST API template: health probes\nand a background-job API. Authentication is opt-in (auth.mode=jwt|bearer|none).\nRate-limiting and idempotency are driven by headers.\n", + "version": "1.0.0", + "contact": { + "name": "Platform team" + } + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "Local dev" + }, + { + "url": "https://api.example.com", + "description": "Production" + } + ], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "__Host-access" + } + }, + "parameters": { + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Opaque client-generated key. First request with this key + body hash\nis executed and its response cached; subsequent requests replay it.\nConflicting body with the same key returns 422.\n", + "schema": { + "type": "string", + "maxLength": 256 + } + }, + "Traceparent": { + "name": "traceparent", + "in": "header", + "required": false, + "description": "W3C Trace Context header.", + "schema": { + "type": "string", + "pattern": "^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$" + } + } + }, + "schemas": { + "Error": { + "type": "object", + "required": [ + "error", + "status" + ], + "properties": { + "error": { + "type": "string" + }, + "status": { + "type": "integer", + "description": "HTTP status code, duplicated into the body" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string" + } + } + }, + "ValidationError": { + "type": "object", + "required": [ + "error", + "status", + "errors" + ], + "properties": { + "error": { + "type": "string", + "enum": [ + "validation_failed" + ] + }, + "status": { + "type": "integer" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "required": [ + "field", + "code", + "message" + ], + "properties": { + "field": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "Job": { + "type": "object", + "required": [ + "id", + "type", + "status", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string" + }, + "payload": { + "type": "object", + "additionalProperties": true + }, + "status": { + "type": "string", + "enum": [ + "pending", + "processing", + "completed", + "failed", + "dead" + ] + }, + "result": { + "nullable": true + }, + "error": { + "type": "string" + }, + "worker_id": { + "type": "string" + }, + "trace_id": { + "type": "string", + "description": "W3C trace id of the submitting request — deep-link into Jaeger" + }, + "retry_count": { + "type": "integer" + }, + "max_retries": { + "type": "integer" + }, + "created_at": { + "type": "integer", + "format": "int64", + "description": "Epoch seconds" + }, + "updated_at": { + "type": "integer", + "format": "int64", + "description": "Epoch seconds" + } + } + }, + "Role": { + "type": "object", + "required": [ + "id", + "name", + "permissions", + "is_default" + ], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permissions": { + "type": "integer", + "description": "Bitmask — Domain::Permission::k*" + }, + "is_default": { + "type": "boolean" + } + } + }, + "User": { + "type": "object", + "required": [ + "id", + "email", + "full_name", + "confirmed", + "role_id", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string", + "format": "email" + }, + "first_name": { + "type": [ + "string", + "null" + ] + }, + "last_name": { + "type": [ + "string", + "null" + ] + }, + "full_name": { + "type": "string" + }, + "confirmed": { + "type": "boolean" + }, + "role_id": { + "type": "integer" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "UserListResponse": { + "type": "object", + "required": [ + "data", + "total", + "limit", + "offset" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + } + }, + "JobListResponse": { + "type": "object", + "required": [ + "data", + "total", + "limit", + "offset" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + } + }, + "DlqListResponse": { + "type": "object", + "required": [ + "data", + "count", + "depth" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Job" + } + }, + "count": { + "type": "integer", + "description": "Number of jobs returned (after type filter + limit)" + }, + "depth": { + "type": "integer", + "description": "Total DLQ depth (unfiltered)" + } + } + }, + "AuditEntry": { + "type": "object", + "required": [ + "id", + "action", + "target_type", + "details", + "created_at" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "actor_id": { + "type": "string", + "nullable": true, + "description": "Acting principal subject (uuid)", + "or null for a system action": null + }, + "action": { + "type": "string", + "description": "Dotted verb, e.g. user.create" + }, + "target_type": { + "type": "string", + "description": "Affected entity kind, e.g. user / role" + }, + "target_id": { + "type": "string", + "nullable": true, + "description": "Affected entity id (uuid or int as text)" + }, + "details": { + "type": "object", + "additionalProperties": true, + "description": "Action-specific context" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "AuditListResponse": { + "type": "object", + "required": [ + "data", + "total", + "limit", + "offset" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuditEntry" + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + } + }, + "JobCreate": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "payload": { + "type": "object", + "additionalProperties": true + }, + "max_retries": { + "type": "integer", + "minimum": 0, + "maximum": 20 + } + } + }, + "MeResponse": { + "type": "object", + "required": [ + "user" + ], + "properties": { + "user": { + "$ref": "#/components/schemas/User" + } + } + }, + "UserDetailResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/User" + } + } + }, + "InviteResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/User" + }, + "message": { + "type": "string" + } + } + }, + "RolesResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Role" + } + } + } + }, + "RoleDetailResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/Role" + } + } + }, + "MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "RegisterResponse": { + "type": "object", + "required": [ + "user", + "message" + ], + "properties": { + "user": { + "$ref": "#/components/schemas/User" + }, + "message": { + "type": "string" + } + } + }, + "Post": { + "type": "object", + "required": [ + "id", + "slug", + "title", + "summary", + "body", + "status", + "topic", + "tags", + "published_at", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "body": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "published" + ] + }, + "topic": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "published_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "PostDetailResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/Post" + } + } + }, + "BillingPackage": { + "type": "object", + "required": [ + "id", + "title", + "amount_cents", + "credits", + "active", + "sort", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "amount_cents": { + "type": "integer", + "format": "int64" + }, + "credits": { + "type": "integer", + "format": "int64" + }, + "active": { + "type": "boolean" + }, + "sort": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "BillingPackageListResponse": { + "type": "object", + "required": [ + "data", + "credits_per_unit", + "min_amount_cents", + "max_amount_cents" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BillingPackage" + } + }, + "credits_per_unit": { + "type": "integer", + "format": "int64", + "description": "billing.credits_per_unit — credits per 100 cents" + }, + "min_amount_cents": { + "type": "integer", + "format": "int64", + "description": "Minimum accepted amount_cents for a custom top-up" + }, + "max_amount_cents": { + "type": "integer", + "format": "int64", + "description": "Maximum accepted amount_cents for a custom top-up" + } + } + }, + "WalletEntry": { + "type": "object", + "required": [ + "id", + "user_id", + "delta_credits", + "kind", + "reference", + "note", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "delta_credits": { + "type": "integer", + "format": "int64" + }, + "kind": { + "type": "string", + "enum": [ + "topup", + "spend", + "adjustment", + "refund" + ] + }, + "reference": { + "type": "string" + }, + "note": { + "type": "string" + }, + "created_by": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "created_at": { + "type": "string" + } + } + }, + "PublicWalletEntry": { + "type": "object", + "required": [ + "id", + "user_id", + "delta_credits", + "kind", + "reference", + "note", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "delta_credits": { + "type": "integer", + "format": "int64" + }, + "kind": { + "type": "string", + "enum": [ + "topup", + "spend", + "adjustment", + "refund" + ] + }, + "reference": { + "type": "string" + }, + "note": { + "type": "string" + }, + "created_at": { + "type": "string" + } + } + }, + "WalletResponse": { + "type": "object", + "required": [ + "data", + "limit", + "offset" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "balance", + "history" + ], + "properties": { + "balance": { + "type": "integer", + "format": "int64" + }, + "history": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicWalletEntry" + } + } + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + } + }, + "TopupResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "order_id", + "approve_url" + ], + "properties": { + "order_id": { + "type": "string", + "description": "PayPal order id — pass back to POST .../capture" + }, + "approve_url": { + "type": "string", + "format": "uri", + "description": "Redirect the buyer here to approve the order on PayPal" + } + } + } + } + }, + "CaptureResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "credited", + "balance", + "status" + ], + "properties": { + "credited": { + "type": "boolean", + "description": "false on an idempotent replay, or when PayPal has not yet COMPLETED the capture" + }, + "balance": { + "type": "integer", + "format": "int64", + "description": "Wallet balance AFTER this call (unchanged if not credited)" + }, + "status": { + "type": "string", + "description": "\"captured\" once this or an earlier call credited the wallet; otherwise PayPal's own capture status verbatim (e.g. \"PENDING\", \"DECLINED\") — PayPal answers 2xx for both, so this is how a caller tells a settled capture from one still in flight.\n" + }, + "pending": { + "type": "boolean", + "description": "Present (true) only when PayPal's capture has not reached COMPLETED yet — the payment is left uncaptured for the webhook to resolve." + } + } + } + } + }, + "WebhookAckResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "handled" + ], + "properties": { + "handled": { + "type": "boolean", + "description": "true if this event type drives real crediting/refund logic (whether or not it was a no-op replay); false for an ignored/unrecognized event type" + } + } + } + } + }, + "AdminPackageResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/BillingPackage" + } + } + }, + "AdminPackageListResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BillingPackage" + } + } + } + }, + "AdminPaymentListResponse": { + "type": "object", + "required": [ + "data", + "total", + "limit", + "offset" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Payment" + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + } + }, + "Payment": { + "type": "object", + "required": [ + "id", + "user_id", + "provider", + "provider_order_id", + "amount_cents", + "currency", + "credits_expected", + "rate_snapshot", + "status", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "provider": { + "type": "string" + }, + "provider_order_id": { + "type": "string" + }, + "provider_capture_id": { + "type": [ + "string", + "null" + ] + }, + "amount_cents": { + "type": "integer", + "format": "int64" + }, + "currency": { + "type": "string" + }, + "credits_expected": { + "type": "integer", + "format": "int64" + }, + "rate_snapshot": { + "type": "integer", + "format": "int64" + }, + "package_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "created", + "approved", + "captured", + "failed", + "refunded" + ] + }, + "failure_reason": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "BillingSettings": { + "type": "object", + "required": [ + "credits_per_unit", + "min_amount_cents", + "max_amount_cents", + "updated_at" + ], + "properties": { + "credits_per_unit": { + "type": "integer", + "format": "int64", + "description": "Credits granted per 100 cents on a custom-amount top-up" + }, + "min_amount_cents": { + "type": "integer", + "format": "int64" + }, + "max_amount_cents": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string" + } + } + }, + "BillingSettingsResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/BillingSettings" + } + } + }, + "AdjustResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "balance", + "credited" + ], + "properties": { + "balance": { + "type": "integer", + "format": "int64", + "description": "Wallet balance AFTER this adjustment" + }, + "credited": { + "type": "boolean", + "description": "false only if this exact adjustment somehow no-op'd (not expected in normal use — adjust() has no idempotency key)" + } + } + } + } + }, + "BillingMetricsResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "period", + "revenue_cents", + "payments_count", + "avg_payment_cents", + "conversion", + "refunds_cents", + "refunds_count", + "outstanding_credits", + "outstanding_value_cents", + "series", + "top_packages", + "top_users" + ], + "properties": { + "period": { + "type": "string", + "enum": [ + "day", + "week", + "month" + ] + }, + "revenue_cents": { + "type": "integer", + "format": "int64", + "description": "Sum of amount_cents over captured payments in-window" + }, + "payments_count": { + "type": "integer", + "format": "int64", + "description": "Count of captured payments in-window" + }, + "avg_payment_cents": { + "type": "integer", + "format": "int64", + "description": "revenue_cents / payments_count (integer division; 0 if payments_count is 0)" + }, + "conversion": { + "type": "object", + "required": [ + "created", + "captured", + "rate" + ], + "properties": { + "created": { + "type": "integer", + "format": "int64", + "description": "Every payment (any status) created in-window" + }, + "captured": { + "type": "integer", + "format": "int64", + "description": "Of those", + "how many reached captured": null + }, + "rate": { + "type": "number", + "format": "double", + "description": "captured / created as a float ratio; 0 if created is 0" + } + } + }, + "refunds_cents": { + "type": "integer", + "format": "int64", + "description": "Sum of billing_refunds.amount_cents in-window, outcome='applied' only" + }, + "refunds_count": { + "type": "integer", + "format": "int64", + "description": "Count of billing_refunds rows in-window, outcome='applied' only" + }, + "outstanding_credits": { + "type": "integer", + "format": "int64", + "description": "SUM(wallet_balances.credits) — all-time liability, NOT windowed" + }, + "outstanding_value_cents": { + "type": "integer", + "format": "int64", + "description": "outstanding_credits * 100 / credits_per_unit (integer math, current billing_settings rate)" + }, + "series": { + "type": "array", + "description": "Calendar-bucketed (hourly for period=day, daily otherwise); every bucket in range is present, zero-filled if no captured payments landed in it", + "items": { + "type": "object", + "required": [ + "bucket_start", + "revenue_cents", + "payments_count" + ], + "properties": { + "bucket_start": { + "type": "string", + "description": "ISO-8601 UTC bucket start" + }, + "revenue_cents": { + "type": "integer", + "format": "int64" + }, + "payments_count": { + "type": "integer", + "format": "int64" + } + } + } + }, + "top_packages": { + "type": "array", + "description": "Top 5 packages by revenue among captured payments in-window", + "items": { + "type": "object", + "required": [ + "package_id", + "title", + "revenue_cents", + "payments_count" + ], + "properties": { + "package_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "revenue_cents": { + "type": "integer", + "format": "int64" + }, + "payments_count": { + "type": "integer", + "format": "int64" + } + } + } + }, + "top_users": { + "type": "array", + "description": "Top 5 users by top-up credits among captured payments in-window", + "items": { + "type": "object", + "required": [ + "user_id", + "email", + "topup_credits", + "revenue_cents" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string", + "format": "email" + }, + "topup_credits": { + "type": "integer", + "format": "int64" + }, + "revenue_cents": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + } + }, + "paths": { + "/": { + "get": { + "summary": "Endpoint discovery — list every registered route", + "tags": [ + "health" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "version": { + "type": "string" + }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "required": [ + "method", + "path", + "description" + ], + "properties": { + "method": { + "type": "string" + }, + "path": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "/healthz": { + "get": { + "summary": "Liveness probe", + "tags": [ + "health" + ], + "responses": { + "200": { + "description": "Process is alive", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status", + "timestamp" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "alive" + ] + }, + "timestamp": { + "type": "integer", + "format": "int64", + "description": "Epoch seconds" + } + } + } + } + } + } + } + } + }, + "/ready": { + "get": { + "summary": "Readiness probe", + "tags": [ + "health" + ], + "responses": { + "200": { + "description": "Ready" + }, + "503": { + "description": "Draining or dependency unhealthy" + } + } + } + }, + "/health": { + "get": { + "summary": "Detailed component health", + "tags": [ + "health" + ], + "responses": { + "200": { + "description": "All components healthy" + }, + "503": { + "description": "One or more components unhealthy" + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "summary": "Register a new user", + "tags": [ + "auth" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8, + "maxLength": 128 + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "User created — confirmation email queued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterResponse" + } + } + } + }, + "400": { + "description": "Validation failed" + }, + "409": { + "description": "Email already registered" + }, + "422": { + "description": "Idempotency-Key conflict — same key, different body (idempotency middleware)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "summary": "Log in", + "tags": [ + "auth" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Logged in — Set-Cookie access + refresh", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeResponse" + } + } + } + }, + "401": { + "description": "Invalid email or password", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "Body content type is not application/json (content-type middleware — applies to every JSON API endpoint)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "summary": "Log out (clears cookies + revokes refresh token)", + "tags": [ + "auth" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Logged out", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "summary": "Rotate access + refresh tokens", + "tags": [ + "auth" + ], + "responses": { + "200": { + "description": "New cookies issued", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeResponse" + } + } + } + }, + "401": { + "description": "Refresh token missing / expired / revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "summary": "Get the authenticated user", + "tags": [ + "auth" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Current user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeResponse" + } + } + } + }, + "401": { + "description": "Not authenticated" + } + } + } + }, + "/api/v1/account/confirm-resend": { + "post": { + "summary": "Resend the email-confirmation link to the current user", + "tags": [ + "account" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Confirmation email queued" + }, + "401": { + "description": "Not authenticated" + } + } + } + }, + "/api/v1/account/confirm/{token}": { + "post": { + "summary": "Confirm an account from an email link", + "tags": [ + "account" + ], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Account confirmed" + }, + "400": { + "description": "Invalid or expired token" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/api/v1/account/reset-password-request": { + "post": { + "summary": "Start a password reset (email-based)", + "tags": [ + "account" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "If the email is registered, a reset link is on its way" + } + } + } + }, + "/api/v1/account/reset-password/{token}": { + "post": { + "summary": "Apply a password reset using an email-link token", + "tags": [ + "account" + ], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "new_password" + ], + "properties": { + "new_password": { + "type": "string", + "minLength": 8, + "maxLength": 128 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Password updated" + }, + "400": { + "description": "Invalid or expired token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/account/change-email-request": { + "post": { + "summary": "Start an email-change flow (verifies password, mails a link to new address)", + "tags": [ + "account" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "new_email", + "password" + ], + "properties": { + "new_email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Confirmation email sent to the new address" + }, + "401": { + "description": "Wrong password or not authenticated" + } + } + } + }, + "/api/v1/account/change-email/{token}": { + "post": { + "summary": "Apply a pending email change from token", + "tags": [ + "account" + ], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Email updated" + }, + "400": { + "description": "Invalid or expired token" + }, + "409": { + "description": "Email already in use" + } + } + } + }, + "/api/v1/account/join-from-invite/{token}": { + "post": { + "summary": "Set password and confirm account from an invite token", + "tags": [ + "account" + ], + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "new_password" + ], + "properties": { + "new_password": { + "type": "string", + "minLength": 8, + "maxLength": 128 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Account ready — the invitee can now sign in" + }, + "400": { + "description": "Invalid or expired invitation token" + } + } + } + }, + "/api/v1/account/change-password": { + "post": { + "summary": "Change password while logged in (verifies old password)", + "tags": [ + "account" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "old_password", + "new_password" + ], + "properties": { + "old_password": { + "type": "string" + }, + "new_password": { + "type": "string", + "minLength": 8, + "maxLength": 128 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Password updated" + }, + "401": { + "description": "Wrong current password or not authenticated" + } + } + } + }, + "/api/v1/account/api-keys": { + "get": { + "summary": "List your API keys (metadata only; never the secret)", + "tags": [ + "account" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Array of API key metadata under data[]" + }, + "401": { + "description": "Not authenticated" + } + } + }, + "post": { + "summary": "Create an API key — the secret is returned ONCE in this response", + "tags": [ + "account" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Key created; `key` field holds the one-time secret" + }, + "401": { + "description": "Not authenticated" + } + } + } + }, + "/api/v1/account/api-keys/{id}": { + "delete": { + "summary": "Revoke one of your API keys", + "tags": [ + "account" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Key revoked" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "No such key (or not yours)" + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "summary": "List users (admin)", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "User page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserListResponse" + } + } + } + }, + "403": { + "description": "Not an admin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "summary": "Create a confirmed user (admin)", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8, + "maxLength": 128 + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "role_id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDetailResponse" + } + } + } + }, + "400": { + "description": "Validation failed" + }, + "403": { + "description": "Not an admin" + }, + "409": { + "description": "Email already registered" + } + } + } + }, + "/api/v1/admin/invite": { + "post": { + "summary": "Invite a user via email (admin)", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "role_id": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Invited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InviteResponse" + } + } + } + }, + "403": { + "description": "Not an admin" + }, + "409": { + "description": "Email already registered" + } + } + } + }, + "/api/v1/admin/users/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "get": { + "summary": "User detail (admin)", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "User row with role", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDetailResponse" + } + } + } + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "User not found" + } + } + }, + "patch": { + "summary": "Update user — email / role_id / first_name / last_name (admin)", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "role_id": { + "type": "integer" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDetailResponse" + } + } + } + }, + "400": { + "description": "Self role-change refused or validation failed" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "User not found" + }, + "409": { + "description": "Email already taken" + } + } + }, + "delete": { + "summary": "Delete user (admin)", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "400": { + "description": "Self-delete refused" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/api/v1/admin/roles": { + "get": { + "summary": "List roles (admin)", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Roles", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RolesResponse" + } + } + } + }, + "403": { + "description": "Not an admin" + } + } + }, + "post": { + "summary": "Create a role (admin)", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "permissions" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "permissions": { + "type": "integer", + "description": "Bitmask — see Domain::Permission::k* in src/domain/Role.hpp" + }, + "is_default": { + "type": "boolean", + "default": false + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleDetailResponse" + } + } + } + }, + "400": { + "description": "Validation failed" + }, + "403": { + "description": "Not an admin" + }, + "409": { + "description": "Role name already exists" + } + } + } + }, + "/api/v1/admin/roles/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "patch": { + "summary": "Update role (admin) — partial; pass any of name / permissions / is_default", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "permissions": { + "type": "integer" + }, + "is_default": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoleDetailResponse" + } + } + } + }, + "400": { + "description": "Empty patch or invalid id" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Role not found" + }, + "409": { + "description": "Role name already exists" + } + } + }, + "delete": { + "summary": "Delete role (admin)", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "400": { + "description": "Default role cannot be deleted" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Role not found" + }, + "409": { + "description": "Role is referenced by users — reassign first" + } + } + } + }, + "/api/v1/admin/audit": { + "get": { + "summary": "List the audit trail (admin)", + "description": "Requires the audit-read permission bit (Domain::Permission::kAuditRead); full admins hold it.", + "tags": [ + "admin" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + { + "name": "action", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Exact action filter, e.g. user.create" + }, + { + "name": "actor_id", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter by acting principal subject" + }, + { + "name": "target_type", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Filter by target kind, e.g. user / role" + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "created_at lower bound" + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + }, + "description": "created_at upper bound" + } + ], + "responses": { + "200": { + "description": "Audit page (newest first)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditListResponse" + } + } + } + }, + "403": { + "description": "Missing the audit-read permission" + } + } + } + }, + "/api/v1/jobs": { + "get": { + "summary": "List jobs (admin; newest first, offset-paginated)", + "tags": [ + "jobs" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 20 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Job page: { data, total, limit, offset }", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobListResponse" + } + } + } + }, + "401": { + "description": "Not authenticated (auth middleware — the route is not in api.public_paths)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not an admin" + } + } + }, + "post": { + "summary": "Submit a background job", + "tags": [ + "jobs" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Submitted" + }, + "400": { + "description": "Validation failed" + } + } + } + }, + "/api/v1/jobs/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "get": { + "summary": "Get job status", + "tags": [ + "jobs" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + } + }, + "delete": { + "summary": "Cancel a pending/processing job", + "tags": [ + "jobs" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Cancelled" + }, + "404": { + "description": "Not found or already finished" + } + } + } + }, + "/api/v1/jobs/dlq": { + "get": { + "summary": "List dead-letter queue jobs", + "tags": [ + "jobs" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DlqListResponse" + } + } + } + } + } + } + }, + "/api/v1/jobs/dlq/{id}/requeue": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "post": { + "summary": "Requeue a DLQ job (resets retry_count, pushes to live queue)", + "tags": [ + "jobs" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Requeued" + }, + "404": { + "description": "Not found or not in DLQ" + } + } + } + }, + "/api/v1/posts": { + "get": { + "summary": "List posts (admin; offset-paginated, filterable)", + "tags": [ + "posts" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Case-insensitive search over title+slug+summary" + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "draft", + "published" + ] + } + }, + { + "name": "topic", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "tag", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ data, total, limit, offset }" + }, + "403": { + "description": "Not an admin" + } + } + }, + "post": { + "summary": "Create post (admin)", + "tags": [ + "posts" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "slug", + "title" + ], + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "summary": { + "type": "string" + }, + "body": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "published" + ], + "default": "draft" + }, + "topic": { + "type": "string", + "maxLength": 80, + "description": "Section label (e.g. Kubernetes)" + }, + "tags": { + "type": "array", + "description": "Keyword tags driving the index tag cloud. No commas or line breaks per tag.", + "items": { + "type": "string", + "maxLength": 40 + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostDetailResponse" + } + } + } + }, + "400": { + "description": "Validation failed" + }, + "403": { + "description": "Not an admin" + }, + "409": { + "description": "Slug already exists" + } + } + } + }, + "/api/v1/posts/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "get": { + "summary": "Get post (admin)", + "tags": [ + "posts" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not found" + } + } + }, + "patch": { + "summary": "Update post (admin) — partial; omitted fields keep their current value", + "description": "Partial update. Only the fields present in the body are changed; omitted fields (including status) keep their current value, so a title-only PATCH does not unpublish the post or clear published_at. Send status=draft explicitly to unpublish.\n", + "tags": [ + "posts" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "minProperties": 1, + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "summary": { + "type": "string" + }, + "body": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "published" + ] + }, + "topic": { + "type": "string", + "maxLength": 80, + "description": "Section label (e.g. Kubernetes)" + }, + "tags": { + "type": "array", + "description": "Keyword tags driving the index tag cloud. No commas or line breaks per tag.", + "items": { + "type": "string", + "maxLength": 40 + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated" + }, + "400": { + "description": "Validation failed" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "Slug already exists" + } + } + }, + "delete": { + "summary": "Delete post (admin)", + "tags": [ + "posts" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Deleted" + }, + "404": { + "description": "Not found" + } + } + } + }, + "/api/v1/posts/{id}/preview-token": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "post": { + "summary": "Admin: issue a draft preview link (stateless HMAC token, 1h TTL)", + "tags": [ + "posts" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "{ data: { url: '/posts/?preview=', expires_at } }" + }, + "400": { + "description": "Invalid UUID" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Not found" + } + } + } + }, + "/api/v1/public/posts": { + "get": { + "summary": "List published posts (public, no auth) — server-filtered, paged, optional facets", + "tags": [ + "posts" + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10 + } + }, + { + "name": "topic", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Exact topic; 'Other' also matches blank topics" + }, + { + "name": "tag", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Exact tag membership" + }, + { + "name": "q", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Case-insensitive search over title+summary" + }, + { + "name": "include", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "facets" + ] + }, + "description": "Embed facets computed over the current filter" + } + ], + "responses": { + "200": { + "description": "Published posts, newest first", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "The template's standard paginated list envelope ({data, total, limit, offset} — see Response::paginated). offset is the 0-based equivalent of the request's 1-based ?page= query param (offset = (page - 1) * limit).\n", + "required": [ + "data", + "total", + "limit", + "offset" + ], + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "slug", + "title", + "summary", + "topic", + "tags", + "published_at", + "read_mins" + ], + "properties": { + "slug": { + "type": "string" + }, + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "topic": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "published_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "read_mins": { + "type": "integer" + } + } + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "facets": { + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/api/v1/public/posts/{slug}": { + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "summary": "Get a published post by slug (public, no auth)", + "tags": [ + "posts" + ], + "parameters": [ + { + "name": "include", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "adjacent" + ] + }, + "description": "Embed prev/next published neighbours: { adjacent: { prev, next } } (null at feed edges)" + }, + { + "name": "preview", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Draft preview token (issued via /api/v1/posts/{id}/preview-token); invalid/expired behaves like 404" + } + ], + "responses": { + "200": { + "description": "OK — { data } (+ data.adjacent with include=adjacent)" + }, + "404": { + "description": "Not found or not published" + } + } + } + }, + "/posts/{slug}": { + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "summary": "Published post body as raw Markdown (public, no auth)", + "tags": [ + "posts" + ], + "parameters": [ + { + "name": "preview", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Draft preview token (issued via /api/v1/posts/{id}/preview-token); reveals that draft's Markdown when valid; invalid/expired/mismatched behaves like 404" + } + ], + "responses": { + "200": { + "description": "Markdown body: a `# {title}` heading line, a blank line, then the post's raw body", + "content": { + "text/markdown": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Content module disabled, unknown slug, or a draft with no (valid, matching) preview token — all render the same Markdown 404 body", + "content": { + "text/markdown": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/sitemap.xml": { + "get": { + "summary": "Dynamic sitemap over published posts (public, no auth)", + "tags": [ + "posts" + ], + "responses": { + "200": { + "description": "`` with the site root plus one `` per published post", + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/uploads/{key}": { + "get": { + "summary": "Serve a stored upload (public, no auth, local storage backend only)", + "description": "Same-origin read path for the objects written by POST /api/v1/admin/uploads. With no CDN origin configured Storage::url() returns /uploads/, which is what the editor embeds in post bodies, and it must be same-origin because the public-site CSP is \"img-src 'self' data:\". The key spans path segments (posts/.), so the route is registered by regex. Content type comes from the extension allowlist, never from the request. When storage.public_base_url IS set the URLs are absolute and this route answers 404.\n", + "tags": [ + "uploads" + ], + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Full storage key, e.g. posts/9f2c0a....png" + } + ], + "responses": { + "200": { + "description": "The stored object, with Cache-Control: public, max-age=31536000, immutable" + }, + "404": { + "description": "Content module disabled, unsafe key, extension outside the raster allowlist, no such object, or a CDN origin is configured" + }, + "503": { + "description": "Storage read failed" + } + } + } + }, + "/api/v1/admin/uploads": { + "post": { + "summary": "Upload an image (admin) → { key, url }", + "tags": [ + "uploads" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Stored — { data: { key, url } }" + }, + "400": { + "description": "no_file | unsupported_type (raster only — SVG is rejected) | bad_size (1 byte – 5 MB) | bad_content (magic bytes do not match the extension)" + }, + "401": { + "description": "Not authenticated (auth middleware — multipart passes the content-type gate but still needs a session)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Content module disabled" + }, + "503": { + "description": "Storage backend not configured" + } + } + }, + "get": { + "summary": "Admin: list uploaded images (media library)", + "tags": [ + "uploads" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "{ data: [ { key, name, url, size_bytes, content_type, created_at } ], total, limit, offset } — newest first" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Content module disabled" + }, + "503": { + "description": "Storage backend not configured" + } + } + } + }, + "/api/v1/admin/uploads/{name}": { + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Single-segment basename of an upload key (posts/)" + } + ], + "delete": { + "summary": "Admin: delete an uploaded image", + "tags": [ + "uploads" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Deleted" + }, + "400": { + "description": "Name is not a single URL-safe segment" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Content module disabled, or no such upload" + }, + "503": { + "description": "Storage backend not configured" + } + } + } + }, + "/api/v1/billing/packages": { + "get": { + "summary": "List active top-up packages", + "description": "Also returns the current per-unit rate (credits_per_unit) and the custom-amount bounds (min_amount_cents/max_amount_cents) alongside the package list — see BillingPackageListResponse.\n", + "tags": [ + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Active packages (catalogue order) + the current rate/bounds", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingPackageListResponse" + } + } + } + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Billing module disabled" + } + } + } + }, + "/api/v1/billing/wallet": { + "get": { + "summary": "Get your own wallet balance + ledger history", + "description": "Always the authenticated caller's own wallet — no user-id parameter of any kind is accepted, by design (see BillingController::getWallet).\n", + "tags": [ + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Balance + ledger page, newest first", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WalletResponse" + } + } + } + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Billing module disabled" + } + } + } + }, + "/api/v1/billing/topup": { + "post": { + "summary": "Start a PayPal top-up (package or custom amount)", + "description": "Provide exactly one of package_id or amount_cents. Credits are always computed server-side (package.credits, or amount_cents * billing.credits_per_unit / 100) — any \"credits\" field in the body is ignored entirely. The resulting amount_cents (a custom amount, OR a package's own price) is bounded by billing.min_amount_cents / billing.max_amount_cents in both cases.\n", + "tags": [ + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "package_id": { + "type": "string", + "format": "uuid", + "description": "Mutually exclusive with amount_cents" + }, + "amount_cents": { + "type": "integer", + "format": "int64", + "minimum": 1, + "description": "Mutually exclusive with package_id" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "PayPal order created — redirect the buyer to data.approve_url", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TopupResponse" + } + } + } + }, + "400": { + "description": "Neither/both of package_id+amount_cents given, the resulting amount out of [min,max] (amount_out_of_range or package_price_out_of_range), or malformed input" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Billing module disabled, or package_id does not name an active package" + }, + "409": { + "description": "provider_order_id already recorded (PayPal order id collision)" + } + } + } + }, + "/api/v1/billing/capture": { + "post": { + "summary": "Capture an approved PayPal order and credit your wallet", + "description": "order_id must belong to the authenticated caller — verified via PaymentRepository::find_owned before any capture is attempted, so one user can never capture (and collect credits for) another user's order. Idempotent: capturing an already-captured order returns credited=false with the unchanged balance instead of calling PayPal again. PayPal answers 2xx even for a PENDING or DECLINED capture — the wallet is only ever credited when PayPal's own capture status is COMPLETED; otherwise the payment is left uncaptured (for the webhook to resolve later) and the response reports credited=false with the real status.\n", + "tags": [ + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "order_id" + ], + "properties": { + "order_id": { + "type": "string", + "description": "PayPal order id", + "as returned by POST .../topup": null + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Captured, replayed idempotently, or still pending/declined on PayPal's side — see CaptureResponse.status/pending to tell them apart.\n", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaptureResponse" + } + } + } + }, + "400": { + "description": "order_id missing or not a string" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "Billing module disabled, or no such order for this caller" + }, + "409": { + "description": "This payment is failed/refunded and cannot be captured (payment_not_capturable), or PayPal reports the order hasn't been approved yet (order_not_approved)" + } + } + } + }, + "/api/v1/billing/paypal/webhook": { + "post": { + "summary": "PayPal webhook — capture/refund notifications", + "description": "PayPal-to-server only, not a browser request: public (no session) and CSRF-exempt (PayPal never presents the session cookie the CSRF check keys off), but every request is verified against PayPal's own verify-webhook-signature API BEFORE the body is trusted, using the paypal-auth-algo / paypal-cert-url / paypal-transmission-id / paypal-transmission-sig / paypal-transmission-time headers PayPal sends. Response codes intentionally do NOT follow the usual REST mapping — PayPal retries any non-2xx delivery for days:\n * PAYMENT.CAPTURE.COMPLETED credits the wallet (idempotent — a\n capture already credited via POST .../capture, including one\n that resolves a PENDING return-flow capture, is a 200 no-op).\n * PAYMENT.CAPTURE.REFUNDED (merchant refund) and\n PAYMENT.CAPTURE.REVERSED (PayPal claws back a capture —\n chargeback/dispute/risk) both DEBIT the wallet via the same\n Billing::refund_capture logic, keyed on the event's own id as\n the idempotency marker — see BillingController::\n handleCaptureRefunded's doc comment for why both event types\n share one handler.\n * Any other event type is acknowledged with 200 and NOT acted on.\n200 is returned for every signature-valid event that was either applied or safely no-op'd; a signature-valid event this handler FAILED to process (a malformed body, or a refund/reversal it couldn't resolve/apply) answers 5xx so PayPal retries instead of the event being silently dropped.\n", + "tags": [ + "billing" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "PayPal webhook event envelope (id, event_type, resource, ...) — verbatim as PayPal sends it." + } + } + } + }, + "responses": { + "200": { + "description": "Signature verified — event handled or deliberately acknowledged-not-acted-on (see WebhookAckResponse.data.handled)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookAckResponse" + } + } + } + }, + "401": { + "description": "Signature verification failed (malformed body, missing paypal-* headers, or PayPal reported the signature invalid) — nothing credited/refunded" + }, + "404": { + "description": "Billing module disabled" + }, + "500": { + "description": "PayPal's own verify-webhook-signature API was unreachable/non-2xx, OR a signature-valid event could not be processed (malformed shape, or a refund/reversal that couldn't be resolved/applied) — retry later; nothing was silently dropped" + } + } + } + }, + "/api/v1/admin/billing/payments": { + "get": { + "summary": "List payments (admin)", + "description": "Paged, newest first. Optional ?status= and ?user_id= filters (AND'd together when both are given).", + "tags": [ + "admin", + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "created", + "approved", + "captured", + "failed", + "refunded" + ] + } + }, + { + "name": "user_id", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "Payment page", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPaymentListResponse" + } + } + } + }, + "400": { + "description": "user_id filter is not a valid UUID" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Billing module disabled" + } + } + } + }, + "/api/v1/admin/billing/packages": { + "get": { + "summary": "List every top-up package, active and inactive (admin)", + "tags": [ + "admin", + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Full package catalogue", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPackageListResponse" + } + } + } + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Billing module disabled" + } + } + }, + "post": { + "summary": "Create a top-up package (admin)", + "tags": [ + "admin", + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title", + "amount_cents", + "credits" + ], + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "amount_cents": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "credits": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "active": { + "type": "boolean", + "default": true + }, + "sort": { + "type": "integer", + "default": 0 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPackageResponse" + } + } + } + }, + "400": { + "description": "Validation failed" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Billing module disabled" + } + } + } + }, + "/api/v1/admin/billing/packages/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "patch": { + "summary": "Update a top-up package — partial; any of title/amount_cents/credits/active/sort (admin)", + "tags": [ + "admin", + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "amount_cents": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "credits": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "active": { + "type": "boolean" + }, + "sort": { + "type": "integer" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPackageResponse" + } + } + } + }, + "400": { + "description": "Empty patch, invalid id, or validation failed" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Billing module disabled, or no such package" + } + } + }, + "delete": { + "summary": "Delete a top-up package (admin)", + "tags": [ + "admin", + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageResponse" + } + } + } + }, + "400": { + "description": "Invalid id" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Billing module disabled, or no such package" + } + } + } + }, + "/api/v1/admin/billing/settings": { + "get": { + "summary": "Read the billing rate/bounds settings (admin)", + "tags": [ + "admin", + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Current settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingSettingsResponse" + } + } + } + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Billing module disabled" + } + } + }, + "put": { + "summary": "Replace the billing rate/bounds settings (admin)", + "description": "A full replace — all three fields are required. Takes effect for the NEXT top-up computed after this call; an in-flight or already-created payment keeps the rate_snapshot/credits_expected it was created with (see Billing::credit_capture — unaffected by this endpoint).\n", + "tags": [ + "admin", + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "credits_per_unit", + "min_amount_cents", + "max_amount_cents" + ], + "properties": { + "credits_per_unit": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "min_amount_cents": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "max_amount_cents": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingSettingsResponse" + } + } + } + }, + "400": { + "description": "Missing/invalid field, or max_amount_cents < min_amount_cents" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Billing module disabled" + } + } + } + }, + "/api/v1/admin/billing/users/{id}/adjust": { + "post": { + "summary": "Manually adjust a user's wallet balance (admin)", + "description": "Routes through Billing::adjust — the only code allowed to write wallet_entries/wallet_balances. note is mandatory (non-empty); created_by on the resulting ledger row is always the authenticated admin's own id, never a client-supplied value. Writes an audit_log row. With notify=true, the target user receives a best-effort wallet-adjustment email (note is reused verbatim as the reason) after the adjustment and its audit row have both committed — email delivery never affects the money path or the response.\n", + "tags": [ + "admin", + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Target user id" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "delta_credits", + "note" + ], + "properties": { + "delta_credits": { + "type": "integer", + "format": "int64", + "description": "Signed; positive credits, negative debits. Zero is refused (400)." + }, + "note": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "notify": { + "type": "boolean", + "default": false, + "description": "When true, sends the target user a best-effort wallet-adjustment email after the adjustment succeeds. Non-boolean values are silently ignored." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Adjusted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdjustResponse" + } + } + } + }, + "400": { + "description": "Empty/missing note, zero delta_credits, malformed user/admin id, or invalid id path param" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Billing module disabled, or no such user" + }, + "409": { + "description": "A negative delta_credits would drive the balance below zero" + } + } + } + }, + "/api/v1/admin/billing/metrics": { + "get": { + "summary": "Business metrics — revenue, conversion, refunds, outstanding liability, top lists (admin)", + "description": "revenue/count/avg and conversion are computed over a rolling window (now() - interval); refunds counts only billing_refunds rows with outcome='applied'; outstanding_credits/outstanding_value_cents are an all-time snapshot of wallet_balances, NOT windowed; series is calendar-bucketed (hourly for period=day, daily for week/month) with every bucket present (zero-filled, no gaps).\n", + "tags": [ + "admin", + "billing" + ], + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "period", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month" + ], + "default": "week" + }, + "description": "day = last 24h (hourly buckets), week = last 7d (daily buckets), month = last 30d (daily buckets)" + } + ], + "responses": { + "200": { + "description": "Metrics snapshot", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BillingMetricsResponse" + } + } + } + }, + "400": { + "description": "period is not one of day, week, month" + }, + "403": { + "description": "Not an admin" + }, + "404": { + "description": "Billing module disabled" + } + } + } + } + }, + "x-generated": { + "by": "scripts/gen-openapi-json.sh", + "source": "docs/openapi.yaml", + "source_bytes": 77090, + "source_fnv1a64": "944b66927d3ba029" + } +} diff --git a/tests/e2e/openapi_check.hpp b/tests/e2e/openapi_check.hpp new file mode 100644 index 0000000..c1e6f0c --- /dev/null +++ b/tests/e2e/openapi_check.hpp @@ -0,0 +1,319 @@ +/** + * @file openapi_check.hpp + * @brief Validate real e2e HTTP response bodies against docs/openapi.yaml. + * + * The route gates (check-openapi-drift.sh / check-routes-registered.sh) + * compare only (method, path) tuples — nothing ever checked that the BODIES + * the server actually sends match the schemas the spec promises. This header + * closes that gap for the e2e bucket: after an existing request, call + * expect_matches_schema(resp, "GET", "/api/v1/auth/me", 200) and the body is + * validated against paths → responses → content → application/json → schema + * from the spec. + * + * The spec is read from tests/e2e/openapi.gen.json — a committed JSON + * conversion of docs/openapi.yaml produced by scripts/gen-openapi-json.sh + * (no YAML parser is linked here, and adding one via vcpkg rebuilds the + * dependency world). Freshness is enforced by expect_spec_json_fresh(): + * the JSON embeds an FNV-1a-64 hash of the raw YAML bytes and this file + * re-hashes the YAML with the same function — a stale artifact fails the + * suite with regeneration instructions instead of validating against an + * old spec. + * + * Deliberately a SUBSET validator (nlohmann only, no JSON-Schema library): + * supported — type (object/array/string/integer/number/boolean/null, + * including ["string","null"] arrays), required, properties, + * items, enum, nullable, additionalProperties (boolean), + * $ref into "#/components/schemas/...". + * ignored — annotations: format, description, title, default, examples. + * unsupported — everything else (oneOf/anyOf/allOf, pattern, minLength, + * minimum, minProperties, ...): reported as an explicit + * "[openapi-schema] SKIP" line, never a silent pass. Today's + * response schemas use none of these; the log line is the + * tripwire for the day one appears. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace OpenApiCheck { + +using json = nlohmann::json; + +// Both paths are relative to the repo root, which is the cwd of every e2e +// run (compose WORKDIR /app; `make coverage` runs from the checkout) — the +// same assumption the server boot makes for migrations/ and logs/. +inline constexpr const char* kSpecJsonPath = "tests/e2e/openapi.gen.json"; +inline constexpr const char* kSpecYamlPath = "docs/openapi.yaml"; + +inline bool read_file(const std::string& path, std::string& out) { + std::ifstream in(path, std::ios::in | std::ios::binary); + if (!in) + return false; + std::ostringstream ss; + ss << in.rdbuf(); + out = ss.str(); + return true; +} + +/// FNV-1a 64 — mirrored in scripts/gen-openapi-json.sh; keep in sync. +inline std::uint64_t fnv1a64(const std::string& bytes) { + std::uint64_t h = 0xCBF29CE484222325ULL; + for (unsigned char c : bytes) { + h ^= c; + h *= 0x100000001B3ULL; + } + return h; +} + +inline std::string to_hex16(std::uint64_t v) { + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016llx", static_cast(v)); + return buf; +} + +/// The parsed spec, loaded once per process. json() (null) when unloadable — +/// every consumer turns that into a test failure with instructions. +inline const json& spec() { + static const json s = [] { + std::string raw; + if (!read_file(kSpecJsonPath, raw)) + return json(); + auto parsed = json::parse(raw, /*cb=*/nullptr, /*allow_exceptions=*/false); + return parsed.is_discarded() ? json() : parsed; + }(); + return s; +} + +/** + * The staleness gate: tests/e2e/openapi.gen.json must have been generated + * from the docs/openapi.yaml sitting in THIS tree. Call from a dedicated + * test so drift fails loudly even when the sidecars are down. + */ +inline void expect_spec_json_fresh() { + ASSERT_FALSE(spec().is_null()) << kSpecJsonPath << " is missing or not valid JSON — regenerate it:\n" + << " ./scripts/gen-openapi-json.sh"; + const json meta = spec().value("x-generated", json()); + ASSERT_TRUE(meta.is_object()) << kSpecJsonPath << " has no x-generated stamp — regenerate it:\n" + << " ./scripts/gen-openapi-json.sh"; + std::string yaml_raw; + ASSERT_TRUE(read_file(kSpecYamlPath, yaml_raw)) + << "cannot read " << kSpecYamlPath << " — e2e must run from the repo root"; + EXPECT_EQ(meta.value("source_fnv1a64", ""), to_hex16(fnv1a64(yaml_raw))) + << kSpecJsonPath << " is STALE: docs/openapi.yaml changed after it was generated.\n" + << "Regenerate and commit it:\n" + << " ./scripts/gen-openapi-json.sh"; +} + +struct Result { + std::vector errors; ///< schema violations — become test failures + std::vector skips; ///< unsupported constructs — logged, never silent +}; + +inline std::string type_name(const json& v) { + switch (v.type()) { + case json::value_t::null: + return "null"; + case json::value_t::boolean: + return "boolean"; + case json::value_t::string: + return "string"; + case json::value_t::array: + return "array"; + case json::value_t::object: + return "object"; + case json::value_t::number_float: + return "number"; + default: + return v.is_number_integer() || v.is_number_unsigned() ? "integer" : "unknown"; + } +} + +inline bool matches_type(const std::string& t, const json& v) { + if (t == "object") + return v.is_object(); + if (t == "array") + return v.is_array(); + if (t == "string") + return v.is_string(); + if (t == "integer") + return v.is_number_integer() || v.is_number_unsigned(); + if (t == "number") + return v.is_number(); + if (t == "boolean") + return v.is_boolean(); + if (t == "null") + return v.is_null(); + return false; // unknown type keyword — caller records a skip +} + +inline std::string brief(const json& v) { + std::string s = v.dump(); + return s.size() > 160 ? s.substr(0, 160) + "..." : s; +} + +/// Recursive core. `where` is a JSON-pointer-ish location inside the instance. +inline void validate(const json& schema, const json& instance, const std::string& where, Result& out, int depth = 0) { + if (depth > 32) { + out.skips.push_back(where + ": recursion depth cap hit — not validated deeper"); + return; + } + if (!schema.is_object()) { + out.skips.push_back(where + ": non-object schema — not validated"); + return; + } + + // $ref — only the components/schemas form the spec uses. + if (auto ref = schema.find("$ref"); ref != schema.end()) { + const std::string target = ref->get(); + constexpr const char* kPrefix = "#/components/schemas/"; + if (target.rfind(kPrefix, 0) != 0) { + out.skips.push_back(where + ": unsupported $ref form '" + target + "'"); + return; + } + const json resolved = spec() + .value("components", json::object()) + .value("schemas", json::object()) + .value(target.substr(std::string(kPrefix).size()), json()); + if (resolved.is_null()) { + out.errors.push_back(where + ": $ref target '" + target + "' not found in components/schemas"); + return; + } + validate(resolved, instance, where, out, depth + 1); + return; + } + + // Loud skip for constraint keywords this subset does not implement. + static const std::set kHandled = { + "type", "required", "properties", "items", "enum", "nullable", "additionalProperties"}; + static const std::set kAnnotations = { + "format", "description", "title", "default", "example", "examples", "deprecated"}; + for (auto it = schema.begin(); it != schema.end(); ++it) { + if (kHandled.count(it.key()) || kAnnotations.count(it.key())) + continue; + out.skips.push_back(where + ": keyword '" + it.key() + "' not supported by this validator — not checked"); + } + + // nullable: true — OpenAPI 3.0 spelling, used alongside type. + if (instance.is_null() && schema.value("nullable", false)) + return; + + // type — a single string, or an array of type names (3.1 spelling of + // nullability: type: ['string', 'null']). + if (auto t = schema.find("type"); t != schema.end()) { + bool ok = false; + std::string wanted; + if (t->is_string()) { + wanted = t->get(); + ok = matches_type(wanted, instance); + } else if (t->is_array()) { + for (const auto& alt : *t) { + wanted += (wanted.empty() ? "" : "|") + alt.get(); + ok = ok || matches_type(alt.get(), instance); + } + } + if (!ok) { + out.errors.push_back(where + ": expected type '" + wanted + "', got " + type_name(instance) + " (" + + brief(instance) + ")"); + return; // structural checks below would only cascade + } + } + + if (auto e = schema.find("enum"); e != schema.end()) { + bool ok = false; + for (const auto& allowed : *e) + ok = ok || allowed == instance; + if (!ok) + out.errors.push_back(where + ": value " + brief(instance) + " not in enum " + e->dump()); + } + + if (instance.is_object()) { + for (const auto& req : schema.value("required", json::array())) + if (!instance.contains(req.get())) + out.errors.push_back(where + ": required property '" + req.get() + "' is missing"); + const json props = schema.value("properties", json::object()); + for (auto it = props.begin(); it != props.end(); ++it) + if (instance.contains(it.key())) + validate(it.value(), instance.at(it.key()), where + "/" + it.key(), out, depth + 1); + if (auto ap = schema.find("additionalProperties"); ap != schema.end()) { + if (ap->is_boolean() && !ap->get()) { + for (auto it = instance.begin(); it != instance.end(); ++it) + if (!props.contains(it.key())) + out.errors.push_back(where + ": property '" + it.key() + + "' not allowed (additionalProperties: false)"); + } else if (ap->is_object()) { + out.skips.push_back(where + + ": schema-valued additionalProperties not supported — extra " + "properties not checked"); + } + } + } + + if (instance.is_array()) { + if (auto items = schema.find("items"); items != schema.end()) + for (std::size_t i = 0; i < instance.size(); ++i) + validate(*items, instance[i], where + "/" + std::to_string(i), out, depth + 1); + } +} + +/** + * Validate @p body against the response schema the spec declares for + * (method, path template, status). Missing operation/status → failure (the + * spec must document what the suite provokes); a response documented WITHOUT + * an application/json schema → explicit SKIP line, nothing silently passes. + */ +inline void expect_matches_schema(const std::string& body, + const std::string& method, + const std::string& path, + int status) { + const std::string label = method + " " + path + " -> " + std::to_string(status); + SCOPED_TRACE("openapi schema check: " + label); + ASSERT_FALSE(spec().is_null()) << kSpecJsonPath << " missing/unparsable — run ./scripts/gen-openapi-json.sh"; + + std::string m = method; + for (auto& c : m) + c = static_cast(std::tolower(static_cast(c))); + const json op = spec().value("paths", json::object()).value(path, json::object()).value(m, json()); + ASSERT_TRUE(op.is_object()) << "spec has no operation for " << label + << " — the (method, path) gates should have caught this"; + const json rsp = op.value("responses", json::object()).value(std::to_string(status), json()); + ASSERT_TRUE(rsp.is_object()) << "spec does not document status " << status << " for " << method << " " << path + << " — the server just sent it; document it in docs/openapi.yaml"; + + const json schema = + rsp.value("content", json::object()).value("application/json", json::object()).value("schema", json()); + if (schema.is_null()) { + std::cout << "[openapi-schema] SKIP " << label + << ": no application/json schema declared — nothing to validate against\n"; + return; + } + + const json instance = json::parse(body, nullptr, /*allow_exceptions=*/false); + ASSERT_FALSE(instance.is_discarded()) << label << ": response body is not valid JSON: " << brief(json(body)); + + Result res; + validate(schema, instance, "#", res); + for (const auto& s : res.skips) + std::cout << "[openapi-schema] SKIP " << label << " " << s << "\n"; + if (!res.errors.empty()) { + std::string all; + for (const auto& e : res.errors) + all += " " + e + "\n"; + ADD_FAILURE() << "response body violates the OpenAPI schema for " << label << ":\n" + << all << "body: " << brief(instance); + } +} + +} // namespace OpenApiCheck diff --git a/tests/e2e/test_http_e2e.cpp b/tests/e2e/test_http_e2e.cpp index ed72bb9..f62bc00 100644 --- a/tests/e2e/test_http_e2e.cpp +++ b/tests/e2e/test_http_e2e.cpp @@ -33,6 +33,7 @@ #include "api/Api.hpp" #include "core/Core.hpp" #include "domain/Role.hpp" +#include "openapi_check.hpp" #include "security/Auth.hpp" #include "security/Jwt.hpp" #include "test_helpers.hpp" @@ -164,6 +165,18 @@ json body_of(const HttpResponsePtr& resp) { return json::parse(std::string(resp->getBody())); } +/** + * Validate a real response body against the schema docs/openapi.yaml declares + * for (method, spec path template, ACTUAL status). Thin adapter over + * OpenApiCheck::expect_matches_schema — see tests/e2e/openapi_check.hpp for + * the supported schema subset and the explicit-SKIP discipline. + */ +void expect_matches_schema(const HttpResponsePtr& resp, const std::string& method, const std::string& spec_path) { + ASSERT_NE(resp, nullptr); + OpenApiCheck::expect_matches_schema( + std::string(resp->getBody()), method, spec_path, static_cast(resp->statusCode())); +} + struct SessionCookies { std::string access; std::string refresh; @@ -191,8 +204,10 @@ void attach_session(const HttpRequestPtr& req, const SessionCookies& sc) { SessionCookies register_and_login(const std::string& email, const std::string& password) { auto reg = send(json_post("/api/v1/auth/register", {{"email", email}, {"password", password}})); EXPECT_EQ(reg->statusCode(), k201Created) << reg->getBody(); + expect_matches_schema(reg, "POST", "/api/v1/auth/register"); auto login = send(json_post("/api/v1/auth/login", {{"email", email}, {"password", password}})); EXPECT_EQ(login->statusCode(), k200OK) << login->getBody(); + expect_matches_schema(login, "POST", "/api/v1/auth/login"); return cookies_of(login); } @@ -200,6 +215,13 @@ SessionCookies register_and_login(const std::string& email, const std::string& p // Tests // --------------------------------------------------------------------------- +// Deliberately NOT gated on REQUIRE_E2E_ENV: this is the drift gate between +// docs/openapi.yaml and the committed tests/e2e/openapi.gen.json the schema +// checks below consume — it must fail even on a machine without the sidecars. +TEST(OpenApiSpec, GenJsonIsFreshAndLoadable) { + OpenApiCheck::expect_spec_json_fresh(); +} + TEST(HttpE2E, HealthzCarriesRequestIdHeader) { REQUIRE_E2E_ENV(); auto req = HttpRequest::newHttpRequest(); @@ -207,6 +229,7 @@ TEST(HttpE2E, HealthzCarriesRequestIdHeader) { auto resp = send(req); EXPECT_EQ(resp->statusCode(), k200OK); EXPECT_EQ(body_of(resp)["status"], "alive"); + expect_matches_schema(resp, "GET", "/healthz"); // Tracing middleware must stamp every response. EXPECT_FALSE(resp->getHeader("x-request-id").empty()); } @@ -241,6 +264,7 @@ TEST(HttpE2E, NonJsonContentTypeRejectedWith415) { req->setContentTypeCode(CT_TEXT_PLAIN); auto resp = send(req); EXPECT_EQ(resp->statusCode(), k415UnsupportedMediaType); + expect_matches_schema(resp, "POST", "/api/v1/auth/login"); // Short-circuited responses (sync advices skip the whole post-handling // chain) must still carry the observability + security headers — // middleware::short_circuit replays them. Before it existed, a 415/401/429 @@ -261,6 +285,8 @@ TEST(HttpE2E, ContentTypeComparisonIsCaseInsensitive) { req->setContentTypeString("Application/JSON; charset=UTF-8"); auto resp = send(req); EXPECT_NE(resp->statusCode(), k415UnsupportedMediaType); + // Junk-credential path — validates the 401 error body against the spec. + expect_matches_schema(resp, "POST", "/api/v1/auth/login"); } TEST(HttpE2E, MultipartPassesContentTypeGate) { @@ -277,6 +303,7 @@ TEST(HttpE2E, MultipartPassesContentTypeGate) { req->setContentTypeString("multipart/form-data; boundary=x"); auto resp = send(req); EXPECT_EQ(resp->statusCode(), k401Unauthorized) << resp->getBody(); + expect_matches_schema(resp, "POST", "/api/v1/admin/uploads"); } TEST(HttpE2E, AuthMiddlewareGuardsNonPublicPaths) { @@ -285,6 +312,7 @@ TEST(HttpE2E, AuthMiddlewareGuardsNonPublicPaths) { req->setPath("/api/v1/jobs"); // not in api.public_paths auto resp = send(req); EXPECT_EQ(resp->statusCode(), k401Unauthorized); + expect_matches_schema(resp, "GET", "/api/v1/jobs"); EXPECT_FALSE(resp->getHeader("www-authenticate").empty()); // The auth advice short-circuits before any pre/post advice — the reply // must still be observable and hardened (see middleware::short_circuit). @@ -307,6 +335,7 @@ TEST(HttpE2E, AccountTokenRoutesArePublic) { auto resp = send(req); EXPECT_EQ(resp->statusCode(), k400BadRequest) << resp->getBody(); EXPECT_EQ(body_of(resp)["error"], "invalid_token"); + expect_matches_schema(resp, "POST", "/api/v1/account/reset-password/{token}"); } TEST(HttpE2E, RegisterLoginMeRoundtripOverWire) { @@ -321,6 +350,7 @@ TEST(HttpE2E, RegisterLoginMeRoundtripOverWire) { auto resp = send(me); ASSERT_EQ(resp->statusCode(), k200OK) << resp->getBody(); EXPECT_EQ(body_of(resp)["user"]["email"], "e2e-alice@example.com"); + expect_matches_schema(resp, "GET", "/api/v1/auth/me"); } TEST(HttpE2E, RefreshRotatesSession) { @@ -333,6 +363,7 @@ TEST(HttpE2E, RefreshRotatesSession) { attach_session(refresh, sc); auto resp = send(refresh); ASSERT_EQ(resp->statusCode(), k200OK) << resp->getBody(); + expect_matches_schema(resp, "POST", "/api/v1/auth/refresh"); auto rotated = cookies_of(resp); EXPECT_FALSE(rotated.refresh.empty()); @@ -347,14 +378,18 @@ TEST(HttpE2E, LogoutRevokesRefreshToken) { logout->setMethod(Post); logout->setPath("/api/v1/auth/logout"); attach_session(logout, sc); - ASSERT_EQ(send(logout)->statusCode(), k200OK); + auto logout_resp = send(logout); + ASSERT_EQ(logout_resp->statusCode(), k200OK); + expect_matches_schema(logout_resp, "POST", "/api/v1/auth/logout"); // The old refresh JTI is revoked in Redis — rotation must now fail. auto refresh = HttpRequest::newHttpRequest(); refresh->setMethod(Post); refresh->setPath("/api/v1/auth/refresh"); attach_session(refresh, sc); - EXPECT_EQ(send(refresh)->statusCode(), k401Unauthorized); + auto denied = send(refresh); + EXPECT_EQ(denied->statusCode(), k401Unauthorized); + expect_matches_schema(denied, "POST", "/api/v1/auth/refresh"); } TEST(HttpE2E, IdempotencyKeyReplaysResponse) { @@ -365,6 +400,7 @@ TEST(HttpE2E, IdempotencyKeyReplaysResponse) { first->addHeader("Idempotency-Key", "e2e-key-001"); auto r1 = send(first); ASSERT_EQ(r1->statusCode(), k201Created) << r1->getBody(); + expect_matches_schema(r1, "POST", "/api/v1/auth/register"); // Identical retry: without the middleware this would be 409 email_taken; // with it, the cached 201 is replayed. @@ -373,12 +409,17 @@ TEST(HttpE2E, IdempotencyKeyReplaysResponse) { auto r2 = send(second); EXPECT_EQ(r2->statusCode(), k201Created) << r2->getBody(); EXPECT_EQ(r2->getHeader("x-idempotent-replayed"), "true"); + // The replayed body must STILL match the spec — a cached response is a + // response. + expect_matches_schema(r2, "POST", "/api/v1/auth/register"); // Same key + DIFFERENT body → 422 conflict. auto third = json_post("/api/v1/auth/register", {{"email", "e2e-other@example.com"}, {"password", "password-e2e-1"}}); third->addHeader("Idempotency-Key", "e2e-key-001"); - EXPECT_EQ(send(third)->statusCode(), k422UnprocessableEntity); + auto r3 = send(third); + EXPECT_EQ(r3->statusCode(), k422UnprocessableEntity); + expect_matches_schema(r3, "POST", "/api/v1/auth/register"); } TEST(HttpE2E, AdminGateChecksPermissionBitmask) { @@ -394,12 +435,16 @@ TEST(HttpE2E, AdminGateChecksPermissionBitmask) { auto as_admin = HttpRequest::newHttpRequest(); as_admin->setPath("/api/v1/admin/users"); as_admin->addHeader("Authorization", "Bearer " + admin_jwt); - EXPECT_EQ(send(as_admin)->statusCode(), k200OK); + auto admin_resp = send(as_admin); + EXPECT_EQ(admin_resp->statusCode(), k200OK); + expect_matches_schema(admin_resp, "GET", "/api/v1/admin/users"); auto as_user = HttpRequest::newHttpRequest(); as_user->setPath("/api/v1/admin/users"); as_user->addHeader("Authorization", "Bearer " + user_jwt); - EXPECT_EQ(send(as_user)->statusCode(), k403Forbidden); + auto user_resp = send(as_user); + EXPECT_EQ(user_resp->statusCode(), k403Forbidden); + expect_matches_schema(user_resp, "GET", "/api/v1/admin/users"); } TEST(HttpE2E, PostMarkdownServedOverWire) { @@ -419,6 +464,7 @@ TEST(HttpE2E, PostMarkdownServedOverWire) { create->addHeader("Authorization", "Bearer " + admin_jwt); auto create_resp = send(create); ASSERT_EQ(create_resp->statusCode(), k201Created) << create_resp->getBody(); + expect_matches_schema(create_resp, "POST", "/api/v1/posts"); auto req = HttpRequest::newHttpRequest(); req->setPath("/posts/e2e-markdown-post"); @@ -443,7 +489,9 @@ TEST(HttpE2E, SitemapListsPublishedPost) { "/api/v1/posts", {{"slug", "e2e-sitemap-post"}, {"title", "E2E Sitemap Post"}, {"body", "Body."}, {"status", "published"}}); create->addHeader("Authorization", "Bearer " + admin_jwt); - ASSERT_EQ(send(create)->statusCode(), k201Created); + auto create_resp = send(create); + ASSERT_EQ(create_resp->statusCode(), k201Created); + expect_matches_schema(create_resp, "POST", "/api/v1/posts"); auto req = HttpRequest::newHttpRequest(); req->setPath("/sitemap.xml"); From 74cc8ec7b2ba8564a9b968f58d2d75c699352af4 Mon Sep 17 00:00:00 2001 From: Michael Tarassov Date: Sun, 23 Aug 2026 15:02:56 +0500 Subject: [PATCH 2/2] chore: regenerate schema.gen.ts for the new OpenAPI schemas --- frontend/src/lib/api/schema.gen.ts | 104 ++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/api/schema.gen.ts b/frontend/src/lib/api/schema.gen.ts index 4b4bf17..659b6c0 100644 --- a/frontend/src/lib/api/schema.gen.ts +++ b/frontend/src/lib/api/schema.gen.ts @@ -70,7 +70,17 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** @enum {string} */ + status: "alive"; + /** + * Format: int64 + * @description Epoch seconds + */ + timestamp: number; + }; + }; }; }; }; @@ -198,7 +208,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["RegisterResponse"]; + }; }; /** @description Validation failed */ 400: { @@ -214,6 +226,15 @@ export interface paths { }; content?: never; }; + /** @description Idempotency-Key conflict — same key, different body (idempotency middleware) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; delete?: never; @@ -264,7 +285,18 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Body content type is not application/json (content-type middleware — applies to every JSON API endpoint) */ + 415: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; }; }; }; @@ -344,7 +376,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["Error"]; + }; }; }; }; @@ -514,7 +548,7 @@ export interface paths { }; }; responses: { - /** @description If the email is registered */ + /** @description If the email is registered, a reset link is on its way */ 200: { headers: { [name: string]: unknown; @@ -568,7 +602,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["Error"]; + }; }; }; }; @@ -930,7 +966,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["Error"]; + }; }; }; }; @@ -1447,11 +1485,11 @@ export interface paths { query?: { limit?: number; offset?: number; - /** @description Exact action filter */ + /** @description Exact action filter, e.g. user.create */ action?: string; /** @description Filter by acting principal subject */ actor_id?: string; - /** @description Filter by target kind */ + /** @description Filter by target kind, e.g. user / role */ target_type?: string; /** @description created_at lower bound */ from?: string; @@ -1520,6 +1558,15 @@ export interface paths { "application/json": components["schemas"]["JobListResponse"]; }; }; + /** @description Not authenticated (auth middleware — the route is not in api.public_paths) */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Not an admin */ 403: { headers: { @@ -1804,7 +1851,9 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["PostDetailResponse"]; + }; }; /** @description Validation failed */ 400: { @@ -2377,6 +2426,15 @@ export interface paths { }; content?: never; }; + /** @description Not authenticated (auth middleware — multipart passes the content-type gate but still needs a session) */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Not an admin */ 403: { headers: { @@ -3361,12 +3419,15 @@ export interface components { schemas: { Error: { error: string; + /** @description HTTP status code, duplicated into the body */ + status: number; message?: string; code?: string; }; ValidationError: { /** @enum {string} */ error: "validation_failed"; + status: number; errors: { field: string; code: string; @@ -3490,6 +3551,29 @@ export interface components { MessageResponse: { message?: string; }; + RegisterResponse: { + user: components["schemas"]["User"]; + message: string; + }; + Post: { + /** Format: uuid */ + id: string; + slug: string; + title: string; + summary: string; + body: string; + /** @enum {string} */ + status: "draft" | "published"; + topic: string; + tags: string[]; + /** Format: date-time */ + published_at: string | null; + created_at: string; + updated_at: string; + }; + PostDetailResponse: { + data: components["schemas"]["Post"]; + }; BillingPackage: { /** Format: uuid */ id: string;