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:
@@ -648,10 +648,9 @@ Guidelines:
|
|||||||
|
|
||||||
## 6. Screen ↔ endpoint map
|
## 6. Screen ↔ endpoint map
|
||||||
|
|
||||||
> **Path note (M11):** endpoints below are written with their current `/api/v1`-relative paths. Under
|
> **Path note:** endpoints below are `/api/v1`-relative and stay that way. The website's router
|
||||||
> **M11** the app moves onto the version-agnostic **`/api/mobile`** facade (a pure rename — same
|
> refactor preserves every URL, and the `/api/mobile` facade that would have renamed them is deferred —
|
||||||
> shapes, same auth); these references update to `/api/mobile/**` when that migration executes. See
|
> see [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.
|
||||||
> [`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Phase 0.
|
|
||||||
|
|
||||||
### 6.1 Public content
|
### 6.1 Public content
|
||||||
- **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner).
|
- **Home/Status** — `GET /public/status`, `GET /public/settings` (branding + maintenance banner).
|
||||||
@@ -883,34 +882,16 @@ push, and Play (M6–M8) follow the designed app.
|
|||||||
under `/admin/wiki/*`). No backend routes added (they already accept bearer + re-check role);
|
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
|
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
|
||||||
editor, Discord-bot config, uo-link config, OAuth-provider setup.
|
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
|
### Deferred (not a milestone)
|
||||||
[`../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
|
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now
|
||||||
force-update**, and calls a mix of mobile-only (`/auth/mobile/*`) and *shared* web routes
|
**deferred with no app work scheduled**. The website's router refactor is being done in place with
|
||||||
(`/auth/me/*`, `/public/*`, `/player/*`, `/admin/*`) — so it is directly coupled to v1 and would
|
every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
|
||||||
break the day v1 is retired. The website introduces a stable, version-agnostic `/api/mobile`
|
endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
|
||||||
facade (a thin BFF that delegates to the current controllers behind pinned wire shapes); this
|
to diverge from web, the migration comes back — starting from a one-line alias mount on the server,
|
||||||
milestone moves the app onto it **once**, after which the app is insulated from all internal
|
not a hand-written delegate layer. Reasoning and revival triggers:
|
||||||
v1→v2→vN churn.
|
[`../website/API_V2_PLAN.md`](../website/API_V2_PLAN.md) § Deferred: the `/api/mobile` facade.
|
||||||
- **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
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. |
|
| `/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 |
|
||||||
| 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. |
|
| `/auth` | 42 | `me` 23 · `mobile` 5 · `sso` 4 · `password` 3 · `invite` 2 · `login` 2 · `logout` 1 · `providers` 1 · `register` 1 |
|
||||||
| Web session model | **Web adopts mobile's access + refresh** | One session model everywhere. Reuses `session.service` machinery that already exists — nothing new invented. |
|
| `/public` | 24 | `shard` 12 · `wiki` 4 · `pages` 2 · `posts` 2 · `contact` 1 · `settings` 1 · `status` 1 · `version` 1 |
|
||||||
| 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. |
|
| `/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
|
Consequences of doing it in place:
|
||||||
`auth/session.service.js` already unifies both into one session.
|
|
||||||
- Mobile already has the full target model: `mintMobileTokens` / `createMobileSession` /
|
- No `/api/v2`, no dual mount, no route-by-route migration, no v1-usage telemetry project, and no
|
||||||
`refreshMobileSession`, with **hashed, rotated, revocable** refresh tokens stored server-side.
|
v1-retirement sequence.
|
||||||
Endpoints live at `/api/v1/auth/mobile/{login,refresh,logout}`.
|
- `BASE = /api/v1` in `client/src/api/client.js` never changes. The Discord bot's `SITE_PUBLIC_URL`
|
||||||
- **The merge is therefore mostly deletion + rename:** web joins the mobile session model, the
|
never changes. The Android app is untouched.
|
||||||
internal `/mobile` auth namespace collapses into unified `/auth/*`, and the cookie code path is
|
- [API_V2_SKELETON.md](./API_V2_SKELETON.md) (the `router/v2/` scaffold) is **superseded and not
|
||||||
removed. (The *app-facing* `/api/mobile` facade is unaffected — it re-points its internals to the
|
scheduled**. It is kept as the concrete recipe if a real contract break ever forces a versioned API.
|
||||||
unified flow; the app sees no change. See Phase 0.)
|
|
||||||
|
**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
|
The earlier plan's Phase 0 stood up a version-agnostic `/api/mobile` namespace and migrated the
|
||||||
ever breaking an installed app. The app currently hardcodes ~70 `api/v1/…` endpoints (plus the SSE
|
Android app onto it, plus an app-version header and a server-side min-version floor.
|
||||||
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.
|
|
||||||
|
|
||||||
**What it is:** a thin routing facade mounted at `/api/mobile`, next to `/api/v1`, that delegates to
|
**Why it was proposed:** the app hardcodes **69** distinct `api/v1/…` paths (`data/api/*.kt`,
|
||||||
the **same controllers with the same middleware** as the routes it mirrors. It is *routing + optional
|
`core/net/ShardStreamClient.kt`, `core/net/HostSelectionInterceptor.kt`,
|
||||||
response-shaping*, never business logic — a Backend-for-Frontend, not a fork.
|
`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`).
|
**If it is ever revived** (the trigger is the mobile contract genuinely needing to diverge from web —
|
||||||
2. **Re-home the app's *entire* surface under it — not just the mobile-auth routes.** The app calls
|
different response shapes, a mobile-only aggregation endpoint, a real breaking change):
|
||||||
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.
|
|
||||||
|
|
||||||
### Android app (`Android-app` repo — separate PR, separate release)
|
- **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
|
||||||
6. **Re-point everything to `/api/mobile`:** the ~70 Retrofit endpoints (`data/api/*.kt`), the SSE
|
per-route maintenance. Build hand-written delegates only for the routes that actually diverge.
|
||||||
path in `ShardStreamClient.kt`, and the SSO start/exchange URLs in `SsoAuthManager.kt`. Drop the
|
- **A facade is a security surface, not a convenience alias.** Any hand-written route must carry the
|
||||||
`api/v1/` and `auth/mobile/` prefixes; the app now knows only `/api/mobile`.
|
*same* middleware chain as the route it mirrors (`requireAuth`, `staffOnly`/`adminOnly`, validators,
|
||||||
7. **Add the app-version header + a server-side min-version floor** (the mechanism the blast-radius
|
the public/admin SSE allowlist split). A re-exposed admin route missing `adminOnly` is privilege
|
||||||
section calls for). Its first job is to sunset the *pre-facade* app so `/api/v1` can eventually be
|
escalation.
|
||||||
deleted; thereafter it is insurance for any genuinely breaking `/api/mobile` change (negotiated
|
- **It needs contract tests.** The moment the app pins a namespace, its response shapes are a
|
||||||
in-band, since URL-path versioning is deliberately gone here).
|
committed contract; an internal refactor that changes a shape must fail a test before it ships to
|
||||||
8. **Ship and let the fleet adopt** before starting v2. The old app keeps working on frozen `/api/v1`
|
installed apps.
|
||||||
until the version floor ages it out.
|
- **The mobile SSE stream stays anonymous** under whatever path it gets — the app sends no
|
||||||
|
`Authorization` header.
|
||||||
### 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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`:
|
**Sequencing fix from the original plan:** the old version both "ships with the auth merge" and called
|
||||||
- `POST /login` → password (+ TOTP) → returns `{ accessToken, refreshToken, user }` (no `Set-Cookie`).
|
for a one-release report-only soak. Those contradict. Correct order is **report-only first, observe one
|
||||||
- `POST /refresh` → rotate: validate + revoke presented refresh, mint a new pair.
|
release, then enforce** — now trivially satisfiable since nothing waits on it.
|
||||||
- `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.
|
|
||||||
|
|
||||||
### 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>`;
|
- `script-src 'self'` — no `'unsafe-inline'` / `'unsafe-eval'`. Primary defense.
|
||||||
on `401`, silent-refresh once via `/auth/refresh`, retry, else bounce to login.
|
- `connect-src 'self'` — the exfiltration channel. Don't widen it unless the API genuinely becomes
|
||||||
7. **Token storage:** access token in memory (JS var/context); refresh token in `localStorage`.
|
cross-origin (which would also reopen the auth-merge question — see the trigger table).
|
||||||
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.
|
|
||||||
|
|
||||||
`style-src 'unsafe-inline'` stays — it permits inline styling, not script execution, and React's
|
`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:
|
Target enforced policy:
|
||||||
|
|
||||||
@@ -170,209 +181,189 @@ form-action 'self';
|
|||||||
frame-ancestors 'none';
|
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
|
- **Add `form-action 'self'`** (currently absent) — blocks an injected `<form action="https://evil">`
|
||||||
token/credentials off-origin (an exfil path `connect-src` doesn't cover).
|
from POSTing credentials off-origin, an exfil path `connect-src` doesn't cover.
|
||||||
- **Tighten `frame-ancestors` `'self'` → `'none'`** — nothing legitimately frames the site.
|
- **Tighten `frame-ancestors`** `'self'` → `'none'` — nothing legitimately frames the site.
|
||||||
- Keep `base-uri 'self'`, `object-src 'none'`, and `img-src … https:` (external `BRAND_*`
|
- Unchanged: `default-src`, `script-src`, `connect-src`, `object-src 'none'`, `base-uri 'self'`, and
|
||||||
logo/hero and `<img>` in sanitized wiki/news bodies rely on `https:`).
|
`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
|
Before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill `<script>`
|
||||||
`fonts.gstatic.com` from `font-src`, removing two third-party origins from the trust surface.
|
into `dist/index.html`, which that directive blocks (harmless, but throws a violation). Confirm it's
|
||||||
- **Trusted Types** — roll out `require-trusted-types-for 'script'` + a `trusted-types` policy in
|
disabled or set `build.modulepreload.polyfill = false` in the Vite config. (The `renderIndexHtml`
|
||||||
**report-only** first; neutralizes most DOM-based XSS at the sink (the exact bug class that would
|
branding injection adds only `<meta>`/`<link>` tags — no inline script, no nonce needed.)
|
||||||
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.
|
|
||||||
|
|
||||||
Verify before trusting `script-src 'self'`: Vite's build injects an inline modulepreload-polyfill
|
Tracked follow-ups (own PRs):
|
||||||
`<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
|
- **Self-host the Cinzel font** → drop `fonts.googleapis.com` from `style-src` and `fonts.gstatic.com`
|
||||||
`renderIndexHtml` branding injection adds only `<meta>`/`<link>` tags — no inline script, no nonce
|
from `font-src`, removing two third-party origins from the trust surface.
|
||||||
needed.)
|
- **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
|
## Phase 2 — The domain split
|
||||||
|
|
||||||
**Rule:** one router file = one business capability; the URL names the domain; related endpoints
|
**This is the reason the plan exists.** Everything else is supporting work.
|
||||||
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.
|
|
||||||
|
|
||||||
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/
|
admin/
|
||||||
dashboard.router.js users.router.js moderation.router.js
|
index.js # mounts the capability routers below under /admin, keeps the
|
||||||
content.router.js wiki.router.js shard.router.js
|
# shared `noindex, isLoggedIn, staffOnly` gate in one place
|
||||||
settings.router.js invites.router.js bot-activity.router.js
|
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/
|
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/
|
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/
|
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)
|
internal/ (unchanged — stays on the unpublished port, never mounted publicly)
|
||||||
```
|
```
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
|
|
||||||
11. Carve `admin.routes.js` (~100 routes) into the per-capability files above, each requiring its
|
1. **Land PR 0 (route manifest) first** — the mechanical proof that later PRs move no URL.
|
||||||
already-existing controller. A thin `admin/index.js` mounts them under `/admin`.
|
2. Carve `admin.routes.js` into the per-capability files above, each requiring its already-existing
|
||||||
12. Split `public.routes.js` and `player.routes.js` the same way.
|
controller. `admin/index.js` keeps the shared gate (`noindex, isLoggedIn, staffOnly`) and mounts
|
||||||
13. `v2.router.js` mounts the domain sub-routers; `api.router.js` mounts `/v1` (frozen) **and** `/v2`.
|
each capability router at its existing prefix; the `adminOnly` / `modAccess` gates move with the
|
||||||
14. Keep `/internal` off the public listener exactly as v1 does (separate `internalApp.js` port).
|
routes that use them.
|
||||||
15. Update `#swagger.*` annotations for every moved route, regenerate the spec, and update
|
3. Split `public.routes.js`, `player.routes.js`, and the remaining `auth.routes.js` groups the same
|
||||||
`PROJECT_TREE.md` + `BACKEND_DESIGN.md` route map.
|
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
|
## Sequencing & PR breakdown
|
||||||
|
|
||||||
**Phase 0 lands entirely before the v2 scaffold** — the app must be off `/api/v1`-direct and onto
|
CSP and the split are independent; the only hard ordering is PR 0 before the first split PR.
|
||||||
`/api/mobile` (and the fleet adopting) before v2 behavior work begins.
|
|
||||||
|
|
||||||
1. **PR 0a — mobile facade (server):** mount `/api/mobile` in `api.router.js`; re-home the app's full
|
1. **PR — CSP report-only.** Tightened policy behind `Content-Security-Policy-Report-Only` + `report-to`.
|
||||||
endpoint surface as thin delegates to existing controllers (same middleware); add contract tests
|
2. **PR — CSP enforce.** One release later, assuming a clean violation report.
|
||||||
pinning the wire shapes; add v1-usage telemetry. No behavior change. See Phase 0.
|
3. **PR 0 — route manifest.** Generator + CI check + committed baseline of today's surface. No routers moved.
|
||||||
2. **PR 0b — mobile migration (separate `Android-app` repo):** re-point the ~70 Retrofit endpoints,
|
4. **PR 1 — admin:** `users`, `account`, `invites`, `auth` (providers).
|
||||||
the `ShardStreamClient` SSE path, and the SSO start/exchange URLs to `/api/mobile`; add the
|
5. **PR 2 — admin:** `moderation`, `bot-activity`, `activity`.
|
||||||
app-version header + server-side min-version floor. Released and adopted on the app-store cadence
|
6. **PR 3 — admin (content):** `posts`, `pages`, `wiki`, `uploads`.
|
||||||
before v2 starts. See `docs/android/PLAN.md`.
|
7. **PR 4 — admin (ops/config):** `shard`, `uo-link`, `email`, `discord-bot`, `settings`, `site-mode`,
|
||||||
3. **PR 1 — v2 scaffold:** `router/v2/` skeleton, `v2.router.js`, mount `/v2` next to `/v1`. Empty
|
`dashboard`.
|
||||||
but wired; no behavior change. Detailed in [API_V2_SKELETON.md](./API_V2_SKELETON.md).
|
8. **PR 5 — `public/*` + `player/*`** (and the residual `auth/*` grouping).
|
||||||
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.
|
|
||||||
|
|
||||||
Each PR: server tests green (`cd website/server && npm test`), Swagger regenerated, matching `docs/`
|
Each PR: **zero-line diff in `routes.manifest.json`**, server tests green
|
||||||
edit, Conventional Commit, AI-disclosure trailer, branch from a freshly-pulled `main`.
|
(`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
|
## Cross-component blast radius
|
||||||
|
|
||||||
The plan above is written as if the website is the whole world. It isn't. **Three independent
|
Three independent clients consume the site's HTTP/SSE API, two of them in separate repos on separate
|
||||||
clients consume the site's HTTP/SSE API, and two of them live in separate repos on separate release
|
release cadences. **With the auth merge and the version bump both gone, the blast radius is empty** —
|
||||||
cadences.** Any change to a URL shape, an auth mechanism, or a namespace is a *contract* change, not
|
no client's URLs or authentication change at all.
|
||||||
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.
|
|
||||||
|
|
||||||
### Who calls the site API
|
| Consumer | Repo (cadence) | Pinning | Impact under this plan |
|
||||||
|
|
||||||
| Consumer | Repo (release cadence) | How it's pinned to v1 | Migration cost |
|
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Browser SPA | `website/client` (same repo, lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | In-plan (Phase 1 client + Phase 2) |
|
| Browser SPA | `website/client` (lockstep) | `BASE = /api/v1` in `client/src/api/client.js` | **None.** Same URLs, same cookie session. |
|
||||||
| **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.** |
|
| 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, 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) |
|
| 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
|
Standing constraints, unchanged:
|
||||||
the load-bearing coupling — the `/api/mobile` facade (Phase 0) is what removes it from the critical
|
|
||||||
path so v2 can proceed freely.**
|
|
||||||
|
|
||||||
### 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
|
## Appendix: mapping from the previous plan
|
||||||
(`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.
|
|
||||||
|
|
||||||
### Shard (site ↔ link) — mostly *out* of the blast radius, with one seam to guard
|
| Previous | Now |
|
||||||
|
|---|---|
|
||||||
The auth merge is a **website↔client** change; the **website↔sidecar** contract is orthogonal and
|
| Phase 0 — `/api/mobile` facade + app-version floor | **Deferred.** See § Deferred: the `/api/mobile` facade |
|
||||||
should not move:
|
| 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 |
|
||||||
- The sidecar's own bearer token lives in the DB (`uoLinkConfig`, write-only in the API), the
|
| Phase 2 — domain split under `router/v2/` | **Phase 2**, in place under `router/v1/`; now the primary driver |
|
||||||
server-side REST client (`utils/uoLinkClient.js`) and live ingest (`utils/shardIngest.js`) talk to
|
| 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 |
|
||||||
the sidecar independent of any browser/mobile session, and `PROTOCOL_VERSION` / `X-UOLink-Version`
|
| PR final — retire v1 | **Not applicable** — v1 is never replaced |
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Website API v2 — `/api/v2` Skeleton (PR 1)
|
# 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
|
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
|
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).
|
behavior change**. Endpoints are filled in by the later PRs (auth merge, then the domain split).
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ Public contact email: **UOMysticmoon@gmail.com**
|
|||||||
|
|
||||||
Skeleton from the spec, with a small number of justified additions marked **(+)**.
|
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/
|
server/
|
||||||
.env.example
|
.env.example
|
||||||
|
|||||||
811
website/api-route-inventory.json
Normal file
811
website/api-route-inventory.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user