{ "openapi": "3.0.0", "info": { "title": "Runic Gateway API", "version": "1.0.0", "description": "REST API for the Runic Gateway website, wiki and admin panel.\n\nThis document is core. Installed modules add their own paths, tags and schemas to it at request time from the fragment each one ships, so `/api/docs.json` on a running instance describes more than `npm run swagger` generates here (docs/website/MODULE_API.md §6.1a).\n\n### Authentication\n- **Web / admin panel** uses an httpOnly session cookie (`rg_token`) issued by `POST /api/v1/auth/login` (plus `/login/totp` when 2FA is enabled).\n- **Native / mobile clients** use bearer access tokens from `POST /api/v1/auth/mobile/login`, refreshed via `/auth/mobile/refresh`.\n\nEndpoints under `/api/v1/admin/**` require a valid session; some are further restricted to the `admin` role (editors are limited to content)." }, "servers": [ { "url": "/", "description": "Same-origin (current host)" }, { "url": "http://localhost:3000", "description": "Local development" } ], "tags": [ { "name": "Health", "description": "Liveness probe" }, { "name": "Auth", "description": "Web session login/logout (cookie + TOTP)" }, { "name": "Auth · Me", "description": "The signed-in account: profile, account security (credentials, 2FA, linked identities, recovery codes), notification streams and devices" }, { "name": "Auth · Mobile", "description": "Native bearer-token login, refresh and logout" }, { "name": "Auth · SSO", "description": "OAuth2 / OIDC provider discovery and redirect flow" }, { "name": "Public", "description": "Unauthenticated site content (settings, posts, wiki, contact)" }, { "name": "Player · Appeals", "description": "Player-submitted moderation appeals" }, { "name": "Settings", "description": "Site-wide settings any authenticated account may read (nav overrides)" }, { "name": "Admin · Dashboard", "description": "Dashboard summary and site mode" }, { "name": "Admin · Posts", "description": "News / five-on-friday / newsletter / screenshots + uploads" }, { "name": "Admin · Pages", "description": "Editable static site pages" }, { "name": "Admin · Wiki", "description": "Wiki pages, categories, tags and revisions" }, { "name": "Admin · Settings", "description": "Site settings (admin only)" }, { "name": "Admin · Email", "description": "Outbound email configuration and delivery test (admin only)" }, { "name": "Admin · Invites", "description": "Registration invites — issue, list and revoke" }, { "name": "Admin · Moderation", "description": "Player reports, appeals and moderator actions" }, { "name": "Admin · Activity", "description": "Admin activity log" }, { "name": "Admin · Bot Activity", "description": "Bot-scoring/ban state and emergency unban (admin only)" }, { "name": "Admin · Discord Bot", "description": "Discord bot token/config and live status (admin only)" }, { "name": "Admin · Auth Providers", "description": "SSO provider configuration (admin only)" }, { "name": "Admin · Users", "description": "User management (admin only)" } ], "paths": { "/api/csp-report": { "post": { "tags": [ "Health" ], "summary": "Content-Security-Policy violation report sink", "description": "Receives CSP violation reports from browsers (both the `report-uri` `application/csp-report` format and the Reporting API `application/reports+json` format). Unauthenticated by necessity — browsers send reports with no session. Reports are logged, never stored or echoed. Always answers 204.", "responses": { "204": { "description": "Report accepted (or ignored). No content." }, "429": { "description": "Too many reports from this address." } }, "security": [] } }, "/api/health": { "get": { "tags": [ "Health" ], "summary": "Liveness probe", "description": "", "responses": { "200": { "description": "Service is up", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "ok" } } } } } } } } }, "/api/v1/admin/activity": { "get": { "tags": [ "Admin · Activity" ], "summary": "List recent admin activity", "description": "", "parameters": [ { "name": "offset", "in": "query", "schema": { "type": "string" } }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Max rows to return." } ], "responses": { "200": { "description": "Activity entries", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/auth/providers": { "get": { "tags": [ "Admin · Auth Providers" ], "summary": "List configured SSO providers (admin only)", "description": "", "responses": { "200": { "description": "Providers (secrets stripped)", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ProviderConfig" } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Auth Providers" ], "summary": "Create a custom SSO provider (admin only)", "description": "Built-in providers (google, discord) are configured via PUT, not created here.", "responses": { "201": { "description": "Created provider", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProviderConfig" } } } }, "400": { "description": "Validation error, or a built-in/invalid kind", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Provider id already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProviderCreateRequest" } } } } } }, "/api/v1/admin/auth/providers/{id}": { "put": { "tags": [ "Admin · Auth Providers" ], "summary": "Update an SSO provider (admin only)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Provider id." } ], "responses": { "200": { "description": "Updated provider", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProviderConfig" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Provider not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProviderCreateRequest" } } } } }, "delete": { "tags": [ "Admin · Auth Providers" ], "summary": "Delete a custom SSO provider (admin only)", "description": "Built-in providers cannot be deleted — disable them instead.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Provider id." } ], "responses": { "200": { "description": "Deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeletedFlag" } } } }, "400": { "description": "Built-in provider cannot be deleted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Provider not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/bot-activity": { "get": { "tags": [ "Admin · Bot Activity" ], "summary": "Bot-scoring / ban state and recent events (admin only)", "description": "", "responses": { "200": { "description": "Banned IPs, scores and recent events", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/bot-activity/unban": { "post": { "tags": [ "Admin · Bot Activity" ], "summary": "Emergency unban an IP (admin only)", "description": "", "responses": { "200": { "description": "Unbanned (echoes the ip and whether an entry was cleared)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnbanResult" } } } }, "400": { "description": "Invalid IP", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnbanRequest" } } } } } }, "/api/v1/admin/dashboard": { "get": { "tags": [ "Admin · Dashboard" ], "summary": "Dashboard summary counts", "description": "", "responses": { "200": { "description": "Summary: site mode, last change, post/user counts and recent activity", "content": { "application/json": { "schema": { "type": "object", "properties": { "site_mode": { "type": "string", "example": "live" }, "last_change": { "type": "object", "properties": { "at": { "type": "string", "nullable": true }, "by": { "type": "string", "nullable": true } } }, "counts": { "type": "object", "properties": { "posts": { "type": "object", "additionalProperties": true }, "users": { "type": "integer" } } }, "warnings": { "type": "array", "description": "Operator warnings needing action; empty when there is nothing to say", "items": { "type": "object", "properties": { "code": { "type": "string", "example": "EMAIL_TRANSPORT_MIGRATION" }, "message": { "type": "string" }, "href": { "type": "string" } } } }, "recent_activity": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/discord-bot/config": { "get": { "tags": [ "Admin · Discord Bot" ], "summary": "Get Discord bot config + live status (admin only)", "description": "", "responses": { "200": { "description": "Masked config + live status", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin · Discord Bot" ], "summary": "Save Discord bot config (admin only)", "description": "token is write-only — omit/blank it to keep the existing one unchanged.", "responses": { "200": { "description": "Updated config + live status", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Validation error, invalid token, or missing token while enabling", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "guildId": { "type": "string" }, "token": { "type": "string" }, "enabled": { "type": "boolean" } } } } } } } }, "/api/v1/admin/email/config": { "get": { "tags": [ "Admin · Email" ], "summary": "Get email delivery config, status and the transport catalog (admin only)", "description": "Credentials are write-only: secret fields are never returned, only a per-field `secretsSet` flag. `transports` carries each registered transport's declared credential fields, which is what the admin form renders.", "responses": { "200": { "description": "Config (secrets stripped) + status + transport catalog", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin · Email" ], "summary": "Update email delivery config (admin only)", "description": "Set the transport, sender identity, credentials and enabled toggle. `credential` is a patch against the stored blob — a secret field submitted empty keeps its stored value. Enabling requires complete credentials and a sender address.", "responses": { "200": { "description": "Updated config", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Unknown transport, or cannot enable without complete credentials and a sender address", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "transport": { "type": "string", "example": "smtp" }, "senderEmail": { "type": "string", "format": "email" }, "senderName": { "type": "string" }, "replyTo": { "type": "string", "format": "email" }, "credential": { "type": "object", "additionalProperties": true }, "enabled": { "type": "boolean" } } } } } } } }, "/api/v1/admin/email/disconnect": { "post": { "tags": [ "Admin · Email" ], "summary": "Clear the stored credentials and disable email (admin only)", "description": "", "responses": { "200": { "description": "Cleared config", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/email/test": { "post": { "tags": [ "Admin · Email" ], "summary": "Send a test email (admin only)", "description": "The real verification of the configuration — host, port, TLS mode, credentials, and whether the relay accepts the configured sender. Failures return a specific diagnostic.", "responses": { "200": { "description": "Sent", "content": { "application/json": { "schema": { "type": "object", "properties": { "sent": { "type": "boolean" }, "to": { "type": "string" } } } } } }, "400": { "description": "Bad Request" }, "502": { "description": "Send failed / not configured", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "to": { "type": "string", "format": "email" } } } } } } } }, "/api/v1/admin/engagement/audiences": { "get": { "tags": [ "Admin · Engagement" ], "summary": "List every declared audience a rule may be pointed at", "description": "Module-declared named sets of users, resolved over the module own data. The resolver itself is never served — an audience answers with user ids on the server side only.", "responses": { "200": { "description": "The declared audiences and the audience-ceiling vocabulary", "content": { "application/json": { "schema": { "type": "object", "properties": { "audiences": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "ceilings": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/engagement/triggers": { "get": { "tags": [ "Admin · Engagement" ], "summary": "List every declared event trigger, with its payload contract and audience ceiling", "description": "Served from the module registries, not from a table: a trigger is declared in code by core or by an installed module, so this is whatever registered on this boot. Each declaration carries the variables a template may interpolate (with an example per variable, for preview and test-send) and the widest audience a rule may ever give it.", "responses": { "200": { "description": "The declared triggers, the audience-ceiling vocabulary, and the variable types", "content": { "application/json": { "schema": { "type": "object", "properties": { "triggers": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "ceilings": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "variableTypes": { "type": "array", "items": { "type": "string" } }, "kinds": { "type": "array", "items": { "type": "string" } } } } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/invites": { "post": { "tags": [ "Admin · Invites" ], "summary": "Create and email an account invite at a chosen access level", "description": "", "responses": { "201": { "description": "Invite created", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "409": { "description": "An account already uses that email address. Addresses are unique, so such an invite could never be accepted; it is refused here rather than at accept time, after the invitee has clicked the link.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "email", "role" ], "properties": { "email": { "type": "string" }, "role": { "type": "string" } } } } } } }, "get": { "tags": [ "Admin · Invites" ], "summary": "List recent invites (no tokens)", "description": "", "parameters": [ { "name": "limit", "in": "query", "schema": { "type": "string" } } ], "responses": { "200": { "description": "Invites, newest first", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/invites/{id}": { "delete": { "tags": [ "Admin · Invites" ], "summary": "Revoke a pending invite", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Invite id." } ], "responses": { "200": { "description": "Revoked", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No pending invite to revoke", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/appeals": { "get": { "tags": [ "Admin · Moderation" ], "summary": "List moderation appeals (default: pending + under_review)", "description": "Filter with ?status= or ?status=all. Paginated with ?limit&offset.", "responses": { "200": { "description": "Appeals queue", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/AppealQueueItem" } } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/appeals/{id}": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Get a single moderation appeal", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Appeal id." } ], "responses": { "200": { "description": "The appeal", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AppealQueueItem" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Appeal not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/appeals/{id}/claim": { "post": { "tags": [ "Admin · Moderation" ], "summary": "Claim a pending appeal (→ under_review)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Appeal id." } ], "responses": { "200": { "description": "The claimed appeal", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AppealQueueItem" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Appeal not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Appeal is not open for claiming", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/appeals/{id}/resolve": { "post": { "tags": [ "Admin · Moderation" ], "summary": "Resolve an appeal (approved | denied); approval may auto-reverse the Discord action", "description": "Approving a ban/mute appeal best-effort asks the bot to reverse the Discord action (unban / clear timeout). The bot being down never fails the resolution — reversal_status is recorded as failed. The response echoes the updated appeal plus a `reversal` object.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Appeal id." } ], "responses": { "200": { "description": "The resolved appeal (with reversal outcome)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AppealResolveResult" } } } }, "400": { "description": "Validation error (status must be approved or denied)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "404": { "description": "Appeal not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Appeal is already resolved", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ResolveAppealRequest" } } } } } }, "/api/v1/admin/moderation/filter-hits": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Recent automated content-filter hits", "description": "", "responses": { "200": { "description": "OK" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/members": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Recent member join/leave events (optionally filtered by type)", "description": "", "parameters": [ { "name": "type", "in": "query", "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/recent": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Recent moderation actions, optionally filtered by type", "description": "", "responses": { "200": { "description": "OK" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/reports": { "get": { "tags": [ "Admin · Moderation" ], "summary": "The member-raised content report queue", "description": "Defaults to the open work (`open` + `reviewing`); filter with ?status= and ?teamId=, page with ?limit&offset. Each row carries its TARGET already resolved — a post’s excerpt and author, a thread’s title, or an upload’s uploader, byte size and SNIFFED mimetype — so triage never means hunting for what was reported. A target that has since been hard-deleted comes back as null and the report still lists: \"somebody reported this and by the time we looked it was gone\" is a fact worth seeing.", "parameters": [ { "name": "status", "in": "query", "schema": { "type": "string" } }, { "name": "teamId", "in": "query", "schema": { "type": "string" } } ], "responses": { "200": { "description": "The queue", "content": { "application/json": { "schema": { "type": "object", "properties": { "reports": { "type": "array", "items": { "$ref": "#/components/schemas/ContentReport" } }, "openCount": { "type": "integer" } } } } } }, "400": { "description": "Bad Request" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/reports/{id}/handle": { "post": { "tags": [ "Admin · Moderation" ], "summary": "Claim, action or dismiss a content report", "description": "Handling a report is bookkeeping about the report, not moderation of the content — acting on the content itself is the ordinary forum moderation route, or a site-wide sanction against the account. Every transition writes activity_log, `dismissed` included: a queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Report id." } ], "responses": { "200": { "description": "The updated report", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContentReport" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Report not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "status" ], "properties": { "status": { "type": "string", "enum": [ "open", "reviewing", "actioned", "dismissed" ] }, "note": { "type": "string", "maxLength": 500 } } } } } } } }, "/api/v1/admin/moderation/search": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Look up moderated users by Discord id or username snapshot", "description": "", "parameters": [ { "name": "q", "in": "query", "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/spam-hits": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Recent automated spam-detection hits", "description": "", "responses": { "200": { "description": "OK" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/stats/summary": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Moderation action counts for 24h/7d/30d (admin or moderator)", "description": "", "responses": { "200": { "description": "OK" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/user/{discordId}": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Per-user moderation summary (counts, latest tag, linked account)", "description": "", "parameters": [ { "name": "discordId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK" }, "400": { "description": "Bad Request" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/user/{discordId}/actions": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Full moderation action history for a user", "description": "", "parameters": [ { "name": "discordId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK" }, "400": { "description": "Bad Request" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/user/{discordId}/appeals": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Appeals submitted for a Discord user", "description": "", "parameters": [ { "name": "discordId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Discord snowflake." } ], "responses": { "200": { "description": "Appeals for the user", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/AppealQueueItem" } } } } }, "400": { "description": "Bad Request" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/moderation/user/{discordId}/notes": { "get": { "tags": [ "Admin · Moderation" ], "summary": "Staff notes for a user (admin_only notes hidden from moderators)", "description": "", "parameters": [ { "name": "discordId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK" }, "400": { "description": "Bad Request" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Moderation" ], "summary": "Add a staff note (admin_only visibility requires the admin role)", "description": "", "parameters": [ { "name": "discordId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "201": { "description": "Created" }, "400": { "description": "Bad Request" }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "visibility": { "example": "any" }, "body": { "example": "any" } } } } } } } }, "/api/v1/admin/modules": { "get": { "tags": [ "Admin · Modules" ], "summary": "List installed modules, their live state, and the source allowlist", "description": "", "responses": { "200": { "description": "Modules and the install source allowlist", "content": { "application/json": { "schema": { "type": "object", "properties": { "modules": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "sourceHosts": { "type": "array", "items": { "type": "string" } } } } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Modules" ], "summary": "Install or upgrade a module from a release install-manifest URL", "description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.", "responses": { "201": { "description": "Installed — restart to mount it", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "The URL, the manifest, the hash or the archive was refused", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" }, "502": { "description": "The source host could not be reached or answered badly", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "url" ], "properties": { "url": { "type": "string", "description": "https URL of the release install manifest, on an allowed host" } } } } } } } }, "/api/v1/admin/modules/restart": { "post": { "tags": [ "Admin · Modules" ], "summary": "Restart the server process so module changes take effect", "description": "Runs the same graceful shutdown a SIGTERM does. The process is brought back by the supervisor, which the shipped docker-compose.yml provides; a bare `npm start` will not come back.", "responses": { "202": { "description": "Shutting down", "content": { "application/json": { "schema": { "type": "object", "properties": { "restarting": { "type": "boolean" } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/modules/sources": { "put": { "tags": [ "Admin · Modules" ], "summary": "Replace the allowlist of hosts modules may be installed from", "description": "", "responses": { "200": { "description": "The new allowlist", "content": { "application/json": { "schema": { "type": "object", "properties": { "sourceHosts": { "type": "array", "items": { "type": "string" } } } } } } }, "400": { "description": "One of the entries is not a hostname", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "hosts" ], "properties": { "hosts": { "type": "string", "description": "Comma- or space-separated hostnames. An empty list forbids all installs." } } } } } } } }, "/api/v1/admin/modules/{id}": { "delete": { "tags": [ "Admin · Modules" ], "summary": "Uninstall a module, optionally deleting its data too", "description": "Removes the module directory and leaves its row disabled. With purge=true it also runs purge.sql first — which is the only moment it can, since purge.sql lives inside the directory being deleted.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Module id." }, { "name": "purge", "in": "query", "required": false, "schema": { "type": "boolean" }, "description": "Also run the module’s purge.sql and drop its row. Destructive and irreversible." } ], "responses": { "200": { "description": "Uninstalled — restart to unmount it", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Purge was asked for and the module ships no purge.sql", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such module", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/modules/{id}/disable": { "post": { "tags": [ "Admin · Modules" ], "summary": "Stop a module now — runs its onShutdown, then its routes answer 404", "description": "The only module action that takes effect without a restart. Re-enabling needs one, because there is no onBoot re-dispatch.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Module id." } ], "responses": { "200": { "description": "Disabled", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such module", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/modules/{id}/enable": { "post": { "tags": [ "Admin · Modules" ], "summary": "Enable a module (takes effect on the next restart)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Module id." } ], "responses": { "200": { "description": "Enabled — restart to start it", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such module", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Conflict" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/modules/{id}/purge": { "post": { "tags": [ "Admin · Modules" ], "summary": "Run a disabled module’s purge.sql, dropping its tables and data", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Module id." } ], "responses": { "200": { "description": "Purged", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "string" }, "purged": { "type": "integer" } } } } } }, "400": { "description": "The module ships no purge.sql", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not Found" }, "409": { "description": "The module must be disabled first", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/pages": { "get": { "tags": [ "Admin · Pages" ], "summary": "List all CMS pages (summaries)", "description": "", "responses": { "200": { "description": "Page summaries", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Pages" ], "summary": "Create a CMS page", "description": "", "responses": { "201": { "description": "Created page", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Invalid slug / title / blocks / metadata / settings", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Slug already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "slug": { "type": "string" }, "title": { "type": "string" }, "status": { "type": "string", "enum": [ "draft", "published" ] }, "blocks": { "type": "array", "items": { "type": "object" } }, "metadata": { "type": "object" }, "settings": { "type": "object" } } } } } } } }, "/api/v1/admin/pages/{id}": { "get": { "tags": [ "Admin · Pages" ], "summary": "Get a CMS page by id (full, incl. blocks)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Page id." } ], "responses": { "200": { "description": "The page", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "patch": { "tags": [ "Admin · Pages" ], "summary": "Update a CMS page (title, status, blocks, metadata, settings)", "description": "slug is immutable; disabling protection is rejected here (use /unprotect).", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Page id." } ], "responses": { "200": { "description": "Updated page", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Validation error (slug immutable, invalid blocks, etc.)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Disabling protection requires /unprotect", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } } }, "delete": { "tags": [ "Admin · Pages" ], "summary": "Delete a CMS page (blocked if protected)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Page id." } ], "responses": { "200": { "description": "Deleted (echoes the id)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeletedId" } } } }, "400": { "description": "Bad Request" }, "403": { "description": "Page is protected", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/pages/{id}/preview": { "post": { "tags": [ "Admin · Pages" ], "summary": "Mint a 1h draft-preview link for a page", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Page id." } ], "responses": { "200": { "description": "Preview token + path", "content": { "application/json": { "schema": { "type": "object", "properties": { "token": { "type": "string" }, "expiresInSeconds": { "type": "integer" }, "path": { "type": "string" } } } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/pages/{id}/unprotect": { "post": { "tags": [ "Admin · Pages" ], "summary": "Disable page protection (password step-up re-auth)", "description": "Verifies the current admin password server-side, then flips protected → false.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Page id." } ], "responses": { "200": { "description": "Updated page (protected=false)", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Password incorrect", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "password": { "type": "string" } }, "required": [ "password" ] } } } } } }, "/api/v1/admin/posts": { "get": { "tags": [ "Admin · Posts" ], "summary": "List all posts (including unpublished)", "description": "", "parameters": [ { "name": "category", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional category filter." } ], "responses": { "200": { "description": "Posts", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Post" } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Posts" ], "summary": "Create a post", "description": "", "responses": { "201": { "description": "Created post", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } }, "400": { "description": "Validation error or unknown category", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostCreateRequest" } } } } } }, "/api/v1/admin/posts/upload": { "post": { "tags": [ "Admin · Posts" ], "summary": "Upload a post image (multipart)", "description": "", "responses": { "201": { "description": "Stored image URL", "content": { "application/json": { "schema": { "type": "object", "properties": { "image_url": { "type": "string", "example": "/uploads/1700000000-abcd.png" } } } } } }, "400": { "description": "No image / disallowed type", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { "image": { "type": "string", "format": "binary" } } } } } } } }, "/api/v1/admin/posts/{id}": { "get": { "tags": [ "Admin · Posts" ], "summary": "Get a post by id", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Post id." } ], "responses": { "200": { "description": "The post", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin · Posts" ], "summary": "Update a post", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Post id." } ], "responses": { "200": { "description": "Updated post", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } }, "400": { "description": "Validation error or unknown category", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostCreateRequest" } } } } }, "delete": { "tags": [ "Admin · Posts" ], "summary": "Delete a post", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Post id." } ], "responses": { "200": { "description": "Deleted (echoes the id)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeletedId" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/posts/{id}/announce": { "get": { "tags": [ "Admin · Posts" ], "summary": "Get the announcement pipeline status for a post", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Post id." } ], "responses": { "200": { "description": "The announce job for the post, or null if never announced", "content": { "application/json": { "schema": { "type": "object", "nullable": true, "additionalProperties": true } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/posts/{id}/announce/retry": { "post": { "tags": [ "Admin · Posts" ], "summary": "Retry one announcement delivery leg", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Post id." } ], "responses": { "200": { "description": "Updated announce job", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No announcement job for this post", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "leg": { "type": "string", "description": "A registered delivery leg id, as returned by GET /announce." } }, "required": [ "leg" ] } } } } } }, "/api/v1/admin/posts/{id}/publish": { "patch": { "tags": [ "Admin · Posts" ], "summary": "Publish / unpublish a post", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Post id." } ], "responses": { "200": { "description": "Updated post", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublishRequest" } } } } } }, "/api/v1/admin/settings": { "get": { "tags": [ "Admin · Settings" ], "summary": "Get all site settings (admin only)", "description": "", "responses": { "200": { "description": "All settings", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin · Settings" ], "summary": "Update site settings (admin only)", "description": "Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.", "responses": { "200": { "description": "Updated settings", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Body must be an object of key/value settings", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true, "description": "An object of key/value settings." } } } } } }, "/api/v1/admin/settings/brand-asset/{slot}": { "post": { "tags": [ "Admin · Settings" ], "summary": "Upload a brand asset and set it as the override (admin only)", "description": "Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.", "parameters": [ { "name": "slot", "in": "path", "required": true, "schema": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "logo", "hero", "favicon" ], "items": { "type": "string" } } } }, "description": "Which asset to replace" } ], "responses": { "201": { "description": "Stored file URL and the updated overrides", "content": { "application/json": { "schema": { "type": "object", "properties": { "url": { "type": "string", "example": "/uploads/1712345678901-ab12cd34.png" }, "brand_assets": { "type": "object", "properties": { "logo": { "type": "string" }, "hero": { "type": "string" }, "favicon": { "type": "string" } } } } } } } }, "400": { "description": "No file, unknown slot, disallowed type, or over the slot size cap", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { "image": { "type": "string", "format": "binary" } } } } } } } }, "/api/v1/admin/settings/{key}": { "delete": { "tags": [ "Admin · Settings" ], "summary": "Reset one setting to its default (admin only)", "description": "Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.", "parameters": [ { "name": "key", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Settings key to reset" } ], "responses": { "200": { "description": "Setting reset", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Message" } } } }, "400": { "description": "Setting is not resettable", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/site-mode": { "put": { "tags": [ "Admin · Dashboard" ], "summary": "Set site mode (admin only)", "description": "Switch the site between live and maintenance.", "responses": { "200": { "description": "Updated site mode", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SiteModeState" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SiteModeRequest" } } } } } }, "/api/v1/admin/teams": { "get": { "tags": [ "Admin · Teams" ], "summary": "List Teams with sync state", "description": "Includes hidden Teams and the module’s sync state verbatim — last attempt, last success, consecutive failures and the last error — which is what an operator debugging a stale projection needs.", "parameters": [ { "name": "archived", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Set to 1 to include archived Teams." } ], "responses": { "200": { "description": "Teams and sync state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AdminTeamList" } } } }, "400": { "description": "Bad Request" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/forum/settings": { "get": { "tags": [ "Admin · Teams" ], "summary": "The forum switch, the image policy, and the acknowledgement’s state", "description": "The two settings themselves ride the ordinary admin settings endpoint and are published to every client; this route adds the one thing that is NOT public — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since. A stale acknowledgement does not disable uploads: it raises a banner and freezes every other forum setting until it is re-given.", "responses": { "200": { "description": "Forum settings state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamForumSettingsState" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/forum/uploads": { "get": { "tags": [ "Admin · Teams" ], "summary": "Upload attribution across every Team forum", "description": "Who uploaded what, when and how much. This view is why an attribution table exists at all: the liability an operator accepts before enabling uploads is meaningless if \"who uploaded this\" cannot be answered afterwards. Deleted rows are excluded unless `deleted=1` — a soft-deleted upload still has bytes on disk until the sweep runs.", "parameters": [ { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Page size (default 100)." }, { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Rows to skip (default 0)." }, { "name": "deleted", "in": "query", "required": false, "schema": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "0", "1" ], "items": { "type": "string" } } } }, "description": "Include soft-deleted uploads." } ], "responses": { "200": { "description": "Uploads with their attribution", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamForumUploadList" } } } }, "400": { "description": "Bad Request" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/integrations": { "get": { "tags": [ "Admin · Teams" ], "summary": "The Team notification bridge’s configuration (admin only)", "description": "Every configured destination for the platform, the deployment-wide default first, alongside the events that may be bridged and which of them are members-only. A members-only event carries content nobody outside the Team may read, so enabling one requires an acknowledgement that the destination channel is restricted to that Team’s members — recorded here with who gave it.", "responses": { "200": { "description": "Bridge configuration", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamIntegrationConfig" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin · Teams" ], "summary": "Create or replace one bridge destination (admin only)", "description": "Omit teamId (or send null) to edit the deployment-wide default; a per-Team row overrides it. Enabling a bridge that carries team.forum.post or team.announcement without membersAck is refused 422 — the events are members-only always, and core cannot see a Discord channel’s permissions, so the operator’s acknowledgement is the only thing that can stand in for the check. Changing the channel clears a previous acknowledgement: it was given for a destination, not for a row.", "responses": { "200": { "description": "The saved row", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamIntegrationRow" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "422": { "description": "Not enableable — no channel, no events, or a members-only event without the acknowledgement", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "teamId": { "example": "any" }, "events": { "example": "any" }, "channelRef": { "example": "any" }, "enabled": { "example": "any" }, "membersAck": { "example": "any" } } } } } } } }, "/api/v1/admin/teams/integrations/{teamId}": { "delete": { "tags": [ "Admin · Teams" ], "summary": "Remove one bridge destination (admin only)", "description": "Pass the literal string default to remove the deployment-wide row. Removing a per-Team override makes that Team fall back to the default, which is not the same as disabling it — disable the row instead if that is what is wanted.", "parameters": [ { "name": "teamId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Team id, or the literal string default." } ], "responses": { "200": { "description": "Removed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Nothing configured for that Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/requests": { "get": { "tags": [ "Admin · Teams" ], "summary": "The moderation approval queue", "description": "Requests filed by moderators for the three actions that publish untrusted game-sourced strings. Decided rows are kept — the record that a moderator asked to publish a name and an admin refused is the part worth having.", "parameters": [ { "name": "status", "in": "query", "required": false, "schema": { "type": "string" }, "description": "pending (default) | approved | rejected | withdrawn | all" } ], "responses": { "200": { "description": "Moderation requests", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamRequestQueue" } } } }, "400": { "description": "Bad Request" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/requests/{id}/decide": { "post": { "tags": [ "Admin · Teams" ], "summary": "Approve or reject a moderation request (admin only)", "description": "Admin only, checked live against the database rather than from a token claim. Approving applies the action; rejecting keeps the row and changes nothing. A request already decided returns 409, so two admins deciding at once cannot double-apply.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Request id." } ], "responses": { "200": { "description": "Decided", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "403": { "description": "Only an admin may decide a request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Already decided", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamDecideRequest" } } } } } }, "/api/v1/admin/teams/resync": { "post": { "tags": [ "Admin · Teams" ], "summary": "Run a reconciliation now", "description": "Awaited, so the response carries the outcome including the provider’s own error when it refused. The four refusal gates still apply — a manual resync cannot make core act on an answer it does not trust.", "responses": { "200": { "description": "The reconciliation result", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamResyncResult" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/review": { "get": { "tags": [ "Admin · Teams" ], "summary": "The reserved-name review queue", "description": "Teams auto-hidden because their name matched a reserved term, each showing which term matched. A Team a human has already ruled on leaves the queue and is never re-hidden by a later sweep.", "responses": { "200": { "description": "Auto-hidden Teams awaiting review", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamReviewQueue" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/voice": { "get": { "tags": [ "Admin · Teams" ], "summary": "Team voice channel configuration and state (admin only)", "description": "The settings, every provisioned channel with its state and last error, and the bot’s own preflight — whether it is connected, whether it holds Manage Channels and Manage Roles, and how close the guild is to Discord’s cap of 250 roles. Access is granted with a role per Team, so that cap is the ceiling on how many Teams can have voice at all.", "responses": { "200": { "description": "Voice configuration and state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamVoiceConfig" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin · Teams" ], "summary": "Save the Team voice settings (admin only)", "description": "Switching voice on is refused 422 while the bot cannot manage channels and roles in the guild — a setting that saves and then quietly does nothing is worse than one that will not save. Switching it off is never gated, and never tears anything down: existing channels stop being reconciled and are removed one at a time by an operator who means it.", "responses": { "200": { "description": "The saved settings", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamVoiceSettings" } } } }, "400": { "description": "Bad Request" }, "422": { "description": "The bot cannot manage channels or roles yet", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "enabled": { "example": "any" } } } } } } } }, "/api/v1/admin/teams/voice/sync": { "post": { "tags": [ "Admin · Teams" ], "summary": "Run a voice reconciliation now (admin only)", "description": "Awaited, so the response carries the outcome. The three suspensions still apply — a manual pass will not run while voice is off, while the Team projection is stale, or while the bot cannot act — and the response says which one stopped it.", "responses": { "200": { "description": "The pass result", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamVoicePassResult" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/voice/{teamId}": { "delete": { "tags": [ "Admin · Teams" ], "summary": "Remove one Team’s voice channel and role (admin only)", "description": "Immediate, ignoring the grace window: the window exists to stop churn on a Team that crosses the threshold twice in a week, and an operator pressing remove is not churn. The channel and the role go together — a role for a channel that no longer exists is a badge for nowhere.", "parameters": [ { "name": "teamId", "in": "path", "required": true, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "Removed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "That Team has no voice channel", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/{id}": { "get": { "tags": [ "Admin · Teams" ], "summary": "Get one Team, with its roster, grant ledger and pending requests", "description": "The roster carries the resolved leadership and what the game actually said, so an override is visible as a decision rather than presented as fact. Departed members are included.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Team id." } ], "responses": { "200": { "description": "The Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AdminTeam" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/{id}/archive": { "post": { "tags": [ "Admin · Teams" ], "summary": "Archive a Team (staff)", "description": "Not gated: archiving withdraws a Team from public surfaces rather than publishing anything.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Team id." } ], "responses": { "200": { "description": "Archived", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamReasonRequest" } } } } } }, "/api/v1/admin/teams/{id}/display-name": { "post": { "tags": [ "Admin · Teams" ], "summary": "Set or clear a Team’s display name — admin applies, moderator requests", "description": "Gated for the same reason as un-hiding: it substitutes free text into the same public surfaces. Identity is untouched — the Team’s `name` stays frozen for the life of the row, and only what is rendered changes. An empty displayName clears the override.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Team id." } ], "responses": { "200": { "description": "Applied, or filed for approval — see `pending`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamModerationResult" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "404": { "description": "No such Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamDisplayNameRequest" } } } } } }, "/api/v1/admin/teams/{id}/forum/moderation": { "get": { "tags": [ "Admin · Teams" ], "summary": "A Team’s forum moderation ledger", "description": "Append-only, and deliberately separate from the site’s mod_actions/appeals pair (§5.3): that one is Discord-sanction-shaped and bot-owned, and routing a guild leader locking a thread through it would make ordinary housekeeping an appealable sanction. `actorRole` records which authority was exercised — a leader’s action appears only here, a staffer’s appears here AND in activity_log. Answers whether or not the forum is switched on.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "The Team id." } ], "responses": { "200": { "description": "The ledger, newest first", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamForumModerationLedger" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/{id}/grants": { "get": { "tags": [ "Admin · Teams" ], "summary": "The full forum-grant ledger for a Team, revoked rows included", "description": "The structured record the access resolver reads. The grant/revoke flow itself lands in the forum phase; this is the read side.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Team id." } ], "responses": { "200": { "description": "The grant ledger", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamGrantLedger" } } } }, "400": { "description": "Bad Request" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/{id}/hide": { "post": { "tags": [ "Admin · Teams" ], "summary": "Hide a Team from public surfaces (staff)", "description": "Deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Team id." } ], "responses": { "200": { "description": "Hidden", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamReasonRequest" } } } } } }, "/api/v1/admin/teams/{id}/leader-override": { "post": { "tags": [ "Admin · Teams" ], "summary": "Grant or deny leadership for one member (staff)", "description": "Applied on top of the synced value at READ time; the projection is never mutated. That is what makes an override survive a resync — one written into team_members would be undone by the next reconciliation. Not gated: it publishes no game-sourced string.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Team id." } ], "responses": { "200": { "description": "Override set", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "404": { "description": "No such Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamLeaderOverrideRequest" } } } } } }, "/api/v1/admin/teams/{id}/leader-override/{memberKey}": { "delete": { "tags": [ "Admin · Teams" ], "summary": "Clear a leadership override (staff)", "description": "The member reverts to whatever the game says at the next read; nothing in the projection changes, because nothing in it was ever changed.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Team id." }, { "name": "memberKey", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The module’s member key." } ], "responses": { "200": { "description": "Override cleared", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such override", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/teams/{id}/unhide": { "post": { "tags": [ "Admin · Teams" ], "summary": "Un-hide a Team — admin applies, moderator requests", "description": "One of the three gated actions: it publishes a name that tripped the impersonation list. An admin applies it at once; a moderator files a pending request and nothing changes publicly until an admin approves.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Team id." } ], "responses": { "200": { "description": "Applied, or filed for approval — see `pending`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamModerationResult" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamReasonRequest" } } } } } }, "/api/v1/admin/uploads": { "post": { "tags": [ "Admin · Posts" ], "summary": "Upload an image for rich-text editors (multipart)", "description": "", "responses": { "201": { "description": "Stored file URL", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UploadResponse" } } } }, "400": { "description": "No file / disallowed type", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { "image": { "type": "string", "format": "binary" } } } } } } } }, "/api/v1/admin/users": { "get": { "tags": [ "Admin · Users" ], "summary": "List users (admin only)", "description": "", "responses": { "200": { "description": "Users", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Users" ], "summary": "Create a user (admin only)", "description": "", "responses": { "201": { "description": "Created user", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Username already taken", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserCreateRequest" } } } } } }, "/api/v1/admin/users/email-dedupe-report": { "get": { "tags": [ "Admin · Users" ], "summary": "Accounts whose email was cleared by de-duplication (admin only)", "description": "When email addresses became unique, accounts sharing an address kept only the earliest-created one; the rest had their address cleared. These users can still sign in but cannot receive password-reset or notification email until they set a new address, so they are the ones to contact.", "responses": { "200": { "description": "The affected accounts", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/EmailDedupeEntry" } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/users/email-dedupe-report/acknowledge": { "post": { "tags": [ "Admin · Users" ], "summary": "Dismiss the de-duplication warning (admin only)", "description": "Marks the report acknowledged so it stops appearing as a dashboard warning. The rows are kept as a record of what the upgrade did.", "responses": { "200": { "description": "Acknowledged", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "acknowledged": { "type": "integer" } } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/users/{id}": { "put": { "tags": [ "Admin · Users" ], "summary": "Update a user (admin only)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "User id." } ], "responses": { "200": { "description": "Updated user", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }, "400": { "description": "Validation error, or cannot demote the last admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Username already taken", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserCreateRequest" } } } } }, "delete": { "tags": [ "Admin · Users" ], "summary": "Delete a user (admin only)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "User id." } ], "responses": { "200": { "description": "Deleted (echoes the id)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeletedId" } } } }, "400": { "description": "Cannot delete your own account or the last admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "get": { "tags": [ "Admin · Users" ], "summary": "Get a single user (admin only)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "User id." } ], "responses": { "200": { "description": "The user", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/users/{id}/mfa/reset": { "post": { "tags": [ "Admin · Users" ], "summary": "Reset a user’s MFA (admin only)", "description": "Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "User id." } ], "responses": { "200": { "description": "MFA reset", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkFlag" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/users/{id}/trusted-devices": { "get": { "tags": [ "Admin · Users" ], "summary": "List a user’s trusted devices (admin only)", "description": "Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "User id." } ], "responses": { "200": { "description": "Trusted devices", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TrustedDevice" } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "delete": { "tags": [ "Admin · Users" ], "summary": "Revoke all of a user’s trusted devices (admin only)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "User id." } ], "responses": { "200": { "description": "Revoked count", "content": { "application/json": { "schema": { "type": "object", "properties": { "revoked": { "type": "integer" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/users/{id}/trusted-devices/{deviceId}": { "delete": { "tags": [ "Admin · Users" ], "summary": "Revoke one of a user’s trusted devices (admin only)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "User id." }, { "name": "deviceId", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Trusted-device id." } ], "responses": { "200": { "description": "Revoked (idempotent)", "content": { "application/json": { "schema": { "type": "object", "properties": { "revoked": { "type": "boolean" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Admin role required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/wiki": { "get": { "tags": [ "Admin · Wiki" ], "summary": "List all wiki pages (including unpublished)", "description": "", "parameters": [ { "name": "q", "in": "query", "schema": { "type": "string" } }, { "name": "category", "in": "query", "schema": { "type": "string" } }, { "name": "tag", "in": "query", "schema": { "type": "string" } }, { "name": "status", "in": "query", "schema": { "type": "string" } } ], "responses": { "200": { "description": "Wiki pages", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/WikiPage" } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Wiki" ], "summary": "Create a wiki page", "description": "", "responses": { "201": { "description": "Created wiki page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiPage" } } } }, "400": { "description": "Validation error or unknown category", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Slug already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiPageCreateRequest" } } } } } }, "/api/v1/admin/wiki/categories": { "get": { "tags": [ "Admin · Wiki" ], "summary": "List wiki categories", "description": "", "responses": { "200": { "description": "Wiki categories", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/WikiCategory" } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Wiki" ], "summary": "Create a wiki category", "description": "", "responses": { "201": { "description": "Created category", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiCategory" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Slug already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiCategoryCreateRequest" } } } } } }, "/api/v1/admin/wiki/categories/{id}": { "put": { "tags": [ "Admin · Wiki" ], "summary": "Update a wiki category", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Category id." } ], "responses": { "200": { "description": "Updated category", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiCategory" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Slug already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiCategoryCreateRequest" } } } } }, "delete": { "tags": [ "Admin · Wiki" ], "summary": "Delete a wiki category", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Category id." } ], "responses": { "200": { "description": "Deleted (echoes the id)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeletedId" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/wiki/tags": { "get": { "tags": [ "Admin · Wiki" ], "summary": "List wiki tags", "description": "", "responses": { "200": { "description": "Wiki tags", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/wiki/{slug}": { "get": { "tags": [ "Admin · Wiki" ], "summary": "Get a wiki page by slug", "description": "", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Wiki page slug." } ], "responses": { "200": { "description": "The wiki page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiPage" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin · Wiki" ], "summary": "Update a wiki page (creates a revision)", "description": "", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Wiki page slug." } ], "responses": { "200": { "description": "Updated wiki page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiPage" } } } }, "400": { "description": "Validation error or unknown category", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/WikiPageCreateRequest" }, { "type": "object", "properties": { "change_note": { "type": "string", "maxLength": 280 } } } ] } } } } }, "delete": { "tags": [ "Admin · Wiki" ], "summary": "Delete a wiki page", "description": "", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Wiki page slug." } ], "responses": { "200": { "description": "Deleted (echoes the slug)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeletedSlug" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/wiki/{slug}/publish": { "patch": { "tags": [ "Admin · Wiki" ], "summary": "Publish / unpublish a wiki page", "description": "", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Wiki page slug." } ], "responses": { "200": { "description": "Updated wiki page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiPage" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublishRequest" } } } } } }, "/api/v1/admin/wiki/{slug}/revisions": { "get": { "tags": [ "Admin · Wiki" ], "summary": "List revisions of a wiki page", "description": "", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Wiki page slug." } ], "responses": { "200": { "description": "Revisions", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/wiki/{slug}/revisions/{id}": { "get": { "tags": [ "Admin · Wiki" ], "summary": "Get a single wiki revision", "description": "", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Wiki page slug." }, { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Revision id." } ], "responses": { "200": { "description": "The revision", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/wiki/{slug}/revisions/{id}/restore": { "post": { "tags": [ "Admin · Wiki" ], "summary": "Restore a wiki page to a revision", "description": "", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Wiki page slug." }, { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Revision id to restore." } ], "responses": { "200": { "description": "Restored wiki page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiPage" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/email/verify/{token}": { "get": { "tags": [ "Auth" ], "summary": "Validate an email-confirmation link", "description": "Returns the target username and the address the link proves, so the confirmation page can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).", "parameters": [ { "name": "token", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Confirmation link is valid", "content": { "application/json": { "schema": { "type": "object", "properties": { "username": { "type": "string" }, "email": { "type": "string", "format": "email" } } } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Invalid or expired confirmation link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } } }, "post": { "tags": [ "Auth" ], "summary": "Confirm an email address from its link", "description": "Consumes the single-use link and installs the address on the account, marking it verified. Issues no session — it proves control of a mailbox, not of an account. Answers 404 for an unusable link AND for an address another account has since verified, deliberately: the two are indistinguishable to a caller so the endpoint cannot be used to test which addresses hold accounts.", "parameters": [ { "name": "token", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Address confirmed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Message" } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Invalid, expired, superseded, or already-used confirmation link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many attempts", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/auth/invite/{token}": { "get": { "tags": [ "Auth" ], "summary": "Look up an email invite by token", "description": "Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.", "parameters": [ { "name": "token", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Invite details", "content": { "application/json": { "schema": { "type": "object", "properties": { "email": { "type": "string" }, "role": { "type": "string" } } } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Invalid or expired invite", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/auth/invite/{token}/accept": { "post": { "tags": [ "Auth" ], "summary": "Accept an email invite (creates the account at the invited role)", "description": "Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.", "parameters": [ { "name": "token", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Account created and session issued", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginResponse" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "404": { "description": "Invalid or expired invite", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Username taken or invite already used", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "username", "password" ], "properties": { "username": { "type": "string" }, "password": { "type": "string" } } } } } } } }, "/api/v1/auth/login": { "post": { "tags": [ "Auth" ], "summary": "Log in with username and password", "description": "On success sets the httpOnly session cookie. If the account has 2FA enabled, returns { totpRequired, challenge } instead and no cookie is set — complete login at POST /login/totp. Rate limited and behind bot/backoff guards.", "responses": { "200": { "description": "Session issued, or TOTP challenge required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginResponse" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Incorrect username or password", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "429": { "description": "Too many attempts (rate limited / backoff)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } } } } }, "/api/v1/auth/login/totp": { "post": { "tags": [ "Auth" ], "summary": "Complete login with a TOTP or recovery code", "description": "Second step for 2FA accounts. Exchange the challenge from /login plus either the current authenticator code OR a single-use recovery code for a session cookie. Set trustDevice to remember this browser and skip TOTP on future logins (30 days); if the trusted-device limit is reached the session is still issued and the response carries { trustLimitReached, devices } so the user can revoke one first.", "responses": { "200": { "description": "Session issued (optionally with a trusted-device-limit prompt)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginResponse" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Invalid code or expired challenge", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many attempts (rate limited / backoff)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TotpLoginRequest" } } } } } }, "/api/v1/auth/logout": { "post": { "tags": [ "Auth" ], "summary": "Log out (clear the cookie and revoke this session)", "description": "", "responses": { "200": { "description": "Logged out", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Message" } } } } } } }, "/api/v1/auth/me": { "get": { "tags": [ "Auth" ], "summary": "Current authenticated user", "description": "", "responses": { "200": { "description": "The signed-in user", "content": { "application/json": { "schema": { "type": "object", "properties": { "user": { "$ref": "#/components/schemas/User" } } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/account": { "get": { "tags": [ "Auth · Me" ], "summary": "Get the current account (self, any role)", "description": "", "responses": { "200": { "description": "The current account", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PlayerAccount" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Account not active", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/account/email": { "patch": { "tags": [ "Auth · Me" ], "summary": "Request a new email address (self, any role)", "description": "Stages the address and emails a confirmation link. The account keeps its current address until that link is used, so a mistyped address cannot redirect password-reset mail. Requires currentPassword when the account has a password; SSO-provisioned accounts with no password are exempt.", "responses": { "200": { "description": "Address staged; a confirmation link was sent", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PendingEmail" } } } }, "400": { "description": "Validation error, wrong current password, or already your address", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "429": { "description": "Too many verification emails", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChangeEmailRequest" } } } } } }, "/api/v1/auth/me/account/email/pending": { "delete": { "tags": [ "Auth · Me" ], "summary": "Abandon the pending email address", "description": "Clears the staged address and retires its outstanding links, so a confirmation email already delivered can no longer install it.", "responses": { "200": { "description": "Pending address cleared", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkFlag" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/account/email/resend": { "post": { "tags": [ "Auth · Me" ], "summary": "Re-send the confirmation link for the pending address", "description": "", "responses": { "200": { "description": "Confirmation link re-sent", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PendingEmail" } } } }, "400": { "description": "No address is awaiting confirmation", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "429": { "description": "Too many verification emails", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/account/identities": { "get": { "tags": [ "Auth · Me" ], "summary": "List linked SSO identities (self)", "description": "", "responses": { "200": { "description": "Linked identities", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/LinkedIdentity" } } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/account/identities/{provider}": { "delete": { "tags": [ "Auth · Me" ], "summary": "Unlink an SSO identity (self)", "description": "", "parameters": [ { "name": "provider", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Provider id." } ], "responses": { "200": { "description": "Unlinked", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnlinkedFlag" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "No linked account for that provider", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/account/password": { "patch": { "tags": [ "Auth · Me" ], "summary": "Change or set the current account’s password (self, any role)", "description": "If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the caller’s own session is re-issued (they stay logged in) while older web sessions are revoked.", "responses": { "200": { "description": "Password changed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkFlag" } } } }, "400": { "description": "Validation error or wrong current password", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "429": { "description": "Too many changes (rate limited)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChangePasswordRequest" } } } } } }, "/api/v1/auth/me/account/recovery-codes/generate": { "post": { "tags": [ "Auth · Me" ], "summary": "Regenerate recovery codes (self, password step-up)", "description": "Generates a fresh set of single-use recovery codes, invalidating any prior set, and returns them ONCE. Requires the current password (accounts that have one); refuses when two-factor is off. Behind the login backoff/bot guards since a wrong password is credential-guessing.", "responses": { "200": { "description": "New recovery codes (shown once)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecoveryCodes" } } } }, "400": { "description": "Wrong password, or two-factor not enabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "429": { "description": "Too Many Requests" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "currentPassword": { "type": "string" } } } } } } } }, "/api/v1/auth/me/account/recovery-codes/status": { "get": { "tags": [ "Auth · Me" ], "summary": "Remaining recovery-code count (self)", "description": "", "responses": { "200": { "description": "Remaining unused codes", "content": { "application/json": { "schema": { "type": "object", "properties": { "remaining": { "type": "integer" } } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/account/totp/disable": { "post": { "tags": [ "Auth · Me" ], "summary": "Disable 2FA by confirming a code", "description": "Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.", "responses": { "200": { "description": "2FA disabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TotpState" } } } }, "400": { "description": "Not enabled, or invalid code", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TotpCodeRequest" } } } } } }, "/api/v1/auth/me/account/totp/enable": { "post": { "tags": [ "Auth · Me" ], "summary": "Enable 2FA by confirming a code", "description": "", "responses": { "200": { "description": "2FA enabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TotpState" } } } }, "400": { "description": "Setup not started, or invalid code", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "409": { "description": "Two-factor already enabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TotpCodeRequest" } } } } } }, "/api/v1/auth/me/account/totp/setup": { "post": { "tags": [ "Auth · Me" ], "summary": "Begin 2FA enrollment (returns secret + QR)", "description": "", "responses": { "200": { "description": "otpauth URL and QR data to scan", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TotpSetup" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "409": { "description": "Two-factor already enabled", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/account/username": { "patch": { "tags": [ "Auth · Me" ], "summary": "Change the current account’s username (self, any role)", "description": "", "responses": { "200": { "description": "Updated username (session re-issued)", "content": { "application/json": { "schema": { "type": "object", "properties": { "username": { "type": "string" } } } } } }, "400": { "description": "Validation error or unavailable username", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "409": { "description": "Username already taken", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many changes (rate limited)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChangeUsernameRequest" } } } } } }, "/api/v1/auth/me/devices": { "post": { "tags": [ "Auth · Me" ], "summary": "Register a push device (endpoint) for the current user", "description": "Registers a UnifiedPush/ntfy endpoint (or an FCM token) so the backend can deliver opt-in push tickles. The endpoint must be an allowed HTTPS relay URL — private/loopback hosts and non-allowed origins are rejected 400. Idempotent per (user, endpoint).", "responses": { "201": { "description": "Device registered", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PushDevice" } } } }, "400": { "description": "Validation error or disallowed endpoint", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RegisterDeviceRequest" } } } } }, "get": { "tags": [ "Auth · Me" ], "summary": "List the current user’s registered push devices", "description": "", "responses": { "200": { "description": "Registered devices", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/PushDevice" } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/devices/{id}": { "delete": { "tags": [ "Auth · Me" ], "summary": "Unregister a push device", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Device id (must belong to the caller)." } ], "responses": { "200": { "description": "Unregistered", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkFlag" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "No such device for this user", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/notifications/streams": { "get": { "tags": [ "Auth · Me" ], "summary": "List subscribable notification streams (catalog)", "description": "The catalog of push streams. `personal`/`requiresLinkedAccount` streams are delivered only to the owning user and need a linked game account.", "responses": { "200": { "description": "Stream catalog", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationStreams" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/notifications/subscriptions": { "get": { "tags": [ "Auth · Me" ], "summary": "Get the current user’s notification subscriptions", "description": "", "responses": { "200": { "description": "Subscribed stream ids", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationSubscriptions" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Auth · Me" ], "summary": "Replace the current user’s notification subscriptions", "description": "Sets the full opted-in stream set (applied to all the user’s devices). Unknown stream ids are ignored; the stored set is echoed back.", "responses": { "200": { "description": "Updated subscriptions", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationSubscriptions" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationSubscriptions" } } } } } }, "/api/v1/auth/me/notifications/teams": { "get": { "tags": [ "Auth · Me" ], "summary": "Get the current user’s per-Team notification preferences", "description": "One entry per Team the caller could be notified about — active membership or an active forum grant — plus any Team they have a stored preference for. Defaults are applied server-side: `muted` false, `emailMode` \"off\".", "responses": { "200": { "description": "Per-Team preferences", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamNotificationPrefs" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Auth · Me" ], "summary": "Replace the current user’s per-Team notification preferences", "description": "Replaces the whole set. The `teams` array is required even when empty. Entries naming a Team the caller has no access to are ignored; the stored set is echoed back.", "responses": { "200": { "description": "Updated preferences", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamNotificationPrefs" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamNotificationPrefs" } } } } } }, "/api/v1/auth/me/sessions": { "get": { "tags": [ "Auth · Me" ], "summary": "List active mobile device sessions (self)", "description": "Active (unrevoked, unexpired) mobile bearer sessions — one per live device — for the Active Devices screen. Never returns tokens.", "responses": { "200": { "description": "Active device sessions", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DeviceSession" } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/sessions/{id}": { "delete": { "tags": [ "Auth · Me" ], "summary": "Revoke one mobile device session (self)", "description": "Revokes a single device by its session id (ownership-scoped). Revoking stops future token renewals; an already-issued access token remains valid until it expires (see the documented revocation-latency window).", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "The session row id from GET /auth/me/sessions." } ], "responses": { "200": { "description": "Revoked (idempotent)", "content": { "application/json": { "schema": { "type": "object", "properties": { "revoked": { "type": "boolean" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/trusted-devices": { "get": { "tags": [ "Auth · Me" ], "summary": "List trusted devices (self)", "description": "Active (unrevoked, unexpired) trusted devices — the browsers/apps allowed to skip the TOTP step at login. Never returns tokens.", "responses": { "200": { "description": "Active trusted devices", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TrustedDevice" } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Auth · Me" ], "summary": "Trust the current device (self)", "description": "Marks the current browser/app as trusted so future logins skip the TOTP step (30 days). Web receives an httpOnly trust cookie; native to store. Returns 409 { error: \"trusted_device_limit\", devices } when the per-user cap is reached — revoke one first, then retry.", "responses": { "200": { "description": "Device trusted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TrustDeviceResult" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "409": { "description": "Trusted-device limit reached", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TrustedDeviceLimit" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "deviceName": { "type": "string" } } } } } } }, "delete": { "tags": [ "Auth · Me" ], "summary": "Revoke all trusted devices (self)", "description": "Untrust every device; future logins on all of them require the full TOTP step again. Also clears this browser’s trust cookie.", "responses": { "200": { "description": "Revoked count", "content": { "application/json": { "schema": { "type": "object", "properties": { "revoked": { "type": "integer" } } } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/me/trusted-devices/{id}": { "delete": { "tags": [ "Auth · Me" ], "summary": "Revoke one trusted device (self)", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Trusted-device id from GET /auth/me/trusted-devices." } ], "responses": { "200": { "description": "Revoked (idempotent)", "content": { "application/json": { "schema": { "type": "object", "properties": { "revoked": { "type": "boolean" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/mobile/login": { "post": { "tags": [ "Auth · Mobile" ], "summary": "Native login → access + refresh tokens", "description": "Bearer-token login for native clients. Single-request 2FA: if the account has TOTP on and no/invalid code is supplied, returns 401 { totpRequired: true } and the client retries with a code (or a single-use recoveryCode). A previously trusted device may present the X-Trust-Token header to skip the code entirely. Set trustDevice to remember this device (the response then carries trustToken to store); if the trusted-device limit is reached the tokens are still issued and the response carries { trustLimitReached, devices }.", "responses": { "200": { "description": "Access + refresh tokens (optionally with trustToken / a trusted-device-limit prompt)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MobileTokenResponse" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Invalid credentials, or a TOTP code is required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many attempts (rate limited / backoff)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MobileLoginRequest" } } } } } }, "/api/v1/auth/mobile/logout": { "post": { "tags": [ "Auth · Mobile" ], "summary": "Revoke the current (or all) refresh tokens", "description": "Requires a valid bearer access token. Revokes the given refresh token, or every session for the user when { all: true }. Idempotent.", "responses": { "200": { "description": "Logged out", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Message" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Missing or invalid bearer token", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MobileLogoutRequest" } } } } } }, "/api/v1/auth/mobile/refresh": { "post": { "tags": [ "Auth · Mobile" ], "summary": "Rotate a refresh token for a fresh token pair", "description": "Refresh tokens are single-use: the presented token is revoked and a new access + refresh pair is issued. Reusing a rotated token fails with 401.", "responses": { "200": { "description": "New access + refresh tokens", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MobileTokenResponse" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Invalid or expired session", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many refresh attempts", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MobileRefreshRequest" } } } } } }, "/api/v1/auth/mobile/sso/exchange": { "post": { "tags": [ "Auth · Mobile" ], "summary": "Exchange an SSO authorization code for mobile tokens", "description": "Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401. If the user ticked \"trust this device\" on the TOTP form during this flow, the response also carries { trustToken } for the app to store and replay via X-Trust-Token — minted here rather than passed through the deep link so it never appears in a URL.", "responses": { "200": { "description": "Access + refresh tokens (optionally with a trustToken to persist)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MobileTokenResponse" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Invalid/expired/used code or failed PKCE verification", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many attempts (rate limited)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MobileSsoExchangeRequest" } } } } } }, "/api/v1/auth/mobile/sso/start": { "get": { "tags": [ "Auth · Mobile" ], "summary": "Begin native SSO login (redirect to the IdP)", "description": "Opened by the Android app in a Custom Tab. Validates the provider is enabled and the redirect_uri is an exact match of a registered app callback, seeds a short-lived bridge session carrying the app PKCE challenge + state, and 302-redirects into the existing website SSO flow. On success the callback redirects to `redirect_uri?code=…&state=…` (a one-time code, never a token). Errors are surfaced to the app as `redirect_uri?error=…&state=…`.", "parameters": [ { "name": "provider", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Provider id from GET /auth/providers (e.g. google, discord)." }, { "name": "code_challenge", "in": "query", "required": true, "schema": { "type": "string" }, "description": "App-generated PKCE S256 challenge (base64url)." }, { "name": "state", "in": "query", "required": true, "schema": { "type": "string" }, "description": "App-generated opaque CSRF value, echoed on the callback for the app to verify." }, { "name": "redirect_uri", "in": "query", "required": true, "schema": { "type": "string" }, "description": "The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback)." } ], "responses": { "302": { "description": "Redirect to the identity provider (or back to the app callback on error)" }, "400": { "description": "Unrecognized redirect URI or validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many attempts (rate limited)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/api/v1/auth/password/forgot": { "post": { "tags": [ "Auth" ], "summary": "Request a password-reset link by email", "description": "Emails a single-use, ~1h reset link to the active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Rate limited per IP.", "responses": { "200": { "description": "Generic acknowledgement (sent if the account exists)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Message" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "429": { "description": "Too many requests", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "email" ], "properties": { "email": { "type": "string", "format": "email" } } } } } } } }, "/api/v1/auth/password/reset/{token}": { "get": { "tags": [ "Auth" ], "summary": "Validate a password-reset link", "description": "Returns the target username for a valid, pending, unexpired reset link so the reset form can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).", "parameters": [ { "name": "token", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Reset link is valid", "content": { "application/json": { "schema": { "type": "object", "properties": { "username": { "type": "string" } } } } } }, "400": { "description": "Bad Request" }, "404": { "description": "Invalid or expired reset link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } } }, "post": { "tags": [ "Auth" ], "summary": "Set a new password from a reset link", "description": "Consumes the single-use link and sets the new password. Rotates the hash and revokes every existing session (web + mobile). Does NOT sign the user in — they log in fresh afterwards (so a 2FA account still passes TOTP). Rate limited per IP.", "parameters": [ { "name": "token", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Password changed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Message" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "404": { "description": "Invalid, expired, or already-used reset link", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many attempts", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "password" ], "properties": { "password": { "type": "string", "minLength": 8, "maxLength": 64 } } } } } } } }, "/api/v1/auth/providers": { "get": { "tags": [ "Auth · SSO" ], "summary": "List enabled SSO providers", "description": "Public discovery used by the login page to render provider buttons. Never exposes secrets.", "responses": { "200": { "description": "Enabled, valid providers", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Provider" } } } } }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/auth/register": { "post": { "tags": [ "Auth" ], "summary": "Register a player account", "description": "Creates a self-service player account and logs it in (sets the session cookie). Available only when an admin has enabled password registration (player_registration = password|both); otherwise returns 403. Rate limited and behind bot/backoff guards; a hidden honeypot field must stay empty.", "responses": { "200": { "description": "Account created and session issued", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginResponse" } } } }, "400": { "description": "Validation error or unavailable username", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "403": { "description": "Registration is not open", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Username already taken", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many attempts (rate limited / backoff)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RegisterRequest" } } } } } }, "/api/v1/auth/sso/totp": { "post": { "tags": [ "Auth · SSO" ], "summary": "Complete an SSO login with a TOTP code", "description": "Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Set trustDevice to remember this browser and skip TOTP on future SSO sign-ins (30 days) — on the mobile flow this browser is the app Custom Tab, and the app additionally receives its own trustToken at /auth/mobile/sso/exchange. If the trusted-device limit is reached the sign-in still completes and the response carries { trustLimitReached, devices }. Rate limited and behind bot/backoff guards.", "responses": { "200": { "description": "Session issued (web), or a deep link to redeem (mobile bridge); optionally with a trusted-device-limit prompt", "content": { "application/json": { "schema": { "type": "object", "properties": { "user": { "$ref": "#/components/schemas/SafeUser" }, "returnTo": { "type": "string" }, "redirect": { "type": "string" }, "trustLimitReached": { "type": "boolean" }, "devices": { "type": "array", "items": { "$ref": "#/components/schemas/TrustedDevice" } } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Invalid code or expired challenge", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "409": { "description": "Conflict" }, "429": { "description": "Too many attempts (rate limited / backoff)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "code" ], "properties": { "code": { "type": "string" }, "trustDevice": { "type": "boolean" }, "deviceName": { "type": "string" } } } } } } } }, "/api/v1/auth/sso/{provider}/callback": { "get": { "tags": [ "Auth · SSO" ], "summary": "OAuth redirect target — completes login or linking", "description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).", "parameters": [ { "name": "provider", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Provider id (e.g. google, discord)." }, { "name": "error", "in": "query", "schema": { "type": "string" } }, { "name": "code", "in": "query", "required": false, "schema": { "type": "string" }, "description": "OAuth authorization code." }, { "name": "state", "in": "query", "required": false, "schema": { "type": "string" }, "description": "OAuth state (matched against the tx cookie)." } ], "responses": { "302": { "description": "Redirect into /admin on success, or back to login/account with an error code" } } } }, "/api/v1/auth/sso/{provider}/link": { "get": { "tags": [ "Auth · SSO" ], "summary": "Begin linking an SSO identity to the current account", "description": "Requires an authenticated session; the signed transaction captures the acting user so the callback can attach the external identity.", "parameters": [ { "name": "provider", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Provider id (e.g. google, discord)." } ], "responses": { "302": { "description": "Redirect to the identity provider (or back to the account page on error)" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/auth/sso/{provider}/start": { "get": { "tags": [ "Auth · SSO" ], "summary": "Begin SSO login (redirect to the IdP)", "description": "Sets a short-lived signed transaction cookie and 302-redirects to the provider authorize URL. On error redirects back to the login page with an sso_error query param.", "parameters": [ { "name": "provider", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Provider id (e.g. google, discord)." }, { "name": "returnTo", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Internal /admin path to return to after login." } ], "responses": { "302": { "description": "Redirect to the identity provider (or back to the login page on error)" } } } }, "/api/v1/player/appeals": { "get": { "tags": [ "Player · Appeals" ], "summary": "List the caller’s moderation appeals", "description": "", "responses": { "200": { "description": "The caller’s appeals", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Appeal" } } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Account not active (disabled/banned)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Player · Appeals" ], "summary": "Submit a moderation appeal for one of the caller’s actions", "description": "Opens an appeal for a ban/mute mod_action that belongs to the caller (its target matches the caller’s linked Discord identity) and has no active appeal.", "responses": { "201": { "description": "Appeal created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Appeal" } } } }, "400": { "description": "Validation error, or the action type is not appealable", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "The action does not belong to the caller", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Mod action not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "An appeal for this action is already open", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateAppealRequest" } } } } } }, "/api/v1/player/appeals/eligible": { "get": { "tags": [ "Player · Appeals" ], "summary": "List the caller’s ban/mute actions eligible for appeal", "description": "The caller’s ban/mute mod_actions that have no active appeal. Returns an empty array when the caller has no linked Discord account (the UI shows a “link Discord” hint).", "responses": { "200": { "description": "Appealable actions", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/AppealEligibleAction" } } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Account not active (disabled/banned)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/player/appeals/{id}/withdraw": { "post": { "tags": [ "Player · Appeals" ], "summary": "Withdraw one of the caller’s pending appeals", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Appeal id (must belong to the caller)." } ], "responses": { "200": { "description": "The withdrawn appeal", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Appeal" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "No such appeal for the caller", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Appeal is already resolved", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/player/teams": { "get": { "tags": [ "Player · Teams" ], "summary": "List the caller’s Teams, with the reason for each", "description": "Membership and forum grants are separate authority paths, so each Team carries `reason`: membership | grant | both. A Team hidden from public surfaces is still listed here — suppression is a public-surface rule, and a member is not a member of the public.", "responses": { "200": { "description": "The caller’s Teams", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PlayerTeamList" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Account not active (disabled/banned)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/player/teams/{slug}/access": { "get": { "tags": [ "Player · Teams" ], "summary": "The caller’s own resolved access on one Team", "description": "Reports viaMembership and viaGrant separately, and keeps both when both hold: the UI presents membership as the current reason while the grant survives as audit history.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." } ], "responses": { "200": { "description": "The caller’s access", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PlayerTeamAccess" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "No such Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/player/teams/{slug}/forum/posts/{id}": { "patch": { "tags": [ "Player · Teams" ], "summary": "Edit a post", "description": "The author inside `teams_forum_edit_window_minutes` (default 15), staff at any time. **The window is decided on the server, twice**: the read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control, and this route re-derives it from `created_at` before allowing the write — a time-bounded permission must not take its clock from the party it bounds. A staff edit of someone else’s post additionally writes `activity_log`; a member fixing their own typo does not.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." }, { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "The post id." } ], "responses": { "200": { "description": "Edited", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "postId": { "type": "integer" }, "threadId": { "type": "integer" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Not your post, or the edit window has closed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Forum off, no such post, or no access", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "body" ], "properties": { "body": { "type": "string" } } } } } } } }, "/api/v1/player/teams/{slug}/forum/posts/{id}/moderate": { "post": { "tags": [ "Player · Teams" ], "summary": "Hide, unhide, delete or restore a post", "description": "Leader or staff, and the same append-only ledger the thread route writes — one table with `target_type` of `thread` or `post`, so \"everything moderated in this Team\" stays one query. `pin` and `lock` are refused by name rather than as an unknown action: they describe a thread’s place in a list and its openness to replies, neither of which a post has. Deleting a post soft-deletes the images attached to it and restoring brings them back, so the pair is reversible inside the retention window.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." }, { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "The post id." } ], "responses": { "200": { "description": "Applied", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "action": { "type": "string" }, "postId": { "type": "integer" }, "threadId": { "type": "integer" } } } } } }, "400": { "description": "An action that applies to a thread, not a post", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Not a leader of this Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not Found" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "action" ], "properties": { "action": { "type": "string", "enum": [ "hide", "unhide", "delete", "restore" ] }, "reason": { "type": "string", "maxLength": 255 } } } } } } } }, "/api/v1/player/teams/{slug}/forum/report": { "post": { "tags": [ "Player · Teams" ], "summary": "Report a thread, post or upload to site staff", "description": "The first user-facing report flow core has ever had. **A report is not a moderation action** — it changes nothing about the content and opens a queue item, which is what keeps it out of the Team’s moderation ledger and stops \"report\" becoming a way for any participant to hide anything. It reaches SITE STAFF and nobody else: leaders moderate their own Team, and a Team’s leaders are exactly the people who will not report their own Team, so there is no leader-facing view of this queue anywhere. One open report per (target, reporter) — a second answers 409 rather than pretending to succeed — plus an hourly per-IP cap.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." } ], "responses": { "200": { "description": "Raised", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "reportId": { "type": "integer" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Forum off, no access, or the target is not in this Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "You already have an open report on this", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "targetType", "targetId", "reason" ], "properties": { "targetType": { "type": "string", "enum": [ "team_forum_thread", "team_forum_post", "team_forum_upload" ] }, "targetId": { "type": "integer" }, "reason": { "type": "string", "enum": [ "spam", "abuse", "sexual", "illegal", "impersonation", "other" ] }, "detail": { "type": "string", "maxLength": 500 } } } } } } } }, "/api/v1/player/teams/{slug}/forum/threads": { "get": { "tags": [ "Player · Teams" ], "summary": "List a Team forum’s threads", "description": "Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` is off, and 404 (never 403) to a caller with no access: in a private room, the contents and the existence are the same secret. Hidden threads are included for a leader or staff and for nobody else.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." } ], "responses": { "200": { "description": "The thread list, with what this caller may do", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamForumThreadList" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Forum off, no such Team, or no access", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Player · Teams" ], "summary": "Open a thread — an announcement or a discussion", "description": "Two kinds of thread, two authorities: an `announcement` is leader-authored and takes no replies, a `discussion` may be opened by any forum participant — including a granted non-member with no game identity, who reads and writes exactly as a member does. `type` defaults to `announcement` so a phase-4 client keeps meaning what it meant. The body is sanitised with the FORUM’s own profile, in which `img` is never allowed — an author writes a URL and core decides at render time whether it becomes a picture.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." } ], "responses": { "200": { "description": "Posted", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "threadId": { "type": "integer" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Only a leader may post an announcement", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not Found" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "title", "body" ], "properties": { "type": { "type": "string", "enum": [ "announcement", "discussion" ], "default": "announcement" }, "title": { "type": "string", "maxLength": 200 }, "body": { "type": "string" } } } } } } } }, "/api/v1/player/teams/{slug}/forum/threads/{id}": { "get": { "tags": [ "Player · Teams" ], "summary": "Read one thread and its posts", "description": "Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." }, { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "The thread id." } ], "responses": { "200": { "description": "The thread", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamForumThread" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Forum off, no such thread, or no access", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/player/teams/{slug}/forum/threads/{id}/moderate": { "post": { "tags": [ "Player · Teams" ], "summary": "Pin, lock, hide or delete a thread", "description": "Leader or staff. Every action writes the Team’s own append-only moderation ledger recording WHICH authority was exercised; a staff-exercised one additionally writes activity_log, so the site’s staff-accountability trail sees it while a leader’s ordinary housekeeping stays out of it. Deliberately not routed through the site’s mod_actions/appeals pair, which is Discord-sanction-shaped.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." }, { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "The thread id." } ], "responses": { "200": { "description": "Applied", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "action": { "type": "string" }, "threadId": { "type": "integer" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Not a leader of this Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not Found" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "action" ], "properties": { "action": { "type": "string", "enum": [ "pin", "unpin", "lock", "unlock", "hide", "unhide", "delete", "restore" ] }, "reason": { "type": "string", "maxLength": 255 } } } } } } } }, "/api/v1/player/teams/{slug}/forum/threads/{id}/posts": { "post": { "tags": [ "Player · Teams" ], "summary": "Reply to a discussion thread", "description": "Any forum participant — member or granted guest. Three refusals with deliberately different codes: 404 for a thread that is absent or hidden from this caller, 400 for an announcement (which takes no replies by TYPE, not by being closed), and **409 for a locked thread**, because the request is well formed and the thread’s state is what refuses. Locked refuses staff too: they hold `unlock`, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." }, { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "The thread id." } ], "responses": { "200": { "description": "Posted", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "threadId": { "type": "integer" }, "postId": { "type": "integer" } } } } } }, "400": { "description": "Announcements do not take replies", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" }, "409": { "description": "The thread is locked", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": [ "body" ], "properties": { "body": { "type": "string" } } } } } } } }, "/api/v1/player/teams/{slug}/forum/uploads": { "post": { "tags": [ "Player · Teams" ], "summary": "Upload an image to a Team forum", "description": "Multipart. Answers 404 in any image mode but `uploads`. Beyond the admin upload path’s 8 MB cap, mimetype allowlist and random filename, this one assumes a hostile uploader: the leading bytes are sniffed and a mismatch with the declared type is rejected (a client’s Content-Type header is a claim, not a fact), a rolling per-account byte quota applies, and every accepted file gets an attribution row naming who uploaded it.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." } ], "responses": { "200": { "description": "Stored", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "id": { "type": "integer" }, "url": { "type": "string" }, "bytes": { "type": "integer" } } } } } }, "400": { "description": "Not the image type it claims to be", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" }, "429": { "description": "Daily upload quota reached", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "properties": { "image": { "type": "string", "format": "binary" } } } } } } } }, "/api/v1/player/teams/{slug}/forum/uploads/{id}": { "delete": { "tags": [ "Player · Teams" ], "summary": "Remove an uploaded image", "description": "The uploader or staff. Soft: the row is marked and the bytes go with the nightly sweep after a retention window, so a mis-click is recoverable. Note that disabling uploads later stops new files being accepted and does not remove files already uploaded — that is what this route is for.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." }, { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "The upload id." } ], "responses": { "200": { "description": "Removed", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Not your upload", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not Found" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/player/teams/{slug}/grants": { "get": { "tags": [ "Player · Teams" ], "summary": "The Team’s forum guests, and the per-Team cap", "description": "Leader or staff. Lists ACTIVE grants for accounts that are not members — someone who is both is a member, appears on the roster, and is absent here. Answers regardless of whether the forum is switched on: a toggle-off revokes no grant, so the access list stays manageable while there is temporarily nothing to grant access to.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." } ], "responses": { "200": { "description": "Forum guests", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TeamForumGuestList" } } } }, "401": { "description": "Unauthorized" }, "403": { "description": "Not a leader of this Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not Found" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Player · Teams" ], "summary": "Grant forum access to an account", "description": "A grant may name ANY Runic Gateway account, including one with no linked game identity — that is the point of it, since letting an unlinked guildmate into the forum must not be a staff ticket. It never writes team_members: the grantee stays off the roster, out of every membership count, and ineligible for external-platform access. A leader is capped at `teams_max_grants_per_team` active grants (default 50) and rate-limited; staff are exempt and are warned on the way past.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." } ], "responses": { "200": { "description": "Granted", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "grantee": { "type": "string" }, "warning": { "type": "string" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Forbidden" }, "404": { "description": "Not Found" }, "409": { "description": "Already granted, or the Team is at its cap", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "userId": { "type": "integer" }, "username": { "type": "string" }, "reason": { "type": "string", "maxLength": 255 } } } } } } } }, "/api/v1/player/teams/{slug}/grants/{userId}": { "delete": { "tags": [ "Player · Teams" ], "summary": "Revoke forum access", "description": "The grant row is updated rather than deleted — the table is the audit ledger as well as the current state. A leader may not revoke a STAFF-issued grant, which is what stops a leader undoing a moderation decision; the issuer’s role is checked at revoke time, so an account that has since lost its staff role stops protecting the grants it made.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." }, { "name": "userId", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "The grantee’s account id." } ], "responses": { "200": { "description": "Revoked", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "grantee": { "type": "string" } } } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Unauthorized" }, "403": { "description": "Not a leader, or the grant was staff-issued", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "Not Found" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "reason": { "example": "any" } } } } } } } }, "/api/v1/public/contact": { "post": { "tags": [ "Public" ], "summary": "Send a contact message", "description": "Emails the site owner (or falls back to a mailto). Rate limited.", "responses": { "200": { "description": "Message sent", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Message" } } } }, "400": { "description": "Validation error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }, "429": { "description": "Too many messages (rate limited)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Mail delivery failed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ContactRequest" } } } } } }, "/api/v1/public/modules": { "get": { "tags": [ "Public" ], "summary": "Installed modules (id, version, capabilities)", "description": "The modules this backend is currently SERVING, in scan order. A module that is disabled or failed to load is absent rather than listed with a state — its routes and nav are absent too, so the client renders a site without that capability. `capabilities` are opaque strings declared by the module for clients (the SPA, the Android app) to feature-detect against; treat an unknown one as absent. Database-free and never gated by site mode, so a client can feature-detect during maintenance.", "responses": { "200": { "description": "The started modules", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicModules" } } } }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/public/pages/{id}/preview/{token}": { "get": { "tags": [ "Public" ], "summary": "Render a page from a draft-preview token", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Page id." }, { "name": "token", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Preview token from POST /admin/pages/:id/preview." } ], "responses": { "200": { "description": "The page (any status)", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "404": { "description": "Token invalid/expired or page missing", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/public/pages/{slug}": { "get": { "tags": [ "Public" ], "summary": "Get a published CMS page by slug", "description": "Drafts 404 for the public; staff sessions see drafts. Gated by site mode.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Page slug." } ], "responses": { "200": { "description": "The page", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/public/posts/{category}": { "get": { "tags": [ "Public" ], "summary": "List published posts in a category", "description": "Gated by site mode: during maintenance only admins with a valid session see content.", "parameters": [ { "name": "category", "in": "path", "required": true, "schema": { "type": "string" }, "description": "news | five-on-friday | newsletter | screenshots" } ], "responses": { "200": { "description": "Published posts", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Post" } } } } }, "404": { "description": "Unknown category", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/public/posts/{category}/{idOrSlug}": { "get": { "tags": [ "Public" ], "summary": "Get a single published post", "description": "", "parameters": [ { "name": "category", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Post category." }, { "name": "idOrSlug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Numeric id or slug." } ], "responses": { "200": { "description": "The post", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } } }, "404": { "description": "Unknown category or post not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/public/settings": { "get": { "tags": [ "Public" ], "summary": "Public site settings + branding", "description": "Whitelisted, non-sensitive settings plus the per-shard brand block (name/colors/logo/hero/favicon) a client themes itself from, and derived registration / game-account-signup availability flags.", "responses": { "200": { "description": "Public settings + branding", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicSettings" } } } }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/public/status": { "get": { "tags": [ "Public" ], "summary": "Site mode / status", "description": "Current site mode (live or maintenance) so the client can show the maintenance page, plus a version block (service id + API/server versions) for a client first-run probe and version-mismatch guard.", "responses": { "200": { "description": "Site status", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicStatus" } } } }, "500": { "description": "Internal Server Error" } } } }, "/api/v1/public/teams": { "get": { "tags": [ "Public · Teams" ], "summary": "List active, publicly visible Teams", "description": "Teams hidden by reserved-name screening or by staff are absent. The response carries { stale, lastSyncAt } so a client can say how recently the projection was confirmed against the game.", "parameters": [ { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Page size, max 200 (default 50)." }, { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Rows to skip (default 0)." } ], "responses": { "200": { "description": "Publicly visible Teams, with sync freshness", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicTeamList" } } } }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/public/teams/by-external/{moduleId}/{externalId}": { "get": { "tags": [ "Public · Teams" ], "summary": "Get one Team by the owning module’s own identifier", "description": "Exists so a module’s page can find core’s Team without holding core’s identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module, so the scope is what stops one module reading another’s Team by guessing a serial. A hidden Team returns 404, like every other public lookup.", "parameters": [ { "name": "moduleId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The module that owns the Team." }, { "name": "externalId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "That module’s own identifier for it." } ], "responses": { "200": { "description": "The Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicTeam" } } } }, "404": { "description": "No such Team, or it is hidden", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/public/teams/unsubscribe/{token}": { "post": { "tags": [ "Public · Teams" ], "summary": "Unsubscribe from one Team’s notification emails", "description": "Honours the tokened link in a Team notification email, including RFC 8058 one-click. Sets the same per-Team mute the account screen shows. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, Team) pairs exist.", "parameters": [ { "name": "token", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The signed token from the email link." } ], "responses": { "200": { "description": "Acknowledged", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkFlag" } } } } }, "security": [ {} ] }, "get": { "tags": [ "Public · Teams" ], "summary": "Land a human on the unsubscribe page", "description": "For mail clients that render the List-Unsubscribe URL as an ordinary link. Redirects to the site’s own confirmation page and changes nothing — a GET must not mutate, or a link scanner would mute Teams nobody asked to leave.", "parameters": [ { "name": "token", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The signed token from the email link." } ], "responses": { "302": { "description": "Redirect to the site’s unsubscribe page" } }, "security": [ {} ] } }, "/api/v1/public/teams/{slug}": { "get": { "tags": [ "Public · Teams" ], "summary": "Get one Team by slug", "description": "An archived Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A hidden Team returns 404, indistinguishable from one that does not exist.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." } ], "responses": { "200": { "description": "The Team", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicTeam" } } } }, "404": { "description": "No such Team, or it is hidden", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/public/teams/{slug}/activity": { "get": { "tags": [ "Public · Teams" ], "summary": "A Team’s activity feed, filtered to what the caller may see", "description": "Items are `public` or `members`. Anyone who can see the Team gets the public ones; members and forum-granted users also get the members-only ones, and the response says which via `scope` so a client can render \"some items are hidden\" rather than presenting a filtered feed as the whole one. Sending a session is optional.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Page size, max 100 (default 50)." }, { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Rows to skip (default 0)." } ], "responses": { "200": { "description": "One page of the feed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicTeamActivity" } } } }, "404": { "description": "No such Team, or it is hidden from this caller", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Service Unavailable" } }, "security": [ {}, { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/public/teams/{slug}/members": { "get": { "tags": [ "Public · Teams" ], "summary": "Get a Team roster", "description": "In-game display names only. A member key is a game-internal identifier and a user id names a site account; neither is published, whatever the module’s projection answers. `linked` answers whether a character has an account behind it without saying which. WHICH rows appear is the module’s audience projection; sending a session is optional and may widen it.", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The Team slug." } ], "responses": { "200": { "description": "The roster, with sync freshness", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicTeamRoster" } } } }, "404": { "description": "No such Team, or it is hidden", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Service Unavailable" } }, "security": [ {}, { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/public/version": { "get": { "tags": [ "Public" ], "summary": "Backend identity + version", "description": "Lightweight, DB-free descriptor of this backend: a stable service id and the API/server versions. A client uses it to recognize a Runic Gateway backend on first-run and to run a version-mismatch guard. Doubles as a cheap liveness check.", "responses": { "200": { "description": "Backend version", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublicVersion" } } } } } } }, "/api/v1/public/wiki": { "get": { "tags": [ "Public" ], "summary": "List published wiki pages", "description": "", "parameters": [ { "name": "q", "in": "query", "schema": { "type": "string" } }, { "name": "category", "in": "query", "schema": { "type": "string" } }, { "name": "tag", "in": "query", "schema": { "type": "string" } } ], "responses": { "200": { "description": "Published wiki pages", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/WikiPage" } } } } }, "500": { "description": "Internal Server Error" }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/public/wiki/categories": { "get": { "tags": [ "Public" ], "summary": "List wiki categories", "description": "", "responses": { "200": { "description": "Wiki categories", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/WikiCategory" } } } } }, "500": { "description": "Internal Server Error" }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/public/wiki/tags": { "get": { "tags": [ "Public" ], "summary": "List wiki tags", "description": "", "responses": { "200": { "description": "Wiki tags", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } }, "500": { "description": "Internal Server Error" }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/public/wiki/{slug}": { "get": { "tags": [ "Public" ], "summary": "Get a single published wiki page", "description": "", "parameters": [ { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Wiki page slug." } ], "responses": { "200": { "description": "The wiki page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WikiPage" } } } }, "404": { "description": "Not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" }, "503": { "description": "Service Unavailable" } } } }, "/api/v1/settings/nav": { "get": { "tags": [ "Settings" ], "summary": "Nav overrides for the admin and player layouts", "description": "Returns the stored nav_admin and nav_player overrides as raw JSON strings (null when the admin never overrode that nav). Any authenticated account may read them: AdminLayout renders for editors and moderators, PlayerPortalLayout for players, and none of them can read GET /admin/settings. Presentation-only — the role/feature filters in the layouts still decide what is actually shown.", "responses": { "200": { "description": "Nav overrides", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NavSettings" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/settings/theme/options": { "get": { "tags": [ "Settings" ], "summary": "Theme presets and the curated option lists", "description": "The closed sets an admin may choose from when theming the site: the three presets (with swatch colors), the curated Google Fonts shortlist per role, the shadow depths, and the editable color/radius field names. Served so the admin form can never offer a value the server would reject. Static — no database read.", "responses": { "200": { "description": "Theme option catalog", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ThemeOptions" } } } }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } } }, "components": { "securitySchemes": { "cookieAuth": { "type": "apiKey", "in": "cookie", "name": "rg_token", "description": "Session JWT set as an httpOnly cookie by POST /api/v1/auth/login." }, "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT", "description": "Access token from POST /api/v1/auth/mobile/login (or /refresh)." } }, "schemas": { "Error": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "message": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Not found" } } } } } } }, "ValidationError": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "errors": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "type": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "field" } } }, "msg": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Invalid value" } } }, "path": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "username" } } }, "location": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "body" } } } } } } } } } } } } }, "SafeUser": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 1 } } }, "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "admin" } } }, "role": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "admin", "editor" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "admin" } } } } } } }, "LoginRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "username", "password" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "admin" } } }, "password": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "password" }, "example": { "type": "string", "example": "super-secret" } } }, "company": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Honeypot — must be empty for humans." }, "example": { "type": "string", "example": "" } } } } } } }, "RegisterRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "username", "password" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "minLength": { "type": "number", "example": 3 }, "maxLength": { "type": "number", "example": 32 }, "example": { "type": "string", "example": "newplayer" } } }, "password": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "password" }, "minLength": { "type": "number", "example": 8 }, "maxLength": { "type": "number", "example": 64 } } }, "email": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "player@example.com" } } }, "company": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Honeypot — must be empty for humans." }, "example": { "type": "string", "example": "" } } } } } } }, "LoginResponse": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Either a session (user) or, for 2FA accounts, a TOTP challenge to complete at /login/totp." }, "properties": { "type": "object", "properties": { "user": { "$ref": "#/components/schemas/SafeUser" }, "totpRequired": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "challenge": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Signed challenge token for the TOTP step." } } } } } } }, "TotpLoginRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "challenge" ], "items": { "type": "string" } }, "description": { "type": "string", "example": "Second step for 2FA login. Supply either code OR recoveryCode." }, "properties": { "type": "object", "properties": { "challenge": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Token returned by /login when totpRequired." } } }, "code": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Current authenticator code." }, "example": { "type": "string", "example": "123456" } } }, "recoveryCode": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "A single-use recovery code (alternative to code)." }, "example": { "type": "string", "example": "abcde-12345" } } }, "trustDevice": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Remember this browser so future logins skip the TOTP step (30 days)." }, "example": { "type": "boolean", "example": false } } }, "deviceName": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Optional friendly label for the Trusted Devices list." }, "example": { "type": "string", "example": "My Laptop" } } } } } } }, "MobileLoginRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "username", "password" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "admin" } } }, "password": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "password" }, "example": { "type": "string", "example": "super-secret" } } }, "code": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "TOTP code (only when 2FA is enabled)." }, "example": { "type": "string", "example": "123456" } } }, "recoveryCode": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Single-use recovery code (alternative to code)." }, "example": { "type": "string", "example": "abcde-12345" } } }, "trustDevice": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Remember this device so future logins skip the TOTP step; the response then carries trustToken." }, "example": { "type": "boolean", "example": false } } }, "device_name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Optional friendly device label for Active/Trusted Devices." }, "example": { "type": "string", "example": "Pixel 8" } } } } } } }, "MobileTokenResponse": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "accessToken": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Short-lived bearer JWT." } } }, "refreshToken": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Long-lived, revocable refresh token." } } }, "expiresIn": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Access token lifetime as a duration string (zeit/ms format, e.g. \"15m\")." }, "example": { "type": "string", "example": "15m" } } }, "user": { "$ref": "#/components/schemas/SafeUser" }, "trustToken": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Present only when trustDevice was requested and accepted — store securely and send as X-Trust-Token on future logins to skip TOTP." } } }, "trustLimitReached": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Present (true) when trustDevice was requested but the device cap is reached; see devices." } } }, "devices": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "nullable": { "type": "boolean", "example": true }, "items": { "$ref": "#/components/schemas/TrustedDevice" }, "description": { "type": "string", "example": "The existing trusted devices, when trustLimitReached is set." } } } } } } }, "MobileRefreshRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "refreshToken" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "refreshToken": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } } } } } }, "MobileLogoutRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "refreshToken": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Revoke a single session." } } }, "all": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Revoke every session for the user." }, "example": { "type": "boolean", "example": false } } } } } } }, "MobileSsoExchangeRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "code", "code_verifier" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "code": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "The single-use authorization code returned to the app callback." } } }, "code_verifier": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "The PKCE verifier for the challenge sent to /auth/mobile/sso/start." } } }, "device_name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Optional friendly device label for Active Devices." }, "example": { "type": "string", "example": "Pixel 8" } } } } } } }, "DeviceSession": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "description": { "type": "string", "example": "Session row id (pass to DELETE /auth/me/sessions/:id)." } } }, "deviceName": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Pixel 8" } } }, "userAgent": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "createdAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "lastUsedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "expiresAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } }, "TrustedDevice": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "description": { "type": "string", "example": "Trusted-device id (pass to DELETE …/trusted-devices/:id)." } } }, "platform": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "web", "mobile" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "web" } } }, "deviceName": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "My Laptop" } } }, "userAgent": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "createdAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "lastUsedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "expiresAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } }, "TrustDeviceResult": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "trusted": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "trustToken": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Native clients only — store securely and send as X-Trust-Token." } } } } } } }, "TrustedDeviceLimit": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "error": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "trusted_device_limit" } } }, "devices": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/TrustedDevice" } } } } } } }, "RecoveryCodes": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "recoveryCodes": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "abcde-12345" } } } } } } } } }, "Message": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "message": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Logged out." } } } } } } }, "ContactRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "message" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "message": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 5000 }, "example": { "type": "string", "example": "When does the shard launch?" } } }, "email": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "example": { "type": "string", "example": "player@example.com" } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 100 }, "example": { "type": "string", "example": "Lord British" } } } } } } }, "Provider": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "google" } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Google" } } }, "icon": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2')." }, "example": { "type": "string", "example": "google" } } }, "loginUrl": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Relative URL to begin the redirect flow." }, "example": { "type": "string", "example": "/api/v1/auth/sso/google/start" } } }, "priority": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "description": { "type": "string", "example": "Sort order (ascending)." }, "example": { "type": "number", "example": 1 } } } } } } }, "ProviderConfig": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "okta" } } }, "kind": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "google", "discord", "oidc", "oauth2" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "oidc" } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Okta" } } }, "enabled": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "clientId": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "hasSecret": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Whether a client secret is stored (the secret itself is never returned)." }, "example": { "type": "boolean", "example": true } } }, "authorizeUrl": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "uri" } } }, "tokenUrl": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "uri" } } }, "userinfoUrl": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "uri" } } }, "scopes": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "openid email profile" } } }, "priority": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 10 } } }, "builtin": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "True for the fixed google/discord providers." }, "example": { "type": "boolean", "example": false } } }, "health": { "$ref": "#/components/schemas/ProviderHealth" } } } } }, "ProviderHealth": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Config-completeness check that gates whether a provider is offered to end users." }, "properties": { "type": "object", "properties": { "valid": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "missing": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "description": { "type": "string", "example": "Names of required config fields that are still missing." }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "example": { "type": "array", "example": [], "items": {} } } } } } } }, "ProviderCreateRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "id", "kind", "name" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "pattern": { "type": "string", "example": "^[a-z0-9-]+$" }, "example": { "type": "string", "example": "okta" } } }, "kind": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "oidc", "oauth2" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "oidc" } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 80 }, "example": { "type": "string", "example": "Okta" } } }, "enabled": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "clientId": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "secret": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "password" } } }, "authorizeUrl": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "uri" } } }, "tokenUrl": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "uri" } } }, "userinfoUrl": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "uri" } } }, "scopes": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 500 }, "example": { "type": "string", "example": "openid email profile" } } }, "priority": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 10 } } } } } } }, "Post": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 12 } } }, "category": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "news" } } }, "title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Server maintenance this weekend" } } }, "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "server-maintenance-this-weekend" } } }, "excerpt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "body": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "image_url": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "/uploads/1700000000-abcd.png" } } }, "published": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "author_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 1 } } }, "created_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "updated_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "published_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "PostCreateRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "category", "title" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "category": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "news" } } }, "title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 200 }, "example": { "type": "string", "example": "Server maintenance this weekend" } } }, "body": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "image_url": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "Required for the screenshots category." } } }, "published": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } } } } } }, "PublishRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "published" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "published": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } } } } } }, "UploadResponse": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "url": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "/uploads/1700000000-abcd.png" } } } } } } }, "WikiPage": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 3 } } }, "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "getting-started" } } }, "title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Getting Started" } } }, "excerpt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "body": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "category_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 2 } } }, "published": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "tags": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "example": { "type": "array", "example": [ "newbie", "guide" ], "items": { "type": "string" } } } }, "created_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "updated_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } }, "WikiPageCreateRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "slug", "title" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "pattern": { "type": "string", "example": "^[a-z0-9-]+$" }, "example": { "type": "string", "example": "getting-started" } } }, "title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 200 }, "example": { "type": "string", "example": "Getting Started" } } }, "excerpt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 400 } } }, "body": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "category_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "published": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "tags": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } } } } } } } }, "WikiCategory": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 2 } } }, "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "guides" } } }, "title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Guides" } } }, "description": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "sort_order": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 0 } } } } } } }, "WikiCategoryCreateRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "slug", "title" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "pattern": { "type": "string", "example": "^[a-z0-9-]+$" }, "example": { "type": "string", "example": "guides" } } }, "title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 200 }, "example": { "type": "string", "example": "Guides" } } }, "description": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 400 } } }, "sort_order": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 0 } } } } } } }, "User": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 1 } } }, "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "admin" } } }, "role": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "admin", "editor", "moderator", "player" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "admin" } } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "active", "disabled", "banned", "pending" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "active" } } }, "email": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "nullable": { "type": "boolean", "example": true } } }, "email_verified": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "totp_enabled": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "last_login_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "created_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } }, "UserCreateRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "username", "password" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "minLength": { "type": "number", "example": 3 }, "maxLength": { "type": "number", "example": 32 }, "example": { "type": "string", "example": "editor1" } } }, "password": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "password" }, "minLength": { "type": "number", "example": 8 }, "maxLength": { "type": "number", "example": 64 } } }, "role": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "admin", "editor", "moderator", "player" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "editor" } } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "active", "disabled", "banned", "pending" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "active" } } }, "email": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "ChangeUsernameRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "username" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "minLength": { "type": "number", "example": 3 }, "maxLength": { "type": "number", "example": 32 }, "example": { "type": "string", "example": "newname" } } } } } } }, "ChangePasswordRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "newPassword" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "newPassword": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "password" }, "minLength": { "type": "number", "example": 8 }, "maxLength": { "type": "number", "example": 64 } } }, "currentPassword": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "password" }, "description": { "type": "string", "example": "Required when the account already has a password. Omit only for an SSO-provisioned account setting its first password." } } } } } } }, "PlayerAccount": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The signed-in account (GET /auth/me/account). Same shape for every role." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 42 } } }, "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "newplayer" } } }, "role": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "admin", "editor", "moderator", "player" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "player" } } }, "email": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "player@example.com" } } }, "email_verified": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Whether the address above has been proved by opening a confirmation link." }, "example": { "type": "boolean", "example": true } } }, "email_pending": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "An address the user has requested but not yet confirmed. It does NOT replace `email` until the confirmation link is used." }, "example": {} } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "active", "disabled", "banned", "pending" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "active" } } }, "totp_enabled": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "has_password": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "False for an SSO-provisioned account that has not set a password yet." }, "example": { "type": "boolean", "example": true } } } } } } }, "ChangeEmailRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "email" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "email": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "maxLength": { "type": "number", "example": 255 }, "example": { "type": "string", "example": "new@example.com" } } }, "currentPassword": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "password" }, "description": { "type": "string", "example": "Required when the account has a password. An address is where account recovery lands, so changing it is re-authenticated; an SSO-provisioned account with no password is exempt." } } } } } } }, "PendingEmail": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The address now awaiting confirmation. The account keeps its existing address until the emailed link is used." }, "properties": { "type": "object", "properties": { "email_pending": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "example": { "type": "string", "example": "new@example.com" } } }, "emailed": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "False when outbound email is not configured or the send failed; the address stays staged so a resend can succeed later." }, "example": { "type": "boolean", "example": true } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "enum": { "type": "array", "example": [ "NOT_CONFIGURED", "SEND_FAILED", null ], "items": {} }, "description": { "type": "string", "example": "Why nothing was sent, when `emailed` is false." }, "example": {} } } } } } }, "EmailDedupeEntry": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One account whose email address was cleared when addresses became unique, because an older account already held it." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 3 } } }, "user_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 42 } } }, "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "someplayer" } } }, "lost_address": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "example": { "type": "string", "example": "shared@example.com" } } }, "cleared_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "acknowledged_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "OkFlag": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "ok": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } } } } } }, "RegisterDeviceRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "endpoint" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "endpoint": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "uri" }, "description": { "type": "string", "example": "The UnifiedPush/ntfy endpoint URL the distributor handed the app (or an FCM token). Must be an allowed HTTPS relay origin — private/loopback hosts are rejected." }, "example": { "type": "string", "example": "https://ntfy.example.com/UP0a1b2c3d4e5f" } } }, "transport": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "unifiedpush", "fcm" ], "items": { "type": "string" } }, "default": { "type": "string", "example": "unifiedpush" }, "example": { "type": "string", "example": "unifiedpush" } } }, "platform": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "maxLength": { "type": "number", "example": 40 }, "example": { "type": "string", "example": "android" } } } } } } }, "PushDevice": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 7 } } }, "transport": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "unifiedpush", "fcm" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "unifiedpush" } } }, "endpoint": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "https://ntfy.example.com/UP0a1b2c3d4e5f" } } }, "platform": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "android" } } }, "createdAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "lastSeenAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } }, "NotificationStream": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One subscribable push stream from the catalog." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "idoc.warning" } } }, "label": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "IDOC warnings" } } }, "description": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "A house falls into its final (IDOC) decay stage." } } }, "personal": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Owner-keyed — delivered only to the owning user, never fanned out publicly." }, "example": { "type": "boolean", "example": false } } }, "requiresLinkedAccount": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "The stream needs a linked game account (personal streams)." }, "example": { "type": "boolean", "example": false } } } } } } }, "NotificationStreams": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "streams": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/NotificationStream" } } } } } } }, "NotificationSubscriptions": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The set of stream ids the user has opted into (used for both GET and PUT)." }, "properties": { "type": "object", "properties": { "streams": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "example": { "type": "array", "example": [ "news.post", "idoc.warning", "vendor.sale" ], "items": { "type": "string" } } } } } } } }, "TeamNotificationPref": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One Team's notification preference for the current user. Absent fields take the stored defaults: push is opt-OUT (not muted) and email is opt-IN (`off`)." }, "properties": { "type": "object", "properties": { "teamId": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 3 } } }, "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "the-silver-hand" } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "The Silver Hand" } } }, "archived": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "muted": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "emailMode": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "off", "digest", "immediate" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "off" } } } } } } }, "TeamNotificationPrefs": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Per-Team notification preferences (used for both GET and PUT). The `teams` array is required on PUT even when empty." }, "properties": { "type": "object", "properties": { "teams": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/TeamNotificationPref" } } } } } } }, "Appeal": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "A player-submitted moderation appeal (as returned to the player and in the staff queue)." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 12 } } }, "mod_action_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 340 } } }, "discord_user_id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "216734083584917504" } } }, "action_type": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "ban", "mute" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "ban" } } }, "user_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 42 } } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "pending", "under_review", "approved", "denied", "withdrawn" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "pending" } } }, "submitted_text": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "I was banned by mistake — please review." } } }, "staff_response": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": {} } }, "handled_by_user_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": {} } }, "handled_by_tag": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": {} } }, "reversal_status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "none", "done", "failed" ], "items": { "type": "string" } }, "description": { "type": "string", "example": "Discord-reversal outcome. done/failed only after an approval; none otherwise." }, "example": { "type": "string", "example": "none" } } }, "submitted_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "resolved_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true }, "example": {} } }, "action_target_tag": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Rogue#1234" }, "description": { "type": "string", "example": "Snapshot of the original action target tag (from mod_actions)." } } }, "action_reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Spam" } } }, "action_created_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "action_duration_seconds": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 86400 } } }, "submitter_username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "newplayer" } } } } } } }, "ContentReport": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "A member-raised report about a piece of content (TEAMS.md §5.6). Generic by design: `targetType` is a string rather than an enum in the schema because a wiki page or a news comment is meant to become a new value here, not a new queue. Reports reach SITE STAFF only — there is no leader-facing view of this queue, because a Team's leaders are exactly the people who will not report their own Team." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 41 } } }, "targetType": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "team_forum_post" }, "description": { "type": "string", "example": "team_forum_thread | team_forum_post | team_forum_upload" } } }, "targetId": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 812 } } }, "teamId": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 7 }, "description": { "type": "string", "example": "Denormalised so the queue can filter by Team." } } }, "reporter": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "wanderer" }, "description": { "type": "string", "example": "Username snapshot; \"[deleted account]\" once the account is gone." } } }, "reporterDeleted": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "spam", "abuse", "sexual", "illegal", "impersonation", "other" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "abuse" } } }, "detail": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "maxLength": { "type": "number", "example": 500 }, "example": { "type": "string", "example": "Personal attacks in the third paragraph." } } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "open", "reviewing", "actioned", "dismissed" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "open" } } }, "handledBy": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "moderator1" } } }, "handledNote": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Post hidden, author warned." } } }, "handledAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "createdAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "target": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "The reported content, already resolved so triage never means hunting. NULL when the target has since been hard-deleted — the report still lists, because \"somebody reported this and by the time we looked it was gone\" is a fact a moderator needs. An upload target carries uploader, byte size and the SNIFFED mimetype (§5.6 rule 4)." }, "properties": { "type": "object", "properties": { "kind": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "thread", "post", "upload" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "post" } } }, "threadId": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 19 } } }, "threadTitle": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Raid night" } } }, "postId": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 812 } } }, "uploadId": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "type": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "enum": { "type": "array", "example": [ "announcement", "discussion" ], "items": { "type": "string" } } } }, "author": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "someone" } } }, "uploader": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "excerpt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Plain-text excerpt of the post body, capped at 300 characters." } } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "enum": { "type": "array", "example": [ "visible", "hidden", "deleted" ], "items": { "type": "string" } } } }, "filename": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "url": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "/uploads/a1b2c3.png" } } }, "mimetype": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "image/png" }, "description": { "type": "string", "example": "The sniffed type, never the client's header." } } }, "byteSize": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 184320 } } }, "deleted": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "nullable": { "type": "boolean", "example": true } } }, "createdAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } } } } } } } } } }, "AppealQueueItem": { "type": "object", "properties": { "allOf": { "type": "array", "items": { "$ref": "#/components/schemas/Appeal" } }, "description": { "type": "string", "example": "A staff-queue appeal row — identical shape to Appeal, with the joined action/submitter columns populated." } } }, "AppealResolveResult": { "type": "object", "properties": { "allOf": { "type": "array", "items": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "reversal": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "What the approval attempted against Discord." }, "properties": { "type": "object", "properties": { "attempted": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "ok": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "reversal_status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "none", "done", "failed" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "done" } } }, "bot_status": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 200 }, "description": { "type": "string", "example": "HTTP status from the bot internal call, or null when no call was made." } } }, "error": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": {} } } } } } } } } } } } } }, "AppealEligibleAction": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "A ban/mute mod_action the caller may appeal (no active appeal outstanding)." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 340 }, "description": { "type": "string", "example": "mod_action id — pass as mod_action_id when submitting." } } }, "action_type": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "ban", "mute" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "ban" } } }, "target_tag": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Rogue#1234" } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Spam" } } }, "duration_seconds": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "number", "example": 86400 } } }, "created_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } }, "CreateAppealRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "mod_action_id", "submitted_text" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "mod_action_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 340 }, "description": { "type": "string", "example": "The ban/mute mod_action to appeal (must belong to the caller)." } } }, "submitted_text": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "minLength": { "type": "number", "example": 1 }, "maxLength": { "type": "number", "example": 4000 }, "example": { "type": "string", "example": "I was banned by mistake — please review." } } } } } } }, "ResolveAppealRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "status" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "approved", "denied" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "approved" } } }, "staff_response": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 4000 }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Reviewed — reversing the ban." } } } } } } }, "TotpCodeRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "code" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "code": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "123456" } } } } } } }, "SiteModeRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "mode" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "mode": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "live", "maintenance" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "live" } } } } } } }, "UnbanRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "ip" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "ip": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "203.0.113.5" } } } } } } }, "TotpSetup": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Enrollment material returned by POST /account/totp/setup." }, "properties": { "type": "object", "properties": { "otpauthUrl": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "otpauth://totp/Runic Gateway:admin?secret=..." } } }, "qr": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "QR code as a data: URL." }, "example": { "type": "string", "example": "data:image/png;base64,iVBORw0KGgo..." } } } } } } }, "TotpState": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Result of enabling/disabling 2FA. Enabling also returns the one-time recovery codes." }, "properties": { "type": "object", "properties": { "totp_enabled": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "recoveryCodes": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Single-use recovery codes, shown ONCE on enable." }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "abcde-12345" } } } } } } } } }, "LinkedIdentity": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "provider": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "google" } } }, "email": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "email" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "user@example.com" } } }, "linked_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } }, "SiteModeState": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Result of PUT /admin/site-mode." }, "properties": { "type": "object", "properties": { "site_mode": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "live", "maintenance" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "maintenance" } } }, "changed_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "changed_by": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "admin" } } } } } } }, "PublicStatus": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Public site status (GET /public/status)." }, "properties": { "type": "object", "properties": { "mode": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "live", "maintenance" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "live" } } }, "status_message": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "" } } }, "version": { "$ref": "#/components/schemas/PublicVersion" } } } } }, "PublicVersion": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Backend identity + version (GET /public/version; also embedded in /public/status)." }, "properties": { "type": "object", "properties": { "service": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "runic-gateway" }, "description": { "type": "string", "example": "Stable backend identifier for first-run recognition." } } }, "api": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "v1" }, "description": { "type": "string", "example": "API contract version (matches the /api/v1 mount)." } } }, "server": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "1.0.0" }, "description": { "type": "string", "example": "Server package version (informational)." } } } } } } }, "PublicModules": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Installed modules currently SERVING (GET /public/modules). A disabled or failed module is absent, not listed with a state — its routes and nav are absent too. Database-free and not site-mode gated." }, "properties": { "type": "object", "properties": { "modules": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/PublicModule" } } } } } } }, "PublicModule": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One started module, as published to anonymous clients." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "uo" }, "description": { "type": "string", "example": "Module id — also the URL segment its routes live under (/api/v1/public/)." } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Ultima Online" }, "description": { "type": "string", "example": "Human label." } } }, "version": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "1.0.0" }, "description": { "type": "string", "example": "The module's own version (semver). Unrelated to the API version." } } }, "capabilities": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "description": { "type": "string", "example": "Opaque strings the module declares. Feature-detect against them; treat an unknown one as absent." }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "shard" } } } } } } } } }, "Brand": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Per-shard branding (BRAND_* env, with admin overrides for name/contactEmail). A client themes itself from this — one instance runs as any shard. Asset fields (logo/hero/favicon) may be site-relative paths; resolve them against the site base URL." }, "properties": { "type": "object", "properties": { "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Runic Gateway" } } }, "shortName": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Runic Gateway" } } }, "tagline": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "an independent private Ultima Online shard" } } }, "description": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "contactEmail": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "" } } }, "url": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "" } } }, "accent": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "#7f99bd" }, "description": { "type": "string", "example": "Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change." } } }, "logo": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "" }, "description": { "type": "string", "example": "Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO." } } }, "hero": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "/assets/img/runic-emblem.png" }, "description": { "type": "string", "example": "Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO." } } }, "favicon": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "/assets/img/favicon.ico" }, "description": { "type": "string", "example": "Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON." } } } } } } }, "PublicSettings": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Public site settings + branding (GET /public/settings). Whitelisted string settings, plus derived availability flags and the brand block a client themes from. Additional whitelisted keys may appear." }, "properties": { "type": "object", "properties": { "site_title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Runic Gateway" } } }, "status_message": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "" } } }, "maintenance_message": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "" } } }, "registration": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "password": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "sso": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } } } } } }, "brand": { "$ref": "#/components/schemas/Brand" }, "theme": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow)." }, "additionalProperties": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "example": { "type": "object", "properties": { "--accent": { "type": "string", "example": "#c9973f" }, "--bg": { "type": "string", "example": "#1a120b" }, "--radius-card": { "type": "string", "example": "2px" } } } } }, "push": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Push-notification relay config (M7). `ntfyUrl` is the client-facing ntfy base URL the app registers its device topic against (from NTFY_PUBLIC_URL / NTFY_ALLOWED_ORIGINS); null when push is not configured for this shard." }, "properties": { "type": "object", "properties": { "ntfyUrl": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "https://ntfy.example.com" } } } } } } } } }, "additionalProperties": { "type": "boolean", "example": true } } }, "NavSettings": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Nav overrides for the two authenticated layouts (GET /settings/nav). Each value is the stored JSON **string** — settings.value is TEXT — or null when that nav was never overridden. Parse fail-safe: treat malformed as absent and fall back to the hardcoded nav." }, "properties": { "type": "object", "properties": { "nav_admin": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "{\"/admin/posts\":{\"label\":\"Blog Posts\",\"order\":10}}" } } }, "nav_player": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": {} } } } } } }, "ThemeOptions": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database." }, "properties": { "type": "object", "properties": { "presets": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "description": { "type": "string", "example": "Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means \"no preset base — the shipped theme plus whatever custom fields are set\"." }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "fantasy" } } }, "label": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Fantasy" } } }, "tokens": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "additionalProperties": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "example": { "type": "object", "properties": { "--bg": { "type": "string", "example": "#1a120b" }, "--accent": { "type": "string", "example": "#c9973f" } } } } } } } } } } }, "colorFields": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "description": { "type": "string", "example": "Editable color fields, each paired with the CSS variable it drives." }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "accent" } } }, "token": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "--accent" } } } } } } } } }, "radiusFields": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "radiusCard" } } }, "token": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "--radius-card" } } } } } } } } }, "shippedTokens": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "What the stylesheet declares by default — the values an unset field resolves to when no preset is selected." }, "additionalProperties": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } } } }, "fonts": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Curated Google Fonts shortlist per role. Each option's `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input." }, "additionalProperties": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "value": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "label": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } } } } } } } } } }, "shadows": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "value": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "label": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } } } } } } } }, "radiusMaxPx": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 999 } } } } } } }, "DeletedId": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 12 } } } } } } }, "DeletedSlug": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "getting-started" } } } } } } }, "DeletedFlag": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "deleted": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } } } } } }, "UnlinkedFlag": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "unlinked": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } } } } } }, "UnbanResult": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "ip": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "203.0.113.5" } } }, "removed": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Whether the IP had an entry that was cleared." }, "example": { "type": "boolean", "example": true } } } } } } }, "OkResponse": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "ok": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } } } } } }, "TeamSyncFreshness": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Freshness of core's projection of the game's Teams. Carried on every public Team payload so a page can say how recently the roster was confirmed rather than presenting stale data as current. `configured` is false when no module supplies a Team provider — a deployment with no game module is not a stale one." }, "properties": { "type": "object", "properties": { "configured": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "stale": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "True past twice the reconcile interval, or when the projection has never synced at all." }, "example": { "type": "boolean", "example": false } } }, "lastSyncAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "consecutiveFailures": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 0 } } } } } } }, "PublicTeam": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "A Team as an anonymous caller sees it. `name` is what is DISPLAYED — a staff display-name override, when one is set — never the frozen identity behind it." }, "properties": { "type": "object", "properties": { "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "the-silver-hand" } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "The Silver Hand" } } }, "abbr": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "TSH" } } }, "memberCount": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 42 } } }, "linkedCount": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "description": { "type": "string", "example": "Members with a linked site account." }, "example": { "type": "number", "example": 11 } } }, "onlineCount": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 3 } } }, "meta": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "additionalProperties": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Module-supplied and opaque to core." } } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "active", "archived" ], "items": { "type": "string" } } } }, "createdAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "rosterSyncedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "archivedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "archivedReason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "renamed" } } }, "successor": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Where an archived Team continued after a rename, so an old link explains itself instead of 404ing." }, "properties": { "type": "object", "properties": { "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } } } } } } } } } }, "PublicTeamList": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "allOf": { "type": "array", "items": { "$ref": "#/components/schemas/TeamSyncFreshness" } }, "properties": { "type": "object", "properties": { "teams": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/PublicTeam" } } }, "total": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 12 } } }, "enabled": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Whether this deployment has Teams at all — a provider is registered, or Teams exist from one that since went away. The `teams` nav feature flag resolves from this; false means bare core, where a Teams link would lead to a permanently empty page." }, "example": { "type": "boolean", "example": true } } } } } } }, "PublicTeamMember": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "A roster row as an anonymous caller sees it. The member key is a game-internal identifier and the user id names a site account; neither is published. `linked` answers whether a character has an account behind it without saying which." }, "properties": { "type": "object", "properties": { "displayName": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Aldric" } } }, "rankLabel": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Module vocabulary, opaque to core." }, "example": { "type": "string", "example": "Warlord" } } }, "isLeader": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "online": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "linked": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } } } } } }, "PublicTeamRoster": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "allOf": { "type": "array", "items": { "$ref": "#/components/schemas/TeamSyncFreshness" } }, "properties": { "type": "object", "properties": { "members": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/PublicTeamMember" } } }, "rosterSyncedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "projected": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Whether the module applied its own audience projection to this roster. False means the module declined or does not project, and the roster was served at core’s public shape — never the full one." }, "example": { "type": "boolean", "example": true } } } } } } }, "PublicTeamActivityItem": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "`summary` is already-rendered text supplied by whoever pushed the item; core never composes one. `kind` and `payload` are opaque to core — only the module’s `team.overview` slot renders anything richer than the text." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 4821 } } }, "source": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "`core` or a module id." }, "example": { "type": "string", "example": "uo" } } }, "kind": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "uo.champion.completed" } } }, "summary": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Completed Champion Neira" } } }, "visibility": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "public", "members" ], "items": { "type": "string" } } } }, "occurredAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "payload": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "additionalProperties": { "type": "boolean", "example": true } } } } } } }, "PublicTeamActivity": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "items": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/PublicTeamActivityItem" } } }, "total": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "description": { "type": "string", "example": "Matching rows for THIS caller’s visibility, so paging is honest." }, "example": { "type": "number", "example": 137 } } }, "limit": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 50 } } }, "offset": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 0 } } }, "scope": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "public", "members" ], "items": { "type": "string" } }, "description": { "type": "string", "example": "Which visibilities this caller received. `public` means members-only items were withheld — render that fact rather than presenting a filtered feed as the whole one." } } } } } } }, "PlayerTeamList": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "allOf": { "type": "array", "items": { "$ref": "#/components/schemas/TeamSyncFreshness" } }, "properties": { "type": "object", "properties": { "teams": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "allOf": { "type": "array", "items": { "$ref": "#/components/schemas/PublicTeam" } }, "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "membership", "grant", "both" ], "items": { "type": "string" } }, "description": { "type": "string", "example": "Which authority path lists this Team for the caller. `both` is a real state and is kept: membership is the current reason while the grant survives as audit history." } } }, "isLeader": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } } } } } } } } } } } }, "PlayerTeamAccess": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "allowed": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "viaMembership": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "viaGrant": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Reported even when membership also holds." } } }, "isLeader": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "The synced value with any staff override applied." } } } } } } }, "AdminTeam": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The full staff view, including what a staff decision overrode." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "moduleId": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "uo" } } }, "externalId": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "The module's own stable id, opaque to core." } } }, "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "The frozen identity. Immutable for the life of the row." } } }, "displayName": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "description": { "type": "string", "example": "What is rendered — the override when set, otherwise `name`." } } }, "displayNameOverride": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "abbr": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "active", "archived" ], "items": { "type": "string" } } } }, "hidden": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "hiddenReason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "enum": { "type": "array", "example": [ "reserved_name", "staff", null ], "items": {} } } }, "hiddenTerm": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Which reserved term matched." }, "example": { "type": "string", "example": "admin" } } }, "nameReviewedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Set once a human has ruled on the name; a later sweep never re-hides it." } } }, "memberCount": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "linkedCount": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "onlineCount": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "rosterSyncedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "membersEmptySince": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "The per-Team empty-roster quarantine." } } }, "succeededBy": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "createdAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "archivedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "archivedReason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "meta": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "additionalProperties": { "type": "boolean", "example": true } } }, "members": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/AdminTeamMember" } } }, "grants": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/TeamGrant" } } }, "pendingRequests": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/TeamModerationRequest" } } } } } } }, "AdminTeamMember": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "memberKey": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "0x40012ab3" } } }, "displayName": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "userId": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Resolved by the module; null means unlinked." } } }, "rankLabel": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "isLeader": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "The resolved answer — synced value with any override applied." } } }, "isLeaderSynced": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "What the game actually said, so an override reads as a decision rather than as fact." } } }, "leaderOverride": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "properties": { "type": "object", "properties": { "effect": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "grant", "deny" ], "items": { "type": "string" } } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "by": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } }, "online": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "active", "departed" ], "items": { "type": "string" } } } }, "firstSeenAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "lastSeenAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "departedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "AdminTeamList": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "allOf": { "type": "array", "items": { "$ref": "#/components/schemas/TeamSyncFreshness" } }, "properties": { "type": "object", "properties": { "teams": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/AdminTeam" } } }, "syncState": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "The module's sync row verbatim, including the last error — what an operator debugging a stale projection needs." }, "properties": { "type": "object", "properties": { "moduleId": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "lastAttemptAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "lastSuccessAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "consecutiveFailures": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "lastError": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "pendingEmptySince": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } } } } } } } } } }, "TeamGrant": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One row of the append-only forum grant/revoke ledger. The username snapshots keep the record readable after an account is deleted — the ids go SET NULL, the audit trail does not." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "team_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "user_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "granted_by": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "granted_username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "granted_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "revoked_by": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "revoked_username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "revoked_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "revoke_reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "TeamGrantLedger": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "grants": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/TeamGrant" } } } } } } }, "TeamModerationRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "team_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "team_name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "team_slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "action": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "unhide", "display_name_override", "clear_display_name_override" ], "items": { "type": "string" } } } }, "payload": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "nullable": { "type": "boolean", "example": true }, "additionalProperties": { "type": "boolean", "example": true } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "requested_by": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "requested_username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "requested_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } }, "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "pending", "approved", "rejected", "withdrawn" ], "items": { "type": "string" } } } }, "decided_by": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "decided_username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "decided_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "decision_note": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "TeamRequestQueue": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "requests": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/TeamModerationRequest" } } } } } } }, "TeamReviewQueue": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "Teams auto-hidden by reserved-name screening and not yet ruled on by a human." }, "properties": { "type": "object", "properties": { "teams": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Admin" } } }, "slug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "hidden_term": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "admin" } } }, "display_name_override": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "member_count": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "created_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } } } } } } } }, "TeamIntegrationRow": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One bridge destination. `team_id` is null on the deployment-wide default row, which every Team without its own row inherits." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "platform": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "discord" } } }, "team_id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "team_name": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "events": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "team.announcement" } } } } }, "channel_ref": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "1024839201048392010" } } }, "enabled": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "members_ack": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "The operator has confirmed the destination channel is restricted to this Team’s members. Required before a members-only event may be enabled; cleared when the channel changes." } } }, "members_ack_by": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "members_ack_username": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "members_ack_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "updated_at": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" } } } } } } }, "TeamIntegrationConfig": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "platform": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "discord" } } }, "events": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "description": { "type": "string", "example": "Every event that may be bridged, and whether it carries members-only content." }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "team.forum.post" } } }, "membersOnly": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } } } } } } } }, "rows": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/TeamIntegrationRow" } } } } } } }, "TeamVoiceSettings": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The operator's voice controls. Access is granted with a role per Team, so Discord's guild-wide cap of 250 roles — not a per-channel overwrite budget — is the ceiling on how many Teams can have voice." }, "properties": { "type": "object", "properties": { "enabled": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Switching this off suspends the reconciler in both directions and leaves existing channels standing. A checkbox does not delete structure in somebody's guild; remove channels individually instead." } } }, "minMembers": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 5 }, "description": { "type": "string", "example": "Every active member counts, whatever they have linked." } } }, "graceDays": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 7 }, "description": { "type": "string", "example": "How long a Team keeps its channel after it stops qualifying. A Team that recovers inside the window keeps the same channel id." } } }, "categoryRef": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "The parent category, created by the bot on the first pass that needs one and stored here." } } }, "staffRoles": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "description": { "type": "string", "example": "Roles allowed into every Team channel. Guild administrators already bypass overwrites, so this is for staff who are not administrators." } } }, "roleCap": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 250 } } } } } } }, "TeamVoicePreflight": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The bot's own answer about whether it can do the job. Asked before voice may be switched on and again at the top of every pass — the operator invites the bot by hand, so nothing else in the system knows what permissions it was granted." }, "properties": { "type": "object", "properties": { "ready": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "connected": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "missingPermissions": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Manage Roles" } } } } }, "roleCount": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "description": { "type": "string", "example": "Roles in the guild, all of them — the cap is shared with every role the operator created themselves." } } }, "roleCap": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 250 } } }, "botRolePosition": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "description": { "type": "string", "example": "The bot can only grant roles below its own. A bot at the bottom of the list creates roles it cannot hand to anybody." } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "TeamVoiceRow": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One Team's provisioned channel and role, as core last believed them." }, "properties": { "type": "object", "properties": { "teamId": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "teamName": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "teamSlug": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "memberCount": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "linkedCount": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "channelRef": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "roleRef": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "state": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "none", "active", "pending_removal", "error" ], "items": { "type": "string" } } } }, "removeAfter": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } }, "lastError": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "syncedAt": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "format": { "type": "string", "example": "date-time" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "TeamVoiceConfig": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "platform": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "discord" } } }, "settings": { "$ref": "#/components/schemas/TeamVoiceSettings" }, "preflight": { "$ref": "#/components/schemas/TeamVoicePreflight" }, "rows": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/TeamVoiceRow" } } }, "lastPass": { "$ref": "#/components/schemas/TeamVoicePassResult" } } } } }, "TeamVoicePassResult": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "What one reconciliation pass did, or why it did nothing. `ran: false` is the ordinary answer on a deployment with voice off, with a stale Team projection, or with a bot that cannot manage channels and roles — and the three read differently in `reason` because an operator fixes them in three different places." }, "properties": { "type": "object", "properties": { "ran": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "synced": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "created": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "scheduled": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "removed": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "failed": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" } } }, "pendingMemberOps": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "description": { "type": "string", "example": "Role grants a bounded pass could not fit. Non-zero asks for another pass rather than waiting out the interval." } } } } } } }, "TeamModerationResult": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The outcome of a gated action. `pending: true` means a moderator filed a request and nothing changed publicly; an admin's call applies at once and reports false." }, "properties": { "type": "object", "properties": { "ok": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "pending": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "requestId": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "TeamResyncResult": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "A reconciliation outcome. `ok: false` carries the provider's own reason and means nothing was written. `quarantined` means an authoritative-but-empty answer was held back for confirmation rather than applied." }, "properties": { "type": "object", "properties": { "ok": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true } } }, "quarantined": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "nullable": { "type": "boolean", "example": true } } }, "created": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "renamed": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "archived": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } }, "rosters": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Rosters actually applied; a refused one is left untouched and not counted." } } }, "rehidden": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "nullable": { "type": "boolean", "example": true } } } } } } }, "TeamReasonRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 255 }, "example": { "type": "string", "example": "impersonates staff" } } } } } } }, "TeamDisplayNameRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "displayName": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "maxLength": { "type": "number", "example": 160 }, "description": { "type": "string", "example": "Empty or null clears the override." }, "example": { "type": "string", "example": "The Old Guard" } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 255 } } } } } } }, "TeamLeaderOverrideRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "memberKey", "effect" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "memberKey": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 191 }, "example": { "type": "string", "example": "0x40012ab3" } } }, "effect": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "grant", "deny" ], "items": { "type": "string" } } } }, "reason": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 255 } } } } } } }, "TeamDecideRequest": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "required": { "type": "array", "example": [ "status" ], "items": { "type": "string" } }, "properties": { "type": "object", "properties": { "status": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "approved", "rejected" ], "items": { "type": "string" } } } }, "note": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "maxLength": { "type": "number", "example": 255 } } } } } } } } } }