{ "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/audience-preview": { "get": { "tags": [ "Admin · Engagement" ], "summary": "Count how many users an audience or segment reaches right now", "description": "Runs the same resolver the engine runs, and returns a COUNT ONLY — never names or ids, because a module-declared segment resolves over game data and the rule editor must not become a user-enumeration surface. `capped` is true when the count hit the 5000-row audience bound and is therefore a floor rather than a total; an `owner` audience answers 0 with a reason, because it resolves per event from an id the event carries.", "parameters": [ { "name": "audience", "in": "query", "description": "A ceiling name (owner, staff, subscribers, members, authenticated, everyone). Ignored when audienceSegmentId is given.", "required": false, "schema": { "type": "string" } }, { "name": "audienceSegmentId", "in": "query", "description": "A saved segment to resolve instead of a plain audience", "required": false, "schema": { "type": "integer" } }, { "name": "triggerId", "in": "query", "description": "The rule trigger, used to resolve a subscribers audience and to report whether the trigger ceiling permits this reach", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The reach", "content": { "application/json": { "schema": { "type": "object", "properties": { "count": { "type": "integer" }, "capped": { "type": "boolean" }, "ceiling": { "type": "string", "nullable": true }, "dormant": { "type": "boolean" }, "reason": { "type": "string", "nullable": true }, "permitted": { "type": "boolean", "nullable": true } } } } } }, "400": { "description": "Unknown audience name, or a non-integer segment id", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/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/channels": { "get": { "tags": [ "Admin · Engagement" ], "summary": "List every registered delivery channel a rule may send on", "description": "From the delivery-channel registry, so the rule editor offers exactly the set the save path checks against. A channel registered by a module appears here without a client release.", "responses": { "200": { "description": "The registered channels", "content": { "application/json": { "schema": { "type": "object", "properties": { "channels": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "label": { "type": "string" }, "defaultMode": { "type": "string" } } } } } } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/engagement/retention": { "get": { "tags": [ "Admin - Engagement" ], "summary": "Read the engagement retention policy", "description": "The three horizons the nightly sweep uses, in days, with the bounds each is validated against. `engagement_suppressions` is deliberately absent: a suppression is a standing decision and does not expire, because ageing out a hard bounce re-mails an address that already bounced. `warnings` carries the one check that cannot be a static bound - a cooldown horizon shorter than the longest cooldown on an ENABLED rule, which would let that rule send twice.", "responses": { "200": { "description": "The current policy", "content": { "application/json": { "schema": { "type": "object", "properties": { "retention": { "type": "object", "properties": { "sends": { "type": "integer" }, "cooldowns": { "type": "integer" }, "outbox": { "type": "integer" } } }, "limits": { "type": "object", "additionalProperties": true }, "longestCooldownSeconds": { "type": "integer" }, "warnings": { "type": "array", "items": { "type": "string" } } } } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin - Engagement" ], "summary": "Set the engagement retention policy", "description": "Sparse: only the horizons named in the body are written, so saving one select cannot clobber a value another admin changed between load and save. Out of range is a 400 rather than a clamp - storing something other than what was typed would leave the screen describing a policy the deployment is not running. The floors are not UI niceties: below 2 days a pruned cooldown row makes the next fire a FIRST fire (a duplicate send), and the send log is counted by the per-rule hourly ceiling.", "responses": { "200": { "description": "The policy as it now stands", "content": { "application/json": { "schema": { "type": "object", "properties": { "retention": { "type": "object", "additionalProperties": { "type": "integer" } }, "limits": { "type": "object", "additionalProperties": true }, "longestCooldownSeconds": { "type": "integer" }, "warnings": { "type": "array", "items": { "type": "string" } } } } } } }, "400": { "description": "A horizon was not a whole number of days, or was out of range", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "sends": { "type": "integer", "nullable": true }, "cooldowns": { "type": "integer", "nullable": true }, "outbox": { "type": "integer", "nullable": true } } } } } } } }, "/api/v1/admin/engagement/rules": { "get": { "tags": [ "Admin · Engagement" ], "summary": "List every engagement rule, annotated with dormancy", "description": "A rule whose trigger, channel or audience segment is not registered right now is listed with `dormant: true` and the reasons why, never deleted and never auto-disabled — an uninstalled module must not destroy an operator configuration.", "responses": { "200": { "description": "The rules", "content": { "application/json": { "schema": { "type": "object", "properties": { "rules": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Engagement" ], "summary": "Create an engagement rule", "description": "A new rule must name a trigger that is registered right now — there is nothing to preserve and a typo should be caught here. It arrives with `enabled` false unless asked otherwise, and its audience is checked against the trigger declared ceiling: an operator may narrow a rule reach and may never widen it.", "responses": { "201": { "description": "The created rule", "content": { "application/json": { "schema": { "type": "object", "properties": { "rule": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Validation failed; `errors` lists every problem", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "triggerId": { "type": "string" }, "name": { "type": "string" }, "enabled": { "type": "boolean" }, "audience": { "type": "string" }, "audienceSegmentId": { "type": "integer", "nullable": true }, "channels": { "type": "array", "items": { "type": "string" } }, "templateKeys": { "type": "object", "additionalProperties": { "type": "string" } }, "conditions": { "type": "object", "nullable": true, "additionalProperties": true }, "cooldownSeconds": { "type": "integer" }, "delaySeconds": { "type": "integer" }, "cancelOn": { "type": "array", "items": { "type": "string" } }, "maxSendsPerHour": { "type": "integer" } }, "required": [ "triggerId", "name", "channels" ] } } } } } }, "/api/v1/admin/engagement/rules/{id}": { "get": { "tags": [ "Admin · Engagement" ], "summary": "Read one engagement rule", "description": "", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The rule", "content": { "application/json": { "schema": { "type": "object", "properties": { "rule": { "type": "object", "additionalProperties": true } } } } } }, "404": { "description": "No such rule", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin · Engagement" ], "summary": "Update an engagement rule", "description": "The trigger is NOT updatable: a rule cooldowns, its pending outbox rows and its send-log history are all about one trigger, and re-pointing the rule silently re-attributes them. An existing rule may keep naming a trigger nobody currently registers, so that a dormant rule stays editable until its module comes back.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The updated rule", "content": { "application/json": { "schema": { "type": "object", "properties": { "rule": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Validation failed; `errors` lists every problem", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such rule", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "enabled": { "type": "boolean" }, "audience": { "type": "string" }, "audienceSegmentId": { "type": "integer", "nullable": true }, "channels": { "type": "array", "items": { "type": "string" } }, "templateKeys": { "type": "object", "additionalProperties": { "type": "string" } }, "conditions": { "type": "object", "nullable": true, "additionalProperties": true }, "cooldownSeconds": { "type": "integer" }, "delaySeconds": { "type": "integer" }, "cancelOn": { "type": "array", "items": { "type": "string" } }, "maxSendsPerHour": { "type": "integer" } } } } } } }, "delete": { "tags": [ "Admin · Engagement" ], "summary": "Delete an engagement rule", "description": "Its cooldown rows and any still-pending outbox rows go with it, and neither means anything without the rule. The send log does NOT — `engagement_sends.rule_id` carries no foreign key — so the record of what was actually mailed outlives the rule.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Deleted" }, "404": { "description": "No such rule", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/engagement/rules/{id}/enabled": { "patch": { "tags": [ "Admin · Engagement" ], "summary": "Turn one rule on or off", "description": "Writes that column and nothing else, without re-validating the rule. Turning a rule off is the panic button: a rule whose module has been uninstalled, or whose trigger has since narrowed its ceiling under a saved audience, is the rule an operator most urgently wants stopped and the one a re-validating update would refuse to save. Turning one on is safe without re-validation because the engine re-checks the ceiling at send time.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The rule, with its new state", "content": { "application/json": { "schema": { "type": "object", "properties": { "rule": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "enabled was not a boolean", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such rule", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "enabled": { "type": "boolean" } }, "required": [ "enabled" ] } } } } } }, "/api/v1/admin/engagement/segments": { "get": { "tags": [ "Admin · Engagement" ], "summary": "List every saved audience segment, annotated with dormancy", "description": "A segment naming an audience whose module has been uninstalled is dormant: it is listed with the missing ids, it resolves to nobody, and it works again when the module comes back.", "responses": { "200": { "description": "The segments", "content": { "application/json": { "schema": { "type": "object", "properties": { "segments": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Engagement" ], "summary": "Save a new audience segment", "description": "The expression is a boolean tree of module-declared audiences. `not` is legal only as a child of `and`, because a complement needs a universe and the only one that does not widen is the set its siblings produced. The ceiling is DERIVED as the narrowest in the tree and is never taken from the caller; two incomparable ceilings have no meet and the composition is refused rather than guessed.", "responses": { "201": { "description": "The created segment, with its derived ceiling", "content": { "application/json": { "schema": { "type": "object", "properties": { "segment": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Validation failed; `errors` lists every problem", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "expression": { "type": "object", "additionalProperties": true } }, "required": [ "name", "expression" ] } } } } } }, "/api/v1/admin/engagement/segments/{id}": { "put": { "tags": [ "Admin · Engagement" ], "summary": "Update an audience segment", "description": "The ceiling is re-derived from the new expression. A rule already pointing at this segment took the ceiling stored at ITS save time, so narrowing a segment does not retroactively widen anything and the engine re-checks at send time either way.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The updated segment", "content": { "application/json": { "schema": { "type": "object", "properties": { "segment": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Validation failed; `errors` lists every problem", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such segment", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "expression": { "type": "object", "additionalProperties": true } }, "required": [ "name", "expression" ] } } } } }, "delete": { "tags": [ "Admin · Engagement" ], "summary": "Delete an audience segment", "description": "Refused with 409 while any rule still points at it, and the message carries the count. There is no foreign key doing this: CASCADE would delete an operator rules and SET NULL would silently fall each rule back to its plain audience column, which reaches a DIFFERENT set of people.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Deleted" }, "409": { "description": "Rules still use this segment", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/engagement/sends": { "get": { "tags": [ "Admin - Engagement" ], "summary": "The send log, newest first", "description": "G15 answered: every terminal delivery outcome, success and failure alike, with the reason. `address_hash` is stored but never returned - the log keeps it so a bounce can be correlated back to a recipient, and shipping it to a browser would turn a delivery screen into an offline dictionary attack against every address on the deployment.", "parameters": [ { "name": "limit", "in": "query", "description": "Page size, 1-200 (default 50)", "required": false, "schema": { "type": "integer" } }, { "name": "offset", "in": "query", "description": "Rows to skip", "required": false, "schema": { "type": "integer" } }, { "name": "triggerId", "in": "query", "description": "Only sends caused by this trigger", "required": false, "schema": { "type": "string" } }, { "name": "ruleId", "in": "query", "description": "Only sends made by this rule", "required": false, "schema": { "type": "integer" } }, { "name": "userId", "in": "query", "description": "Only sends to this user", "required": false, "schema": { "type": "integer" } }, { "name": "status", "in": "query", "description": "sent, failed, suppressed, bounced or complained", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "One page of the log, with the total matching the same filters", "content": { "application/json": { "schema": { "type": "object", "properties": { "sends": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "total": { "type": "integer" }, "limit": { "type": "integer" }, "offset": { "type": "integer" }, "testSendTrigger": { "type": "string" } } } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/engagement/suppressions": { "get": { "tags": [ "Admin - Engagement" ], "summary": "Addresses this deployment has stopped mailing", "description": "G16. Rows carry `address_masked` (`d***@example.com`) and never `address_hash` - the same rule the send log follows, and for the same reason: a sha256 of every address on the deployment, handed to a browser, is an offline dictionary attack. The mask keeps the domain intact so a whole-domain delivery failure is visible, and destroys the local part so the list cannot be turned back into an address book. `byReason` is deliberately unfiltered - it is the summary strip above the table.", "parameters": [ { "name": "limit", "in": "query", "description": "Page size, 1-200 (default 50)", "required": false, "schema": { "type": "integer" } }, { "name": "offset", "in": "query", "description": "Rows to skip", "required": false, "schema": { "type": "integer" } }, { "name": "reason", "in": "query", "description": "bounce, complaint, manual or unverified", "required": false, "schema": { "type": "string" } }, { "name": "channel", "in": "query", "description": "Only this channel (default: all)", "required": false, "schema": { "type": "string" } }, { "name": "search", "in": "query", "description": "Substring of the masked address - a domain is what this is for", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "One page of the list, with per-reason totals", "content": { "application/json": { "schema": { "type": "object", "properties": { "suppressions": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "total": { "type": "integer" }, "limit": { "type": "integer" }, "offset": { "type": "integer" }, "byReason": { "type": "object", "additionalProperties": { "type": "integer" } }, "reasons": { "type": "array", "items": { "type": "string" } } } } } } }, "400": { "description": "Unknown reason", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin - Engagement" ], "summary": "Suppress an address by hand", "description": "For a bounce or a complaint reported out of band. The reason is forced to `manual` rather than read from the body: an admin typing an address is not evidence of a bounce, and a `reason` column that sometimes means \"the relay said so\" and sometimes means \"somebody chose this word\" cannot diagnose anything. An address already on the list answers 200 with `created: false` rather than 409 - the operator asked for it to be suppressed and it is.", "responses": { "200": { "description": "Already suppressed; nothing changed", "content": { "application/json": { "schema": { "type": "object", "properties": { "created": { "type": "boolean" }, "address": { "type": "string", "nullable": true } } } } } }, "201": { "description": "Suppressed", "content": { "application/json": { "schema": { "type": "object", "properties": { "created": { "type": "boolean" }, "address": { "type": "string", "nullable": true } } } } } }, "400": { "description": "Not a valid address", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "address": { "type": "string" }, "detail": { "type": "string", "nullable": true } }, "required": [ "address" ] } } } } }, "delete": { "tags": [ "Admin - Engagement" ], "summary": "Lift a suppression", "description": "The only way out of the list, and the reason the screen exists: a hard bounce is written by a background worker with no human in the loop, so a mistyped-then-corrected mailbox would otherwise be silenced permanently. The address goes in the BODY, not the path - a path parameter lands in the access log, the browser history and every proxy in front of the deployment, and this one belongs to a real person. The hash cannot be used instead because the screen is never given one.", "responses": { "200": { "description": "Lifted", "content": { "application/json": { "schema": { "type": "object", "properties": { "removed": { "type": "boolean" } } } } } }, "400": { "description": "No address given", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "That address is not suppressed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "address": { "type": "string" }, "channel": { "type": "string", "nullable": true } }, "required": [ "address" ] } } } } } }, "/api/v1/admin/engagement/suppressions/by-hash/{hash}": { "delete": { "tags": [ "Admin - Engagement" ], "summary": "Lift a suppression by its row handle", "description": "The per-row Lift button (Phase 14). Same effect as the route above, different input: the screen shows a mask, so the operator does not know the address and can only act on the row handle the list gives them. The handle IS safe in the path where an address is not - it is a sha256 already served only to an admin session, so an access log or proxy that captures it learns nothing new. 404 rather than 200 when nothing matched, so a stale screen (two admins, one list) says so instead of claiming success.", "parameters": [ { "name": "hash", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The address_hash the list returns for that row, 64 hex characters" }, { "name": "channel", "in": "query", "description": "Defaults to email", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Lifted", "content": { "application/json": { "schema": { "type": "object", "properties": { "removed": { "type": "boolean" } } } } } }, "400": { "description": "Not a suppression handle", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "That address is not suppressed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/engagement/templates": { "get": { "tags": [ "Admin - Engagement" ], "summary": "List every message template, annotated", "description": "Each row carries three flags the list renders as warnings. `dormant`: the template is pinned to a trigger no installed module declares, so its variable palette cannot be checked. `triggerBehind`: the module is installed but has moved its declaration on past the version this template was authored against. `seedBehind`: a newer shipped default exists for the seed this row came from, and was NOT applied because a person had edited it.", "responses": { "200": { "description": "The templates", "content": { "application/json": { "schema": { "type": "object", "properties": { "templates": { "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/templates/{id}": { "get": { "tags": [ "Admin - Engagement" ], "summary": "One template, with the variables it may reference", "description": "The `variables` array is the editor palette and comes from the trigger declaration (or, for a template tied to no trigger, from the shipped seed) merged with the ambient variables every template may use. It is served with the row so the editor never guesses what is legal.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The template", "content": { "application/json": { "schema": { "type": "object", "properties": { "template": { "type": "object", "additionalProperties": true } } } } } }, "404": { "description": "No such template", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin - Engagement" ], "summary": "Edit a template, including a shipped default", "description": "A seeded template is edited IN PLACE; the save sets `customized = 1`, which is what stops a later seed bump from taking the edit back. `key` and `channel` cannot be changed and a request that tries is refused rather than ignored - mailer renders by key, so a rename would break the message it names with no error anywhere. Two refusals are the point of this route: a token naming a variable the trigger does not declare is refused WITH THE VARIABLE NAMED, and a template published with no plain-text part is refused, because the text part is checked by rendering rather than by inspecting the blocks.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The updated template", "content": { "application/json": { "schema": { "type": "object", "properties": { "template": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Validation failed; `errors` lists every problem", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such template", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "subject": { "type": "string", "nullable": true }, "blocks": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "textBody": { "type": "string", "nullable": true }, "status": { "type": "string", "enum": [ "draft", "published" ] }, "triggerId": { "type": "string", "nullable": true } } } } } } }, "delete": { "tags": [ "Admin - Engagement" ], "summary": "Delete a template", "description": "Refused with 409 for a protected template - the system breaks without a password-reset body, so those are editable and not deletable - and refused with 409 while any rule points at the key, naming the rules. The second is the answer a segment in use already gets, for the same reason: the alternative is a rule that silently stops producing mail.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "204": { "description": "Deleted" }, "409": { "description": "Protected, or still used by a rule", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/engagement/templates/{id}/duplicate": { "post": { "tags": [ "Admin - Engagement" ], "summary": "Copy a template under a new key", "description": "The only way a template that is not a shipped seed comes into being, so every template on a deployment descends from one that renders. The copy always starts as a DRAFT whatever the original was, is never protected, and inherits the source seed reference - which is what keeps its variable palette, not bookkeeping.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "201": { "description": "The new template", "content": { "application/json": { "schema": { "type": "object", "properties": { "template": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "The key is not a legal template key", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "That key is already taken", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "key": { "type": "string" }, "name": { "type": "string" }, "triggerId": { "type": "string", "nullable": true } }, "required": [ "key" ] } } } } } }, "/api/v1/admin/engagement/templates/{id}/preview": { "post": { "tags": [ "Admin - Engagement" ], "summary": "Render the draft on screen, without saving it", "description": "Renders the body in the REQUEST, using the example value each variable declares, so no live game event is needed - which is why `example` is a required part of a trigger declaration rather than documentation. The HTML comes back as a JSON string and the client must render it inside a sandboxed iframe with no allow-scripts: operator-authored HTML served as a document from this origin would run under the site CSP with access to its cookies.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Both parts, plus the variable palette and any variable with no value", "content": { "application/json": { "schema": { "type": "object", "properties": { "subject": { "type": "string" }, "html": { "type": "string" }, "text": { "type": "string" }, "missing": { "type": "array", "items": { "type": "string" } }, "variables": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "400": { "description": "The draft is not renderable; `errors` says why", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "subject": { "type": "string", "nullable": true }, "blocks": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "textBody": { "type": "string", "nullable": true }, "triggerId": { "type": "string", "nullable": true } } } } } } } }, "/api/v1/admin/engagement/templates/{id}/test-send": { "post": { "tags": [ "Admin - Engagement" ], "summary": "Send the draft on screen to one address", "description": "Sends what is on screen, saved or not, through the configured transport, and records the attempt in the send log under a synthetic `core.admin.test-send` trigger - including when it fails, which is the outcome an operator most needs a record of. It deliberately does not consult channel preferences or the suppression list: the address is typed by an admin about their own deployment and is not derived from a user.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Sent", "content": { "application/json": { "schema": { "type": "object", "properties": { "sent": { "type": "boolean" }, "to": { "type": "string" } } } } } }, "400": { "description": "No address, or the draft is not renderable", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Email is not configured on this deployment", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "The transport refused the message; the message is the relay reason", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "to": { "type": "string" }, "subject": { "type": "string", "nullable": true }, "blocks": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "textBody": { "type": "string", "nullable": true }, "triggerId": { "type": "string", "nullable": true } }, "required": [ "to" ] } } } } } }, "/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, the variable types and the condition operators", "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" } }, "operators": { "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/events": { "get": { "tags": [ "Admin · Events" ], "summary": "List every event definition with its state and current version", "description": "", "parameters": [ { "name": "state", "in": "query", "description": "Only definitions in this state: draft, ready or archived", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The definitions", "content": { "application/json": { "schema": { "type": "object", "properties": { "events": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "403": { "description": "Not staff", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Events" ], "summary": "Create a draft event definition", "description": "Creates a draft. The slug is derived from the title once and frozen afterwards, because the public event page lives at it. The spec defaults to one empty phase; steps are validated against the action catalog, and an unknown action id is refused.", "responses": { "201": { "description": "The created draft", "content": { "application/json": { "schema": { "type": "object", "properties": { "event": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Validation failed; every problem is listed", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } }, "403": { "description": "Not an admin or editor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "title": { "type": "string" }, "summary": { "type": "string", "nullable": true }, "body": { "type": "string", "nullable": true }, "imageUrl": { "type": "string", "nullable": true }, "seriesId": { "type": "integer", "nullable": true }, "seriesOrder": { "type": "integer" }, "concurrencyKey": { "type": "string", "nullable": true }, "graceSeconds": { "type": "integer" }, "timezone": { "type": "string" }, "spec": { "type": "object", "additionalProperties": true } }, "required": [ "title" ] } } } } } }, "/api/v1/admin/events/calendar": { "get": { "tags": [ "Admin · Events" ], "summary": "The calendar for a window: materialised runs and projected occurrences", "description": "Staff, like every other read here. Each entry is one of two kinds and the difference matters: a run entry is a real row with a status, a pinned version and a console, and somebody can cancel it; a projected entry is arithmetic - no row, nothing committed, nothing to cancel. Runs exist inside the runner materialisation horizon (14 days by default, horizonDays in the response); beyond it the same recurrence arithmetic forecasts what will be materialised, so a monthly event is still visible three weeks out. A projection is never emitted for an instant a run already occupies, which is also why a cancelled occurrence does not reappear as a forecast. Instants are UTC and each entry carries the event own IANA zone: the event owns the time, the reader owns the calendar. Filtering by status or by a named scope suppresses projections, because a forecast has no status and automatic expansion happens at the empty scope.", "parameters": [ { "name": "from", "in": "query", "description": "Window start, a UTC instant", "required": true, "schema": { "type": "string" } }, { "name": "to", "in": "query", "description": "Window end, a UTC instant. At most 92 days after from", "required": true, "schema": { "type": "string" } }, { "name": "status", "in": "query", "description": "Only runs in this status; suppresses projections", "required": false, "schema": { "type": "string" } }, { "name": "scope", "in": "query", "description": "Only runs at this scope; suppresses projections", "required": false, "schema": { "type": "string" } }, { "name": "seriesId", "in": "query", "description": "Only events belonging to this series", "required": false, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "The window", "content": { "application/json": { "schema": { "type": "object", "properties": { "window": { "type": "object", "additionalProperties": true }, "horizon": { "type": "string" }, "horizonDays": { "type": "integer" }, "truncated": { "type": "boolean" }, "entries": { "type": "array", "items": { "type": "object", "properties": { "kind": { "type": "string" }, "runId": { "type": "integer", "nullable": true }, "definitionId": { "type": "integer" }, "title": { "type": "string" }, "slug": { "type": "string" }, "seriesName": { "type": "string", "nullable": true }, "scheduledFor": { "type": "string" }, "timezone": { "type": "string" }, "scope": { "type": "string" }, "status": { "type": "string", "nullable": true }, "health": { "type": "string", "nullable": true }, "adjusted": { "type": "string", "nullable": true } } } } } } } } }, "400": { "description": "The window is missing, inverted or wider than 92 days", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } }, "403": { "description": "Not staff", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/catalog": { "get": { "tags": [ "Admin · Events" ], "summary": "List every registered event action, with its param schema, risk class and reversibility", "description": "Served from the module registries, not from a table: an action is declared in code by core or by an installed module, so this is whatever registered on this boot, and an uninstalled module simply stops appearing. Core always declares core.announce, core.wait and core.cue. Also carries the closed vocabularies the authoring form renders — risk classes, reversibility classes, param types, failure dispositions and the spec size limits — so the editor offers exactly the set the save path checks against.", "responses": { "200": { "description": "The registered actions and the vocabularies over them", "content": { "application/json": { "schema": { "type": "object", "properties": { "actions": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "risks": { "type": "array", "items": { "type": "string" } }, "reversible": { "type": "array", "items": { "type": "string" } }, "paramTypes": { "type": "array", "items": { "type": "string" } }, "onFailure": { "type": "array", "items": { "type": "string" } }, "onFailureByRisk": { "type": "object", "additionalProperties": true }, "scheduleKinds": { "type": "array", "items": { "type": "string" } }, "limits": { "type": "object", "additionalProperties": true } } } } } }, "403": { "description": "Not staff", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/runs": { "get": { "tags": [ "Admin · Events" ], "summary": "List event runs across every definition, newest occurrence first", "description": "A run is one occurrence of one definition in one scope. Until the runner ships, every row here sits at `scheduled` — that is correct for this phase rather than a stalled run.", "parameters": [ { "name": "definitionId", "in": "query", "description": "Only runs of this definition", "required": false, "schema": { "type": "integer" } }, { "name": "status", "in": "query", "description": "Only runs in this status", "required": false, "schema": { "type": "string" } }, { "name": "limit", "in": "query", "description": "How many rows, 1..500 (default 100)", "required": false, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "The runs", "content": { "application/json": { "schema": { "type": "object", "properties": { "runs": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "403": { "description": "Not staff", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/runs/{runId}": { "get": { "tags": [ "Admin · Events" ], "summary": "One run: its status, health, cleanup state and every step with its params and idempotency key", "description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat.", "parameters": [ { "name": "runId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The run, its steps and the status counts", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "type": "object", "additionalProperties": true }, "steps": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "counts": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such run", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/runs/{runId}/cancel": { "post": { "tags": [ "Admin · Events" ], "summary": "Cancel a run", "description": "Legal from every non-terminal status, `scheduled` included. Pending steps and any parked cue are cancelled with it; a step with a live lease is left alone, because nothing can recall a command already sent and a second writer on that row would race the process dispatching it. `cleanup` is not a parameter yet — the resource ledger it would work over arrives in Phase 8, and a flag that changes nothing is worse than one that is not there.", "parameters": [ { "name": "runId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The cancelled run and how many steps were closed out with it", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "type": "object", "additionalProperties": true }, "cancelledSteps": { "type": "integer" } } } } } }, "400": { "description": "Bad Request" }, "403": { "description": "Not an admin or moderator", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "The run has already reached a terminal status", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { "reason": { "type": "string", "description": "Why. Recorded on the run and in its log, with the actor." } } } } } } } }, "/api/v1/admin/events/runs/{runId}/log": { "get": { "tags": [ "Admin · Events" ], "summary": "The diagnostic log for one run", "description": "Structured and queryable, unlike activity_log.detail: this is what answers \"why did not phase 3 start?\" without reading server logs. The audit of who published what is written separately to the activity log.", "parameters": [ { "name": "runId", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "limit", "in": "query", "description": "How many lines, 1..2000 (default 500)", "required": false, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "The log, newest first, and the closed set of line kinds", "content": { "application/json": { "schema": { "type": "object", "properties": { "log": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, "kinds": { "type": "array", "items": { "type": "string" } } } } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such run", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/runs/{runId}/pause": { "post": { "tags": [ "Admin · Events" ], "summary": "Pause a run in flight", "description": "A paused run is excluded from the runner\\'s sweep and nothing advances it until resume. Legal from `starting` and `running` only — a `scheduled` occurrence that should not happen is cancelled, not paused, because resuming one after its grace window had passed would produce a `missed` from a button labelled resume. Takes effect at once even mid-tick: the runner re-reads the run\\'s status between steps.", "parameters": [ { "name": "runId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The paused run", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Bad Request" }, "403": { "description": "Not an admin or moderator", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "The run is not in flight", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { "reason": { "type": "string", "description": "Recorded in the run log with the actor" } } } } } } } }, "/api/v1/admin/events/runs/{runId}/resume": { "post": { "tags": [ "Admin · Events" ], "summary": "Resume a paused run", "description": "Where the run goes back to is derived rather than remembered: a paused run with a `current_phase` was running, one without never got past `starting`. `last_error` is cleared — the operator has just dealt with it — and `health` is not, because \"this run has already had trouble\" stays true whoever pressed resume.", "parameters": [ { "name": "runId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The resumed run", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Bad Request" }, "403": { "description": "Not an admin or moderator", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "The run is not paused", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/runs/{runId}/steps/{stepId}/confirm": { "post": { "tags": [ "Admin · Events" ], "summary": "Confirm a parked step — the GM cue", "description": "The other half of `core.cue`. The action posts an instruction and parks the step `running` with a NULL lease — genuinely in flight, nothing holding it, so no sweep takes it back and a cue posted on Friday is still waiting on Monday. This ends it, as `done` rather than `skipped`: a person saying they did the thing is the step having succeeded. The optional note is what they did, and it is kept on the step and in the log.", "parameters": [ { "name": "runId", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "stepId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The confirmed step", "content": { "application/json": { "schema": { "type": "object", "properties": { "step": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Bad Request" }, "403": { "description": "Not an admin or moderator", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such run, or no such step on it", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "The step is not waiting on anyone", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { "note": { "type": "string", "description": "What was actually done in-client" } } } } } } } }, "/api/v1/admin/events/runs/{runId}/steps/{stepId}/retry": { "post": { "tags": [ "Admin · Events" ], "summary": "Re-queue the failed step a paused run is stopped at, and resume it", "description": "One action rather than two, because there is no state in which you would want half of it: retry is legal only while the run is paused, and a paused run is paused AT this step. The step must be the one its phase is stopped at — a failed step under an `on_failure` of `skip` is one the run has already moved past, and re-queueing that would put a pending row behind the runner\\'s cursor. `attempts` returns to zero: the attempt ceiling bounds what the runner does unattended, and a named person deciding is the thing it is unattended from.", "parameters": [ { "name": "runId", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "stepId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The re-queued step and the run, with whether the resume took", "content": { "application/json": { "schema": { "type": "object", "properties": { "step": { "type": "object", "additionalProperties": true }, "run": { "type": "object", "additionalProperties": true }, "resumed": { "type": "boolean" } } } } } }, "400": { "description": "Bad Request" }, "403": { "description": "Not an admin or moderator", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such run, or no such step on it", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "The run is not paused, or the run is not stopped at this step", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/runs/{runId}/steps/{stepId}/skip": { "post": { "tags": [ "Admin · Events" ], "summary": "Skip a step nobody is going to run", "description": "A step that has not started, or a parked cue. This is what the `skipped` status was reserved for, and why all three `on_failure` dispositions write `failed` instead — a status meaning both \"a human decided against this\" and \"this was attempted three times and never worked\" would make the console summary unreadable. A step with a live lease cannot be skipped; a failed one does not need to be, because resuming the run already carries the phase past it.", "parameters": [ { "name": "runId", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "stepId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The skipped step", "content": { "application/json": { "schema": { "type": "object", "properties": { "step": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Bad Request" }, "403": { "description": "Not an admin or moderator", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such run, or no such step on it", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "The step or its run is in a status that cannot be skipped", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { "reason": { "type": "string" } } } } } } } }, "/api/v1/admin/events/series": { "get": { "tags": [ "Admin · Events" ], "summary": "List the event series a definition may belong to", "description": "A series is the arc several definitions form together - Royal Spy Mission then Risky Partner then Message From the Void - which is continuity the tooling this feature replaces has no field for at all. definitionCount is how many definitions currently belong to each.", "responses": { "200": { "description": "The series", "content": { "application/json": { "schema": { "type": "object", "properties": { "series": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "slug": { "type": "string" }, "description": { "type": "string", "nullable": true }, "ordering": { "type": "integer" } } } } } } } } }, "403": { "description": "Not staff", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "post": { "tags": [ "Admin · Events" ], "summary": "Create an event series", "description": "Admin or editor, not admin alone: naming an arc is authoring, and the narrow gate of section N2 is about committing the deployment to a run (publish, start), which this does not. The slug is derived from the name once and then frozen, because the public arc page lives at it; renaming the series afterwards is free. ordering places this series among the others on the calendar, and is not a position within it - a definition place in its arc is its own seriesOrder.", "responses": { "201": { "description": "The created series", "content": { "application/json": { "schema": { "type": "object", "properties": { "series": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Validation failed", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } }, "403": { "description": "Not an admin or editor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "description": { "type": "string", "nullable": true }, "ordering": { "type": "integer" } }, "required": [ "name" ] } } } } } }, "/api/v1/admin/events/series/{seriesId}": { "put": { "tags": [ "Admin · Events" ], "summary": "Rename or reorder an event series", "description": "The slug is deliberately not editable: it is the address the arc page lives at, and a slug that moved would break every link to it.", "parameters": [ { "name": "seriesId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The updated series", "content": { "application/json": { "schema": { "type": "object", "properties": { "series": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Validation failed", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } }, "403": { "description": "Not an admin or editor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such series", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "name": { "type": "string" }, "description": { "type": "string", "nullable": true }, "ordering": { "type": "integer" } }, "required": [ "name" ] } } } } }, "delete": { "tags": [ "Admin · Events" ], "summary": "Delete an event series, detaching whatever belonged to it", "description": "A hard delete, and the only one in this feature - a definition is archived instead. A series is a label rather than authored content: nothing pins one, no run references one, and event_definitions.series_id is ON DELETE SET NULL, so its definitions survive without an arc and re-attaching is a dropdown. The response says how many were detached.", "parameters": [ { "name": "seriesId", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Deleted; detached is how many definitions lost their series", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "detached": { "type": "integer" } } } } } }, "403": { "description": "Not an admin or editor", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "404": { "description": "No such series", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/{id}": { "get": { "tags": [ "Admin · Events" ], "summary": "One event definition, including its working spec", "description": "The editor reads this. The list route serves a summary; this is the whole authored tree, phases and steps included.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The definition", "content": { "application/json": { "schema": { "type": "object", "properties": { "event": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such definition", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] }, "put": { "tags": [ "Admin · Events" ], "summary": "Edit a definition and its working spec", "description": "Editing is free and never touches a published version: a live run keeps the version it pinned. A step naming an action whose module has since been uninstalled is KEPT and marked dormant rather than refused, so an uninstall is never destructive after the fact — but a dormant step blocks publish. An archived definition cannot be edited.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The saved definition", "content": { "application/json": { "schema": { "type": "object", "properties": { "event": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Validation failed; every problem is listed", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } }, "409": { "description": "The definition is archived", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "title": { "type": "string" }, "summary": { "type": "string", "nullable": true }, "body": { "type": "string", "nullable": true }, "imageUrl": { "type": "string", "nullable": true }, "seriesId": { "type": "integer", "nullable": true }, "seriesOrder": { "type": "integer" }, "concurrencyKey": { "type": "string", "nullable": true }, "graceSeconds": { "type": "integer" }, "timezone": { "type": "string" }, "spec": { "type": "object", "additionalProperties": true } } } } } } }, "delete": { "tags": [ "Admin · Events" ], "summary": "Archive a definition — never a hard delete", "description": "Archiving keeps the definition history without it ever running again. Refused while a run of it is still in flight: cancel the run first. A hard delete is not offered at all, because a run pins a version and a run that could not be explained afterwards defeats the audit this system exists to provide.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The archived definition", "content": { "application/json": { "schema": { "type": "object", "properties": { "event": { "type": "object", "additionalProperties": true } } } } } }, "400": { "description": "Bad Request" }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "A run of this definition is still in flight", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/{id}/publish": { "post": { "tags": [ "Admin · Events" ], "summary": "Snapshot the working spec into an immutable version and mark the definition ready", "description": "Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it", "content": { "application/json": { "schema": { "type": "object", "properties": { "event": { "type": "object", "additionalProperties": true }, "version": { "type": "integer" }, "versionId": { "type": "integer" }, "repinned": { "type": "integer" } } } } } }, "400": { "description": "The spec is invalid, or no phase has any steps", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "A step names an action no module registers, or the definition is archived", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/api/v1/admin/events/{id}/runs": { "post": { "tags": [ "Admin · Events" ], "summary": "Create an occurrence of a published definition", "description": "Admin only, on the same reasoning as publish: starting commits the deployment to a run. Materialised with INSERT IGNORE against UNIQUE (definition_id, scope, scheduled_for), so asking twice for one occurrence answers with the existing row and `created: false` rather than creating a second. Until the runner ships the row stays `scheduled` and nothing dispatches — its steps and their idempotency keys are inspectable in the meantime.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The occurrence already existed and is returned unchanged", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "type": "object", "additionalProperties": true }, "created": { "type": "boolean" } } } } } }, "201": { "description": "The occurrence was created", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "type": "object", "additionalProperties": true }, "created": { "type": "boolean" } } } } } }, "400": { "description": "Bad Request" }, "403": { "description": "Not an admin", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "The definition is not ready, or has no published version", "content": { "application/json": { "schema": { "type": "object", "properties": { "errors": { "type": "array", "items": { "type": "string" } } } } } } } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { "scope": { "type": "string", "description": "Module-opaque. Core stores it verbatim and never parses it." }, "scheduledFor": { "type": "string", "description": "UTC instant; defaults to now" }, "rehearsal": { "type": "boolean" }, "params": { "type": "object", "additionalProperties": true, "description": "Rendered into the definition concurrency_key template" } } } } } } } }, "/api/v1/admin/events/{id}/versions": { "get": { "tags": [ "Admin · Events" ], "summary": "The version history of one definition", "description": "Versions are immutable and nothing edits one. The row flagged `current` is what a new run pins.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The versions, newest first", "content": { "application/json": { "schema": { "type": "object", "properties": { "versions": { "type": "array", "items": { "type": "object", "additionalProperties": true } } } } } } }, "400": { "description": "Bad Request" }, "404": { "description": "No such definition", "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": { "get": { "tags": [ "Auth · Me" ], "summary": "One page of the caller’s notification inbox", "description": "The in-app channel’s items for the signed-in user, newest first. Paged with a keyset cursor (`before`), not an offset, because the list gains rows at the top while it is being read. `unread` counts the whole inbox, not the page. There is no way to name another user: the caller is the only account these routes can read.", "parameters": [ { "name": "limit", "in": "query", "required": false, "schema": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "minimum": { "type": "number", "example": 1 }, "maximum": { "type": "number", "example": 100 }, "default": { "type": "number", "example": 30 } } }, "description": "Page size (capped at 100)." }, { "name": "before", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Return items with an id lower than this — the cursor from the previous page." }, { "name": "unread", "in": "query", "required": false, "schema": { "type": "boolean" }, "description": "Only items that have not been read." } ], "responses": { "200": { "description": "A page of the inbox", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationInbox" } } } }, "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/notifications/channels": { "get": { "tags": [ "Auth · Me" ], "summary": "Get the current user’s per-channel notification preferences", "description": "The delivery channels (email, push, in-app) with their defaults, plus one item per subscribable id — every push stream and every event trigger, one namespace — carrying the effective mode on each channel that applies to it. A trigger-only id has no push toggle. Modes not stored are reported as the channel’s default, so a client never has to know which it is looking at.", "responses": { "200": { "description": "Per-channel preferences", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationChannelPrefs" } } } }, "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": "Update the current user’s per-channel notification preferences", "description": "A SPARSE update: only the (id, channel) pairs in `prefs` are written and every other pair is left untouched, so setting `email` does not disturb `push`. Entries naming an unknown id, a channel that does not apply to that id, or a mode that channel does not accept are ignored. A `push` entry is mirrored into /notifications/subscriptions. The full stored state is echoed back.", "responses": { "200": { "description": "Updated preferences", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationChannelPrefs" } } } }, "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/NotificationChannelPrefsUpdate" } } } } } }, "/api/v1/auth/me/notifications/read-all": { "post": { "tags": [ "Auth · Me" ], "summary": "Mark the caller’s whole inbox read", "description": "", "responses": { "200": { "description": "Marked read", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationReadResult" } } } }, "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/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/notifications/unread-count": { "get": { "tags": [ "Auth · Me" ], "summary": "How many inbox items the caller has not read", "description": "The badge. Its own route because it is polled — asking “is there anything new” should not make the server assemble a page of bodies to answer with one integer.", "responses": { "200": { "description": "The unread count", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationUnreadCount" } } } }, "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/{id}/read": { "post": { "tags": [ "Auth · Me" ], "summary": "Mark one inbox item read", "description": "Idempotent: a second call does not move the timestamp. 404 both when no such item exists and when it belongs to another account — the same answer on purpose, so this cannot be used to ask whether an id is anybody’s.", "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" }, "description": "Notification id (must belong to the caller)." } ], "responses": { "200": { "description": "Marked read", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationReadResult" } } } }, "400": { "description": "Bad Request" }, "401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "403": { "description": "Forbidden" }, "404": { "description": "No such item for this user", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Internal Server Error" } }, "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ] } }, "/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/engagement/unsubscribe/{token}": { "post": { "tags": [ "Public · Engagement" ], "summary": "Unsubscribe from one channel for one scope", "description": "Honours the tokened link in an engagement email, including RFC 8058 one-click. The token names a delivery channel and a scope; the write turns that channel off for that scope and nothing else. Always answers 200 — a response that distinguished a valid token from a forged one would be an oracle for which (user, scope) pairs exist. Tokens signed before this route existed are still honoured, at this path and at the older /public/teams one.", "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 · Engagement" ], "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 unsubscribe people who asked for nothing.", "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/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 (legacy path)", "description": "The pre-Phase-6 path, kept permanently because links in mail already sent point at it. Identical to POST /public/engagement/unsubscribe/{token}. Honours the tokened link including RFC 8058 one-click; a token signed before Phase 6 turns off that Team’s email and no longer mutes its push. 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" } } } } } } } }, "DeliveryChannel": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One delivery channel from the registry (ENGAGEMENT.md §3.1). A channel is what kind of sink this is; a transport is how it delivers." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "email" } } }, "label": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "Email" } } }, "description": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "A message to your verified address." } } }, "carriesContent": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "False for push, which only ever sends a content-free tickle the client then pulls against." }, "example": { "type": "boolean", "example": true } } }, "defaultMode": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "off", "instant", "digest" ], "items": { "type": "string" } }, "description": { "type": "string", "example": "The mode that applies when the user has stored no preference for an id on this channel." }, "example": { "type": "string", "example": "off" } } }, "supportsDigest": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "modes": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "off", "instant", "digest" ], "items": { "type": "string" } } } }, "description": { "type": "string", "example": "The modes this channel will accept. Excludes `digest` unless supportsDigest." }, "example": { "type": "array", "example": [ "off", "instant", "digest" ], "items": { "type": "string" } } } } } } } }, "NotificationChannelPrefItem": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One subscribable id — a push stream, an event trigger, or both — with the effective mode on each channel that applies to it." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "news.post" } } }, "label": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "News posts" } } }, "description": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "New news / Five-on-Friday / newsletter posts." } } }, "personal": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "requiresLinkedAccount": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "ceiling": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "The trigger’s audience ceiling, or null for an id with no trigger declaration." }, "example": { "type": "string", "example": "authenticated" } } }, "channels": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "string" } } }, "description": { "type": "string", "example": "Which channels apply. A trigger-only id has no `push` — nothing is registered to push it." }, "example": { "type": "array", "example": [ "push", "email", "inapp" ], "items": { "type": "string" } } } }, "modes": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "additionalProperties": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "off", "instant", "digest" ], "items": { "type": "string" } } } }, "description": { "type": "string", "example": "Effective mode per applicable channel: the stored value, or the channel’s default where nothing is stored." }, "example": { "type": "object", "properties": { "push": { "type": "string", "example": "instant" }, "email": { "type": "string", "example": "off" }, "inapp": { "type": "string", "example": "off" } } } } } } } } }, "NotificationChannelPrefs": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The per-channel preferences surface: the channel registry plus one item per subscribable id. Returned by both GET and PUT." }, "properties": { "type": "object", "properties": { "channels": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/DeliveryChannel" } } }, "items": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/NotificationChannelPrefItem" } } } } } } }, "NotificationChannelPrefsUpdate": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "A SPARSE preference update. Only the (id, channel) pairs listed are written; every other pair is left untouched. `off` is a mode, never an omission." }, "properties": { "type": "object", "properties": { "prefs": { "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": "string" }, "example": { "type": "string", "example": "news.post" } } }, "channel": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "email" } } }, "mode": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "enum": { "type": "array", "example": [ "off", "instant", "digest" ], "items": { "type": "string" } }, "example": { "type": "string", "example": "digest" } } } } } } }, "example": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "example": "news.post" }, "channel": { "type": "string", "example": "email" }, "mode": { "type": "string", "example": "digest" } } } } } } } } } }, "NotificationItem": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One item in the caller’s in-app inbox." }, "properties": { "type": "object", "properties": { "id": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 412 } } }, "triggerId": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "team.forum.post" } } }, "title": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "example": { "type": "string", "example": "The Silver Anvil — new forum post" } } }, "body": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "example": { "type": "string", "example": "Darrow posted in The Silver Anvil." } } }, "url": { "type": "object", "properties": { "type": { "type": "string", "example": "string" }, "nullable": { "type": "boolean", "example": true }, "description": { "type": "string", "example": "Site-relative path only. An absolute or protocol-relative url is never stored." }, "example": { "type": "string", "example": "/guilds/the-silver-anvil/forum/412" } } }, "read": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": false } } }, "readAt": { "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" } } } } } } }, "NotificationInbox": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "One page of the inbox, newest first. `unread` counts the whole inbox, not the page." }, "properties": { "type": "object", "properties": { "items": { "type": "object", "properties": { "type": { "type": "string", "example": "array" }, "items": { "$ref": "#/components/schemas/NotificationItem" } } }, "hasMore": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "description": { "type": "string", "example": "Whether another page exists. Fetch it with `before` set to the last item’s id." }, "example": { "type": "boolean", "example": false } } }, "unread": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 3 } } } } } } }, "NotificationUnreadCount": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "properties": { "type": "object", "properties": { "unread": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 3 } } } } } } }, "NotificationReadResult": { "type": "object", "properties": { "type": { "type": "string", "example": "object" }, "description": { "type": "string", "example": "The result of marking one item, or the whole inbox, read. `unread` is the count after the change, so a client never has to re-poll for the badge." }, "properties": { "type": "object", "properties": { "ok": { "type": "object", "properties": { "type": { "type": "string", "example": "boolean" }, "example": { "type": "boolean", "example": true } } }, "changed": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "description": { "type": "string", "example": "Mark-all only: how many items changed." }, "example": { "type": "number", "example": 3 } } }, "unread": { "type": "object", "properties": { "type": { "type": "string", "example": "integer" }, "example": { "type": "number", "example": 0 } } } } } } }, "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 } } } } } } } } } }