Repository navigation
feat: moderated (by request) access for public maps, with embedding - #533
Merged
Merged
Conversation
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>
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
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.
Links are valid for one week, and so is the access they grant.
Access modes
How it works
Access control is in one place.
canViewPublicMapinsrc/server/utils/auth.tsis now the single rule for whether a visitor may see a public map's data.publicMap.get,mapReadProcedureandcanReadDataSource(and so the markers route and the data source procedures) all go through it.What visitors see.
publicMap.getnever sends moderator emails to visitors.dataSourceConfigs, only the name, description and styling for the gate.Grants.
VisitorGrantsobject on the tRPC context (ctx.grants, replacingctx.shareGrants).PublicMapAccess) is a signed JWT sethttpOnly; Secure; SameSite=None; Partitioned, so it works inside a cross-site iframe.public_map_accessrow. Revoking takes effect immediately, and re-approving doesn't revive sessions minted before the latest decision.Magic links.
POST /api/public-map-access/claimexchanges a token for the cookie. The gate calls it from inside the iframe, so the partitioned cookie lands under the embedding site.Moderation.
/public-map-access/[token], authorised by a signed token covering one request.Requests.
Embedding.
public/embed.jsturns<div data-mapped-map="…">into an iframe.mapped_page), forwardsmapped_accessfrom the page URL into the iframe, and strips the token from the embedding page's address bar.<iframe>withoutembed.jsis told to use the embed code.Editor.
Changes outside the feature
src/proxy.ts: the subdomain rewrite now keeps the query string, so the public map page can readembed.js's parameters. No tests cover the proxy.getPublicMapBaseUrl/getPublicMapUrlinsrc/utils/appUrl.tsreplace two copies of thehttp://localhost:3001dev fallback in the editor.mainand merged in.mainhasn't been pushed, so they appear in this diff:8fd37155: markers refetch when switching between explore and publish.publicMapViewIdwas 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:access_mode,moderator_emailsandembed_originstopublic_map, with matching keys in existing drafts.public_map_access.Open, so nothing changes for them.Testing
tests/unit/server/trpc/publicMapAccess.test.tscovers 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.tests/unit/server/trpc/publicMapAccessFlow.test.tscovers request, moderator email, approve, link destination, refused unlisted embeds, repeat requests, expiry and revocation.tests/unit/utils/publicMapAccess.test.tscovers origin matching and link building.PublicMaprepository andremoveDataSourceReferencestests.localhost:8080) through request, approve, magic link and the map loading.Not covered yet
🤖 Generated with Claude Code