feat: read-only private map sharing — backend - #468
Merged
Conversation
… control) Stages 1-3 of the read-only private maps feature (see READ_ONLY_PRIVATE_MAPS.md for the full plan): - map_share table: one share per map, unguessable 144-bit token, enabled flag, optional scrypt password hash, password_updated_at for grant invalidation - mapShare tRPC router (mapWriteProcedure): get/enable/disable/ setPassword/regenerateToken, never exposing the hash - Share grant cookie (signed JWT) + validation against the live share row: enabled, shareId match, 7-day grant age, and iat >= passwordUpdatedAt while a password is set - Access-control branches in mapReadProcedure, canReadDataSource (destructured-input refactor), and the markers REST route - New viewerProcedure tier (authenticated user OR valid share grant); area.search and area.byCode moved onto it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Backend for read-only private map sharing: letting users share a private map, exactly as configured, with a small audience outside their organisation via an unguessable link, optionally protected by a password. Distinct from Public Maps (campaign sites for the general public) — this is for sharing statistics and analysis with a small, trusted audience.
This PR is stages 1–3 of the plan in
READ_ONLY_PRIVATE_MAPS.md(included). The frontend (viewer route, password form, share dialog) follows in a separate PR.How it works
Data model — new
map_sharetable, one row per map: unguessable 24-char/144-bittoken,enabledflag, nullable scryptpassword_hash, andpassword_updated_at. Disabling keeps the row so re-enabling restores the same link; only "reset link" rotates the token.Management API — new
mapSharetRPC router undermapWriteProcedure:get/enable/disable/setPassword/regenerateToken. Every response is an explicit{ token, enabled, hasPassword }shape — the hash never leaves the server (defence in depth on top of the existinghasPasswordHashSerializer). Password hashing happens in the router (not the repository) to avoid autils/auth ↔ repositories/MapShareimport cycle.Grant cookie — anonymous viewers hold a signed JWT cookie (
SharedMaps) containing grants{ shareId, mapId, iat }, minted by the (upcoming) share page/verify endpoint viaaddShareGrant(). The cookie is necessary but never sufficient: every check re-validates against the livemap_sharerow —enabled(disable ⇒ instant revocation)shareIdmatches (token resets don't leak across shares)grant.iat >= password_updated_at(changing/adding a password boots all existing viewers; removing it restores link-only access)Access-control branches — all four anonymous-read gates get the share-grant branch:
mapReadProcedure(unlocksmap.byId,dataSource.listForMapView,mapView.inspectorConfigs)canReadDataSource(unlocksdataRecord.*,area.stats) — refactored to destructured input; the new branch checks the data source is actually visualised on the shared map viafindMapShareVisualisingDataSource(membersDataSourceId / markerDataSourceIds / any view's areaDataSourceId), so a grant for map A cannot read a data source that only appears on map B/api/data-sources/[id]/markers)viewerProceduretier: authenticated user OR ≥1 valid grant;area.searchandarea.byCodemoved onto it (previouslyprotectedProcedure) so shared-map viewers get area search without opening it to the public internetTest plan
tests/unit/server/repositories/MapShare.test.ts(11) — token format/uniqueness, idempotent upsert, disable/re-enable keeps link, password set/change/remove with scrypt verification, token rotation, FK cascadetests/unit/server/trpc/routers/mapShare.test.ts(11) — member/non-member/anonymous authorisation, hash never serialised, password min-length, NOT_FOUND before enablingtests/unit/server/trpc/shareGrantAccess.test.ts(18) — grant unlocks map reads; wrong-shareId / aged-out / disabled-share / other-map grants rejected; full password-change lifecycle;canReadDataSourcepositive paths + cross-map negative;viewerProcedureaccept/reject/authenticated casesnpm run lintclean (prettier, eslint, tsc, madge circular-dep check)Notes for reviewers
Context.shareGrantsis optional so hand-built contexts (tests, server callers) can omit it; absent = no grants.grants_revoked_atcolumn checked likepassword_updated_atis the clean fix (called out in the plan doc).area.search/area.byCodeare the only procedures whose auth was relaxed, and only from "logged in" to "logged in OR holds a valid share grant" — boundary data is not org-scoped.🤖 Generated with Claude Code