Appearance
API Reference
All endpoints are served by the Flask API (dps-code-api/app.py) on https://api.eccdps.org (production) / https://demo-api.eccdps.org (demo).
Conventions
- Authentication is one of: session (HttpOnly cookie set after Discord OAuth) or API key (
Authorizationheader equal to the environment'sAPI_KEY, noBearerprefix). Some routes accept both; many accept only one. - Success:
{"success": true, ...}. - Error:
{"success": false, "error": "<message>"}with an HTTP status. - CORS: credentialed responses only for the configured dashboard/training origins.
- Body limit: 1 MiB (
MAX_CONTENT_LENGTH); oversized bodies → 413. - Fields marked server-managed are never accepted from the client.
Legend for the auth column: 🔑 API key · 👤 browser session · either.
Health and root
GET /
Liveness probe. Returns the plain string DPS API Online. Exempt from environment resolution so Render health checks work.
Authentication and session
GET /auth/discord/login
Redirects the browser to Discord OAuth. Query: popup=1 (popup flow), return_to=<url> (must resolve to an allowlisted dashboard/training origin — otherwise 400). The OAuth state is environment-prefixed and stored in the session. Errors: 503 when OAuth is not configured, 400 for invalid return_to.
GET /auth/discord/callback
Discord redirect target. Verifies state (environment prefix + constant-time token compare), exchanges the code for a token (via the OAuth proxy Worker when configured, direct otherwise, with bounded retry), fetches /users/@me, resolves the Discord ID against agents, and stores discord_id in the session (clearing any previous session). Failures redirect to the dashboard login with an error code (discord_authorization_denied, oauth_state_mismatch, not_a_dps_agent, agent_not_active, demo_not_authorized, …). The demo environment maps allowlisted real IDs to fictional demo agents.
GET /auth/me
👤 Returns {agent, permissions, can_handle_supervisor, minimum_clearances, admin} — the live agent, every CLEARANCE_POLICY permission flag, supervisor rank flag, the clearance table, and server-computed admin capabilities (allowlist + Director-rank aware). 401 when not signed in.
POST /auth/logout
👤 Clears the session. Returns {"success": true}.
POST /demo/reset
👤 Demo environment only (403 elsewhere). Re-seeds the demo database from demo_seed.py. 401 without a session.
Generators (bot-facing)
All generator routes require the API key (401 otherwise). They generate random identifiers for onboarding; the API never regenerates stored identifiers.
| Endpoint | Produces |
|---|---|
GET /generate/agreement | AGM-##### |
GET /generate/agent | DPS-AG-##### |
GET /generate/join | SC-###X# |
GET /generate/case | DPS-CASE-##### |
GET /generate/investigation | INV-##### |
GET /generate/clearance | CL-### |
Utilities (bot-facing)
POST /util/timestamp
🔑 Converts a JavaScript Date.toString() string (e.g. Thu Jul 16 2026 02:57:13 GMT+0000 (Coordinated Universal Time)) to a Discord-ready Unix timestamp. Body: {"date": "…"}. 200 {"timestamp": <int>}; 400 when date is missing or unparseable.
Reports / Dockets
POST /reports/create
either · manage_dockets clearance. Creates a report. Required: report_id, reporter, reported, reason, notes, evidence. Optional: reporter_name, reported_name, status (must be in the display status set), assigned_agent, is_supervisor, thread_id, evidence_url (http(s) only). Accepts form-encoded or JSON. Side effects: report row + "Report created" timeline + report.created event + optional initial evidence row in one transaction (rpc_create_report). 201 with the full report; 409 duplicate; 400 validation.
GET /reports
either · view_dashboard clearance. Lists reports, newest first, optionally ?status=. Without limit: legacy full fetch (bot embeds, Analytics, Agents). With limit/offset (+ statuses, type, contact_open, sorts): server-side pagination via rpc_list_reports with total + per-type counts. Supervisor-flagged rows are redacted to a restricted placeholder (and supervisor counts zeroed) for non-supervisor callers.
GET /reports/<report_id>
either · view_dashboard. Full report: base fields, notes, evidence, timeline, discord profile snapshots, resolved names. 404 unknown; 403 supervisor-flagged for non-supervisors.
PATCH /reports/<report_id>/update
either · manage_dockets (or reassign_docket when assigned_agent is set — Head Investigator rank required). Updates whitelisted fields only (reporter, reported, notes, reason, status, assigned_agent, is_supervisor). is_supervisor/notes/reason are bot-only (403 for sessions). Browser sessions must be the assigned agent. Status may only be set to Open/Pending/Appealed (or Pending to conclude an in-progress investigation). Side effects: updated_at bump, timeline row, canonical event (report.assigned / report.status_changed / report.updated) with before/after state. 200 with the full report; 403/409/400 as described in Reports.
DELETE /reports/<report_id>
either · delete_docket clearance (5). Hard-deletes a report. Requires a reason ({"reason": "…"}) from every caller. rpc_delete_report captures the pre-image, deletes (working data cascades; timeline/events retained), and writes the forensic audit + canonical report.deleted event in the same transaction. 200 {"deleted": …}; 400 no reason; 404 unknown.
POST /reports/<report_id>/action
either · manage_dockets. Body {"action": "<name>", "reason": "…"}. Actions: validate, invalidate, investigate, contact_reporter, claim. Routes through perform_report_action → rpc_report_action (row lock, status guard, timeline, event, contact message, queue insert — one transaction). 200 {action, queued: true, report}; 409 on status conflict/duplicate contact/already claimed; 403 assignment/supervisor; 400 unknown action.
POST /reports/<report_id>/investigation/begin
either · manage_dockets. Legacy adapter for action=investigate — same authoritative mutation and side effects. 200 {report}.
POST /reports/<report_id>/investigation/end
either · manage_dockets. Thin adapter over conclude_investigation → rpc_report_action(action='conclude') (Under Investigation → Pending, no queue row). 200 {report}; 409 when no investigation is in progress.
POST /reports/<report_id>/viewed
either · view_dashboard. No-op (API compatibility; view tracking was removed because it flooded the audit log). 200.
POST /reports/<report_id>/evidence/opened
either · view_dashboard. No-op (same rationale). 200.
POST /reports/<report_id>/notes
either · add_case_note clearance (1). Body {"note": "…"} (author derived from session for browser callers). Requires assignment (or supervisor rank) and, for sessions, status Under Investigation. rpc_add_note writes note + timeline + report.note_added event atomically. 201 {note, report}; 403/409 per gates.
POST /reports/<report_id>/evidence
either · add_evidence clearance (1). Body {"description": "…", "url": "http(s)…"} (submitter derived from session). Same assignment/investigation gates as notes. URL scheme validated. 201 {evidence, report}.
POST /reports/<report_id>/timeline
either · manage_dockets. Body {"event": "…"}. Browser callers: actor derived from session, assignment + investigation-in-progress gates. Writes timeline row + report.timeline_entry_added event + updated_at bump. 201 {event, report}.
POST /reports/<report_id>/discord-profiles
🔑 BotGhost-only. Body: normalized {reporter: {...}, reportee|reported: {...}} profile payloads. Upserts into discord_profiles (deduplicated by discord_id). Report must exist (404). 200 {stored: [...]}.
Contact threads
GET /reports/<report_id>/contact
either · manage_dockets. Full thread: messages, closed, closed_at, closed_by, closed_by_name, message_count. 403 for non-assigned agents.
POST /reports/<report_id>/contact/reply
either · manage_dockets. Body {"body": "…"}. Assigned-agent gate; blocked when the thread is closed (409). rpc_contact_reply: message + timeline + contact.message_added event + bot DM queue insert atomically (actor Discord ID as p_actor_id). 201 with the updated thread.
POST /reports/<report_id>/contact/close
either · manage_dockets. Ends the conversation (409 if already closed). rpc_close_contact_thread: close state + timeline + contact.thread_closed event + contact_closed bot action queued. 200 with the updated thread.
POST /reports/<report_id>/contact/respond
🔑 Bot relays the reporter's DM. Body {"body": "…", "sender_name": "…"}. Blocked when closed (409). rpc_contact_respond records the reporter message + timeline + event.
Evidence
GET /evidence
either · view_dashboard. All evidence rows newest first, enriched with a lightweight report summary (batched) and submitted_by_id resolution.
Audit log
GET /audit
either · decide_appeal clearance (4). Global timeline audit log, paginated, searchable, sorted, supervisor-filtered — all in SQL (rpc_audit_log_v2). Query: search, event (comma-separated substrings), report_id, limit (≤500), offset, sort (newest/oldest/event/by/report), supervisor (only/exclude). Non-supervisors can never see supervisor events regardless of params. Returns {total, offset, limit, events} with identifier-scrubbed text.
Action queues (report bot work)
Queue statuses: pending → processing → success/failed (stale recovery: 10-minute timeout, 3-attempt cap).
GET /actions/pending
🔑 Pending/processing actions joined with report basics (limit 50).
GET /actions/next
🔑 Single-action pickup: stale requeue sweep, then atomic claim (rpc_claim_action, FOR UPDATE SKIP LOCKED). 200 with the claimed action; 204 empty body when the queue is empty.
GET /actions/next/operator · GET /actions/next/supervisor
🔑 Queue-specific pickups (non-supervisor / supervisor reports) for BotGhost's two read-event triggers. Same contract as /actions/next.
GET /actions/pending/operator · GET /actions/pending/supervisor
either · list. Operator list: API key or session. Supervisor list additionally requires Senior Agent rank for sessions. ?status= filter (pending,processing,success,failed; default pending,processing). Returns {count, actions} with failure metadata (attempts, result_note).
POST /actions/<action_id>/complete
🔑 Bot records the outcome. Body: {"result": "success"|"failed", "note": "…"}. rpc_complete_action: row lock + status guard + terminal transition + canonical queue.action_completed/queue.action_failed event in one transaction. 404 ACTION_NOT_FOUND; 409 ACTION_ALREADY_COMPLETED; 400 invalid result.
POST /actions/<action_id>/requeue
either · manage_dockets. Failed → pending (attempts reset) via rpc_action_requeue (locked, queue.action_requeued event). 409 when not failed.
Agents
GET /agents
either · view_agents. All agents newest first, optional ?status=.
GET /agents/<discord_id>
either · view_agents. One agent. 404 unknown.
POST /agents/create
either · manage_agents. Body: discord_id (required), optional name, status (default onboarding), agent_rank. Creates the onboarding row; all onboarding identifiers start null. 201; 409 duplicate.
PATCH /agents/<discord_id>/update
Dual-mode endpoint.
- 🔑 Bot path: posts whitelisted onboarding fields (
name,status,security_id,agreement_id,agent_id,clearance_id,clearance_level1–5) viarpc_agent_set_onboarding_fields.agent_rankis rejected (400) — rank changes require a Director via the dashboard. Every write is audited. - 👤 Dashboard path:
require_admin("admin_agents")+ requiredreason→rpc_admin_agent_update. Rank/clearance changes are Director-only; self-modification is blocked.
200 with the serialized agent; 400/403/404 per the guards.
Punishments & discipline
Punishment routes are browser-session only (the API key gets 403).
POST /punishments
👤 · manage_punishments (4). Issue a punishment. Required: user_id, reason. Optional: username, detailed_reason, duration, expires_at (ISO), status, evidence, notes, report_id (must exist). Server-managed fields are rejected. rpc_punishment_create generates the AA0AA0 ID transactionally, validates, writes the row + audit + punishment.issued event, and enqueues the issue delivery. 201 with the serialized punishment (incl. delivery state and stored user profile).
GET /punishments
👤 · view_punishments (2). List with ?user_id=, ?status=, bounded limit/offset (default 200, max 500).
GET /punishments/<punishment_id>
👤 · view_punishments (2). One punishment. 404 unknown.
PATCH /punishments/<punishment_id>
👤 · manage_punishments (4). Body includes the required audit reason plus whitelisted fields (username, detailed_reason, duration, expires_at, status, evidence, notes, report_id). status=Revoked via PATCH → 409 (use the revoke endpoint). Before/after audit + punishment.updated event.
POST /punishments/<punishment_id>/revoke
👤 · manage_punishments (4). Reason required. Status → Revoked (locked row, before/after audit, punishment.revoked event, revoke delivery enqueued). 409 when already revoked/expired.
GET /punishments/users/<discord_id>/profile
👤 · view_punishments (2). Stored Discord profile snapshot for the discipline issue flow. 404 when none on record (the flow then enqueues a user_lookup).
GET /discipline/next
🔑 Bot delivery pickup: stale sweep + atomic claim (rpc_discipline_claim) with the live punishment row embedded. 200; 204 when empty.
GET /discipline/pending
either · list. ?status= (default pending,processing). Returns {count, items} with the delivery surface + serialized punishment.
POST /discipline/user-lookup
👤 · manage_punishments (4). Body {"user_id": "…"}. Enqueues a user_lookup delivery so BotGhost captures the user's profile. 201 with the lookup; 409 when a lookup is already pending for that user.
POST /discipline/<item_id>/complete
🔑 Body: {"result": "success"|"failed", "note": "…", "ref": "…", "retry_after_seconds": n}. rpc_discipline_complete: locked terminal transition (or paced retry for transient failures) + canonical delivery.completed/delivery.failed/delivery.requeued event. 409 DISCIPLINE_ALREADY_COMPLETED.
POST /discipline/user-lookup/<item_id>/profile
🔑 BotGhost delivers the captured profile: upserts discord_profiles and completes the queue item (idempotent on retry; discord_id must match the lookup target). 200 with the stored profile.
POST /discipline/<item_id>/requeue
either · manage_punishments. Failed → pending (attempts reset), delivery.requeued event. 409 unless failed.
System Administration
Admin routes are browser-session only and require require_admin(...): allowlist membership + clearance (+ Director rank for Director-only capabilities). Reasons are required for mutations; every mutation writes an audit row and a canonical event.
GET /admin/health
👤 · admin_health (4). Status-site payload: 10 sections (overall, database, api, actions, queue, agents, evidence, auth, audit, events), each ok/warn/error, plus incident history (recorded on overall-status change).
GET /admin/queue
👤 · admin_queue (4). All queue actions across ?status= (default pending,processing,failed), joined with report basics, limit 200.
POST /admin/queue/<action_id>/requeue
👤 · admin_queue. Reset a failed action; reason audited (admin.queue_requeued).
POST /admin/queue/<action_id>/cancel
👤 · admin_queue. Cancel a pending/processing action; reason audited (admin.queue_cancelled).
POST /admin/queue/sweep
👤 · admin_queue. Manual stale sweep (requeue past timeout, fail past retry cap); reason audited (admin.queue_swept).
GET /admin/audit
👤 · admin_audit (4). Paginated admin audit trail; limit (≤200), offset, agent, action, target filters. Returns {total, rows}.
GET /admin/agents
👤 · admin_agents (4). Full agent list for the admin Agents tab.
PATCH /admin/agents/<discord_id>
👤 · admin_agents + guards. Body: {"fields": {...}, "reason": "…"}. Whitelisted fields; clearance changes Director-only; rank above Head Investigator Director-only; self-modification blocked; hierarchy guard (only at/below your rank). rpc_admin_agent_update re-checks everything in the DB. Returns the RPC result with correlation_id.
GET /admin/access
👤 · Director-only. Admin allowlist.
POST /admin/access/grant · POST /admin/access/revoke · POST /admin/access/remove
👤 · Director-only; reason required; audited (admin.access_granted / admin.access_revoked / admin.access_removed). Directors are protected from revoke/remove (DIRECTOR_PROTECTED → 403).
GET /admin/reports
👤 · admin_reports (Director). Read-only report search (?search=, sanitized against PostgREST filter injection; limit 50).
POST /admin/reports/<report_id>/correct
👤 · admin_reports (Director). Whitelisted field correction; reason required; before/after audited (admin.report_corrected).
GET /admin/schema
👤 · admin_schema (Director). Applied migrations + RPC function inventory.
POST /admin/schema/reload
👤 · admin_schema (Director). Fires a PostgREST schema reload (NOTIFY), audited (admin.schema_reloaded).
Training Center
Training routes are browser-session only — the bot API key is rejected with 403. Trainers = is_trainer flag or supervisor rank; controls require being the session's assigned trainer; trainees see only their own sessions.
GET /training/me
👤 · Session. {is_trainer, agent} — the frontend's role discovery.
GET /training/realtime-config
👤 · Session. {supabase_url, anon_key, token_path} — public Supabase config for the browser Realtime client.
GET /training/realtime-token
👤 · Session. Mints a short-lived JWT (role=authenticated, sub=<discord_id>, default TTL 600 s) signed with the environment's SUPABASE_JWT_SECRET. {token, ttl, expires_at}. 503 when Realtime is not configured.
GET /training/scenarios
👤 · Trainer. Scenario catalog, including unpublished drafts, ordered by title.
GET /training/trainees
👤 · Trainer. Agents with status exactly onboarding (the only eligible trainees), ordered by name.
GET /training/sessions
👤 · Session. Role-scoped list (max 300): trainers see sessions they trained; trainees see their own active/paused/completed sessions (pending/aborted hidden), newest first, with batched scenario titles and agent names.
POST /training/sessions
👤 · Trainer. Body: {"trainee_id": "…", "scenario_id": <int>?}. Creates the parent session (pending) + membership rows + optional first scenario child + SESSION_CREATED event (broadcast to both users). Trainee must be onboarding (400 otherwise). 201 with the full session payload.
GET /training/sessions/<session_id>
👤 · Participant. Full role-aware payload (session, active scenario, all scenario instances with definitions, ≤200 events, role-filtered notes, injections, agent names) built from four parallel batched reads. Trainees on pending/aborted sessions get 409 NO_ACTIVE_SESSION.
POST /training/sessions/<session_id>/start
👤 · Assigned trainer. pending → active, activates the first waiting scenario if present, records SESSION_STARTED (+ SCENARIO_ACTIVATED), broadcasts to both user channels. 409 when closed.
POST /training/sessions/<session_id>/pause · /resume · /restart · /reset · /end · /abort
👤 · Assigned trainer. Lifecycle controls, each status-guarded (409 on invalid transitions), each recording its SESSION_* event and broadcasting to trainer/trainee user channels:
pause:active→paused, stores elapsed time.resume:paused→active.restart: any run state → freshactiverun (state wiped, evaluation cleared).reset: any run state →pending(trainee redoes the prologue; scenario child back towaiting).end:active/paused→completed(grading via/evaluatealso completes).abort: non-terminal →aborted(trainee lands on the no-active state).
All return slim {"success": true}.
POST /training/sessions/<session_id>/events
👤 · Participant. Records a whitelisted trainee action (type, target?, client_id?, session_scenario_id?, payload?). Actor is derived from the session; session must be active (409 otherwise); duplicate client_id retries are idempotent (duplicated: true). The scenario engine then evaluates objectives/evidence unlocks. Returns {recorded, duplicated, engine_events}.
POST /training/sessions/<session_id>/scenarios/<scenario_id>/activate
👤 · Assigned trainer. Introduces a scenario into the session (completes any active child), records SCENARIO_ACTIVATED, broadcasts. 409 when already in the session; 404 unknown scenario; 409 unless pending/active/paused.
POST /training/sessions/<session_id>/evidence/<key>/release · /lock
👤 · Assigned trainer. Manual evidence override against the active scenario (404 unknown key). Persists instance + session state, records TRAINER_RELEASED_EVIDENCE/TRAINER_LOCKED_EVIDENCE, broadcasts EVIDENCE_RELEASED/EVIDENCE_LOCKED to the session channel.
POST /training/sessions/<session_id>/objectives/<id>/complete · /reset
👤 · Assigned trainer. Manual objective override (404 unknown objective), records TRAINER_COMPLETED_OBJECTIVE/TRAINER_RESET_OBJECTIVE.
POST /training/sessions/<session_id>/inject
👤 · Assigned trainer. Sends information to the trainee. type ∈ supplemental_report, witness_statement, admin_information, custom_message, simulated_evidence; content required (≤2000 chars), title ≤120. Persists the injection + TRAINER_INJECTED_INFORMATION event; broadcast via the event path. Returns {injection, event}.
POST /training/sessions/<session_id>/notes
👤 · Participant. Trainer private note (starts released=false) or trainee investigation note (visible to the trainer for monitoring, starts released). body required (≤1000 chars). Records TRAINER_NOTE_ADDED/TRAINEE_NOTE_ADDED.
POST /training/sessions/<session_id>/notes/<note_id>/release
👤 · Assigned trainer. Releases a private trainer note to the trainee (404 unknown note), records TRAINER_NOTE_RELEASED.
POST /training/sessions/<session_id>/evaluate
👤 · Assigned trainer. Manual grading: score 0–100, result pass/fail, optional comments/released_feedback (≤2000 each). Completes the session (status → completed, evaluation fields set). Trainees can never write their own grade. Records SESSION_EVALUATED, broadcasts to both users.
ER:LC integration
GET /erlc/player/<roblox_id>
🔑 Player lookup against the current ER:LC server via the relay (erlc_relay.fetch_player — relay selection, failover, circuit breaker owned there). 200 {online, robloxId, username, permission, team, wantedStars, location, raw} (online=false with nulls when the player is not in the server); 400 non-positive ID; upstream errors mapped with Retry-After when provided.
Error responses (summary)
| Status | Meaning |
|---|---|
| 400 | Validation failure (fields, enums, URLs, bounds, reasons missing where required) |
| 401 | Missing/invalid authentication (session or API key) |
| 403 | Authorization failure (clearance, rank, assignment, allowlist, bot-key rejection, self-modification, Director required) |
| 404 | Unknown resource (report, agent, punishment, session, queue item) |
| 409 | State conflict (status changed, duplicate action, already claimed/completed/closed, already pending) |
| 413 | Request body too large |
| 500 | Database/API error (message included for diagnostics) |
| 503 | Environment unavailable / integration not configured |