Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
113 changes: 103 additions & 10 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading