16e31de087da78bda34b78e10939a9f11e947d0f
356 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 16e31de087 |
Merge pull request 'feat(teams): phase 4 — the forum access model, announcements and the operator's controls' (#153) from feature/teams-phase4-forum-access into edge
Reviewed-on: #153 |
|||
| 57286594e7 |
test(teams): the four acceptance criteria, and regenerate the API artifacts
Four tests are named "acceptance" and are Phase 4's criteria verbatim. Each names a property the code around it can lose without any screen looking different: 1. A granted, unlinked account reads the forum, is absent from the member rows, and is still refused external-platform eligibility. The membership projection is asserted byte-identical across a grant, which is what "non-contamination" means in practice. 2. With the switch off every forum route 404s AND nothing is read or written on the way there — a guard that 404s after loading the thread is one that still bumped a counter. 3. The stored HTML is byte-identical between `disabled` and `remote`; only the rendered output differs. That is the property the renderer-owned design exists to give, and it is what makes flipping the policy back a no-op rather than a migration. 4. Selecting `uploads` without a matching acknowledgement is refused server-side, with the admin checkbox bypassed. Plus the ones that are not criteria but are the same kind of claim: an author cannot smuggle an <img> or its attributes through in any mode, http and non-image URLs stay plain links, a leader cannot revoke a staff-issued grant, a demoted account stops protecting the grants it made, moderation records which authority was exercised, and a RIFF container that is not WebP is not accepted as one. Twelve new routes in the manifest, all annotated and in the OpenAPI spec. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| cbb7339a3a |
feat(teams): the forum panel core fills, and the operator's controls
The forum had nowhere to live. TEAMS.md 3.1 gave it a CORE page, and phase 3 deleted every core Team page — Teams is a contract primitive and core does not own the word for one. So the forum follows the activity feed: module-uo declares a second place on its guild page and core fills it. TWO slots rather than one, because a slot holds one component and the first fill wins. Stacking the feed and the forum into a single fill would take from the module the ability to place core's two contributions separately on its own page, which is the whole point of the module owning it. The panel navigates by SEARCH PARAM (?thread=12) rather than by route. A thread has to be linkable and core cannot mount a route for one — the route belongs to the module's page — so a search param gives a shareable URL under whatever path the module chose, with the back button intact and no core route anywhere in it. That is why the fill is one component holding both a list view and a detail view. Post bodies arrive already rendered by the server under the current image policy, which is why they are set as HTML here rather than sanitised again: the body was cleaned on write with the forum's own profile, and any <img> in it was emitted by core's own renderer with a fixed attribute set. A client-side sanitiser would have to strip exactly the tag core just decided to add. The published image mode is read only to decide which composer to draw — never what renders. The composer puts an uploaded file's URL into the body as TEXT, not as a tag. The author never writes markup, which is what keeps the operator's policy enforceable. The admin panel carries both settings, the always-on help text, and the confirmation dialog with its two checkboxes and one recorded acknowledgement — plus the three additions the org lead settled: attribution and staff removal, the warning that disabling later does not delete existing files, and who "users" actually means. A stale acknowledgement raises a banner and freezes the settings; it does not turn uploads off. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 4ac353684a |
feat(teams): harden the upload path for an uploader who is not an admin
The existing admin upload path is already good for an admin: an 8 MB cap, a mimetype allowlist, a random filename, an extension derived from the mimetype map and never from originalname, and nosniff forced on serve. All of it is kept. What it does not have is anything that assumes a hostile uploader, because until now it has not had one. Magic-byte sniffing, because `file.mimetype` is the client's own Content-Type header — a player can send image/png with arbitrary bytes and land arbitrary content under a .png. Unrecognised bytes are a rejection and never a fallback to what the header claimed. The file is on disk before it can be sniffed, so the rejection path removes it: a rejected upload left on disk is the same disk-exhaustion vector reached another way. A rolling per-account byte quota and a per-IP rate limit, because community uploads with no ceiling is disk exhaustion on the operator's own host. An attribution row per accepted file. Not bookkeeping: the acknowledgement is meaningless if "who uploaded this" cannot be answered afterwards, which is exactly what the operator has just accepted responsibility for. A nightly sweep for soft-deleted files past retention and for never-referenced orphans, in the same in-process shape as the activity prune. It runs whether or not `uploads` is the current mode, and that is the point — an operator who turns uploads off after a problem still has the files, and a sweep that switched itself off with the setting would strand exactly the bytes they were trying to be rid of. It works from the forum's own rows outward and never from the directory listing inward, because UPLOAD_DIR is shared with the admin upload path. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| e27c368234 |
feat(teams): the grant flow, announcements, and the routes behind both guards
Path 3's WRITE half. The resolver landed in phase 2; this is who may hand access
out, to whom, and what stops a leader turning a Team forum into open hosting on
the operator's site.
Two authorities, and not one authority with different reach. Staff may act on any
Team, uncapped, and may revoke anything. A leader may grant and revoke ordinary
access on their own Team, is capped at `teams_max_grants_per_team` (default 50),
is rate-limited, and may NOT revoke a staff-issued grant — which is what stops a
leader undoing a moderation decision. The issuer's role is checked at revoke time
rather than stored, so an account that has since lost its staff role stops
protecting the grants it made.
Nothing on this path writes team_members, in either direction. A grant may name any
account, including one with no linked game identity — that is the point of it — and
that account stays off the roster, out of every count, and ineligible for external
platforms.
Announcements are a degenerate thread rather than their own object, so phase 5 adds
no migration. Moderation records WHICH authority was exercised: a staff action also
writes activity_log, a leader's writes only the Team's own ledger. Merging the two
would make a guild leader locking a thread an appealable Discord sanction.
Every forum route answers 404 while the switch is off, and 404 — never 403 — to a
caller with no access: in a private room the contents and the existence are the
same secret. The grant routes deliberately answer even while the forum is OFF,
because a toggle-off revokes no grant and the access list has to stay manageable.
Under /player rather than /admin: a leader is a player, and the /admin tier gate is
requireRole('admin','editor','moderator') — putting a leader endpoint behind it
would mean widening that gate.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| fb70013adf |
feat(teams): the forum's own HTML profile, and core's image renderer
The load-bearing decision of the whole forum design, and deliberately not how the rest of the site works. Core's shared sanitizer allows <img> from any host — it is tuned for rich text from the ADMIN editor, where the author is already trusted. Handing that to arbitrary players would make `teams_forum_images` unenforceable: every post could hotlink in every mode and the setting would be decoration. So the forum derives its own profile in which `img` is never an allowed tag, in any mode. What an author writes is a URL. What decides whether it becomes a picture is this file's renderer, at READ time. Four properties fall out: the policy cannot be evaded, because the only code that can emit an <img> is core's; flipping the setting back to `disabled` un-renders every image on every existing post with no data migration, since the images were never stored; there is no author-supplied srcset, onerror, width or style to smuggle anything through; and a blocked or dead image degrades to the URL the author actually wrote. Two details found while building it: `rel` had to be ADDED to the allowed attributes to make links safer, not laxer. The profile writes rel="noopener noreferrer nofollow" through a transform, and sanitize-html strips any attribute not on the allowlist — including one its own transform just added. Without the entry, every forum link shipped without noopener. The bare-URL linkifier runs AFTER sanitising, over the sanitiser's own output and only on text outside tags. That ordering is the security property: every text node is HTML-escaped by then, so the matched URL is safe in both the href and the link text. Running it first would be an injection point. https: only, because the CSP is `img-src 'self' data: https:` — an http: image is blocked by the browser and renders broken, which presents as "images are broken on my forum" with nothing in any log. And the server never fetches a user-supplied URL: that is an SSRF vector, and an allow-set is useless when the point is arbitrary hosts. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 11fd9821bf |
feat(teams): the forum schema, the operator's two switches, and the ack gate
The whole forum schema lands at once — threads, posts, the moderation ledger and upload attribution — including the columns only phase 5's discussion threads use. That is TEAMS.md 5.1's split BY LAYER rather than by feature: phase 5 opens paths instead of migrating data. Three settings keys, and only one of them is ordinary. `teams_forums_enabled` and `teams_forum_images` are enum keys on the existing admin settings endpoint; `teams_forum_images` also carries a server-side PRECONDITION, which is why the three live in their own model rather than in the generic setMany() loop where a reader would never find it. The gate is the server's. `PUT teams_forum_images = 'uploads'` is rejected 400 unless the same request carries the acknowledgement version — the admin checkbox is how the gate is presented, never the gate. What is stored is the TEXT VERSION, so "which wording did they agree to" is answerable later; settings already record updated_by/updated_at, and an activity_log row puts it in the staff audit trail. A reworded notice makes a stored acknowledgement stale, and neither obvious answer is right: uploads KEEP WORKING, and no other forum setting may be saved until it is re-given. Non-destructive, and impossible to ignore. Both reads fail closed. A DB fault reports the forum off and images disabled — a forum that 404s for a minute is the cheap failure; a policy that is not a policy is not. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 7ed2ac9983 |
Merge pull request 'feat(teams): the activity feed, the roster projection, and the inverted slot' (#152) from feat/teams-phase3-pages-activity into edge
Reviewed-on: #152 |
|||
| 5d9d10b245 |
refactor(teams)!: Teams is a contract, not a surface — invert the slots
Org lead's correction, and it changes what this phase ships.
TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.
Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.
Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.
**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.
Three things about the inverted direction are load-bearing:
- the name is namespaced under the declaring module and that is enforced, not
conventional: it is the only thing keeping two modules off one name;
- core's fills are applied at MOUNT rather than eagerly. Core's bundle
evaluates before every module chunk, so when core registers a fill the slot
does not exist yet — filling eagerly would silently do nothing;
- a fill for a slot nobody declared is a no-op, never an error. The declaring
module is simply not installed, which is the ordinary case. That is the
opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
there, core declares first, so an unknown name is always a typo.
`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.
`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 203ce9c654 |
chore(teams): regenerate the OpenAPI spec and the route manifests
`npm run swagger` + `npm run routes:manifest` for the one added route, `GET /api/v1/public/teams/:slug/activity`, and for `optionalAuth` joining `/teams/:slug/members`. The guards manifest names `optionalAuth` on both, which is the point of that file: a reviewer can see that two public routes now read the caller's identity without reading the routers. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 8f4aff6946 |
feat(teams): the public Team pages, the two slots and the nav flag
TEAMS.md §3.1–§3.5. Four core pages — the index, a Team's overview, its full
roster and the player portal's "My Teams" — plus the two extension slots a
module adds to them, and the nav rows that lead there.
These are CORE routes, not module ones. A Team is a core platform entity that a
module merely populates, so the whole experience renders on bare core; a module
adds to these pages rather than supplying them.
`team.member.row` is declared with `{ displayName, isLeader, linked }` and not
§3.4's `{ memberKey, userId, displayName }`. The two documents contradict each
other and §3.2 is the one that is a security rule: a slot component runs in the
browser, so those props can only reach it by publishing a game-internal
identifier and a site account id in every public roster response, for every
visitor, module installed or not. Recorded as an amendment.
The presentation logic is split into lib/teams.js with its own tests, following
lib/teamAdmin.js, because these pages have to state differences that read as
bugs unless they are worded deliberately:
- "37 members · 21 linked" — the gap is information (a character with no site
account behind it), and the header says what each number IS rather than
showing both and hoping;
- an empty roster has three unrelated causes — nobody in the Team, a rung
that shows nobody, and a module that could not be asked — and reporting the
last as the first is a statement about the game that happens to be false;
- a stale projection says how old it is rather than presenting itself as
current.
`teams` is the first CORE nav row to carry a `feature` since the shard rows left
with the module cutover, and it brings core's own feature provider back with it.
It gates on whether this deployment has Teams AT ALL, not on who is looking —
Team pages are public and the server gates them. It fails open, so an unknown
answer shows the link: a Teams link leading somewhere empty is a far cheaper
mistake than a Team page nobody can find.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 03631d7d40 |
feat(teams): the roster's audience projection, and optionalAuth to resolve it
TEAMS.md §3.3, as the eighth member of MODULE_API 1.6.0 — amended in place per the org lead, on the rule Protocol 4 was given in phase 2: a contract owes a bump only once it has landed on `main`. Two questions meet on the roster and they belong to different owners. WHICH ROWS a viewer may see is the module's, because the audience rungs and their configuration live there and core does not know what a rung is. WHAT A ROW LOOKS LIKE stays core's. So `projectRoster` answers with member KEYS, not rows. §3.3 said rows, and rows would let a module widen what is published — handing back a `userId` core had withheld — leaving core's field guarantee resting on every module's good behaviour. Core asks which rows and re-normalises the answer through its own public shape, so a module can narrow and cannot widen. "The module declines" needed splitting before it could be implemented. No module at all and a module whose rungs could not be consulted are opposite situations: the first withholds nothing and must serve the roster whole, the second must serve none of it. The refusal carries `projects`, and only `projects: true` fails closed. Without the split, bare core serves an empty roster on every Team page. This is also the first public route whose CONTENT depends on identity, which needed a middleware core did not have. `attachSession` only decodes a token, so a banned account, a password change or a logout would have kept working against the private half of a feed until the JWT expired. `optionalAuth` runs requireAuth's full database re-validation and, on any failure, continues ANONYMOUSLY rather than rejecting — a caller whose session is no longer good sees the public view, which is what they are entitled to. `GET /public/teams/:slug/activity` lands here for the same reason: §2.11's route table had no activity endpoint though §4.3 describes a filtered feed. Paged, with the visibility resolved from the session and never from a parameter. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| aa332eda82 |
feat(teams): the activity feed, its two writers and its retention
TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.
Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.
Three rules shape the model:
- core never composes a summary. It arrives already rendered and is stored
verbatim; core cannot phrase "gained 15,000 gold" for a game whose
vocabulary it does not know.
- visibility fails closed. An item with no stated visibility is `members`.
- a push never throws at its call site. It is called from inside a game-event
handler, and a storage problem of core's must not become the module's
control flow.
Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.
Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.
The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 1f175786a7 |
Merge pull request 'feat(teams): Team core — the reconciler, the four authority paths, and the impersonation controls' (#151) from feat/teams-phase2-team-core into edge
Reviewed-on: #151 |
|||
| cf2666e5bc |
feat(teams): the Team read API, the moderation routes, and Admin -> Teams
The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations,
and the staff screen that drives them.
Two rules shape the read model. Hidden means absent from every public surface --
the index, the lookup and the roster alike, and a hidden Team 404s
indistinguishably from one that does not exist, because "absent" includes not
confirming it is there. And staleness is surfaced rather than silent: every
public payload carries { configured, stale, lastSyncAt }, so a page can say how
recently the projection was confirmed instead of presenting stale data as
current.
The public roster withholds both the member key and the user id -- one is a
game-internal identifier, the other names a site account. `linked` answers the
only question a public page has without publishing which account. The module's
per-audience field projection is phase 3's; this is a conservative core one.
The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all
eighteen; three of them mean something different when they do, and the server
decides from the role it re-validates on every request rather than from a token
claim. The client has no "file as request" argument to get wrong.
Found by booting the real server against the real database, and not by any test:
**the index and the by-slug lookup disagreed about what exists.** listPublic was
keyed on a registered team provider while findBySlug is not, so with no module
installed `/teams` returned an empty list while `/teams/:slug/members` served a
full roster -- the index denying a Team that direct URLs answered for in full.
The rows are core's and they outlive the module that filled them: an uninstalled
module leaves a projection that is unmaintained, not one that stopped existing,
and `configured: false` is how a client learns that. The read side no longer
takes the provider into account at all. There is now a test named for the
property.
Also verified live: the public routes answer anonymously, an unknown and a hidden
slug both 404, the player and admin tiers 401 an anonymous caller, a seeded
roster projects correctly, and the reconciler logs that it is staying idle with
no provider registered rather than failing a boot.
Process obligations, all done: #swagger.* annotations on every route, `npm run
swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas
they reference added), `npm run routes:manifest` regenerated -- additions only,
184 public routes -- and BACKEND_DESIGN.md updated across the schema section and
all three tier tables.
Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a
row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view
renders it. That split earns itself here specifically -- the screen's job is to
make "the shard has no Teams" and "core has not been able to ask for two hours"
impossible to confuse, and those two produce the same empty table. The four
freshness states are named and tested for exactly that reason, and the last
provider error is shown verbatim rather than paraphrased.
The button labels follow the caller's role: a moderator sees "Request publish",
so the pending result is not a surprise. Hiding is offered to everyone with no
gate, matching the server.
Server 894 passed, client 206 passed, client build clean. 17 route tests, 20
client display tests.
Refs docs/website/TEAMS.md §2.11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 8fe2e01466 |
feat(teams): reserved-name screening, auto-hide, and the admin-approval gate
The one place untrusted game data becomes a public page (docs/website/TEAMS.md
§2.8), and the gate on releasing it (§2.9).
A Team's name is written by a player, in the game, with no review, and this
platform turns it into a public page, a URL and eventually a Discord channel
name. Someone naming their guild "Admin" or "<Brand> Staff" gets an
official-looking page on the operator's own site for free.
Hide, never reject. Core cannot refuse a name -- the guild already exists in the
game and core is a mirror of it, not an authority over it. A match hides the Team
from public surfaces and files it in a review queue, and it keeps working
completely for its own members: their forum, their grants, their notifications.
The people in it are not being punished for a name their leader chose.
That asymmetry -- a false positive costs a human glance, a false negative costs
an impersonated staff page -- is what lets the matcher be conservative. It is not
licence to be sloppy the other way: a check that fires on "Badminton" gets
switched off, and then the real cost is paid in full. So matching is whole WORDS
after normalisation, never substrings, following the precedent
scripts/checkModuleIdentifiers.js set for exactly this reason.
Three matcher gaps found by writing the tests, all real impersonation vectors:
- "Guild of Moderators" did not match `moderator`. Only a trailing s off the
WHOLE term is stripped, so "Nomads" still does not match `mod`.
- "G.M." normalises to two single-letter words and matched nothing. A run of
two or more single-letter words is now also offered joined. Deliberately not
a whole-name condensation, which would re-admit substring matching.
- The multi-word condensed form was already handled and is what makes
"RunicGateway" match the two-word term -- the form an impersonator would
reach for, since it is what the Gitea org and every URL use.
Terms resolve at CHECK time, never baked in, so renaming a deployment protects
the new name without a redeploy. A failed settings read falls back to the static
role and project terms rather than to an empty list: screening fewer terms is
bad, screening none is the whole hole.
Re-screening runs on every reconcile, over names no human has ruled on. Names are
immutable per row, so it only ever changes an outcome when the TERM LIST changed
-- an operator adding one, or a rename -- which is exactly what a create-time-only
check would miss forever. `name_reviewed_at` is what makes a staff decision
sticky; without it an override would be undone every fifteen minutes.
The gate is scoped to three actions because they publish untrusted game-sourced
strings, and to nothing else. Ordinary forum grants, leadership overrides,
archives and forum moderation still apply immediately and are audited. A
moderator initiating one files a pending request; an admin applies at once.
Never four-eyes on admins: users.role defaults to admin and `npm run seed`
creates exactly one, so most deployments have precisely one and a second-approver
rule would wedge them with no way out.
Hiding is deliberately NOT gated. Publishing untrusted data needs a second pair
of eyes; withdrawing it needs to be possible at once, by whoever is on duty.
Two concurrency details worth the review: a decision moves the row out of
`pending` under a guard and applies its effect only if the row actually moved,
so two admins clicking approve cannot double-apply or overwrite each other's
record; and a JSON payload is parsed defensively, because the driver returns
JSON columns already parsed on some versions and as a string on others.
Screening is stubbed in the reconciler's own tests -- it is a separate unit, and
the real call reads settings, which this suite must never do against a live
database. That was caught the hard way: the suite went from 11s to hanging, and
the cause was the reconciler reaching a dead pool through the new call.
44 tests in the reconciler file (up from 39), 19 for the matcher, 25 for the
gate. Full suite 877 passed, 0 failed.
Refs docs/website/TEAMS.md §2.8, §2.9, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| bfd844e8fb |
feat(teams): the four-path access resolver and staff leadership overrides
The four authority paths of docs/website/TEAMS.md §2.5, and the rule that they stay four: four tables answering four questions, and no resolver reads another path's table. 1. Is this account a member? module team_members 2. Does this account lead the Team? module team_members.is_leader + override 3. May it use the Team forum? CORE team_forum_grants OR path 1 4. May it get external access? CORE derived, nothing of its own The temptation this resists is collapsing 1 and 3 into one boolean. They answer different questions about different populations: a forum grant may name any Runic Gateway account, including one with no game identity at all -- that is the point of it, since letting an unlinked guildmate into a forum must not require a staff ticket. Reading "has forum access" as "is a member" would put that person on the public roster, into every membership count, and into the external-platform grant, which is where a modelling preference becomes an impersonation risk. Path 4 is deliberately blind to path 3, and the reason is written down so nobody "fixes" it: an integration cannot verify that an unlinked, forum-granted account corresponds to a real game member, so it must not hand that account a privilege on a platform where impersonation has consequences. A forum is a room on the operator's own site with a known moderator; a Discord role is an identity claim in someone else's space. Leadership overrides are applied ON TOP of the synced value at read time, never written into the projection. The sync owns that column and rewrites it every interval, so an override stored there would be undone fifteen minutes after staff set it -- which is the whole reason §2.5.1 is a separate table. The roster carries both the resolved answer and `is_leader_synced`, so an admin sees that a decision was made rather than being shown it as fact. Three tests are named INVARIANT rather than for behaviour, because what they protect is structural and a reasonable-looking refactor destroys it silently: a grant never writes the membership projection, a granted user is absent from the roster, and a grant does not confer external eligibility. None of those failures appears on a screen as a bug -- the first shows up as a stranger on a public roster, the second as a Discord role handed to an account nobody can tie to a real player. Every unit test here stubs the db layer, so the SQL itself was verified separately: all 44 statements across teams.db.js and teamAccess.db.js were run against MariaDB 11 with a throwaway module id and cleaned up after. That run also confirmed live what the reconciler's tests could only assert against a stub -- an upsert does not overwrite is_leader, a revoked grant frees the unique key for a new one while the ledger keeps both, and an archived team stays resolvable at its old slug while its external_id is free for the successor row. 19 tests. Full suite 828 passed, 0 failed. Refs docs/website/TEAMS.md §2.5, §2.5.1, §2.6, Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 92631347f9 |
feat(teams): the reconciler, its four refusal gates, and ctx.teams (API 1.6.0)
Core's projection of the module's Teams, kept in step (docs/website/TEAMS.md
§2.4), plus the two ctx members a module pushes through.
The four gates are the file, and each is invariant 1 in a different costume --
module unavailability is staleness, never emptiness:
1. getTeams() not ok -> record the failure, touch NOTHING, return.
2. ok but empty, core holds >=1 -> quarantine; apply only if the NEXT
authoritative answer, an interval later,
agrees.
3. getTeamMembers() not ok -> that Team's roster untouched and stale; the
other Teams sync normally.
4. ok but zero members, had some -> the same two-strikes quarantine, per Team.
Gates 2 and 4 exist because an authoritative-looking empty answer during a cold
start is the one failure indistinguishable from a real wipe. "Every Team on the
shard disbanded at once" costs one interval to confirm; getting it wrong empties
every roster on the site.
Events are an optimisation, never the source of truth. Member and leadership
deltas apply at once for a Team core already knows; team.created and
team.disbanded only ask for a run. §2.2 scopes archival to an authoritative full
list, so a repeated or spurious disband event costs a reconcile rather than a
Team -- and a Team invented from a delta would have no name, no roster and no
leaders anyway.
Two columns TEAMS.md did not contemplate, both on `teams`:
- roster_synced_at, because team_sync_state holds one row per MODULE and gate 3
leaves ONE Team behind while the others sync. Without a per-Team stamp that
Team's page would report the module's last success as its own -- exactly the
staleness the gate exists to surface.
- members_empty_since, gate 4's per-Team quarantine. The twin of
team_sync_state.pending_empty_since, which is per module and cannot express it.
One real bug found by its own test. The roster upsert was writing is_leader, so a
refused getTeamLeaders() left every member demoted -- the roster had already
written `leader: false` before the authoritative call was even made. §2.5 is
explicit that path 2 is answered by getTeamLeaders(), so is_leader is now set on
INSERT only (seeding a Team so it is not leaderless while that call fails) and
moved afterwards by setLeaders() alone. Two writers for one column was the whole
defect.
MODULE_API_VERSION 1.6.0 on both halves -- they state one contract and a module
declares one coreApi range. The number covers the whole Team surface per Part 11;
the members arrive by phase. registerTeamProvider, ctx.teams.publish and
ctx.teams.reconcile are live. ctx.teams.activity.push (§4, phase 3) and
api.registerSlashCommands (§7.1, phase 7) are present and THROW with a sentence
naming their phase, rather than being absent or silently accepting data into
tables that do not exist yet.
39 tests here, and the ctx surface guard in moduleLoader.test.js updated -- it
caught the addition, which is what it is for. Server 809 passed, client 192
passed, 0 failed.
Refs docs/website/TEAMS.md §2.2, §2.3, §2.4, Part 11, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 8b63ffc725 |
feat(modules): registerTeamProvider, and a call path that cannot answer "empty"
The registration a module uses to become the authoritative source of Teams
(docs/website/TEAMS.md §2.3), plus the wrapper core calls it through.
registerTeamProvider is the first registration where core CALLS THE MODULE and
waits for an answer. Every existing one is either the module claiming a mount or
core notifying it; the closest precedent is registerAnnounceLeg's dispatch, and
this is modelled on it rather than invented. It also holds a single value rather
than a map, unlike every other registry: Teams have one authoritative source by
construction, and two modules answering "what teams exist" would produce two
disjoint sets under one `teams` table with no rule for merging them. A second
registration is therefore a collision, named against the module that holds it.
teamProvider.js is where invariant 1 -- module unavailability is staleness,
never emptiness -- is actually enforced. It is deliberately generous about what
counts as a failure: a rejected promise, a synchronous throw, a timeout, a
non-object, a bare array, a missing `ok`, or a structurally malformed row all
leave as the same `{ ok: false }` a module would have sent on purpose. There is
no shape a broken provider can produce that arrives at the reconciler looking
like an authoritative empty list -- which is the entire argument for the
envelope, since a bare array has exactly one such shape and it is the one a
module returns while its sidecar is still connecting.
A malformed row fails the whole call rather than being dropped. Salvaging is the
dangerous option: one unreadable member quietly omitted from a roster is
indistinguishable, downstream, from that member having left, and the sync would
mark them departed on the strength of a broken payload. Refusing costs one stale
interval.
The deadline timer is unreffed as well as cleared. Clearing covers the case
where the race settles; it cannot cover a module promise that never settles at
all, where nothing exists to clear until the deadline fires. Caught by the test
file taking 10.2s to run 265ms of assertions -- the same class of bug as the
mariadb pool that used to hold the suite open (test/_setup.js). 292ms now.
28 tests. Full suite 770 passed, 0 failed.
Refs docs/website/TEAMS.md §2.3, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 225663d62e |
feat(teams): core schema for Teams, membership, sync state and moderation
The six core tables Team core is built on (docs/website/TEAMS.md §2.1, §2.5,
§2.5.1, §2.9), plus the §2.10 account-deletion decisions expressed as foreign
keys rather than left to whatever the defaults happened to be.
Every table is core-internal (§10.3): a module populates them through the team
provider and must never read or write one directly. They carry no <moduleId>_
prefix, correctly -- MODULE_API.md §2.6's prefix rule binds modules, and these
are core's.
team_forum_grants lands in this phase rather than in phase 4, so the four-path
resolver is written once and its non-contamination tests are real. Nothing
writes it yet; the grant/revoke flow, the per-Team cap and the leader UI are
phase 4's.
Two departures from the SQL as TEAMS.md sketched it, both recorded in the file:
- team_forum_grants.user_id is nullable with ON DELETE SET NULL, following
§2.10 (the audit trail of who granted whom must survive the account) rather
than §2.5's CASCADE.
- its uniqueness marker is derived from revoked_at alone, with user_id moved
into the unique KEY. §2.5's `active_user AS (IF(revoked_at IS NULL, user_id,
NULL))` cannot coexist with the line above: MariaDB refuses ON DELETE SET
NULL on a foreign key whose column is a base column of a STORED generated
column (error 1901). The semantics are identical -- at most one active grant
per (team, user), unlimited revoked rows.
Verified by running ensureSchema() against MariaDB 11: all six tables create,
both generated columns materialise, and every foreign key's delete rule matches
§2.10's table. The uniqueness encoding was checked directly -- a second active
grant for the same (team, user) is rejected 1062 while revoked rows accumulate
freely.
Refs docs/website/TEAMS.md Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| e0c961c690 |
Merge pull request 'feat(modules)!: the module system cutover — a game-agnostic core reaches main' (#150) from edge into main
Reviewed-on: #150 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com> |
|||
| 3669696532 |
Merge pull request 'chore(modules): declare the UO module for the UOMysticmoon instance' (#149) from chore/declare-uo-module-for-uomm into edge
Reviewed-on: #149 |
|||
| 953d0c25f6 |
chore(modules): declare the UO module for the UOMysticmoon instance
The module-system cutover puts a game-agnostic core on `main`, so the image UOMysticmoon deploys stops carrying any UO code of its own. Everything that instance is actually for — the shard pages, the player's characters, vendors and houses, Admin -> Shard and the uo-link connection — arrives as RunicGateway/Module-uo or does not arrive at all. Declare it in the tenant template, next to the other values that pin this instance to production, so an operator copying the file gets a working shard rather than a working site with no game on it. The compose host resolves the set itself at boot (MODULE_SYSTEM.md 2.7.2 decision 4), which is what keeps the site from being game-less between the image roll and someone clicking install in Admin -> Modules. Nothing here is new machinery: MODULES and its no-op-without-network behaviour shipped in phase 4 slice 3, MODULE_SOURCE_HOSTS already defaults to the host this URL names, and core's .env.example documents the variable and deliberately leaves it commented out. Only this instance's template is opinionated, which is the split the module system exists to make. Verified the declared manifest resolves anonymously (200, coreApi ^1.3.0 against core's MODULE_API_VERSION 1.5.0) — the container fetches it with no credentials. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 4ad8b2bb0e |
Merge pull request 'feat(modules): PublicLayout takes a shell, MODULE_API_VERSION 1.5.0' (#148) from fix/public-layout-shell into edge
Reviewed-on: #148 |
|||
| 1433b60d6c |
feat(modules): PublicLayout takes a shell, MODULE_API_VERSION 1.5.0
The Integration Kit's acceptance run (Phase 5 slice 3) put a cold agent in front
of the kit alone and asked it to build a module for a second game. It built one
that works — and its page rendered outside the site.
PublicLayout supplies the chrome and not the body. Every core public page wraps
its own content in `<div className="shell-... page-body">`: the centred column,
the top and bottom padding, and — through `page-body { flex: 1 }` — the thing
that pushes the footer to the bottom of the viewport. Nine of nine core pages do
it, so the omission has never shown. A module cannot do it: it receives
PublicLayout through the UI kit and those two class names appear in no contract.
The result was a page at x=0 with the footer riding up under the content, which
is the exact failure MODULE_API.md §3.4 says the kit exists to prevent.
So the wrapper moves behind the component a module already has:
<PublicLayout shell="narrow"> // or "mid" / "wide"
`shell` is opt-in and omitting it is 1.4.0's behaviour exactly, so core's nine
pages are untouched and keep their own wrapper. An unrecognised width falls back
to narrow rather than to nothing — a module page at the wrong width still looks
like the site; a page with no wrapper does not.
1.5.0 is minor, not major. §3.4 makes *changing* a kit component's props major
because that breaks a call already written; adding an optional one breaks
nothing. module-uo's `coreApi: "^1.3.0"` still resolves.
The width map and its fallback live in client/src/lib/pageShell.js rather than in
the component, for the reason lib/adminNav.js does: the client runner has no DOM
and cannot import .jsx at all, so a rule inside a component is a rule no test can
reach. Five tests cover it, including that every width it offers is a class
theme.css actually defines — the contract now names those widths to module
authors, so a rename has to fail here instead of silently in someone's page.
Also from the same run: modules/shared.js called the UI kit "seven" members while
exporting eight (§3.4's table has five rows because PageState contributes three),
and its note said AdminPage "appears in §3.4's table" when the table dropped it in
Phase 2 PR 7.
742 server + 192 client tests pass (+5). routes.manifest.json and the OpenAPI
spec regenerate byte-identical — no route changed.
Verified in a browser against the acceptance module (MODULE_API.md §7.7), which
is the only place this seam is visible: the untouched build renders full-bleed,
and shell="narrow" lands the page in the same column as core's own.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 1b692bf624 |
Merge pull request 'chore(modules): bump MODULE_API_VERSION to 1.4.0 — the sidecar rule' (#147) from chore/module-api-1.4.0 into edge
Reviewed-on: #147 |
|||
| 5410e7e0b3 |
chore(modules): bump MODULE_API_VERSION to 1.4.0 — the sidecar rule
Phase 5 decision 4 (MODULE_SYSTEM.md §2.11.1): a module does not open a connection to a game server from the website process. It talks to a sidecar, which owns the durable copy of the game's state. No member was added, removed or changed — the surface is identical to 1.3.0. Minor rather than major because module-uo's `coreApi: "^1.3.0"` still resolves and module-uo already complies, but a module written against 1.3.0 could satisfy every member and still be built the wrong way round, which is what this number now says. The rule itself is MODULE_API.md §2.7 (docs, separate PR) and is the one prohibition there with no CI behind it: an outbound socket is not statically detectable the way an internal require is (§5.1). 742 server + 187 client tests pass; routes.manifest.json and swagger-output.json regenerate byte-identical. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| c3120ea3da |
Merge pull request 'fix(modules): stop a module before purging its tables' (#146) from fix/module-uninstall-stop-before-purge into edge
Reviewed-on: #146 |
|||
| a4da1cc438 |
fix(modules): stop a module before purging its tables
Uninstall-with-purge ran purge.sql while the module was still started: the tables went, and the module kept serving and ingesting against a schema that no longer existed until lifecycle.stop() finished — up to the five-second hook budget. For module-uo that is the uo-link WebSocket writing shard events into dropped tables, and requests in flight answering 500 where a stopped module answers 404. Nothing required the old order. The comment justified it as "purge while the SQL is still readable", but removeDir is the only step that touches the filesystem, so purge.sql stays readable until after the stop. The 400 for a module that ships no purge.sql is now resolved before anything is stopped, so a refused request leaves the module exactly as it found it. Found while proving Phase 4's acceptance criterion 2 against the real module-uo v0.3.0 release on an empty database (MODULE_SYSTEM.md §2.7.2). 742 server tests (+1); routes.manifest.json and swagger-output.json byte-identical. AI disclosure: this contribution was AI-assisted (Claude Code). Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 12df79430f |
Merge pull request 'test(login): stop the backoff-guard test racing its own one-second lock' (#145) from fix/login-backoff-flake into edge
Reviewed-on: #145 |
|||
| ec2b530be7 |
test(login): stop the backoff-guard test racing its own one-second lock
A single recordFailure() locks for BASE_MS * 2 ** 0 — exactly one second — and the test then does a real HTTP round trip against it. On CI that round trip took 1,456 ms and the guard correctly answered 200, failing the run for a reason that has nothing to do with what the test is about. Five failures lock for sixteen seconds. The subject is the guard's answer while locked out, which is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 8bc09d8b53 |
Merge pull request 'feat(modules): the declarative Docker path (phase 4, slice 3)' (#144) from feature/module-docker-path into edge
Reviewed-on: #144 |
|||
| 9b16f39a52 |
feat(modules): the declarative Docker path (phase 4, slice 3)
MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.
Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.
A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.
Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.
741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 75f4d29e93 |
Merge pull request 'feat(admin): the Modules screen (phase 4, slice 2)' (#143) from feature/module-admin-screen into edge
Reviewed-on: #143 |
|||
| 9083e4135a |
feat(admin): the Modules screen (phase 4, slice 2)
The screen slice 1's API was written for: install from a release URL, enable, disable, uninstall, purge, and restart. Admin-only, matching the server, and core's own screen because it is how a module reaches the volume at all. 182 client tests (+21), manifest and OpenAPI unchanged. Everything that decides what a row SAYS and which buttons it offers is in `lib/moduleAdmin.js` -- plain JS, so the DOM-less runner can reach it, the same reason `lib/adminNav.js` is. The JSX renders what it returns. Three sources of truth, and they are allowed to disagree -------------------------------------------------------- The row records what the operator decided and what the last boot did; the loader says what is mounted and answering; the volume says whether there is a directory at all. Picking one and rendering it is simpler and lies. The case that makes it concrete is the one decision 3 creates on purpose: disable a module (its onShutdown runs) and enable it again, and the row says `enabled` while the loader still says `disabled` because nothing can start it before a restart. Neither "Running" nor "Disabled" is true; "Restart to start" is. Two shapes that are deliberately unlike the rest of the panel: the restart is a BANNER, because a restart is a property of the server rather than of a module and an operator who installed three modules should restart once; and purge is offered inside the uninstall flow as a second confirm, because purge.sql lives inside the directory being deleted and there is no later. What the browser found that no test could ----------------------------------------- Installing over a row the previous boot had left `startup_failed` rendered "Failed at the require stage: module directory not present on the volume" one second after the files had been written to the volume -- and, because that branch is not pending, it suppressed the restart banner the install had just told the operator to use. Every unit test passed, because none of them had modelled a stale row plus a fresh install. The fix is a derivation rather than a special case: the loader scans the volume once at require time, so a module that is on the volume now and has no live record arrived after that scan, and everything the row says about it predates the install. That check runs before the failure one. The same class, one place further on: an upgrade leaves the old code loaded, so the row's version is a promise about the next boot. `liveVersion` (slice 1) lets the screen say "Restart to finish upgrading" instead of reporting the new version as running. Verified against a live server and the real published release: pasted the v0.3.0 install-manifest URL, restarted, watched the module register its five mounts and seven streams and its own nav rows appear in the sidebar. Disable ran its onShutdown for real -- the uo-link WebSocket closed, its routes went to 404, and it left /public/modules -- and enable then showed the decision-3 state with the banner. The restart button itself was exercised through its endpoint rather than clicked, because a window.confirm wedges the browser automation. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 732927a6bb |
fix(modules): three defects a real install exposed (phase 4, slice 1)
Standing the slice-2 screen up against a live server and installing the
published module-uo v0.3.0 through it found three things, none of which any
unit test in this repo could have caught. Two of them are older than this
phase.
1. The boot refresh nulled every install's provenance
--------------------------------------------------------
`installed_modules.source` and `.sha256` exist so the admin panel can say
where a module came from. They never survived a restart.
`lifecycle.boot()` re-records every scanned module with no source and no
sha256 -- correctly, because a scan finds a directory and never where it came
from -- and `upsert` assigned both columns unconditionally. So an install's
provenance lasted exactly until the restart that install asked for, and the
screen then described a module installed from a URL as "placed on the volume
by hand". Verified live: install, restart, provenance gone.
Nothing could have caught it before now. Phase 4 wrote the first non-null
value these columns had ever had, so lifecycle.js's comment asserting that
"recordInstalled leaves what it is not given" described an intention rather
than the statement below it -- and modules.model.test.js's fake reproduced
the defect faithfully, assigning unconditionally just like the SQL.
Fixed with COALESCE(VALUES(col), col): a value overwrites, a NULL leaves what
is there. The fake now matches, and two tests pin both directions -- a boot
refresh must not wipe it, and a re-install from a new URL must still replace
it, or the column would become write-once and an upgrade would for ever show
where the first version came from.
2. The restart killed the server on Windows instead of stopping it
------------------------------------------------------------------
The route called `process.kill(process.pid, 'SIGTERM')` to reach server.js's
graceful-shutdown handler. That works on Linux. **Windows has no POSIX
signals, and Node documents SIGTERM there as unconditional termination of the
target process** -- so on a Windows host the restart killed the server
outright: no module onShutdown, no listener close, no pool close, no log
flush. Observed exactly that: the process was gone and the shutdown handler
had logged nothing at all.
`process.on('SIGTERM', ...)` is an ordinary EventEmitter listener, so
`process.emit('SIGTERM')` reaches the same handler on every platform without
involving the OS. One shutdown path, still; it just gets there by an event.
Deployment is Linux containers and would never have shown this. Development
is not, and neither is the smoke that found it.
The test was worse than useless: it stubbed `process.kill` and asserted it
had been called with SIGTERM, which is precisely the call whose MEANING
differs by platform. It now waits for the SIGTERM EVENT -- what server.js is
actually subscribed to -- so a pass here means the handler would run.
3. `present()` did not publish the running version
--------------------------------------------------
An upgrade writes new files and a new row while the old code stays loaded, so
the row's version is a promise about the next boot rather than a description
of this one. Adds `liveVersion` from the loader beside `liveState`, so the
screen can tell the two apart instead of reporting the new version as running.
723 server tests (+2), manifest and OpenAPI both unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 50d719cf46 |
Merge pull request 'feat(modules): install, uninstall, purge and restart (phase 4, slice 1)' (#142) from feature/module-install-service into edge
Reviewed-on: #142 |
|||
| b30e82cde2 |
feat(modules): install, uninstall, purge and restart (phase 4, slice 1)
The consumer half of a release module-uo's CI has been publishing since
phase 3 closed. Before this, core had the installed_modules provenance
columns and no code that could ever fill them: nothing fetched, verified,
unpacked, removed or purged anything, and there was no admin route at all.
Adds modules/archive.js, modules/install.js, schema.runPurge(),
lifecycle.stop(), loader.stopHook(), and /api/v1/admin/modules with eight
routes. 797 server tests (+76), manifest 158 -> 166 + 2 internal, OpenAPI
gains 8 operations and loses nothing.
Reject, never sanitise
----------------------
The download is the easy part: an https-only allowlist re-checked on every
redirect hop, a declared sha256 compared against the bytes that arrived, and
a byte cap. Unpacking is where the archive chooses the filenames, and core
writes into a directory bind-mounted from the host, so an escape is not
confined to the container.
archive.js inspects the whole archive before a byte is unpacked and refuses
absolute and drive-absolute paths, `..` segments, NUL bytes, backslashes,
anything that is not a regular file or a directory, more than one top-level
entry, and anything over the entry or byte caps. Refusing symlinks and
hardlinks outright is what keeps this off the majority of node-tar's
published advisories rather than depending on the library to contain them.
That two-pass shape is load-bearing, and it was measured rather than assumed:
extracting an archive whose fourth member escapes upward throws under
node-tar 7.5.22 -- and leaves the first three members on disk. The loader
scans that directory at require time on the next boot, so a half-unpacked
module is a module. Everything therefore happens in a scratch directory that
is removed on any failure, and the move into place is the last step.
`tar` is pinned to ^7.5.22 rather than the ^6 that installs by default: 6.x
is flagged critical, and reading the advisory list is what the file's header
now says out loud -- almost all of it is hardlink or symlink traversal and
PAX header interpretation differentials, which is exactly this feature's
threat model.
Two things the plan had wrong
-----------------------------
The bundle's top-level directory is `module-uo-<version>`, not the module id
-- so "the top-level name must equal the id" was checked against nothing real.
The extractor strips that level instead, because its name belongs to whoever
published the bundle and the directory it lands in is core's. What is checked
instead is the unpacked module.json: a manifest promising `uo` and delivering
something else is refused rather than installed under the name it promised.
And purge cannot be a follow-up action (decision 5): purge.sql lives inside
the directory uninstall deletes. It is offered in the uninstall flow and as a
standalone action on a still-installed module, and the standalone one refuses
unless the module is already disabled -- dropping tables under something that
is still serving leaves it answering out of a world that no longer exists.
Disable now means stopped
-------------------------
lifecycle.stop() dispatches that one module's onShutdown before flipping the
guard, so a module an operator switches off actually releases its sockets and
closes its streams instead of merely becoming unreachable. The hook runs
first and the state moves after it, because while onShutdown runs the module
is still `started` and that is the only state in which its routes and the
world it is tearing down agree. A hook that throws does not stop the disable
-- the opposite of the boot path's rule, and deliberately.
Enable is not its mirror and there is no start(id) beside it. There is no
onBoot re-dispatch and the hooks were never promised re-entrant, so enable
moves the row and the restart route starts it. A test pins that enable does
not touch the loader, because "fixing" it is a one-line change that would put
a module with closed sockets back on the nav.
Restart raises SIGTERM against its own process rather than calling the
shutdown path directly, so server.js's handler stays the one graceful-shutdown
path and this route cannot drift from it.
The allowlist bootstraps from MODULE_SOURCE_HOSTS into a settings row and is
admin-managed after that (decision 6); seedDefault is INSERT IGNORE, so
changing the variable on an existing deployment is a no-op by design. An empty
list forbids every install rather than allowing every host -- the safe
direction for a value someone might blank by accident.
Verified against the real v0.3.0 release
----------------------------------------
Not a fixture: fetched the published install manifest over the real Gitea
host and its redirect chain, verified the sha256, inspected and unpacked the
252,517-byte artifact to 82 files, and then booted core against the result --
the module registered its five mounts, seven streams and eight capabilities
and resolved its client chunk, with no scratch directory left behind.
Two defects this slice's own tooling caught, both of which had already been
written down as classes:
- the controller destructured runPurge at require time, capturing the
function rather than the module, which made the one dependency whose
ORDER matters the one that could not be substituted;
- two swagger annotations carried an apostrophe inside a quoted string,
dropped silently by swagger-autogen before slice 5 taught it to fail loudly.
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 2cb549e9e5 |
Merge pull request 'feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)' (#141) from feature/module-openapi-merge into edge
Reviewed-on: #141 |
|||
| adff20be7b |
feat(modules): merge module OpenAPI fragments into /api/docs.json (phase 3, slice 5)
Core's half of the slice that closes phase 3. Two things: the request-time
fragment merge core has owed since phase 1, and the last of core's UO copy.
**The merge (MODULE_API.md §6.1a).** `swagger-output.json` is core's own routes
and cannot be anything else — it is generated on a developer's machine and
committed, so it must come out the same regardless of what they had checked out,
and a module arrives on a volume long after the image was built. Module routes
therefore reach the document at request time, from the `swagger-fragment.json`
each module ships: `swagger/docsSpec.js` merges the fragments of STARTED modules
over the committed spec, cached on a new loader state version and rebuilt when a
module's state moves.
Until now neither half existed. `swagger/mergeSpec.js` named the request-time
caller in its header and that caller was never written, so the 72 routes
module-uo serves were in no OpenAPI spec at all — core's standing rule ("never
ship a route that isn't in the spec") broken by the extraction rather than by a
route.
Core wins every key collision, `swagger-output.json` is never mutated (it is a
require()d JSON module — one in-place merge would be permanent AND cumulative),
and a fragment that is missing or unreadable costs that module its paths and
nothing else. The Swagger UI is now built per request for the same reason the
JSON is: bound once at require time it would show core's routes for the life of
the process while /api/docs.json showed the merged set.
**The last of core's UO copy** (slice 4 deferred it; §5.2's check reads code, not
prose, so none of this was caught):
- 31 UO schemas and 4 UO tags in `swagger/swagger.js`, describing routes core has
not served since slice 1 — 578 lines. They moved to module-uo, namespaced
`Uo…`, and arrive back through the merge on an instance that installs it.
- `info.description` said "a private Ultima Online shard".
- README.md's 48 UO mentions, including the architecture diagram and the whole
`## Shard integration (uo-link)` section, now `## Modules`.
- `TOWNCRIER_DURATION_SEC` and `UOLINK_*` in the two `.env.example`s: read by the
module, not by core, and documented in the module's README instead.
**Two dropped annotations, and the reason nobody knew.** swagger-autogen reports
an annotation it cannot parse and then prints Success in green, having skipped
it. `npm run swagger` now captures its diagnostics and fails — which immediately
found `POST /api/v1/admin/invites` and `POST /api/v1/auth/invite/:token/accept`
documented with an EMPTY request body, both since the day they were written.
Fixing the tag list also cleared five tags used by routes but never declared
(`Admin · Email`, `Admin · Invites`, `Admin · Moderation`, `Admin · Pages`,
`Auth · Me`) — the same defect class, in the other direction.
- 646 server tests (+9), 157 client tests unchanged
- routes.manifest.json unchanged (158 public + 2 internal); check:modules clean
- swagger-output.json: 128 paths, 69 schemas, 0 orphan tags, 0 orphan schemas
- verified against a real boot with module-uo installed: 197 merged paths
(128 core + 69 module), all four module tags, 31 Uo schemas, no dangling $refs,
/api/docs renders the module's operations with zero console errors
Refs: docs/website/MODULE_API.md §2.8, §6.1a; MODULE_SYSTEM.md §2.7.1
Co-Authored-By: Claude <noreply@anthropic.com>
|
|||
| 87230c879a |
Merge pull request 'refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4)' (#140) from feature/module-de-uo-core into edge
Reviewed-on: #140 |
|||
| 0c4eacfa4a |
refactor(modules)!: de-UO core's copy, and enforce it (phase 3, slice 4)
Phase 3's acceptance criterion 1, made real. Three things, one review: **The dead bindings.** `client/src/api/client.js` still carried ~190 lines of UO namespaces — `shard`, `atlas`, the two SSE URLs, `admin.shard/shardOps/atlas/ userShard`, the uo-link and town-crier calls, `player.shard` — with zero core consumers since slice 3 deleted the views. module-uo vendors its own bindings. The five assertions core's `apiClient.test.js` made about those URLs moved with them (Module-uo#5); the encoding test that used `governorHistory` now uses a core route. **The copy.** Core is the platform, not one game's site, so its words are game-neutral now: `About`, `Screenshots`, `Website`'s cards, `Status` (which was never about a game server at all — it reports site mode), `Wiki`, `SiteFooter`, the default hero, `brand.js`'s tagline and description, the seeded wiki categories, and two user-visible NavEditor strings that named a module's admin screen by its proper name. Which game an instance is for is the operator's to say — BRAND_* vars, the hero editor, CMS pages — and every real instance already does: `.env.uomysticmoon.example` sets both brand strings explicitly, so nothing live changes wording. Wiki page SLUGS are untouched: `seedDefault*` only inserts what is absent, so renaming one adds a duplicate page to every install. Also gone: an orphan comment block in `schema.sql` describing the spawn-atlas tables slice 1 took away, and the two settings rows core seeded for a module (`game_account_signup`, `uo_link_protocol_3_migrated`). The second was a live defect — see Module-uo#5, which takes ownership of both and repairs the one-shot migration core's ordering had disabled. **The check.** `scripts/checkModuleIdentifiers.js` + `npm run check:modules`, first step of the server-tests job because it needs no dependencies. It reads CODE, not prose — file names, import specifiers, route path literals, declared identifiers and property names — per §5.2, so core's English may still say "shard" where saying it is worth more than the word costs. Two things it gets right only because getting them wrong was tried first: it matches WHOLE WORDS (a substring pass flags `defaultImage`, which contains "ultIma", four times in this repo), and it strips comments and string bodies in one character walk (a comment contains quotes, a string contains `//`) — the `checkImports.js` lesson. It has its own 17-test suite, because a boundary check that silently stops checking is worse than none. The three §6.5 grandfathering allowlists are exempt by name, and an exemption that stops matching fails the build rather than lingering. BREAKING CHANGE: core no longer seeds `game_account_signup` or `uo_link_protocol_3_migrated`; module-uo's schema fragment does. An install running core without module-uo keeps whatever rows it already has and gains no new ones — nothing in core reads either key. Deferred to slice 5, deliberately: README.md's 48 UO mentions, including a `## Shard integration (uo-link)` section and the architecture diagram. That is documentation, which §5.2 does not cover, and it belongs with the phase-closing docs pass rather than half-done here. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| a99ead4ee4 |
Merge pull request 'refactor(client): delete the UO client half (phase 3, slice 3)' (#139) from feature/module-extract-client into edge
Reviewed-on: #139 |
|||
| 5bdb6a7e10 |
fix(modules): guard the portal's nav icon, resolve MODULES_DIR absolutely
Both found by the §7.7 browser smoke, running the slice-3 pair together, and neither is visible to any test in either repo. `PlayerPortalLayout` rendered `<n.icon />` unguarded while `AdminLayout` guarded its equivalent. `icon` is optional in the nav contract, and every core row in that sidebar has always had one — so the difference cost nothing until a module registered a row without, and then it was not a missing glyph, it was React error #130 and a blank player portal. Guarded now, like its neighbour. `MODULES_DIR` is resolved absolute. `resolveClient` checks containment by comparing an absolute `path.resolve(dir, entry)` against the module directory, so a RELATIVE `MODULES_DIR` — which is what §7.7's own recipe produces when run from `server/` — failed every module with "client.entry escapes the module directory". A perfectly-placed entry, and a message pointing at the module. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| f7d27f7a06 |
refactor(client): delete the UO client half (phase 3, slice 3)
35 files and 5,332 lines out — twelve public pages, seven admin views, two player views, eight components, the two `data/` leaves and the three `lib/` ones, plus the two tests that came with them. §2.7.1's estimate of 51 files / ~3,700 lines was measured differently and is corrected in the docs PR. The seams core keeps, each smaller than what it replaced: Nine rows leave the public header and six leave the admin sidebar, and both lists are now free of `feature` gates and of `IconShard`. `moduleTitle` already handled a module page's heading, so the six TITLES entries and the `/admin/characters` branch of `sectionTitle` simply go. `/player` had `PlayerCharacters` as its index — a UO page — and rather than name a replacement or invent a landing screen it now resolves to the first row of the portal nav this viewer can reach (`firstDestinationFor`, beside `allowedPathsFor` and reading the BASE nav for the same reason: an override is presentation and where everybody lands is behaviour). With the module installed that is still Characters, so a player's first screen after signing in does not change. Deliberately generic and deliberately not in the portal layout — the admin index is the same question with a hardcoded answer, and if the two logged-in areas ever become one this is what serves both. `game_account_signup` goes with the rest of core's UO prose: the mode list, the derived public flag, the validation and a Site Settings field whose help text named Bridge.cfg. The row itself is untouched and module-uo reads it through ctx.settings — the data stays, the semantics move. KNOWN BREAK, accepted by the org lead: the shipped Android app reads `gameAccountSignup` off `/public/settings` (PublicDto.kt:80). The field has a `= false` default so nothing crashes; the app silently stops offering game-account creation until it reads the module's `/public/shard/features` instead. Out of scope here, recorded in the Android plan, and it lands well before this workstream's cutover reaches `main`. 620 server + 161 client tests. Manifest 158 public + 2 internal, unchanged; routes.guards unchanged. The OpenAPI spec loses exactly one property, and only because it was hand-written in swagger.js — regeneration alone would have left the spec documenting a field core no longer returns. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 5b5006c365 |
feat(modules): a third slot, nav icons, and api.BASE (phase 3, slice 3)
The three things core owes the client half before it can leave, all additive, all MODULE_API 1.2.0 → 1.3.0. `player.invite.accepted` is the third extension slot. Core's invite page owned a UO game-account step — it read a `gameAccountSignup` flag out of core's own settings and posted to a shard route — and an invite is a core concept that staff receive too, so the page stays and its optional next step becomes a slot. Named for the place, like the other two. Whether there is a step at all is the filling module's call, made from data core does not have; core keeps the shell, the skip control and the destination. `icon` on a nav item, because without it the six extracted UO rows would have been the only text-only entries in a sidebar where every other row has a glyph. Core supplies no fallback — an invented one is core making a presentation choice for content it knows nothing about. `icon` was already among the fields an override may not touch, so the concept predates a module being able to send one. `api.BASE` was in §3.5 from the first draft and never actually published. `request` is fetch-only, so an EventSource builds its own URL, and the shard's live feed is two of them; the alternative is a module hardcoding `/api/v1`, which asserts something about core that core has not promised. `AcceptInvite` is the one legitimate reader of `extensionFor` outside Slot.jsx: the answer decides a NAVIGATION, not a decoration. Decoration goes inside `<Slot wrap>`, which is why `hasExtension` stayed deleted. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 91964c8898 |
Merge pull request 'feat(modules): client extension slots (phase 3, slice 2)' (#138) from feature/module-client-slots into edge
Reviewed-on: #138 |
|||
| d667565ae7 |
refactor(modules): move core's UO page content behind the two slots
Core declares site.footer.status and admin.users.detail in main.jsx and fills both itself, under owner id `core` -- the client twin of registries.registerCore() and the same trick useShardFlags already uses. The rendered page is unchanged; what changes is that the content now arrives the way a module's will. The footer's Shard Status link becomes ShardStatusLink.jsx, and UserDetail's six UO sections become UserShardSections.jsx. Both are files rather than inline markup so that the client half of phase 3 deletes a registration and a file instead of editing a core page under extraction pressure -- which is also what proves the mechanism before anything depends on it. The user-detail slot is handed userId and not scope. api.admin.userShard is a UO binding that leaves core with the client half, so a slot passing it would hand a module something core is about to delete; an extension builds its own client for the routes it registered at the other end. Core's own fill now does exactly what the module will. Verified in a browser against a real chunk (MODULE_API.md 7.7): a throwaway module fills both slots and renders its own label and target in the footer with core's linkStyle, and receives userId on the admin page; a deliberate render failure is contained to that one spot with the slot named in the console; core's own fills leave the pages byte-identical to before; and with no module installed both slots render nothing. Zero CSP reports throughout. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 1d1350558b |
feat(modules): client extension slots (phase 3, slice 2)
The client twin of the server's declareSlot/registerExtension, and the same rule in both halves: core declares a slot, only core declares one, and at most one module fills it. Core renders <Slot name> and gets nothing back when the slot is unfilled, so an instance with no module installed renders exactly what it rendered before -- the same untouched-path guarantee withModuleNav makes. A slot is named for a PLACE, never for a meaning. Core supplies the position and the styling; the label, the target, the data and whether anything renders at all are the module's. The moment core types a slot by its content it has re-acquired the game semantics phase 3 exists to remove. This is the one place the client registry is not fail-open. An unknown slot, a non-component and a second fill all throw, matching checkExtensionShape server-side, because a dropped nav row costs a link the viewer can reach another way while a silently dropped extension is invisible to everyone including its author. A throw is always a programming error and never a race: core declares in its own bundle and every module chunk is a deferred script injected after it. Reading stays fail-safe -- undeclared and unfilled both read null -- and a filling component renders inside an error boundary. That asymmetry is where the client differs from the server: a module route that throws costs the module's own page, but an extension throws inside CORE's, and the whole reason core keeps ownership of that page is that it stays usable. Core decorates a slot through <Slot wrap>, not by asking whether it is filled. The obvious alternative is right about the unfilled case and wrong about the failed one -- the extension is filled, so the separator renders, and then the component throws into the boundary and leaves the separator behind on its own. wrap puts core's decoration inside the boundary where it shares the extension's fate. Found in a browser, with the footer's separator, which is the only place either could have been found. MODULE_API_VERSION 1.1.0 -> 1.2.0, both halves: the two state ONE version. Contract: docs/website/MODULE_API.md 3.7. Co-Authored-By: Claude <noreply@anthropic.com> |
|||
| 7a236cad8b |
Merge pull request 'refactor(modules)!: move the UO server half out to module-uo (phase 3, slice 1)' (#137) from feature/module-extract-server into edge
Reviewed-on: #137 |