Merge pull request 'docs(website): finalize the API plan — in-place router split, no /api/v2' (#48) from docs/api-plan-final into main

Reviewed-on: #48
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-27 19:41:11 +00:00
5 changed files with 1127 additions and 330 deletions

View File

@@ -648,10 +648,9 @@ Guidelines:
## 6. Screen ↔ endpoint map
> **Path note (M11):** endpoints below are written with their current `/api/v1`-relative paths. Under
> **M11** the app moves onto the version-agnostic **`/api/mobile`** facade (a pure rename — same
> shapes, same auth); these references update to `/api/mobile/**` when that migration executes. See
> [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Phase 0.
> **Path note:** endpoints below are `/api/v1`-relative and stay that way. The website's router
> refactor preserves every URL, and the `/api/mobile` facade that would have renamed them is deferred —
> see [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.
### 6.1 Public content
- **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner).
@@ -883,34 +882,16 @@ push, and Play (M6M8) follow the designed app.
under `/admin/wiki/*`). No backend routes added (they already accept bearer + re-check role);
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
editor, Discord-bot config, uo-link config, OAuth-provider setup.
12. **M11 — Migrate to the version-agnostic `/api/mobile` facade** (post-v1; decided 2026-07-22).
Prerequisite for the website's API v2 work — see
[`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Phase 0. Today the app hardcodes ~70
`api/v1/…` endpoints, the SSE stream path, and the SSO URLs, has **no version negotiation and no
force-update**, and calls a mix of mobile-only (`/auth/mobile/*`) and *shared* web routes
(`/auth/me/*`, `/public/*`, `/player/*`, `/admin/*`) — so it is directly coupled to v1 and would
break the day v1 is retired. The website introduces a stable, version-agnostic `/api/mobile`
facade (a thin BFF that delegates to the current controllers behind pinned wire shapes); this
milestone moves the app onto it **once**, after which the app is insulated from all internal
v1→v2→vN churn.
- **App repoint (this milestone's core):** drop the `api/v1/` and `auth/mobile/` prefixes and
re-point everything to `/api/mobile` — the ~70 Retrofit endpoints in `data/api/*.kt`, the
`STREAM_PATH` in `core/net/ShardStreamClient.kt`, and the SSO start/exchange URLs in
`core/auth/sso/SsoAuthManager.kt`. Pure rename; no behavior, auth-model, or token-shape change
(the app is already bearer). The public shard stream stays **anonymous** (no `Authorization`
header) under its `/api/mobile` path.
- **App-version floor (ships in this milestone):** the app sends an app-version header on every
request, and the website gains a server-side min-supported-version gate that can return a
"please update" response. Its first job is to let the shard **sunset the pre-facade app** so the
website can finally delete `/api/v1`; thereafter it is the in-band mechanism for any breaking
`/api/mobile` change (URL-path versioning is deliberately absent on the facade).
- **Rollout & ordering:** the facade + app must be **released and adopted before the website
begins its v2 auth-merge / domain-split work** (website PR 0a = facade + telemetry; PR 0b =
this app repoint + the version floor). The old, un-updated app keeps working against frozen
`/api/v1` until the version floor ages it out — at which point v1 is deletable.
- **Not in scope:** any new screen, any auth-flow change, any UI work. This is a networking-layer
rename plus the version-floor plumbing. Once landed, the endpoint paths throughout §6 read
`/api/mobile/**`; update this doc's references when the migration executes.
### Deferred (not a milestone)
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now
**deferred with no app work scheduled**. The website's router refactor is being done in place with
every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
to diverge from web, the migration comes back — starting from a one-line alias mount on the server,
not a hand-written delegate layer. Reasoning and revival triggers:
[`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.
---

View File

@@ -1,159 +1,170 @@
# Website API v2 — Plan
# Website API — router domain split + CSP hardening
Status: **planning** · Target repo: `website/` · Docs owner: this file + `BACKEND_DESIGN.md`
v2 is three sequenced pieces of work, landed in order:
> **This file replaces the earlier "API v2" plan** (auth merge → CSP → domain split, with a parallel
> `/api/v2` mount and an `/api/mobile` facade). Three of those four pieces are **not being built**:
> the auth merge and the mobile facade are deferred with their reasoning recorded below, and the
> parallel-version scaffold in [API_V2_SKELETON.md](./API_V2_SKELETON.md) is superseded. The filename
> is kept so existing links resolve. What remains is genuinely useful work:
>
> 1. **CSP hardening** — small, independent, ships on its own cadence.
> 2. **The domain split** — `admin.routes.js` (1552 lines, 110 routes) broken into one router file per
> business capability, **in place, with every URL unchanged**. This is the actual driver.
0. **The mobile facade** (lands *first*, before any v2 work) — a **version-agnostic `/api/mobile`
namespace** is stood up over the current controllers, and the Android app is migrated to it. From
then on the app is pinned to `/api/mobile`, insulated from all internal version churn — so the
auth merge and domain split below never touch it. See Phase 0.
1. **The auth merge** — httpOnly session cookies go away; a bearer JWT (access) + rotating
refresh token becomes the single session model for *every* client (web and mobile).
2. **The domain split** — the monolithic route wiring (esp. `admin.routes.js`, 1552 lines) is
broken into one router file per business capability, so a developer can predict where an
endpoint lives from its URL.
---
## Locked decisions
## Why the auth merge is out
| Decision | Choice | Consequence |
The original plan replaced httpOnly session cookies with a bearer JWT + rotating refresh token for
every client, so web and mobile would share one session model. Reasons that no longer hold up:
1. **The current model is the more secure one.** httpOnly + SameSite cookies are unreadable from JS
and carry CSRF protection by default. Every migration target is a sideways or backwards move:
- Refresh token in `localStorage` → any XSS becomes **persistent full account takeover**, not a
bounded access-token window. A short access TTL does not help; the attacker mints new pairs.
- Refresh token in an httpOnly cookie scoped to the refresh endpoint → safe, but that is
*cookies with extra steps*. It concedes the premise.
2. **"One session model everywhere" is already true where it matters.** `auth/session.service.js`
unifies cookie and bearer into a single session, and `auth/token.js` already extracts from either
`Cookie` or `Authorization: Bearer`. That abstraction is written, working, and paid for. The merge
would move complexity *out* of `extractToken` and *into* the SPA.
3. **The cookie codepath survives the merge anyway.** The SSO / email-connect redirect flow must keep
its short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies — the browser leaves for the
IdP and returns with no JS context. So the merge never actually delivered "cookies are gone."
4. **It carried the plan's most bug-prone work as a dependency.** The admin SSE rewrite
(`EventSource` → hand-rolled `fetch` + `ReadableStream` + SSE frame parser + reconnect/backoff +
refresh-on-401) existed *only* to serve the bearer model. Without the merge, the admin stream stays
on `EventSource` with `withCredentials` and that code is never written.
5. **It is a contract change with no user-visible payoff**, competing for the same review attention as
the domain split, which is the thing that actually hurts today.
### Dropped with it
- v2 bearer auth routes (`/api/v2/auth/{login,refresh,logout,login/totp}`).
- Deletion of the `setAuthCookie` / `clearAuthCookie` path.
- `rg_trust` cookie → `X-Trust-Token` header migration for web (the header stays available for native
clients via `extractTrustToken`, unchanged).
- `api/client.js` bearer + silent-refresh rewrite.
- `lib/useShardFeed.js` admin-stream fetch rewrite. **The admin SSE stream stays as-is.**
### Kept from it
- **CSP hardening** — now its own phase (below). It was justified as a compensating control for a
JS-held token; it is worth doing regardless, just no longer urgent.
- **The public/admin SSE allowlist split** — unchanged security boundary, unrelated to session model.
- **The tx-cookie carve-out reasoning** — recorded here so a future merge attempt doesn't rediscover it.
---
## Deferred: the auth merge
Not cancelled — parked behind trigger conditions. Revisit if **any** of these become true:
| Trigger | Why it changes the answer |
|---|---|
| The API becomes genuinely cross-origin (separate API host) | `SameSite` cookies stop being the easy path; bearer becomes the natural model. |
| Third-party or OAuth clients are introduced | Cookies don't serve clients you don't control. |
| Mobile and web session behavior diverge enough to cause real bugs | The unification argument gets teeth it currently lacks. |
If it is ever revived, two specs the original plan lacked must be written **first**:
- **Refresh-token reuse detection.** Rotation is only useful with it: replay of an already-consumed
refresh must revoke the entire token family, not just fail the one request.
- **Rollback procedure.** Once web clients have discarded their cookies, a bad deploy locks everyone
out. Needs a documented path back.
---
## Why there is no `/api/v2`
The domain split reorganizes router *files*. It does not need to move a single URL — because the URL
surface is **already grouped by capability**. Inventory taken from the live Express stack — 196
`/api/v1` routes, plus three outside it (`GET /api/health`, `GET /api/docs.json`,
`GET /.well-known/assetlinks.json`) and 2 on the internal port. Full machine-readable list:
[`api-route-inventory.json`](./api-route-inventory.json).
| Group | Routes | Second segment → capability |
|---|---|---|
| Mobile decoupling | **A version-agnostic `/api/mobile` facade, landed before v2** | The Android app pins one stable namespace; internal v1→v2→vN churn never reaches it. The app leaves the v2 blast radius entirely. |
| Versioning | **New `/api/v2` mounted in parallel** with a frozen `/api/v1` | Migrate the *web* client route-by-route; delete v1 once no caller remains. No big-bang break. |
| Web session model | **Web adopts mobile's access + refresh** | One session model everywhere. Reuses `session.service` machinery that already exists — nothing new invented. |
| Live-feed (SSE) auth | **admin stream → fetch + `Authorization: Bearer`; public stream stays anonymous** | Admin token stays out of URLs/logs. Public/mobile stream keeps `EventSource`, no auth header. |
| `/admin` | 110 | `shard` 16 · `moderation` 15 · `users` 15 · `wiki` 14 · `posts` 9 · `pages` 7 · `account` 6 · `email` 6 · `uo-link` 5 · `auth` 4 · `invites` 3 · `bot-activity` 2 · `discord-bot` 2 · `settings` 2 · `activity` 1 · `dashboard` 1 · `site-mode` 1 · `uploads` 1 |
| `/auth` | 42 | `me` 23 · `mobile` 5 · `sso` 4 · `password` 3 · `invite` 2 · `login` 2 · `logout` 1 · `providers` 1 · `register` 1 |
| `/public` | 24 | `shard` 12 · `wiki` 4 · `pages` 2 · `posts` 2 · `contact` 1 · `settings` 1 · `status` 1 · `version` 1 |
| `/player` | 20 | `account` 8 · `shard` 8 · `appeals` 4 |
## What already exists (so we don't rebuild it)
Every capability already owns a URL prefix, so each new router file mounts at the prefix it already
owns and the emitted paths are **byte-identical**. No URL change means no contract change, and no
contract change means no reason to mount a parallel version.
- `auth/token.js` already extracts a token from cookie **or** `Authorization: Bearer`, and
`auth/session.service.js` already unifies both into one session.
- Mobile already has the full target model: `mintMobileTokens` / `createMobileSession` /
`refreshMobileSession`, with **hashed, rotated, revocable** refresh tokens stored server-side.
Endpoints live at `/api/v1/auth/mobile/{login,refresh,logout}`.
- **The merge is therefore mostly deletion + rename:** web joins the mobile session model, the
internal `/mobile` auth namespace collapses into unified `/auth/*`, and the cookie code path is
removed. (The *app-facing* `/api/mobile` facade is unaffected — it re-points its internals to the
unified flow; the app sees no change. See Phase 0.)
Consequences of doing it in place:
- No `/api/v2`, no dual mount, no route-by-route migration, no v1-usage telemetry project, and no
v1-retirement sequence.
- `BASE = /api/v1` in `client/src/api/client.js` never changes. The Discord bot's `SITE_PUBLIC_URL`
never changes. The Android app is untouched.
- [API_V2_SKELETON.md](./API_V2_SKELETON.md) (the `router/v2/` scaffold) is **superseded and not
scheduled**. It is kept as the concrete recipe if a real contract break ever forces a versioned API.
**Tripwire:** if any endpoint turns out to *need* a new URL, that is a contract change, not a
refactor. List it explicitly, and reopen the versioning question before writing the code — do not
smuggle a URL change into a "mechanical" PR.
---
## Phase 0 — The mobile facade (`/api/mobile`, lands before v2)
## Deferred: the `/api/mobile` facade and the app-version floor
**Goal:** make the Android app's contract *version-agnostic* so the v2 work below can proceed without
ever breaking an installed app. The app currently hardcodes ~70 `api/v1/…` endpoints (plus the SSE
path and SSO URLs) and has **no version negotiation and no force-update** — which is exactly why v1
can't otherwise be retired on the web client's schedule. Phase 0 pays that migration **once**, up
front, against a pure rename with no behavior change, and never again.
The earlier plan's Phase 0 stood up a version-agnostic `/api/mobile` namespace and migrated the
Android app onto it, plus an app-version header and a server-side min-version floor.
**What it is:** a thin routing facade mounted at `/api/mobile`, next to `/api/v1`, that delegates to
the **same controllers with the same middleware** as the routes it mirrors. It is *routing + optional
response-shaping*, never business logic — a Backend-for-Frontend, not a fork.
**Why it was proposed:** the app hardcodes **69** distinct `api/v1/…` paths (`data/api/*.kt`,
`core/net/ShardStreamClient.kt`, `core/net/HostSelectionInterceptor.kt`,
`core/auth/sso/SsoAuthManager.kt`), has no version negotiation and no force-update, and installs in
the wild cannot be forced forward. Under a parallel-`/api/v2` plan that made the app the load-bearing
coupling: v1 could not be retired until the fleet aged out.
### Server
**Why it is deferred:** with the split done in place, no URL moves and nothing is being deleted — so
there is no fleet to sunset and no coupling to break. A facade would add ~70 permanently maintained
delegate routes plus a contract-test suite to solve a problem that does not currently exist. The
version floor was scoped to sunsetting the pre-facade fleet, so it goes with it.
1. **Mount `/api/mobile` in `api.router.js`** next to `/v1` (today it mounts only `/v1`).
2. **Re-home the app's *entire* surface under it — not just the mobile-auth routes.** The app calls
mostly *shared* routes (`auth/me/*`, `public/*`, `player/*`, `admin/*`) plus the mobile-only auth
routes and the anonymous `public/shard/stream`. All of them move under `/api/mobile/**`. If any
endpoint the app needs is left only under `/api/v1`, the app is not decoupled and the whole point
is lost. Cross-check against the app's endpoint inventory (§ Cross-component blast radius).
3. **Delegate; never bypass auth.** Each facade route requires the *same* middleware chain as its
underlying route (`requireAuth`, `staffOnly`/`adminOnly`, validators, the public/admin SSE
allowlist split). A re-exposed admin route missing `adminOnly` is a privilege-escalation hole —
treat the facade as a security surface, not a convenience alias.
4. **Keep the mobile SSE stream anonymous.** `/api/mobile/…/shard/stream` carries no auth (the app
sends no `Authorization` header); only the public/safe kinds, same allowlist as today.
5. **Pin the wire shapes with contract tests.** `/api/mobile` is now a **committed stable contract**:
a v2 internal refactor that changes a response shape must fail a test here *before* it can ship to
installed apps. This is the facade's ongoing cost and its entire value — enforce it in CI.
**If it is ever revived** (the trigger is the mobile contract genuinely needing to diverge from web —
different response shapes, a mobile-only aggregation endpoint, a real breaking change):
### Android app (`Android-app` repo — separate PR, separate release)
6. **Re-point everything to `/api/mobile`:** the ~70 Retrofit endpoints (`data/api/*.kt`), the SSE
path in `ShardStreamClient.kt`, and the SSO start/exchange URLs in `SsoAuthManager.kt`. Drop the
`api/v1/` and `auth/mobile/` prefixes; the app now knows only `/api/mobile`.
7. **Add the app-version header + a server-side min-version floor** (the mechanism the blast-radius
section calls for). Its first job is to sunset the *pre-facade* app so `/api/v1` can eventually be
deleted; thereafter it is insurance for any genuinely breaking `/api/mobile` change (negotiated
in-band, since URL-path versioning is deliberately gone here).
8. **Ship and let the fleet adopt** before starting v2. The old app keeps working on frozen `/api/v1`
until the version floor ages it out.
### Docs / spec
9. Document `/api/mobile` as its own tagged surface in Swagger; record the facade + the version-floor
mechanism in `docs/android/PLAN.md`, and note the app-contract change in the Android repo's docs.
**Not in scope for Phase 0:** any behavior change, any auth-model change, any v2 route. The facade
maps 1:1 onto today's controllers; the auth merge happens later and reaches the app only as an
internal re-point behind the unchanged `/api/mobile` shapes.
- **Start with the alias mount, not a delegate layer:** `apiRouter.use('/mobile', v1Router)` gives the
app a stable, version-agnostic namespace with identical wiring, identical middleware and zero
per-route maintenance. Build hand-written delegates only for the routes that actually diverge.
- **A facade is a security surface, not a convenience alias.** Any hand-written route must carry the
*same* middleware chain as the route it mirrors (`requireAuth`, `staffOnly`/`adminOnly`, validators,
the public/admin SSE allowlist split). A re-exposed admin route missing `adminOnly` is privilege
escalation.
- **It needs contract tests.** The moment the app pins a namespace, its response shapes are a
committed contract; an internal refactor that changes a shape must fail a test before it ships to
installed apps.
- **The mobile SSE stream stays anonymous** under whatever path it gets — the app sends no
`Authorization` header.
---
## Phase 1 — The auth merge (cookie removal)
## Phase 1 — CSP hardening (independent)
### Server
Previously bundled with the auth merge as a compensating control for a JS-held token. With no token in
JS, this is **defense in depth on its own merits** — cheap, worth doing, blocking nothing. It has no
dependency on the domain split and can ship at any time.
1. **Promote the mobile flow to the mainline v2 auth routes.** Under `/api/v2/auth`:
- `POST /login` → password (+ TOTP) → returns `{ accessToken, refreshToken, user }` (no `Set-Cookie`).
- `POST /refresh` → rotate: validate + revoke presented refresh, mint a new pair.
- `POST /logout` → revoke the current refresh/session server-side.
- `POST /login/totp` → second-factor step, same token shape.
The `/auth/mobile/*` namespace is **not** carried into v2 — it collapses into these.
2. **Delete the session-cookie code path in v2 controllers.** Stop calling `setAuthCookie` /
`clearAuthCookie`. `extractToken` keeps its Bearer branch; the cookie branch is dead for v2
routes (v1 keeps it until v1 is removed).
3. **Separate *session* cookies from *transaction* cookies — the latter stay.** The SSO / email-
connect redirect flow (`sso.controller.js`, `emailConfig.controller.js`) *must* keep its
short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies: the browser leaves for the IdP
and returns with no JS context to carry a bearer across the hop. Only the **final session**
handoff changes — the callback ends by issuing bearer tokens (redirect with a one-time code the
SPA exchanges, so tokens never land in the URL) instead of setting `rg_token`.
4. **Trusted-device token** already supports the `X-Trust-Token` header for native clients
(`extractTrustToken`). Web switches to the same header + client storage; `rg_trust` cookie is
dropped for v2.
5. **SSE auth is per-channel — the public stream stays anonymous.** The **admin** shard stream
(today gated by the session cookie via `isLoggedIn`) moves to `requireAuth` on the Bearer header,
since its browser client becomes fetch-based (below). The **public** shard stream
(`/public/shard/stream`) has **no auth middleware today and must keep none** — it is consumed by
logged-out browser visitors *and* by the Android `ShardStreamClient`, neither of which sends an
`Authorization` header. Adding `requireAuth` to it would black out the public live boards on web
and mobile. Keep the public/admin allowlist split — that security boundary is unchanged.
**Sequencing fix from the original plan:** the old version both "ships with the auth merge" and called
for a one-release report-only soak. Those contradict. Correct order is **report-only first, observe one
release, then enforce** — now trivially satisfiable since nothing waits on it.
### Client
The app already ships a tuned policy (`server/src/app.js`). Two directives are load-bearing and are
**already correct** — the job is to keep them that way:
6. **`api/client.js`:** drop `credentials: 'include'`; attach `Authorization: Bearer <access>`;
on `401`, silent-refresh once via `/auth/refresh`, retry, else bounce to login.
7. **Token storage:** access token in memory (JS var/context); refresh token in `localStorage`.
Short access TTL keeps the XSS window small — the accepted tradeoff for losing httpOnly.
8. **`lib/useShardFeed.js`:** only the **admin** stream needs the rewrite — replace
`EventSource(adminShardStreamUrl, { withCredentials: true })` with a `fetch()` + `ReadableStream`
reader that sends the Bearer header, parses SSE frames, and adds reconnect/backoff +
access-token refresh-on-401. The **public** stream stays on `EventSource` (no credentials, so
nothing to change) and keeps its free auto-reconnect. Do not convert both blindly.
### Docs / spec (required, same PR)
9. Update `BACKEND_DESIGN.md` auth section (cookie → bearer-everywhere; tx-cookie exception).
10. Regenerate Swagger (`npm run swagger`) — v2 auth routes, `Authorization` security scheme,
remove `Set-Cookie` from documented responses.
---
## Phase 1b — CSP hardening (ships with the auth merge)
Once httpOnly is gone the access token lives in JS, so CSP's job becomes: **injected script can't
run, and if it somehow runs it can't phone home.** The app already ships a tuned policy
(`server/src/app.js`); v2 tightens it rather than rewriting it.
Two directives are load-bearing for the token-theft threat and must stay tight:
- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense; guard it.
- `connect-src 'self'` — the exfiltration channel. Do **not** widen it (e.g. to a separate API host)
unless the API genuinely becomes cross-origin; the SPA + REST + fetch-SSE are all same-origin here.
- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense.
- `connect-src 'self'` — the exfiltration channel. Don't widen it unless the API genuinely becomes
cross-origin (which would also reopen the auth-merge question — see the trigger table).
`style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's
pervasive `style={{…}}` attributes can't be nonce'd. It is not a meaningful hole.
pervasive `style={{…}}` attributes can't be nonce'd. Not a meaningful hole. The `/api/docs` route keeps
its deliberately looser policy (swagger-ui injects an inline bootstrap script); that carve-out is
scoped to the one route and stays scoped.
Target enforced policy:
@@ -170,209 +181,189 @@ form-action 'self';
frame-ancestors 'none';
```
Changes vs. the current policy:
Delta vs. the policy in `server/src/app.js` today — the whole change is two directives:
- **Add `form-action 'self'`** — blocks an injected `<form action="https://evil">` from POSTing the
token/credentials off-origin (an exfil path `connect-src` doesn't cover).
- **Tighten `frame-ancestors` `'self'``'none'`** — nothing legitimately frames the site.
- Keep `base-uri 'self'`, `object-src 'none'`, and `img-src … https:` (external `BRAND_*`
logo/hero and `<img>` in sanitized wiki/news bodies rely on `https:`).
- **Add `form-action 'self'`** (currently absent) — blocks an injected `<form action="https://evil">`
from POSTing credentials off-origin, an exfil path `connect-src` doesn't cover.
- **Tighten `frame-ancestors`** `'self'``'none'` — nothing legitimately frames the site.
- Unchanged: `default-src`, `script-src`, `connect-src`, `object-src 'none'`, `base-uri 'self'`, and
`img-src … https:` (external `BRAND_*` logo/hero and `<img>` in sanitized wiki/news bodies rely on
`https:`).
Tracked follow-ups (own PRs, not blocking the merge):
**Rollout:** ship via `Content-Security-Policy-Report-Only` with `report-to` for one release, watch for
violations, then flip to enforce.
- **Self-host the Cinzel font** → drop `fonts.googleapis.com` from `style-src` and
`fonts.gstatic.com` from `font-src`, removing two third-party origins from the trust surface.
- **Trusted Types** — roll out `require-trusted-types-for 'script'` + a `trusted-types` policy in
**report-only** first; neutralizes most DOM-based XSS at the sink (the exact bug class that would
steal a JS-held token). Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first.
- **Report-only rollout** — ship the tightened policy via `Content-Security-Policy-Report-Only` with
`report-to` for one release, watch for violations, then flip to enforce.
Before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill `<script>`
into `dist/index.html`, which that directive blocks (harmless, but throws a violation). Confirm it's
disabled or set `build.modulepreload.polyfill = false` in the Vite config. (The `renderIndexHtml`
branding injection adds only `<meta>`/`<link>` tags — no inline script, no nonce needed.)
Verify before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill
`<script>` into `dist/index.html`, which that directive blocks (harmless but throws a violation).
Confirm it's disabled or set `build.modulepreload.polyfill = false` in the Vite config. (The
`renderIndexHtml` branding injection adds only `<meta>`/`<link>` tags — no inline script, no nonce
needed.)
Tracked follow-ups (own PRs):
- **Self-host the Cinzel font** → drop `fonts.googleapis.com` from `style-src` and `fonts.gstatic.com`
from `font-src`, removing two third-party origins from the trust surface.
- **Trusted Types** — `require-trusted-types-for 'script'` + a `trusted-types` policy, report-only
first. Audit `dangerouslySetInnerHTML` + the `sanitizeHtml` render path first.
---
## Phase 2 — The domain split
**Rule:** one router file = one business capability; the URL names the domain; related endpoints
live together regardless of HTTP method; no generic `admin.router.js` / `api.router.js` catch-alls.
Controllers are **already** domain-split — Phase 2 is mostly re-wiring the routes, not the logic.
**This is the reason the plan exists.** Everything else is supporting work.
Target tree (`router/v2/`):
**Rule:** one router file = one business capability; the URL names the domain; related endpoints live
together regardless of HTTP method; no generic `admin.routes.js` catch-all. Controllers are **already**
domain-split — this re-wires routes, not logic.
**Invariant:** each capability router mounts at the prefix it already owns, so the emitted URL set does
not change. Proved per PR by the route manifest (§ PR 0).
Target tree — derived from the inventory above, **inside `router/v1/`** (no `v2/` directory):
```
router/v2/
router/v1/
admin/
dashboard.router.js users.router.js moderation.router.js
content.router.js wiki.router.js shard.router.js
settings.router.js invites.router.js bot-activity.router.js
index.js # mounts the capability routers below under /admin, keeps the
# shared `noindex, isLoggedIn, staffOnly` gate in one place
users.router.js account.router.js invites.router.js
authProviders.router.js moderation.router.js botActivity.router.js
posts.router.js pages.router.js wiki.router.js
uploads.router.js shard.router.js uoLink.router.js
email.router.js discordBot.router.js settings.router.js
dashboard.router.js # + the /activity and /site-mode singletons
auth/
login.router.js sso.router.js totp.router.js session.router.js
login.router.js register.router.js password.router.js invite.router.js
sso.router.js mobile.router.js me.routes.js (already split, 23 routes)
public/
news.router.js wiki.router.js page.router.js shard.router.js
news.router.js (posts) pages.router.js wiki.router.js shard.router.js
site.router.js # status, settings, version, contact
player/
profile.router.js appeals.router.js shard.router.js
account.router.js shard.router.js appeals.router.js
internal/ (unchanged — stays on the unpublished port, never mounted publicly)
```
Steps:
11. Carve `admin.routes.js` (~100 routes) into the per-capability files above, each requiring its
already-existing controller. A thin `admin/index.js` mounts them under `/admin`.
12. Split `public.routes.js` and `player.routes.js` the same way.
13. `v2.router.js` mounts the domain sub-routers; `api.router.js` mounts `/v1` (frozen) **and** `/v2`.
14. Keep `/internal` off the public listener exactly as v1 does (separate `internalApp.js` port).
15. Update `#swagger.*` annotations for every moved route, regenerate the spec, and update
`PROJECT_TREE.md` + `BACKEND_DESIGN.md` route map.
1. **Land PR 0 (route manifest) first** — the mechanical proof that later PRs move no URL.
2. Carve `admin.routes.js` into the per-capability files above, each requiring its already-existing
controller. `admin/index.js` keeps the shared gate (`noindex, isLoggedIn, staffOnly`) and mounts
each capability router at its existing prefix; the `adminOnly` / `modAccess` gates move with the
routes that use them.
3. Split `public.routes.js`, `player.routes.js`, and the remaining `auth.routes.js` groups the same
way. `auth/me.routes.js` is already a separate file and stays.
4. Keep `/internal` off the public listener exactly as today (separate `internalApp.js` port).
5. Move each route's `#swagger.*` annotations **with** the route, then regenerate
(`cd website/server && npm run swagger`).
6. Update `BACKEND_DESIGN.md` §2 (folder structure) and §4 (API contract — it names
`admin.routes.js → admin.controller.js` and friends) as routers move, plus `PROJECT_TREE.md`.
Because the auth model is untouched and the URLs are frozen, each PR is a **pure mechanical refactor
with a green test suite and a zero-diff route manifest as its acceptance criteria** — which is what
makes grouped PRs actually reviewable.
### PR 0 — the route manifest (prerequisite of the first split PR)
"Every URL is unchanged" must be *proved by a diff*, not asserted in review. PR 0 lands the tool that
proves it, with no router file moved.
- **Generator:** `server/scripts/routeManifest.js`, wired as `npm run routes:manifest`. It requires
`src/app.js` (which exports the app and neither listens nor connects to the DB — `server.js` owns
those), walks `app._router.stack` recursively through mounted routers, reconstructs each full path
from the layer regexps, and writes a **sorted** array of
`{ "method": "GET", "path": "/api/v1/admin/users/:id" }` to `server/routes.manifest.json`.
`internalApp.js` is walked into a separate `internal` section so the unpublished port is inventoried
without being confused for public surface.
- **Scope it to the API surface, or it won't be deterministic.** Three mounts are *filesystem*
conditional: the SPA catch-all `GET *` (only when `client/dist/index.html` exists), the `/brand`
static mount, and `/api/docs*` (only when `swagger-output.json` is present — it is committed, so it
is stable). The manifest keeps only `/api/**`, `/.well-known/**`, and the internal app's routes, so
it does not change depending on whether CI built the client. Static mounts are not API contract.
- **The baseline already exists:** [`api-route-inventory.json`](./api-route-inventory.json) in this
directory is today's frozen surface — **199 API routes** (110 of them `/api/v1/admin`) plus 2
internal. PR 0's generator must **reproduce this file byte-for-byte**; that is PR 0's own acceptance
test, and it means the freeze is already in effect before the first router moves.
- **Runtime introspection, not source parsing.** It is authoritative about mounts, and the route paths
in `admin.routes.js` sit on the line *after* `adminRouter.get(`, which defeats naive greps.
- **Not `swagger-output.json`.** That is annotation-derived (only annotated routes appear) and churns
for unrelated reasons; it documents intent, the manifest records reality.
- **Frozen key: method + path only.** That is exactly the contract being preserved. Handler names are
useless as a guard check here — `requireRole(...)` returns an anonymous arrow, and router-level gates
like `adminRouter.use(noindex, isLoggedIn, staffOnly)` never appear in a route's own stack.
- **Guard coverage, separately.** The generator also emits a non-gated review aid: per route, the
handler count plus any *named* middleware collected along the mount chain. If a stable behavioral
check proves cheap, prefer it — a test that fires an **unauthenticated** request at every manifest
path and snapshots the status code catches a dropped `adminOnly` (403 → 200) in a way names cannot.
Try it in PR 0; if DB-touching public routes make it slow or noisy against the dead-port pool the
tests use, drop it rather than ship a flaky gate.
- **CI:** `.gitea/workflows/pr-checks.yml` runs `npm run routes:manifest` and
`git diff --exit-code server/routes.manifest.json`. A PR that moves a URL fails unless it
deliberately commits the new manifest — which puts the URL change in front of a reviewer instead of
letting it pass silently.
- **Published copy:** `docs/website/api-route-inventory.json` mirrors `server/routes.manifest.json` and
is refreshed in each split PR's mandatory docs edit. The markdown table above is orientation for a
human reader; **the manifest is the authoritative freeze.**
---
## Sequencing & PR breakdown
**Phase 0 lands entirely before the v2 scaffold** — the app must be off `/api/v1`-direct and onto
`/api/mobile` (and the fleet adopting) before v2 behavior work begins.
CSP and the split are independent; the only hard ordering is PR 0 before the first split PR.
1. **PR 0a — mobile facade (server):** mount `/api/mobile` in `api.router.js`; re-home the app's full
endpoint surface as thin delegates to existing controllers (same middleware); add contract tests
pinning the wire shapes; add v1-usage telemetry. No behavior change. See Phase 0.
2. **PR 0bmobile migration (separate `Android-app` repo):** re-point the ~70 Retrofit endpoints,
the `ShardStreamClient` SSE path, and the SSO start/exchange URLs to `/api/mobile`; add the
app-version header + server-side min-version floor. Released and adopted on the app-store cadence
before v2 starts. See `docs/android/PLAN.md`.
3. **PR 1 — v2 scaffold:** `router/v2/` skeleton, `v2.router.js`, mount `/v2` next to `/v1`. Empty
but wired; no behavior change. Detailed in [API_V2_SKELETON.md](./API_V2_SKELETON.md).
4. **PR 2 — auth merge (server):** v2 bearer auth routes + SSO callback code-exchange + admin-SSE-on-
Bearer + docs/swagger. **Re-point `/api/mobile`'s internals to the unified flow behind unchanged
wire shapes** (contract tests must stay green) — the app sees nothing.
5. **PR 3 — auth merge (web client):** `client.js` bearer + silent-refresh; `useShardFeed.js` fetch
stream for the **admin** stream only.
6. **PR 4…N — domain split:** one PR per admin capability (dashboard, users, moderation, content,
wiki, shard, settings, …) to keep diffs reviewable; then public + player. Keep `/api/mobile`
delegating correctly as controllers move; contract tests catch any shape drift.
7. **PR final — retire v1:** only once (a) the web client is fully on v2, **and** (b) telemetry shows
no `/api/v1` traffic from the pre-facade app, i.e. the old fleet has aged past the version floor.
Then delete `router/v1` and the dead cookie code in `token.js`. See § Cross-component blast radius.
1. **PR — CSP report-only.** Tightened policy behind `Content-Security-Policy-Report-Only` + `report-to`.
2. **PR — CSP enforce.** One release later, assuming a clean violation report.
3. **PR 0 — route manifest.** Generator + CI check + committed baseline of today's surface. No routers moved.
4. **PR 1admin:** `users`, `account`, `invites`, `auth` (providers).
5. **PR 2 — admin:** `moderation`, `bot-activity`, `activity`.
6. **PR 3 — admin (content):** `posts`, `pages`, `wiki`, `uploads`.
7. **PR 4 — admin (ops/config):** `shard`, `uo-link`, `email`, `discord-bot`, `settings`, `site-mode`,
`dashboard`.
8. **PR 5 — `public/*` + `player/*`** (and the residual `auth/*` grouping).
Each PR: server tests green (`cd website/server && npm test`), Swagger regenerated, matching `docs/`
edit, Conventional Commit, AI-disclosure trailer, branch from a freshly-pulled `main`.
Each PR: **zero-line diff in `routes.manifest.json`**, server tests green
(`cd website/server && npm test`), Swagger regenerated, matching `docs/` edit, Conventional Commit,
AI-disclosure trailer, branch from a freshly-pulled `main`.
---
## Cross-component blast radius
The plan above is written as if the website is the whole world. It isn't. **Three independent
clients consume the site's HTTP/SSE API, and two of them live in separate repos on separate release
cadences.** Any change to a URL shape, an auth mechanism, or a namespace is a *contract* change, not
a local refactor — the same discipline the docs demand for the shard↔sidecar wire protocol applies
here. This section is the coordination map the per-repo work must respect.
Three independent clients consume the site's HTTP/SSE API, two of them in separate repos on separate
release cadences. **With the auth merge and the version bump both gone, the blast radius is empty**
no client's URLs or authentication change at all.
### Who calls the site API
| Consumer | Repo (release cadence) | How it's pinned to v1 | Migration cost |
| Consumer | Repo (cadence) | Pinning | Impact under this plan |
|---|---|---|---|
| Browser SPA | `website/client` (same repo, lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | In-plan (Phase 1 client + Phase 2) |
| **Android app** | `Android-app` (separate repo, app-store cadence, un-updatable installs in the wild) | **~70 Retrofit endpoints hardcode `api/v1/…`** across `data/api/*.kt`; SSE path hardcoded in `ShardStreamClient.kt`; SSO in `SsoAuthManager.kt` | **Migrated once to the version-agnostic `/api/mobile` facade in Phase 0; thereafter decoupled from v2.** |
| Discord bot | `website/bot` (separate deploy, but env-configured) | `SITE_PUBLIC_URL` env → `/api/v1/public` (`announce`/`wiki` commands) | Trivial — repoint the env var once `/v2/public` exists; unaffected by the auth merge (anonymous public reads) |
| Browser SPA | `website/client` (lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | **None.** Same URLs, same cookie session. |
| Android app | `android-app` (app-store cadence, un-updatable installs in the wild) | 69 hardcoded `api/v1/…` paths; SSE path in `ShardStreamClient.kt`; SSO in `SsoAuthManager.kt` | **None.** No repoint, no release required. |
| Discord bot | `website/bot` (separate deploy, env-configured) | `SITE_PUBLIC_URL` env → `/api/v1/public` | **None.** Anonymous public reads on unchanged paths. |
Only the browser migrates in true lockstep. The bot is a one-line env change. **The Android app was
the load-bearing coupling — the `/api/mobile` facade (Phase 0) is what removes it from the critical
path so v2 can proceed freely.**
Standing constraints, unchanged:
### Mobile (site ↔ Android) — decoupled by the Phase 0 facade
- **The public SSE stream stays anonymous.** Consumed by logged-out browser visitors *and* the Android
`ShardStreamClient`, neither of which sends an `Authorization` header. Adding `requireAuth` blacks
out the public live boards on web and mobile. The single most likely regression in a careless
refactor is reflexively wrapping *both* shard streams in auth.
- **The admin SSE stream keeps its current cookie-based gating** (`isLoggedIn`, `EventSource` +
`withCredentials`) — the rewrite that would have changed this went out with the auth merge.
- **The public/admin allowlist split is a security boundary**, not an implementation detail. Preserve
it verbatim in `utils/shardBroadcast.js` / `utils/shardIngest.js` as routes move.
- **SSO / email-connect transaction cookies are load-bearing for web *and* native.** The Android SSO
flow opens a Custom Tab to the website's `/auth/…/sso/start` and rides the same server-side redirect
transaction and the same tx cookies. Nothing in this plan touches them; don't let a future "cookies
go away" push delete them.
- **`link/` is out of scope.** The sidecar contract (`uoLinkConfig`, `utils/uoLinkClient.js`,
`utils/shardIngest.js`, `X-UOLink-Version`) is a separate versioning axis. `PROTOCOL_VERSION` does
**not** bump for this work.
The facade turns the mobile problem from "the app must chase every version rename forever" into "the
app pins one stable namespace, migrated once." What remains to keep in view:
---
1. **The facade must be *complete* or the decoupling leaks.** The app uses mostly *shared* routes
(`auth/me/*`, `public/*`, `player/*`, `admin/*`), not just the mobile-auth ones. Every endpoint in
the app's inventory (below) must exist under `/api/mobile`, or the app still calls `/api/v1`
directly and is not actually insulated. This is the single most important Phase 0 check.
2. **The one-time cutover is still fleet-gated — the facade doesn't erase that, it *contains* it.**
The *pre-facade* installed app still calls `/api/v1/*`, and with no force-update it does so until
its users update. So `/api/v1` stays alive until that old fleet ages out. The difference: this is
now a **pure-rename** migration decoupled from v2 behavior, paid once, and closed out by the
version floor (next point) rather than blocking the auth merge.
3. **The version floor is still needed — its role just changed.** Add an app-version header + a
server-side min-version gate (Phase 0 step 7). Its *first* job is to sunset the pre-facade app so
`/api/v1` can finally be deleted; *afterward* it is the in-band mechanism for any breaking
`/api/mobile` change, since URL-path versioning is deliberately absent there. Without it there is
still no safe way to force the last stragglers off v1.
4. **v1-usage telemetry** — per-route counts, tagged to distinguish the migrated browser from
lingering pre-facade app installs, so "nothing calls v1" is measured, not assumed.
5. **`/api/mobile` is now a frozen wire contract** — contract tests (Phase 0 step 5) must guard its
response shapes so a v2 internal refactor can't silently break installed apps. This replaces
"don't break the app during the auth merge" with "the auth merge can't reach the app at all."
6. **The public/mobile SSE stream must stay anonymous** (Phase 0 step 4 / Phase 1 step 5) — the app's
`ShardStreamClient` sends no auth header.
7. **Record the mobile side in `docs/android/PLAN.md`.** This plan owns the `/api/mobile` server
contract + the facade; that plan owns the app migration + version-floor rollout.
## Appendix: mapping from the previous plan
### Shard (site ↔ link) — mostly *out* of the blast radius, with one seam to guard
The auth merge is a **website↔client** change; the **website↔sidecar** contract is orthogonal and
should not move:
- The sidecar's own bearer token lives in the DB (`uoLinkConfig`, write-only in the API), the
server-side REST client (`utils/uoLinkClient.js`) and live ingest (`utils/shardIngest.js`) talk to
the sidecar independent of any browser/mobile session, and `PROTOCOL_VERSION` / `X-UOLink-Version`
are their own versioning axis. **None of that changes for v2**`link/` needs no edit and
`PROTOCOL_VERSION` does **not** bump for this work.
- **The one seam:** the *admin* SSE stream re-fans the sidecar's full feed (including sensitive kinds).
When that endpoint moves to Bearer auth under v2, the public/admin allowlist split in the fan-out
(`utils/shardBroadcast.js` / `shardIngest.js`) is the same security boundary it is today and must be
preserved verbatim. This is a website-internal concern; it does not reach into `link/`.
### SSO transaction cookies are load-bearing for *both* web and native
Phase 1 step 3's carve-out (keep the short-lived httpOnly tx / PKCE-verifier / pending-TOTP cookies)
isn't only a web concern. The Android SSO flow opens a Custom Tab to the website's
`/auth/…/sso/start`, so it rides the **same** server-side redirect transaction and the **same** tx
cookies. The mobile side already ends in a code-exchange (`/auth/mobile/sso/exchange`) — which is the
exact pattern v2's web callback is adopting. So: don't let "cookies go away" delete the tx cookies, or
you break native SSO as well as web SSO.
### Contract-sync checklist (this is a multi-repo change)
A v2 endpoint or auth change is not "done" until every consumer's contract is reconciled:
- `docs/website/BACKEND_DESIGN.md` — route map + auth/security contract, incl. the `/api/mobile`
facade as its own documented, version-agnostic surface with pinned response shapes.
- `server/swagger/swagger-output.json` — regenerated (tag `/api/mobile` separately from v1/v2).
- Facade contract tests — the frozen `/api/mobile` wire shapes; kept green across every v2 change.
- `docs/android/PLAN.md` — the `/api/mobile` migration milestone + the app-version-floor mechanism.
- The Android app's own endpoint constants + contract comments (`data/api/*.kt`,
`ShardStreamClient.kt`, `SsoAuthManager.kt`) — re-pointed to `/api/mobile` in a separate
`Android-app` PR.
- Bot: note the `SITE_PUBLIC_URL` repoint in `website/` deploy docs when `/v2/public` lands.
## Risks / watch-items
- **XSS is now token-theft.** Losing httpOnly means any XSS can read the access token. Mitigation:
short access TTL + refresh rotation + revocation, paired with the Phase 1b CSP hardening.
- **SSO/email tx cookies cannot be removed** — don't let "cookies go away" over-reach into the
redirect transaction. Only the session handoff changes.
- **SSE reconnect regressions** — `EventSource` gave auto-reconnect + `Last-Event-ID` for free; the
fetch reader must reproduce backoff and (if used) event-id resume, plus refresh a stale token
mid-stream.
- **Incomplete facade defeats the purpose** — if any endpoint the app needs is left only under
`/api/v1`, the app still calls v1 directly and Phase 0's decoupling silently leaks. Reconcile the
facade against the app's full endpoint inventory before shipping PR 0a.
- **The facade is a security surface, not an alias** — each `/api/mobile` route must carry the *same*
auth middleware as the route it mirrors. A re-exposed admin route missing `adminOnly`, or the
mobile SSE leaking sensitive kinds, is a privilege/data-exposure hole introduced by the facade.
- **Silent shape drift through the facade** — once `/api/mobile` internals re-point to v2, a v2
response-shape change can break installed apps with no compile error. Contract tests on the facade
are the guardrail; treat a failing one as a release blocker, not a test to update.
- **Double maintenance while v1 and v2 coexist** — bug fixes may need both. Keep the window short;
drive the *web* client to v2 completion. The facade means the window is no longer bounded by app
behavior — only by the pre-facade fleet aging out (see § Cross-component blast radius).
- **Public SSE must not become auth-gated** — the single most likely regression in Phase 1 is
reflexively wrapping *both* shard streams in `requireAuth`. The public stream is anonymous by
contract; doing so blacks out the public live boards for every logged-out browser and every phone.
- **v1 deletion is still fleet-gated (once)** — even with the facade, the *pre-facade* installed app
calls `/api/v1` until the version floor ages it out. The facade contains this to a one-time,
behavior-free cutover done *before* v2; it does not make v1 deletable on the web client's schedule.
- **`link/` is not in scope but is adjacent** — resist bumping `PROTOCOL_VERSION` or touching the
sidecar client for v2 work; the only shared seam is preserving the admin-stream allowlist split.
| Previous | Now |
|---|---|
| Phase 0 — `/api/mobile` facade + app-version floor | **Deferred.** See § Deferred: the `/api/mobile` facade |
| Phase 1 — auth merge | **Removed.** See § Why the auth merge is out and § Deferred: the auth merge |
| Phase 1b — CSP hardening (shipped with the auth merge) | **Phase 1**, standalone; report-only-first ordering fixed |
| Phase 2 — domain split under `router/v2/` | **Phase 2**, in place under `router/v1/`; now the primary driver |
| PR 1 — `/api/v2` scaffold ([API_V2_SKELETON.md](./API_V2_SKELETON.md)) | **Superseded**, kept as the recipe if a versioned API is ever forced |
| PR final — retire v1 | **Not applicable** — v1 is never replaced |

View File

@@ -1,5 +1,14 @@
# Website API v2 — `/api/v2` Skeleton (PR 1)
> ## ⚠ Superseded — not scheduled
>
> The router domain split is being done **in place**, with every URL byte-identical, so there is no
> parallel version to stand up and this scaffold will not be built. See
> [API_V2_PLAN.md](./API_V2_PLAN.md) § Why there is no `/api/v2`.
>
> The file is kept, unedited below, as the concrete recipe **if** a real contract break ever forces a
> versioned API. Nothing here describes current or planned work.
Companion to [API_V2_PLAN.md](./API_V2_PLAN.md) — this is the concrete scaffold for **PR 1** in that
plan's sequencing. It stands up `/api/v2` **empty but wired**, next to a frozen `/api/v1`, with **no
behavior change**. Endpoints are filled in by the later PRs (auth merge, then the domain split).

View File

@@ -29,6 +29,11 @@ Public contact email: **UOMysticmoon@gmail.com**
Skeleton from the spec, with a small number of justified additions marked **(+)**.
> **Planned change:** the monolithic route files below (`admin.routes.js` especially, 1552 lines /
> 110 routes) are being split into one router file per business capability — **in place, with every
> URL unchanged**. This section and §4 get updated as each split PR lands. See
> [API_V2_PLAN.md](./API_V2_PLAN.md) § Phase 2.
```
server/
.env.example

View File

@@ -0,0 +1,811 @@
{
"$comment": "Generated route inventory - the authoritative freeze of the URL surface. Regenerate with `npm run routes:manifest` in website/server; a domain-split PR must produce a zero-line diff here.",
"public": [
{
"method": "GET",
"path": "/.well-known/assetlinks.json"
},
{
"method": "GET",
"path": "/api/docs.json"
},
{
"method": "GET",
"path": "/api/health"
},
{
"method": "GET",
"path": "/api/v1/admin/account"
},
{
"method": "GET",
"path": "/api/v1/admin/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/admin/account/identities/:provider"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/admin/account/totp/setup"
},
{
"method": "GET",
"path": "/api/v1/admin/activity"
},
{
"method": "GET",
"path": "/api/v1/admin/auth/providers"
},
{
"method": "POST",
"path": "/api/v1/admin/auth/providers"
},
{
"method": "DELETE",
"path": "/api/v1/admin/auth/providers/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/auth/providers/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/bot-activity"
},
{
"method": "POST",
"path": "/api/v1/admin/bot-activity/unban"
},
{
"method": "GET",
"path": "/api/v1/admin/dashboard"
},
{
"method": "GET",
"path": "/api/v1/admin/discord-bot/config"
},
{
"method": "PUT",
"path": "/api/v1/admin/discord-bot/config"
},
{
"method": "GET",
"path": "/api/v1/admin/email/config"
},
{
"method": "PUT",
"path": "/api/v1/admin/email/config"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/callback"
},
{
"method": "GET",
"path": "/api/v1/admin/email/connect/start"
},
{
"method": "POST",
"path": "/api/v1/admin/email/disconnect"
},
{
"method": "POST",
"path": "/api/v1/admin/email/test"
},
{
"method": "GET",
"path": "/api/v1/admin/invites"
},
{
"method": "POST",
"path": "/api/v1/admin/invites"
},
{
"method": "DELETE",
"path": "/api/v1/admin/invites/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/appeals"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/appeals/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/appeals/:id/claim"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/appeals/:id/resolve"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/filter-hits"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/members"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/recent"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/spam-hits"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/stats/summary"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId/actions"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId/appeals"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/user/:discordId/notes"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/user/:discordId/notes"
},
{
"method": "GET",
"path": "/api/v1/admin/pages"
},
{
"method": "POST",
"path": "/api/v1/admin/pages"
},
{
"method": "DELETE",
"path": "/api/v1/admin/pages/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/pages/:id"
},
{
"method": "PATCH",
"path": "/api/v1/admin/pages/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/pages/:id/preview"
},
{
"method": "POST",
"path": "/api/v1/admin/pages/:id/unprotect"
},
{
"method": "GET",
"path": "/api/v1/admin/posts"
},
{
"method": "POST",
"path": "/api/v1/admin/posts"
},
{
"method": "DELETE",
"path": "/api/v1/admin/posts/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/posts/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/posts/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/posts/:id/announce"
},
{
"method": "POST",
"path": "/api/v1/admin/posts/:id/announce/retry"
},
{
"method": "PATCH",
"path": "/api/v1/admin/posts/:id/publish"
},
{
"method": "POST",
"path": "/api/v1/admin/posts/upload"
},
{
"method": "GET",
"path": "/api/v1/admin/settings"
},
{
"method": "PUT",
"path": "/api/v1/admin/settings"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/account"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/accounts"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/audit"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/ban"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/broadcast"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/char/:serial"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/houses"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/kick"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/link"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/pages"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/pages/:id/close"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/pages/:id/respond"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/roster/:account"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/sales"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/unban"
},
{
"method": "GET",
"path": "/api/v1/admin/shard/vendors/:account"
},
{
"method": "PUT",
"path": "/api/v1/admin/site-mode"
},
{
"method": "GET",
"path": "/api/v1/admin/uo-link/config"
},
{
"method": "PUT",
"path": "/api/v1/admin/uo-link/config"
},
{
"method": "GET",
"path": "/api/v1/admin/uo-link/stream"
},
{
"method": "POST",
"path": "/api/v1/admin/uo-link/towncrier"
},
{
"method": "DELETE",
"path": "/api/v1/admin/uo-link/towncrier/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/uploads"
},
{
"method": "GET",
"path": "/api/v1/admin/users"
},
{
"method": "POST",
"path": "/api/v1/admin/users"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/users/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/users/:id/mfa/reset"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/accounts"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/houses"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/shard/link/:account"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/online"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/sales"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/shard/standing"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/trusted-devices"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/trusted-devices"
},
{
"method": "DELETE",
"path": "/api/v1/admin/users/:id/trusted-devices/:deviceId"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki"
},
{
"method": "POST",
"path": "/api/v1/admin/wiki"
},
{
"method": "DELETE",
"path": "/api/v1/admin/wiki/:slug"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/:slug"
},
{
"method": "PUT",
"path": "/api/v1/admin/wiki/:slug"
},
{
"method": "PATCH",
"path": "/api/v1/admin/wiki/:slug/publish"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/:slug/revisions"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/:slug/revisions/:id"
},
{
"method": "POST",
"path": "/api/v1/admin/wiki/:slug/revisions/:id/restore"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/categories"
},
{
"method": "POST",
"path": "/api/v1/admin/wiki/categories"
},
{
"method": "DELETE",
"path": "/api/v1/admin/wiki/categories/:id"
},
{
"method": "PUT",
"path": "/api/v1/admin/wiki/categories/:id"
},
{
"method": "GET",
"path": "/api/v1/admin/wiki/tags"
},
{
"method": "GET",
"path": "/api/v1/auth/invite/:token"
},
{
"method": "POST",
"path": "/api/v1/auth/invite/:token/accept"
},
{
"method": "POST",
"path": "/api/v1/auth/login"
},
{
"method": "POST",
"path": "/api/v1/auth/login/totp"
},
{
"method": "POST",
"path": "/api/v1/auth/logout"
},
{
"method": "GET",
"path": "/api/v1/auth/me"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account/identities/:provider"
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/password"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/recovery-codes/generate"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account/recovery-codes/status"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/auth/me/account/totp/setup"
},
{
"method": "PATCH",
"path": "/api/v1/auth/me/account/username"
},
{
"method": "GET",
"path": "/api/v1/auth/me/devices"
},
{
"method": "POST",
"path": "/api/v1/auth/me/devices"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/devices/:id"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/streams"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications/subscriptions"
},
{
"method": "PUT",
"path": "/api/v1/auth/me/notifications/subscriptions"
},
{
"method": "GET",
"path": "/api/v1/auth/me/sessions"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/sessions/:id"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/trusted-devices"
},
{
"method": "GET",
"path": "/api/v1/auth/me/trusted-devices"
},
{
"method": "POST",
"path": "/api/v1/auth/me/trusted-devices"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/trusted-devices/:id"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/login"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/logout"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/refresh"
},
{
"method": "POST",
"path": "/api/v1/auth/mobile/sso/exchange"
},
{
"method": "GET",
"path": "/api/v1/auth/mobile/sso/start"
},
{
"method": "POST",
"path": "/api/v1/auth/password/forgot"
},
{
"method": "GET",
"path": "/api/v1/auth/password/reset/:token"
},
{
"method": "POST",
"path": "/api/v1/auth/password/reset/:token"
},
{
"method": "GET",
"path": "/api/v1/auth/providers"
},
{
"method": "POST",
"path": "/api/v1/auth/register"
},
{
"method": "GET",
"path": "/api/v1/auth/sso/:provider/callback"
},
{
"method": "GET",
"path": "/api/v1/auth/sso/:provider/link"
},
{
"method": "GET",
"path": "/api/v1/auth/sso/:provider/start"
},
{
"method": "POST",
"path": "/api/v1/auth/sso/totp"
},
{
"method": "GET",
"path": "/api/v1/player/account"
},
{
"method": "GET",
"path": "/api/v1/player/account/identities"
},
{
"method": "DELETE",
"path": "/api/v1/player/account/identities/:provider"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/password"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/disable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/enable"
},
{
"method": "POST",
"path": "/api/v1/player/account/totp/setup"
},
{
"method": "PATCH",
"path": "/api/v1/player/account/username"
},
{
"method": "GET",
"path": "/api/v1/player/appeals"
},
{
"method": "POST",
"path": "/api/v1/player/appeals"
},
{
"method": "POST",
"path": "/api/v1/player/appeals/:id/withdraw"
},
{
"method": "GET",
"path": "/api/v1/player/appeals/eligible"
},
{
"method": "POST",
"path": "/api/v1/player/shard/account"
},
{
"method": "GET",
"path": "/api/v1/player/shard/accounts"
},
{
"method": "GET",
"path": "/api/v1/player/shard/char/:serial"
},
{
"method": "GET",
"path": "/api/v1/player/shard/houses"
},
{
"method": "POST",
"path": "/api/v1/player/shard/link"
},
{
"method": "GET",
"path": "/api/v1/player/shard/roster/:account"
},
{
"method": "GET",
"path": "/api/v1/player/shard/sales"
},
{
"method": "GET",
"path": "/api/v1/player/shard/vendors/:account"
},
{
"method": "POST",
"path": "/api/v1/public/contact"
},
{
"method": "GET",
"path": "/api/v1/public/pages/:id/preview/:token"
},
{
"method": "GET",
"path": "/api/v1/public/pages/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/posts/:category"
},
{
"method": "GET",
"path": "/api/v1/public/posts/:category/:idOrSlug"
},
{
"method": "GET",
"path": "/api/v1/public/settings"
},
{
"method": "GET",
"path": "/api/v1/public/shard/champs"
},
{
"method": "GET",
"path": "/api/v1/public/shard/economy"
},
{
"method": "GET",
"path": "/api/v1/public/shard/feed"
},
{
"method": "GET",
"path": "/api/v1/public/shard/governors"
},
{
"method": "GET",
"path": "/api/v1/public/shard/governors/:city/history"
},
{
"method": "GET",
"path": "/api/v1/public/shard/guilds"
},
{
"method": "GET",
"path": "/api/v1/public/shard/houses"
},
{
"method": "GET",
"path": "/api/v1/public/shard/idoc"
},
{
"method": "GET",
"path": "/api/v1/public/shard/online"
},
{
"method": "GET",
"path": "/api/v1/public/shard/presence"
},
{
"method": "GET",
"path": "/api/v1/public/shard/status"
},
{
"method": "GET",
"path": "/api/v1/public/shard/stream"
},
{
"method": "GET",
"path": "/api/v1/public/status"
},
{
"method": "GET",
"path": "/api/v1/public/version"
},
{
"method": "GET",
"path": "/api/v1/public/wiki"
},
{
"method": "GET",
"path": "/api/v1/public/wiki/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/wiki/categories"
},
{
"method": "GET",
"path": "/api/v1/public/wiki/tags"
}
],
"internal": [
{
"method": "GET",
"path": "/health"
},
{
"method": "GET",
"path": "/internal/bot-config"
}
]
}