Skip to content

feat: moderated (by request) access for public maps, with embedding - #533

Merged
joaquimds merged 14 commits into
mainfrom
feat/moderated-public-map-access
Oct 29, 2026
Merged

joaquimds merged 14 commits into
mainfrom
feat/moderated-public-map-access

Conversation

@joaquimds

@joaquimds joaquimds commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds a by request access mode for public maps, built for the Praxis migrants' guide (praxis-migrants-guide.org.uk), which will embed a moderated map on its own site.

  1. A visitor enters their email on the map.
  2. Every moderator is emailed a review link. Moderators don't need Mapped accounts.
  3. A moderator approves the request, and the visitor is emailed a magic link.
  4. The link takes the visitor back to the page they asked from, on the embedding site or the map's own address, and the map appears.

Links are valid for one week, and so is the access they grant.

Access modes

Setting Map's own address Embedded
Open Anyone Anywhere, no setup
By request, no websites listed Request form, magic link Refused: "This map hasn't been set up to be embedded on this site"
By request, websites listed Request form, magic link On any page of a listed website; refused elsewhere

How it works

Access control is in one place.

  • canViewPublicMap in src/server/utils/auth.ts is now the single rule for whether a visitor may see a public map's data.
  • publicMap.get, mapReadProcedure and canReadDataSource (and so the markers route and the data source procedures) all go through it.
  • The published public map lookups now return every match, not the first. A data source on both an open and a moderated map is decided correctly.

What visitors see.

  • publicMap.get never sends moderator emails to visitors.
  • Visitors without access to a moderated map don't get its dataSourceConfigs, only the name, description and styling for the gate.

Grants.

  • Share grants and the new public map access grants are bundled into one VisitorGrants object on the tRPC context (ctx.grants, replacing ctx.shareGrants).
  • The access cookie (PublicMapAccess) is a signed JWT set httpOnly; Secure; SameSite=None; Partitioned, so it works inside a cross-site iframe.
  • Every check re-validates against the live public_map_access row. Revoking takes effect immediately, and re-approving doesn't revive sessions minted before the latest decision.

Magic links.

  • Tokens are random (192 bits) and stored as a SHA-256 hash.
  • Each new link replaces the previous one. Links aren't single-use: forwarded links are acceptable for this use case.
  • POST /api/public-map-access/claim exchanges a token for the cookie. The gate calls it from inside the iframe, so the partitioned cookie lands under the embedding site.

Moderation.

  • The review page is /public-map-access/[token], authorised by a signed token covering one request.
  • Deciding is always a button press (a POST), never the page load, so email link scanners can't approve requests.
  • Moderators can approve, reject, revoke and re-approve.

Requests.

  • An already-approved email asking again gets a fresh link straight away, without the moderator.
  • The form never reveals whether an email is pending, approved or rejected.

Embedding.

  • public/embed.js turns <div data-mapped-map="…"> into an iframe.
  • It tells the map which page it's on (mapped_page), forwards mapped_access from the page URL into the iframe, and strips the token from the embedding page's address bar.
  • Websites are stored as origins. The link returns to the exact page the request came from. Requests from unlisted origins are refused, both in the gate (rendered server-side, before anyone types) and in the request mutation.
  • A bare <iframe> without embed.js is told to use the embed code.

Editor.

  • Under Access: the access setting, moderator emails (required to publish a moderated map), and "Websites this map is embedded on" (optional).
  • Embed code, with a copy button. On a by-request map with no websites listed, it shows the map's own address and asks for a website first.

Changes outside the feature

  • src/proxy.ts: the subdomain rewrite now keeps the query string, so the public map page can read embed.js's parameters. No tests cover the proxy.
  • Public map URL helper: getPublicMapBaseUrl/getPublicMapUrl in src/utils/appUrl.ts replace two copies of the http://localhost:3001 dev fallback in the editor.
  • Two fixes made on local main and merged in. main hasn't been pushed, so they appear in this diff:
    • 8fd37155: markers refetch when switching between explore and publish. publicMapViewId was missing from the marker query key, so past events weren't hidden until a refresh.
    • 7e3469e8: markers no longer use the on-choropleth (white, outlined) style on public maps, where the choropleth isn't drawn.

Migration

1790300000000_public_map_moderated_access:

  • Adds access_mode, moderator_emails and embed_origins to public_map, with matching keys in existing drafts.
  • Creates public_map_access.
  • Existing maps default to Open, so nothing changes for them.

Testing

  • New: tests/unit/server/trpc/publicMapAccess.test.ts covers the access rule: open, moderated, pending, approved, revoked, re-approved, expired and mismatched grants, and a data source on both an open and a moderated map.
  • New: tests/unit/server/trpc/publicMapAccessFlow.test.ts covers request, moderator email, approve, link destination, refused unlisted embeds, repeat requests, expiry and revocation.
  • New: tests/unit/utils/publicMapAccess.test.ts covers origin matching and link building.
  • Updated: share grant, PublicMap repository and removeDataSourceReferences tests.
  • Full suite passes: 506 passed, 15 skipped.
  • Manually tested end to end locally: an embed on a stand-in site (localhost:8080) through request, approve, magic link and the map loading.

Not covered yet

  • Partitioned cookies in real browsers inside a cross-site iframe, especially iOS Safari. This needs staging and a real embedding page.
  • No list of requests in Mapped. Moderators act only via the links in their emails.
  • No rate limiting on the request form, which is deliberately out of scope for now. One consequence: anyone can trigger a fresh link to an approved email, which replaces that person's previous link. Their existing sessions are unaffected.
  • The public map still fetches area stats, which it doesn't need.

🤖 Generated with Claude Code

joaquimds and others added 14 commits October 29, 2026 12:52
First chunk of moderated public map access (magic links approved by a
moderator, for embedding on a partner's site):

- public_map gains access_mode, moderator_emails and embed_url (also in
  the draft); new public_map_access table, one row per (map, email)
- canViewPublicMap in server/utils/auth.ts is now the single rule for
  whether a visitor may see a public map's data. publicMap.get,
  mapReadProcedure and canReadDataSource all go through it
- Published public map lookups return every match, so a data source on
  both an open and a moderated map is decided correctly
- Visitor grants (share + public map access) are bundled into one
  VisitorGrants object on the tRPC context; the new access grant cookie
  is SameSite=None; Partitioned so it works inside an iframe
- publicMap.get never sends moderator emails to visitors, and withholds
  dataSourceConfigs from visitors without access
- Editor: Access setting, moderator emails and embed page URL; publishing
  a moderated map requires at least one moderator
- Public map page shows an access gate instead of the map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second chunk of moderated public map access:

- embed_url becomes embed_urls, an allowlist of embedding pages. Magic
  links go back to the listed page the request came from, else the first
  listed page, else the map's own subdomain; the URL in the link is always
  the configured one, never the visitor-supplied one
- public_map_access.requested_from_url remembers the last listed page a
  visitor asked from
- publicMapAccess tRPC router: request (from the gate), review and decide
  (for moderators, authorised by a signed link in their email, since they
  are not Mapped users). Approving emails the visitor a magic link; an
  already-approved email asking again gets a fresh link without the
  moderator; pending/rejected/revoked requests send nothing, and the form
  never reveals which case applied
- Magic link tokens are random, stored hashed, valid for a week, and
  replaced by each new link; POST /api/public-map-access/claim swaps one
  for the partitioned access grant cookie
- Gate: email form, and claims a token from the URL (stripping it from
  the address bar) with blocked-cookie detection
- Review page at /public-map-access/[token]: approve, reject, revoke,
  re-approve; deciding is always a button press, never the page load,
  so email link scanners cannot approve requests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Third chunk of moderated public map access:

- public/embed.js turns <div data-mapped-map="..."> into an iframe of
  the map, passes the embedding page's URL (mapped_page) into it, and
  forwards an access link's token (mapped_access) while removing it from
  the embedding page's address bar
- The publish sidebar shows the embed code, with a copy button, once the
  map has a subdomain

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In dev (empty NEXT_PUBLIC_BASE_URL), public map subdomains are served at
http://<sub>.localhost:3001 via the dev proxy, but the embed snippet and
the magic link subdomain fallback took their protocol and host from the
app URL (https://localhost:3000), producing addresses that don't exist.
One getPublicMapBaseUrl/getPublicMapUrl helper now serves the editor's
URL display, the embed snippet and access links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- embed_urls becomes embed_origins: moderated maps list websites, not
  pages. Any page on a listed website can embed the map, and magic links
  go back to the exact page the request came from
- Requests from the map's own address get links to its own address, so
  moderated maps work without embedding too
- Embeds on unlisted websites are refused: the gate says the map hasn't
  been set up for this site (rendered server-side, before anyone types),
  and the request mutation refuses them as a backstop
- A bare <iframe> without embed.js is told to use the embed code
- By-request maps only show the embed code once a website is listed; open
  maps show it as before
- The subdomain rewrite now keeps the query string, so the public map page
  can read embed.js's parameters

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edding

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The marker request depends on publicMapViewId (public listing titles,
and the record dates the public map uses to hide past events), but it
was missing from the query key. Switching from explore to publish reused
explore's markers, which have no dates, so past events were never hidden
until a refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Markers switch to a white, outlined style over a choropleth fill, but the
choropleth is only rendered in private mode. On public maps (and in the
publish editor) the area stats still load, so markers flashed solid then
turned white over a plain basemap. Require private mode, matching Map.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Store each magic link in its own row, so a new request no longer
  invalidates a link the visitor has yet to click
- Retry moderator notification on a repeat request until one is emailed
- Guard decisions against concurrent moderators
- Undo an approval if the link can't be emailed or built
- sendEmail now reports failure by returning false
- Block publishing while a list field has invalid lines
- Render review timestamps in a fixed timezone to avoid hydration mismatch
- Extract shared signed grant cookie helper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- On a public map's host, auth errors reload to show the access gate
  instead of redirecting to /login (which can't be embedded); at most
  once a minute to avoid reload loops
- Magic links back to an embedding page name the map they're for, so
  embed.js only forwards the token into that map's iframe
- Check public map grants in parallel in canViewAnyPublicMap
- Share one getSubdomain helper across the publish editor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joaquimds
joaquimds merged commit f30dda6 into main Oct 29, 2026
1 check passed
@joaquimds
joaquimds deleted the feat/moderated-public-map-access branch October 29, 2026 18:00
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