Skip to content

feat: read-only private map sharing — backend - #468

Merged
joaquimds merged 1 commit into
mainfrom
feat/read-only-private-maps
Aug 5, 2026
Merged

feat: read-only private map sharing — backend#468
joaquimds merged 1 commit into
mainfrom
feat/read-only-private-maps

Conversation

@joaquimds

Copy link
Copy Markdown
Member

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_share table, one row per map: unguessable 24-char/144-bit token, enabled flag, nullable scrypt password_hash, and password_updated_at. Disabling keeps the row so re-enabling restores the same link; only "reset link" rotates the token.

Management API — new mapShare tRPC router under mapWriteProcedure: 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 existing hasPasswordHashSerializer). Password hashing happens in the router (not the repository) to avoid a utils/auth ↔ repositories/MapShare import cycle.

Grant cookie — anonymous viewers hold a signed JWT cookie (SharedMaps) containing grants { shareId, mapId, iat }, minted by the (upcoming) share page/verify endpoint via addShareGrant(). The cookie is necessary but never sufficient: every check re-validates against the live map_share row —

  1. row exists and is enabled (disable ⇒ instant revocation)
  2. shareId matches (token resets don't leak across shares)
  3. grant is under 7 days old (independent of cookie expiry)
  4. if a password is currently set: 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 (unlocks map.byId, dataSource.listForMapView, mapView.inspectorConfigs)
  • canReadDataSource (unlocks dataRecord.*, area.stats) — refactored to destructured input; the new branch checks the data source is actually visualised on the shared map via findMapShareVisualisingDataSource (membersDataSourceId / markerDataSourceIds / any view's areaDataSourceId), so a grant for map A cannot read a data source that only appears on map B
  • markers streaming route (/api/data-sources/[id]/markers)
  • new viewerProcedure tier: authenticated user OR ≥1 valid grant; area.search and area.byCode moved onto it (previously protectedProcedure) so shared-map viewers get area search without opening it to the public internet

Test 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 cascade
  • tests/unit/server/trpc/routers/mapShare.test.ts (11) — member/non-member/anonymous authorisation, hash never serialised, password min-length, NOT_FOUND before enabling
  • tests/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; canReadDataSource positive paths + cross-map negative; viewerProcedure accept/reject/authenticated cases
  • npm run lint clean (prettier, eslint, tsc, madge circular-dep check)
  • Migration applied and verified against the dev database

Notes for reviewers

  • Context.shareGrants is optional so hand-built contexts (tests, server callers) can omit it; absent = no grants.
  • Known nuance, deliberate for now: "reset link" invalidates the URL but not already-minted grants — existing viewers age out within 7 days. If reset should also boot active viewers, a grants_revoked_at column checked like password_updated_at is the clean fix (called out in the plan doc).
  • area.search/area.byCode are 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.
  • No UI changes in this PR; nothing mints grant cookies yet, so the new branches are unreachable in production until the frontend PR lands.

🤖 Generated with Claude Code

… 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>
@joaquimds
joaquimds merged commit 3e12428 into main Aug 5, 2026
1 check passed
@joaquimds
joaquimds deleted the feat/read-only-private-maps branch August 5, 2026 13:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant