DESIGN_API: API Contract & Security β
This document defines the interface between the camply-backend (FastAPI) and camply-frontend (React). It also outlines the security flow for Auth0 and optional invite-only access.
Python package boundaries β
The existing ASGI application is camply.server.app:app, enabled by the server extra. This installation supports API browsing and scan management with SQLite. Operations overview/task-list responses use existing unavailable/error fields when distributed is absent unless CAMPLY_RUNTIME_MODE=standalone is active.
Runtime and hosting contract β
CAMPLY_RUNTIME_MODE=standalone serves built root-based frontend assets and the API from Python. Known API routes keep their normal errors, /api/* misses never become HTML, and /metrics is unavailable. HTML navigation falls back to the entry page for root/deep links; missing asset paths return 404. GET/HEAD are supported, other static methods return 405, hashed /assets/ files are immutable, and entry HTML is not cached. Auth mode, CSRF, invitation checks, and administrator authorization remain independent of runtime.
GET /api/health checks database readiness in both runtimes with a two-second query budget, additionally checks supervised local loops in standalone, and returns HTTP 503 when unhealthy. Its existing response shape is unchanged. /api/ping remains dependency-independent liveness. Admin operations adds runtime_mode, capabilities, and local scheduler_status. Standalone returns broker_connected: null, unsupported reserved/scheduled counts as null, measured queue/running counts, and bounded local attempt history (1,000 entries/24 hours, cleared on restart). Historical trends use the existing unavailable response shape and are intentionally hidden by capability in the frontend. Failed loops display a real unhealthy state rather than a broker outage.
Administrator provider sync dispatches to the selected runtime. Repeated local sync requests share the active job identity and return promptly. Existing distributed broker/inspection/Prometheus behavior remains the default. See admin response details.
π― API Philosophy β
- OpenAPI-First: Every endpoint must be documented so that the frontend TypeScript client can be automatically generated.
- Standardized Responses: All successful responses use
ORJSONResponsefor performance. - Strict Validation: Every request payload must be a Pydantic v2 model.
- Auth-Gated: All user-specific endpoints are gated by the
CurrentUserDepdependency.
π Security & Middleware β
1. Authentication and local development β
The owner selects none, session, or auth0 with CAMPLY_AUTH_MODE, independently of the environment. HTTP Basic authentication is unsupported.
- Automatic login (
none, default): every request uses the shared admin identity; no login/signup/logout UI. - Password sessions (
session): the app posts username/password JSON toPOST /api/login. The response contains the profile and sets a signed HTTP-only session cookie plus a CSRF cookie. The frontend confirms the cookie withGET /api/mebefore opening the dashboard; rejected cookies keep the user on the login form with setup guidance. Scan queries do not automatically retry 401/403 responses. A scan query's 401 clears the cached profile and scans, cancels any profile refresh, and returns the user to sign-in. Mutations requireX-CSRF-Tokenmatching the signed session.POST /api/logoutclears the cookies. The shared account has no signup. Cookies have an absolute lifetime, and credential/secret changes invalidate them. - Auth0 (
auth0): requests require an RS256 bearer token validated against issuer and API audience. Users are provisioned by subject on first access. The application starts sign-in on/authand uses the Auth0 SDK. GET /api/auth-config: public configuration returnsauth_mode,auth0_domain,auth0_client_id,auth0_audience,auto_login,signup_enabled, andinvite_only. The React SDK requests the returned audience. Auth0 mode must supply domain, audience, and client ID.GET /api/me: the profile exposesis_invited,is_admin, andscanning_enabled. The existing database column remainsis_early_access_userfor compatibility with stored data.
Session cookies include a credential version derived by HMAC from the current salted password hash. Every authenticated session request checks this version against the database, so a password reset or change invalidates earlier cookies without adding a database column or exposing the hash. POST /api/change-password refreshes the current browser's cookies and preserves its CSRF token. Legacy cookies without a credential version require sign-in again. Stored bootstrap passwords replace the configured password for authentication; the configured username remains an alias. Session mode honors the stored administrator role, including bootstrap account demotions. Automatic login restores administrator access and ignores password-change flags; Auth0 also ignores these session-only flags.
2. Optional invite-only guard β
CAMPLY_INVITE_ONLY defaults to false. When enabled, every /api/scans operation requires the user's is_invited flag. Uninvited authenticated callers receive 403 with ERR_INVITE_REQUIRED. The frontend gates dashboard and scan detail routes with the invitation request screen. Profiles and public metadata remain accessible. Existing scan ownership checks apply in both configurations.
3. Invitation requests (collection only) β
POST /api/request-access stores a pending email request and accepts duplicates idempotently. It does not grant access or deliver notifications.
TODO: Provide an approval/revocation workflow, verified identity matching, and invitation notification delivery.
ποΈ Endpoint Definitions β
1. Search & Metadata (Public/Auth-Light) β
GET /api/search?query=<term>: Full-text search for campgrounds and recreation areas (uses theSearchFTS table).GET /api/search/explore: Paginated search with totals and facets. Optionalwest,south,east, andnorthfilter the full matching set before pagination and aggregation. Supply all four finite bounds or none; longitude is within [-180, 180], latitude within [-90, 90], south < north, and west β east. West > east crosses the antimeridian. Viewport filtering excludes missing/invalid coordinates and lat/lon of 0/0; unbounded search retains unmappable places.GET /api/providers: List supported providers and their scanning capabilities.GET /api/provider/{id}: Get a single provider by ID.GET /api/campground/{provider}/{id}: Get a single campground.GET /api/rec-area/{provider}/{id}: Get a recreation area.GET /api/rec-area/{provider}/{id}/campgrounds: List campgrounds within a recreation area.
2. User & Profile (Auth-Required) β
GET /api/me: Get current user profile, invitation status, administrator role, scanning eligibility, and password change status.PATCH /api/me: Update user-specific settings (e.g.,pushover_token). Requires password update first ifmust_change_passwordis set.POST /api/change-password: Update user password and clearmust_change_password(session mode).
3. Scan Management (Auth-Required + Optional Invitation) β
GET /api/scans: List all scans belonging to the current user (paginated).POST /api/scans: Create a new scan.- Logic: Backend calculates the target
hash, creates/links theUniqueTarget(de-duplication), and creates theUserScan. - Returns
201 Createdon success,409 Conflictif a duplicate scan exists.
- Logic: Backend calculates the target
GET /api/scans/{id}: Detailed view of a scan, including recentscan_results.- Result expiration: User-facing results and
found_countomit nights before the current UTC date or outside the target interval, then apply the saved minimum stay. This also applies to paused scans. Successful provider checks replace availability, including clearing results for an empty response; failed checks preserve the latest successful snapshot and timestamp. PATCH /api/scans/{id}: Edit the campground/provider, dates, filters (min_stay_length,preferred_types,require_electric), or toggleis_active. All fields are optional. Target changes validate the merged date range and campground, then relink only this subscription to a new or existingUniqueTarget; shared targets and other subscribers remain unchanged. Duplicate subscriptions return409, invalid date ranges return422, and unknown campgrounds return404. Editing preserves the scan ID, creation date, and active preference unless explicitly changed. Suspended users can edit saved scans, but cannot resume them.DELETE /api/scans/{id}: Unsubscribe from a scan (returns204 No Content).
4. Administration & Operations (Admin-Only) β
GET /api/admin/overview: Summary counts (users, active scans, targets, 24h results).GET /api/admin/users: List users with scanning status, scan counts, pagination, and search.POST /api/admin/users: Create a new database user with password, scanning status, admin status, and optional forced password change (session mode only).GET /api/admin/users/{id}: User detail with scans and audit history.PATCH /api/admin/users/{id}: Update scanning access or administrator role, rejecting self-demotion. Password resets andmust_change_passwordupdates require session mode; requests containing either field outside session mode return400before applying changes. Password resets revoke existing sessions and default to requiring password rotation.GET /api/admin/scans: List all system scans with filters and pagination.GET /api/admin/scans/{id}: Scan detail including target and subscriber info.PATCH /api/admin/scans/{id}: Update scan status (e.g., toggleis_active).GET /api/admin/targets/{id}: Shared target detail and subscribers.GET /api/admin/audit: Immutable admin audit event log with pagination.GET /api/admin/operations: Celery worker states, queue depth, discovery metadata, and recent tasks.GET /api/admin/operations/tasks: Paginated Valkey telemetry stream for recent task executions.GET /api/admin/operations/tasks/{task_id}: Detailed telemetry for a single task execution.GET /api/admin/trends: Server-side Prometheus query proxy forusage,api,worker, andprovidermetrics across24h,7d,30d.
π¦ Request / Response DTOs (Pydantic) β
All schemas are defined in src/camply/server/schemas.py.
ScanCreateRequest β
class ScanCreateRequest(BaseModel):
provider_id: int = Field(..., description="Provider identifier")
campground_id: str = Field(..., description="Provider-internal campground ID")
start_date: date = Field(..., description="Check-in date")
end_date: date = Field(..., description="Check-out date")
min_stay_length: int = Field(default=1, ge=1)
preferred_types: list[str] = Field(default_factory=list)
require_electric: bool = Field(default=False)ScanResponse β
class ScanResponse(BaseModel):
id: UUID
provider_id: int
campground_id: str
campground_name: str
recreation_area_name: str
start_date: date
end_date: date
is_active: bool
min_stay_length: int
preferred_types: list[str]
require_electric: bool
last_checked_at: datetime | None
found_count: int
created_at: datetimeScanDetailResponse extends ScanResponse β
Adds:
results: list[ScanResultItem]ScanResultItem β
class ScanResultItem(BaseModel):
campsite_id: str
campsite_name: str
available_dates: list[str]
campsite_type: str | None
is_electric: bool | None
is_accessible: bool | None
booking_url: strUser scan results and found_count apply the saved site preferences and minimum consecutive stay. Dates represent available nights in qualifying blocks; check-out is the day after the last night. Booking links are derived from the provider's campground URL without making a provider request. Legacy snapshots have an empty name and unknown attributes until refreshed. Admin shared-target views remain unfiltered.
π Client Generation Workflow β
- Backend developer updates the FastAPI router.
- Run
task backend:checkto ensure types are correct. - Start the backend:
task backend:dev. - Run:
task frontend:codegenfrom the repository root.- Fetches
http://localhost:8000/api/openapi.json. - Generates TypeScript types in
src/frontend/lib/api/generated/schema.ts.
- Fetches
- Register new endpoints in
src/frontend/lib/api.tsandsrc/frontend/lib/structs.ts.
πΊοΈ Endpoint Summary β
| Method | Path | Auth | Invitation (when enabled) | Purpose |
|---|---|---|---|---|
| GET | /api/search?query= | β | β | Search campgrounds/rec areas |
| GET | /api/providers | β | β | List providers |
| GET | /api/provider/{id} | β | β | Single provider |
| GET | /api/campground/{provider}/{id} | β | β | Single campground |
| GET | /api/rec-area/{provider}/{id} | β | β | Single rec area |
| GET | /api/rec-area/{provider}/{id}/campgrounds | β | β | Campgrounds in rec area |
| GET | /api/me | β | β | Current user profile |
| PATCH | /api/me | β | β | Update profile |
| POST | /api/change-password | β | β | Change password (session) |
| GET | /api/scans | β | β | List user scans |
| POST | /api/scans | β | β | Create scan |
| GET | /api/scans/{id} | β | β | Scan detail |
| PATCH | /api/scans/{id} | β | β | Update scan |
| DELETE | /api/scans/{id} | β | β | Delete scan |
| GET | /api/health | β | β | Health check |
| GET | /api/admin/overview | β (Admin) | β | Admin KPI overview |
| GET | /api/admin/users | β (Admin) | β | List users with status |
| POST | /api/admin/users | β (Admin) | β | Create user with credentials |
| GET | /api/admin/users/{id} | β (Admin) | β | User details and audit |
| PATCH | /api/admin/users/{id} | β (Admin) | β | Update user (toggle scanning) |
| GET | /api/admin/scans | β (Admin) | β | List all system scans |
| GET | /api/admin/scans/{id} | β (Admin) | β | Scan detail and target info |
| PATCH | /api/admin/scans/{id} | β (Admin) | β | Update scan (toggle active) |
| GET | /api/admin/targets/{id} | β (Admin) | β | Shared target detail |
| GET | /api/admin/audit | β (Admin) | β | Immutable audit event log |
| GET | /api/admin/operations | β (Admin) | β | Celery worker states & queue |
| GET | /api/admin/operations/tasks | β (Admin) | β | Valkey telemetry stream |
| GET | /api/admin/operations/tasks/{id} | β (Admin) | β | Task execution telemetry |
| POST | /api/admin/operations/sync-providers | β (Admin) | β | Dispatch provider catalog sync |
| GET | /api/admin/trends | β (Admin) | β | Prometheus metrics proxy |
Admin contract and monitoring availability β
Admin frontend types reference the generated OpenAPI schema. Run task api:generate after changing response models; task api:check verifies drift without starting a server and runs in CI. Overview returns suspended/dormant counts and the actual overdue threshold (cooldown plus two discovery intervals). Scan lists support target_status=active|overdue|dormant; target detail uses the same status values.
Operations reports broker connectivity separately from worker inspection and marks incomplete snapshots as stale. Queue/discovery outages preserve prior successful values with explicit availability flags. Task-stream outages return available=false, not an empty success. Prometheus outages retain the last successful trend series and timestamp with available=false. Celery inspection remains read-only and browser requests only reach FastAPI.
Admin workload fields are nullable: an unanswered or malformed inspection reply is unknown, while a responding workerβs empty task list is a measured zero. Undefined Prometheus samples retain their timestamps with value: null, preserving chart gaps and table coverage. Recent task records include an optional event_id and one-based attempt; older producer records use their Valkey stream ID for row identity and leave the attempt unknown. History readers exclude records older than 24 hours.
Geographic discovery (public) β
GET /api/search/map/summary returns global filtered total, mappable_total, unmappable_total, and nullable circular bounds. GET /api/search/map adds required west, south, east, north, and zoom (0β19), returning an echoed viewport and at most 512 location/group features. Longitude west > east means an antimeridian crossing; west=-180/east=180 is the full world. Invalid/nonfinite request bounds return 422. Only finite catalog coordinates within Β±90/Β±180 other than lat/lon 0/0 are mapped.
Both routes and GET /api/search/map/members reuse the existing Explore predicates, including PostgreSQL fuzzy matching. Map membership ignores list pagination/sort. Groups aggregate every matching visible row in a server-selected geographic grid; singleton features carry the existing search-result metadata. Global counts differ from visible represented counts. Member requests carry cell_id, the group's captured viewport, and the same filters, plus page_size (default 50, maximum 100) and optional after_id (the previous next_cursor). This keyset interface keeps co-located places reachable without fetching every list page or transferring the catalog.
These routes expose public catalog metadata and make no campsite-availability claim. Each request reflects the current catalog; no cross-request snapshot is guaranteed during metadata refresh. Existing protected scan endpoints still enforce monitoring access. See the mapping API contract. Generate/check types using task api:generate and task api:check.