Protocol 3.0 Part A. Admin-configurable, per-feature and per-field
audience control over every shard-derived surface, replacing the static
PUBLIC_KINDS allowlist that used to be the whole boundary.
- SHARD_VISIBILITY.md (new): the admin-facing guide - the ladder, what
each of the ten features exposes, the defaults, the two rules that are
code rather than configuration, and worked examples.
- BACKEND_DESIGN.md 6.5 (new): the same thing as a security contract -
the ladder and how viewerLevel resolves it, the locked acct/webId rule,
the fail-closed kind map, the asymmetric ladder fallbacks, and the
three enforcement points. Plus the shard_feature_visibility schema, the
/public/shard/features route, and the adminOnly tier on
/admin/shard/visibility.
Defaults reproduce pre-3.0 behavior everywhere, with one deliberate
exception which is the leak Part A was written to close: guilds and
governors previously returned the raw stored payload, whose leader and
governor actors carry acct and webId, to anonymous callers.
Co-Authored-By: Claude <noreply@anthropic.com>
Surveys the live ServUO tree against everything the bridge already
surfaces and records the full gap list (17 items), then specs the four
features scoped for 3.0.
3.0 has three scope areas:
- A: the visibility framework. Admin-configurable, per-feature and
per-field audience control over all ten shard-derived surfaces (the
four new ones plus the six that already ship), on an
anonymous -> logged_in -> player -> staff -> admin ladder. Every
default reproduces today's behavior, so the retrofit is a no-op until
an admin changes something. Two rules an admin cannot override: acct
and webId are admin-only always, and an unmapped event kind is never
broadcast below admin. This also fixes a verified leak - guild leader
acct/webId are readable today on the anonymous /public/shard/guilds.
- B: three new wire streams - world.ruleset, points.board, and
vendor.listing/vendor.listing.remove.
- C: the spawn atlas, built from static ServUO data files with no wire
involvement.
Visibility lives entirely on the website; the sidecar stays a dumb
forwarder that defines no access parameters and advertises no
capabilities.
PROTOCOL_VERSION goes 2 -> 3 once, at the end: every part PRs into an
edge branch per repo, and the coordinated edge -> main merge is the
cutover. A schema migration moves uo_link_config.protocol so operators
don't have to.
Co-Authored-By: Claude <noreply@anthropic.com>
Doc side of website + Android-app feat/sso-trusted-device.
TRUSTED_DEVICES_MFA.md §6 gains an "SSO login paths" subsection: SSO is not
exempt from the second factor, and a trusted device skips it exactly as on the
password path (previously SSO consulted trust nowhere, so an external-identity
user was asked for a code on every sign-in). Documents the callback-side skip,
the new trustDevice/deviceName on POST /auth/sso/totp, and why recovery codes
stay password-login only.
Also writes down how this reaches the Android app, since it is not obvious: the
app's SSO runs in a Custom Tab that shares the system browser's cookie jar, so
the rg_trust cookie covers native SSO with no app change and no trust token in a
start URL (which would leak a secret into query strings and logs). The app's own
token is minted at /auth/mobile/sso/exchange instead — an authenticated
app→server call — so it never travels in the deep link, and the bridge row holds
only a boolean. Notes that one tick yields two independently-revocable rows.
§4 documents the new mobile_auth_sessions.trust_device column; BACKEND_DESIGN.md
gets the same column in its bridge table, the trust note on the /exchange row,
and a pointer from the bridge intro to the Custom Tab cookie model.
Co-Authored-By: Claude <noreply@anthropic.com>
Two corrections found by a live smoke test of all 200 routes at every access
level (website PR: fix/uolink-client-throw-and-sitemode-gate).
ARCHITECTURE.md: the "uoLinkClient never throws" invariant was true of the HTTP
call but not of resolving the config, which decrypts the stored auth token and
throws when the ciphertext can't be authenticated (SECRET_ENC_KEY rotated, or a
DB dump restored under a different key). Spell out that this is now handled
inside the client, reported as { ok: false, error: 'uo-link config unreadable' }
with a distinct ERROR log, and that GET /admin/uo-link/config keeps working —
it is the screen an admin needs to re-enter the token and recover.
BACKEND_DESIGN.md: GET /dashboard is staff-wide while PUT /site-mode on the
same screen is adminOnly — the one place a single screen spans two tiers. Note
that the client must gate that control itself rather than relying on the route
gate that admitted the user to the page.
Co-Authored-By: Claude <noreply@anthropic.com>
The domain split is complete. API_V2_PLAN.md gains a "PR 5 — as landed"
section (route table, the four zero-diff gates, and the findings worth
carrying forward) and its status line and sequencing list are updated: only
the CSP enforce PR remains, blocked on soak data rather than on code.
BACKEND_DESIGN.md §2 replaces the auth.routes.js / public.routes.js entries
with the full per-capability tree for auth/, public/ and player/, and §4's
group headings now point at the index.js files. The /player prose names the
three routers behind the shared gate.
Findings recorded rather than left in the code alone:
- public/ and auth/ deliberately have no group gate — the obvious hardening
edit to either is an outage.
- GET /auth/me depends on session.router.js being mounted last, because
use('/me', meRouter) matches the bare /me and supplies its noindex header.
- Two root-mounted routers (public/site, auth/session) on the PR 4 dashboard
precedent, safe only because neither declares router-level middleware.
- loginGuards is the PR's shared module, the counterpart to PR 3's
imageUpload.js.
- Filename deviations from the target tree (posts not news, session.router.js
added) and why public.controller.js was not split.
Co-Authored-By: Claude <noreply@anthropic.com>
Covers website PR 4, the last admin split PR: shard (16), uo-link (5), email
(6), discord-bot (2), settings (2) and dashboard/site-mode (2) leave the
residual file, which is deleted. The admin group is fully split.
API_V2_PLAN.md gains a "PR 4 — as landed" section recording the two decisions a
reviewer would otherwise have to reconstruct: dashboard.router.js is mounted at
the group root (the single relaxation of the mount-at-a-prefix rule, safe only
because it declares no router-level middleware), and /shard keeps two gate tiers
in one router because prefix ownership beats swagger-tag grouping. Sequencing
item 7 is marked landed; PR 5 (public/player/auth) is the only split PR left.
BACKEND_DESIGN.md §2 gets the six new routers in the folder tree and drops the
residual entry; §4's /admin preamble now describes the ops/config gates instead
of pointing at a file that no longer exists.
WIKI_UPGRADE.md's two links into admin.routes.js are repointed at wiki.router.js
and admin/imageUpload.js.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds a "PR 3 — as landed" section to API_V2_PLAN.md and ticks the sequencing
list. 31 routes extracted, 33 left in admin.routes.js; all four zero-diff gates
came back clean and 434 server tests passed.
Findings carried forward:
- The residual 33 is exactly PR 4's list, so admin.routes.js is deleted by
PR 4 rather than PR 5.
- First shared module in the split: the multer config, because POST
/posts/upload and POST /uploads no longer live in the same file.
- POST /uploads keeps its Admin · Posts swagger tag — retagging is a real
OpenAPI diff and does not belong in a route-move PR.
- The wiki router has load-bearing intra-file route order (/categories and
/tags ahead of /:slug) that no gate can catch, because the manifest sorts
its entries. Verified by introspecting the built router stack instead.
BACKEND_DESIGN.md §2 gets the four new routers plus imageUpload.js in the
folder tree, and §4 notes that the content capabilities add no gate beyond
staffOnly.
Co-Authored-By: Claude <noreply@anthropic.com>
Matches the code change in website: 18 more admin routes carved into
moderation.router.js (15), botActivity.router.js (2) and activity.router.js (1),
leaving 64 in the residual admin.routes.js.
API_V2_PLAN.md gains a "PR 2 — as landed" section recording the four zero-diff
gates and two decisions worth carrying into PRs 3-5:
- /activity gets its own file rather than the target tree's plan to park it as
a singleton inside dashboard.router.js — honouring the tree would have left
one route in the residual file for two PRs, and it is a genuinely separate
capability (the staff audit log, not the dashboard's stats overview and not
the botScore middleware's ban state). PR 4 therefore mounts dashboard and
site-mode only; the target tree is updated to match.
- A gate moves to a router-level `use` only where it was already a *prefix*
mount (moderation's modAccess). Bot-activity's per-route adminOnly stays
per-route, because the per-route handler count is the only thing in
routes.guards.json that would catch a dropped gate — requireRole(...) returns
an anonymous arrow and never appears by name.
BACKEND_DESIGN.md §2 (folder structure) and §4 (the /admin contract preamble) are
updated for the new files and their gates. PROJECT_TREE.md is left alone — since
website#98 it is auto-generated by the sync-project-tree workflow.
Co-Authored-By: Claude <noreply@anthropic.com>
Documentation half of the first of five domain-split PRs (API_V2_PLAN.md § Phase 2).
BACKEND_DESIGN.md
- §2 folder structure: admin/ now shows index.js (shared gate + mount table) and
the four capability routers with their route counts, prefixes and extra gates;
admin.routes.js is labelled as the 82-route residual that goes away with PR 5.
- §4 /admin heading: was "admin.routes.js -> admin.controller.js", now points at
admin/index.js and notes staffOnly, which the old heading omitted.
- The "planned change" note becomes "in progress" with what has landed.
API_V2_PLAN.md
- Status: planning -> in progress; PR 1 marked landed in the sequencing list.
- New "PR 1 — as landed" section: the route-count table (6+15+3+4+82 = 110) and
three findings for PRs 2-5 — why the self-service /shard/* routes stay with the
shard capability despite their Admin · Account tag, why a prefix mount must not
be "simplified" to a pathless one (a bare use(gate) would then run for requests
headed to later mounts), and that routes.guards.json came back zero-diff too.
- New section on the swagger path-normalization prerequisite and its consequence:
with sorted path keys, a pure route move produces no spec diff, so the spec
becomes a third zero-diff gate alongside the manifest and guards files.
- Correction to step 6: PROJECT_TREE.md is auto-generated by the sync-project-tree
workflow since website#98 and must not be hand-edited in split PRs.
api-route-inventory.json is unchanged — verified still byte-identical to
server/routes.manifest.json (200 public + 2 internal), which is the point.
Co-Authored-By: Claude <noreply@anthropic.com>
Matches website PR "build(swagger): normalize and sort generated OpenAPI path
keys", which post-processes swagger-autogen's output ahead of the admin router
domain split (API_V2_PLAN.md § Phase 2).
- website-README.md § Regenerating the spec: why trailing slashes are stripped
(a capability router mounted at /users declaring router.get('/') would document
/api/v1/admin/users/, a URL no client calls) and why path keys are sorted.
- BACKEND_DESIGN.md § generated artifacts: note that both the route manifest and
the spec are emitted sorted, so a diff in either is proportional to the change.
Co-Authored-By: Claude <noreply@anthropic.com>
Companion to website "feat(security): soak the tightened CSP on report-only".
The plan's Phase 1 claimed a two-directive delta, one of which was adding
`form-action 'self'` as "currently absent". It was not absent. The directives
object in app.js does not list it, but the middleware runs `useDefaults: true`
and helmet's default set already supplies it, so production has been serving it
all along. The plan was written from the config rather than from the live
header; the correction, and how it was caught, are now recorded in place rather
than quietly fixed.
That leaves `frame-ancestors 'self'` -> `'none'` as the entire behavioural delta
of the phase. Worth noting that this is also the directive that most justifies a
soak: a frame-ancestors violation is reported by the browser of whoever framed
the site, so it is the only available way to discover a legitimate embed before
an enforcing policy breaks it.
Also documented:
* POST /api/csp-report -- the same-origin sink report-to/report-uri point at,
why it is same-origin, why it lives outside /api/v1, both wire formats, the
Reporting-Endpoints header requirement, and the properties that make an
unauthenticated public POST safe (always-204, caps, truncation, rate limit).
* That the sink is scoped to the soak, so the enforce PR must decide
explicitly whether to retire it or keep a report-to group on the enforced
policy -- rather than leaving an orphan route behind.
* The `[csp]` log tag in section 7.5 as the thing to watch during the soak,
and what silence across one release means.
* client/vite.config.js already sets `modulePreload: { polyfill: false }`, so
the plan's inline-polyfill prerequisite was already satisfied.
api-route-inventory.json moves 199 -> 200 for the new route. That is the PR 0
freeze working as intended: the first manifest diff since the baseline is a
deliberate, reviewed one.
Co-Authored-By: Claude <noreply@anthropic.com>
Companion to website PR 0 (chore(server): freeze the URL surface with a
generated route manifest).
BACKEND_DESIGN.md gains § 4.0, naming the two generated artifacts that are
actually authoritative about the API and what each is authoritative *for*: the
manifest records which URLs exist (introspection-derived, reality), the Swagger
spec records what they mean (annotation-derived, intent). The prose tables in
§ 4 are orientation and can drift; those two files cannot. Also documents
routes.guards.json as a review aid that is explicitly not a contract.
API_V2_PLAN.md marks PR 0 shipped and records its two deviations. The optional
unauthenticated-status snapshot was tried and dropped exactly as that section
allowed — against the dead-port mariadb pool the tests use it sits on the acquire
timeout rather than failing fast — replaced by a deterministic assertion that
every /admin/** and /player/** route still carries requireAuth.
routes.guards.json is committed and staleness-checked even though a diff in it
is not a contract change, because an ungenerated review aid rots into a
misleading one.
The sequencing section is corrected: PR 0 now runs before the CSP pair. The CSP
report-only PR must stand up a POST /api/csp-report collector for `report-to` to
target, which is a new URL under /api/**; landing it first would have left PR 0
generating 200 routes against a 199-route baseline, destroying its own acceptance
test. With PR 0 first, the collector appears as a reviewed, deliberate +1 in the
manifest — the mechanism working as intended.
api-route-inventory.json is unchanged, which is the point: the generator
reproduced it byte-for-byte on first run.
Co-Authored-By: Claude <noreply@anthropic.com>
The API v2 plan is revised down to the work that is actually justified: a CSP
hardening pass and an in-place domain split of the monolithic route wiring.
- Auth merge (httpOnly cookies -> bearer + rotating refresh for every client) is
removed and re-filed as deferred behind trigger conditions. httpOnly+SameSite
is the stronger model, session.service.js already unifies cookie and bearer,
the SSO/PKCE transaction cookies survive any merge, and it dragged the admin
SSE fetch/ReadableStream rewrite along as a dependency for no user-visible
payoff. A revival must first spec refresh-token reuse detection and a rollback
procedure.
- No parallel /api/v2. The URL surface is already grouped by capability, so each
new router file mounts at the prefix it already owns and every URL stays
byte-identical. No dual mount, no per-route migration, no v1 retirement; the
SPA, Discord bot, and Android app are all untouched. API_V2_SKELETON.md is
marked superseded (kept as the recipe if a versioned API is ever forced).
- The /api/mobile facade and app-version floor are deferred with the revival note
that it starts as a one-line alias mount, not ~70 hand-written delegates. The
M11 milestone is dropped from android/PLAN.md.
- Adds PR 0: a generated route manifest, so "every URL is unchanged" is proved by
a zero-line diff rather than asserted in review. The baseline
api-route-inventory.json (199 API routes + 2 internal) is committed here and is
what PR 0's generator must reproduce byte-for-byte.
- Split sequenced as five grouped PRs; CSP fixed to report-only first, then
enforce (the old plan contradicted itself), with the verified delta being just
form-action 'self' and frame-ancestors 'none'.
Co-Authored-By: Claude <noreply@anthropic.com>
Expand the API v2 plan to account for consumers beyond the browser and
insulate the Android app from version churn before the v2 work begins.
- Inventory the three v1 API consumers (browser, Android app, Discord bot)
and map the cross-component contracts (site<->link, site<->mobile).
- Fix two concrete plan bugs: the public shard SSE stream must stay
anonymous (logged-out browsers and the app's ShardStreamClient send no
auth header), and the useShardFeed fetch-rewrite is admin-stream-only.
- Add "Phase 0 - the mobile facade": a version-agnostic /api/mobile
namespace (a thin BFF delegating to current controllers behind pinned
wire shapes), landed before v2 so the auth merge never touches the app.
- Note link/ is essentially out of scope (no PROTOCOL_VERSION bump), with
the admin-stream allowlist split as the only shared seam.
- Resequence PRs (Phase 0 first) and gate v1 retirement on the pre-facade
app fleet aging out via an app-version floor, not the web client.
- Add M11 to docs/android/PLAN.md: migrate the app to /api/mobile + ship
the app-version floor, cross-referenced with the website plan's Phase 0.
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Companion doc to API_V2_PLAN.md describing PR 1: stand up router/v2/ empty but
wired next to a frozen /api/v1, with a trivial GET /api/v2/version to make the
mount testable and no behavior change. Includes the file tree, the api.router.js
/ v2.router.js wiring, empty capability-router stubs, and acceptance criteria.
Adds a forward link from the plan's PR-1 line to the skeleton doc.
Co-Authored-By: Claude <noreply@anthropic.com>
Plan for website API v2, sequenced in two phases behind a parallel /api/v2:
- Phase 1: retire httpOnly session cookies; unify web + mobile on the existing
bearer access + rotating/revocable refresh model. Separates removable session
cookies from the SSO/email transaction cookies that must stay. SSE moves to
fetch-based streaming with Authorization: Bearer.
- Phase 1b: tighten the shipped CSP for the now-JS-held token (add form-action
'self', frame-ancestors 'none'); self-host fonts + Trusted Types as follow-ups.
- Phase 2: break the monolithic route wiring (admin.routes.js, ~100 routes) into
one router per business capability so the URL predicts the file.
Co-Authored-By: Claude <noreply@anthropic.com>