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/mapswith nocampaignIdreturns every map in the database to any authenticated user. ThecampaignIdbranch callscheckCampaignAccessand filtershiddenFromPlayers; theelsebranch (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 400campaignId 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 passcampaignIdexplicitly; the ~8useMaps()call sites that pass''orundefinedare 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/mapspage (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 newuseAllMaps()hook, sharing['maps', id]cache keys withuseMapsso invalidation and socket patches still reach both.useMaps(campaignId)now disables its query outright when there is no campaign, which covers all ~12campaignId || ''call sites in one place. The whole-file audit also found three more unauthorized handlers, fixed in the same commit:GET /:idlet any authenticated user read any map by id, andPOST /andPUT|PATCH /:idnever looked at the caller at all.) - [x]
GET /api/chat-messages?campaignId=Xperforms no access check — the handler never readsreq.auth, so knowing (or guessing) any campaign id returns that campaign's chat log, combat log included. Gate it oncheckCampaignAccessand 403 on failure. Audit the rest ofchatMessages.tsin the same commit; the:idroute has the same shape. Decisions: any campaign member mayPOST; only the message's author mayPATCH/PUT;DELETE /:idis removed entirely along with the unscopedDELETE /api/chat-messages, which today wipes every chat message in the database for any authenticated user and has no caller anywhere inapps/weborpackages/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_messageshas no author column —useris 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 stripscampaignIdfrom the payload so a member cannot move a message out of the campaign the check was made against.DELETE /:idwas kept, gated to the campaign owner, rather than removed: it does have a caller — the GM-only "Delete Message" item inDiceRollMessage's menu, reached viaChatLog'shandleDeleteMessage. The unscopedDELETE /api/chat-messagesis gone as specified; that one really had no caller.) - [x]
GET /api/drawings?mapId=Xperforms no access check either — it validates thatmapIdis present and nothing else. A map has no owner of its own, so authorization has to resolvemap -> campaignIdfirst and thencheckCampaignAccess, the patterntokens.tsalready uses for its?characterId=scope. Same audit for the other verbs indrawings.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 wayGET /api/mapsalready filtershiddenFromPlayers— 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_layershas 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:createdByis 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 (nocreatedBy) 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/drawingsor/api/chat-messageson its own behalf. Module routers are unauthenticated, so a module that does would start 403ing. (Clean. Neither string appears anywhere invtt-modules,genai,system5,system8orvtt-assets— source or builtdist/. The three modules with aserverentry (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()inlib/queryClient.tsis 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.
CampaignInviteListenernever invalidates['campaigns']. Real accept/deny for campaign invites is not in scope here — it arrives with theacceptedcolumn 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. (
CharacterAccessGrantedListenernever invalidates['impersonatable-tokens'], andImpersonationPermissionGuardactively 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.
createCharacterinvalidates['characters']immediately, but the campaign-scoped list is narrowed server-side by thecampaignCharacterslink, 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 existinginvalidateSocketSyncedQueries()), 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 intouseCharacters().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.)
- 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.
- [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:
useSocketConnectionregisters itsconnect/disconnecthandlers once, inside the first mounting consumer's effect, closing over that instance'ssetIsConnected. Every later consumer readsglobalSocket.connectedat 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 — patchingConnectionStatusDotalone would leave the same bug for every other reader ofisConnected.) (Done.globalIsConnectedis now a tiny external store — a listener set plususeSyncExternalStore— so one set of socket handlers updates every consumer. Worth noting the invisible half:usePendingInvitationsgates its socket listener onisConnected, so a latchedfalsethere 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 dnd5e0.11.0and system50.6.0; publish the tags before bumpinghosted.json. (Done and tagged locally —dnd5e/v0.11.0invtt-modules,v0.6.0insystem5— but not pushed or published, somodules.hosted.jsonis deliberately untouched. Implementation is Mantine'sdata-autofocusmarker 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 anEquipmentPickerbeside itsTraitPicker, and "all picker subtypes" covers it. Remaining manual steps before this release merges: push both tags, publish both packages, then bumpmodules.hosted.json— which is separately stale, still pinning dnd5e0.9.0and system50.4.0against the published0.10.0/ tagged0.5.0.)