Skip to content

v0.59.0 — Security & Sync

Scoped deliberately tight: the three live data-exposure holes, and the four already-diagnosed sync bugs. No features. Everything that was previously filed under v0.59.0 and is larger than a bug fix has moved to v0.60.0 or to a Phase 1 release on the roadmap.

Security

All three are reachable from an ordinary authenticated session, not just a hand-crafted request. One commit each, each covering its whole file.

  • [x] GET /api/maps with no campaignId returns every map in the database to any authenticated user. The campaignId branch calls checkCampaignAccess and filters hiddenFromPlayers; the else branch (packages/server-core/src/routes/maps.ts, the "Fetching all maps" path) has no owner or membership filter at all. Decision: reject rather than scope — the unfiltered branch returns 400 campaignId is required. There is no current use case for a cross-campaign map list, so a hard filter with no exceptions is preferable to a scoped query that quietly grows one. Every frontend caller must pass campaignId explicitly; the ~8 useMaps() call sites that pass '' or undefined are part of this commit. Platform admins get no bypass here — /admin/* is the admin surface and passes its own hard admin-role filter. (Done. Correction to the decision above: there were two live cross-campaign callers — the /maps page (AllMapsList) and global search, both deliberately unscoped since v0.48.0. The hard 400 stands; those two now fan out one authorized request per campaign through a new useAllMaps() hook, sharing ['maps', id] cache keys with useMaps so invalidation and socket patches still reach both. useMaps(campaignId) now disables its query outright when there is no campaign, which covers all ~12 campaignId || '' call sites in one place. The whole-file audit also found three more unauthorized handlers, fixed in the same commit: GET /:id let any authenticated user read any map by id, and POST / and PUT|PATCH /:id never looked at the caller at all.)
  • [x] GET /api/chat-messages?campaignId=X performs no access check — the handler never reads req.auth, so knowing (or guessing) any campaign id returns that campaign's chat log, combat log included. Gate it on checkCampaignAccess and 403 on failure. Audit the rest of chatMessages.ts in the same commit; the :id route has the same shape. Decisions: any campaign member may POST; only the message's author may PATCH/PUT; DELETE /:id is removed entirely along with the unscoped DELETE /api/chat-messages, which today wipes every chat message in the database for any authenticated user and has no caller anywhere in apps/web or packages/client-sdk. Neither has a current use case; a "clear this campaign's chat" feature, if ever wanted, gets a scoped GM-gated route then rather than inheriting these. (Done, with two corrections found while auditing. PATCH/PUT is gated on campaign membership, not authorship: chat_messages has no author column — user is a display-name/avatar blob — and the two flows that patch a message are cross-author by design (the GM or the damaged token's owner resolves a [DAMAGE_PENDING:…] block inside the attacker's message; a 3D roll result lands in a message the roller may not have sent). Author-only would break both. Recording a real author id is a schema change; filed against v0.61.0. The handler also now strips campaignId from the payload so a member cannot move a message out of the campaign the check was made against. DELETE /:id was kept, gated to the campaign owner, rather than removed: it does have a caller — the GM-only "Delete Message" item in DiceRollMessage's menu, reached via ChatLog's handleDeleteMessage. The unscoped DELETE /api/chat-messages is gone as specified; that one really had no caller.)
  • [x] GET /api/drawings?mapId=X performs no access check either — it validates that mapId is present and nothing else. A map has no owner of its own, so authorization has to resolve map -> campaignId first and then checkCampaignAccess, the pattern tokens.ts already uses for its ?characterId= scope. Same audit for the other verbs in drawings.ts. Decisions: any campaign member may create a drawing; only the creator or the GM may edit or delete one; and the read path filters out drawings on GM-hidden layers for non-owners, the way GET /api/maps already filters hiddenFromPlayers — without that the fix would close the door and leave the window open. (Done except the hidden-layer filter, which is blocked: there is no GM-hidden layer. map_layers has exactly six behaviour columns — allowsTokens, allowsDrawings, isProtected, isBuiltin, builtinId, tokenRenderMode — and no player-visibility flag anywhere in the schema, ILayer, or the layers UI. Adding one is a migration plus UI, i.e. a feature, not a security fix; it belongs with the layer work, not here. Fog and VBL shapes, the things that would leak, are separate tables with their own routes, so nothing is left exposed by the omission. Also: createdBy is now stamped server-side from the authenticated caller — the web client never sent one, so the creator-or-GM rule had nothing to compare against. Pre-existing rows (no createdBy) stay editable by any campaign member rather than freezing every drawing on every existing map.)
  • [x] Before merging to main, sanity-check locally that no module's server entry calls /api/drawings or /api/chat-messages on its own behalf. Module routers are unauthenticated, so a module that does would start 403ing. (Clean. Neither string appears anywhere in vtt-modules, genai, system5, system8 or vtt-assets — source or built dist/. The three modules with a server entry (scenes, dnd5e, system8) reach exactly one platform path between them, /api/modules. Nothing to fix.)

Multiplayer Issues

  • [x] One fix, three symptoms. Every socket listener in components/Layout/ shows a toast and invalidates no React Query cache, while the caches they would need to invalidate all carry real staleTimes (['campaigns'] 2 min, ['characters', campaignId] 2 min, ['impersonatable-tokens'] 30 s). The pattern already exists — invalidateSocketSyncedQueries() in lib/queryClient.ts is called on socket reconnect — it was simply never extended to these events. Fix that first, as one commit, then verify all three cases below; anything still broken afterwards gets its own follow-up commit.
    • GM invites PC to campaign. PC sees the notification pop up. They accept. In the data, they are now a member of the campaign. But the UI doesn't update. PC was on the /campaigns page and had to hard reload to see the new campaign. After accepting, they should have the option to go to the campaign they were invited to. (Confirmed flow: recipient was logged in, GM added them directly, they saw the toast but not the campaign. CampaignInviteListener never invalidates ['campaigns']. Real accept/deny for campaign invites is not in scope here — it arrives with the accepted column in v0.61.0.)
    • PC doesn't have access to a character, clicks Request Access. GM or owner grants access. PC sees a notification that they have access, and in that notification they can click a button to impersonate the character. Expected: they impersonate the character, because they now have access. Observed: it still says they have no access until a hard reload. (CharacterAccessGrantedListener never invalidates ['impersonatable-tokens'], and ImpersonationPermissionGuard actively reverts any impersonation not in that cached set. The Impersonate button should also await the refetch before acting, so a slow network cannot reproduce this as a race.)
    • As a user invited to another user's campaign, I created a character, then went back to the campaign page. No characters were visible. On hard reload, the character appeared. (Confirmed created from the campaign page. createCharacter invalidates ['characters'] immediately, but the campaign-scoped list is narrowed server-side by the campaignCharacters link, which is a second write — so the refetch can beat the link and cache an empty answer. Invalidate after the link write, not after the create.) (All three done. The listener invalidation landed as one commit (invalidateMembershipScopedQueries() beside the existing invalidateSocketSyncedQueries()), plus the awaited refetch on Impersonate. The third symptom is not a socket-listener problem, so it survived that fix and got the follow-up commit the item anticipated: the link POST moved into useCharacters().linkCharacterToCampaign(), which invalidates after the link. All ten inline copies of the endpoint now go through it, and a test fails if an eleventh appears.)
  • [x] Minor: in testing session, both connected players had full functionality and could see both of their users in the Connected Players UI, but the GM's version showed a red icon as if there were a connection issue. (Root cause: useSocketConnection registers its connect/disconnect handlers once, inside the first mounting consumer's effect, closing over that instance's setIsConnected. Every later consumer reads globalSocket.connected at mount and never hears another word, so a dot that mounts before the socket connects latches red forever. Fix by making connection state a real subscription every consumer subscribes to — patching ConnectionStatusDot alone would leave the same bug for every other reader of isConnected.) (Done. globalIsConnected is now a tiny external store — a listener set plus useSyncExternalStore — so one set of socket handlers updates every consumer. Worth noting the invisible half: usePendingInvitations gates its socket listener on isConnected, so a latched false there meant invitations silently stopped arriving for that consumer, not just a wrong-coloured dot.)

Character Sheet

  • [x] Spell selection modal: focus the search field when opened. Applies to all picker subtypes (spells, feats, equipment), not just spells, and to both modules — the modal is buildTraitPicker() in the dnd5e module with a near-identical copy in system5. Ships as dnd5e 0.11.0 and system5 0.6.0; publish the tags before bumping hosted.json. (Done and tagged locally — dnd5e/v0.11.0 in vtt-modules, v0.6.0 in system5 — but not pushed or published, so modules.hosted.json is deliberately untouched. Implementation is Mantine's data-autofocus marker rather than a ref plus an effect: Mantine focuses that element after the modal's transition finishes, and a focus call made mid-transition is silently dropped. Four files, not two — each module has an EquipmentPicker beside its TraitPicker, and "all picker subtypes" covers it. Remaining manual steps before this release merges: push both tags, publish both packages, then bump modules.hosted.json — which is separately stale, still pinning dnd5e 0.9.0 and system5 0.4.0 against the published 0.10.0 / tagged 0.5.0.)