diff --git a/READ_ONLY_PRIVATE_MAPS.md b/READ_ONLY_PRIVATE_MAPS.md new file mode 100644 index 000000000..60b7d972a --- /dev/null +++ b/READ_ONLY_PRIVATE_MAPS.md @@ -0,0 +1,405 @@ +# Read-only Private Maps + +## Summary + +Allow users to share a private map, exactly as it is configured, with a small audience +outside their organisation via a link, optionally protected by a password. + +This is distinct from **Public Maps**, which are campaign-facing sites for providing +geographical information to the general public. Read-only shares are for showing +statistics and analysis to a much smaller, trusted audience — the private map view +with no control panels: just the legend, the boundary hover info, and the inspector +with no settings buttons. + +## Decisions (agreed 2026-08-05) + +| Question | Decision | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Entry UX | New **Share button + dialog** in the navbar; rename the existing mode toggle from "Explore \| Share" to **"Explore \| Publish"** | +| Share scope | **Whole map, all views** — one link per map, recipients can switch between views | +| Visible controls | Legend, boundary hover info, settings-free inspector, **plus** zoom control, map style selector, timeline control, and the area search box | +| Gating | New organisation feature flag, e.g. `Feature.SharedMaps` | + +## UX design + +### Navbar (map editor) + +``` +┌────────────────────────────────────────────────────────────┐ +│ ‹ Maps / Map name [views] [Explore | Publish] [⤴ Share] │ +└────────────────────────────────────────────────────────────┘ +``` + +- `MapModeToggle` (`src/components/MapModeToggle.tsx`) is relabelled + **Explore | Publish**. Behaviour is unchanged (`?mode=publish` URL param); only the + "Share" label changes, freeing the word "share" for the new feature. Audit other + user-facing copy that calls publishing "share". +- A new **Share button** sits next to the toggle in `PrivateMapNavbar` + (`src/app/(private)/map/[id]/components/PrivateMapNavbar.tsx`), gated on + `Feature.SharedMaps`. It opens a popover/dialog (shadcn `Popover` or `Dialog`). + +### Share dialog + +``` +┌───────────────────────────────────┐ +│ Share this map │ +│ │ +│ Read-only link [●] │ ← switch: enable/disable +│ Anyone with the link can view │ +│ this map, but not edit it. │ +│ │ +│ ☑ Require a password │ +│ [••••••••••] [Set] │ +│ │ +│ [https://…/share/aB3xY…] [Copy] │ +│ [Reset] │ ← regenerate link (revokes old one) +└───────────────────────────────────┘ +``` + +- Enabling the link creates (or re-enables) the share and shows the URL immediately. +- The password is optional and set separately; it can be changed or removed at any + time. Changing it forces existing viewers to re-enter it (see grant cookie below). +- "Reset link" regenerates the token, invalidating the old URL — the escape hatch if + a link leaks. +- Viewers always see the **live current state** of the map, not a snapshot. The + dialog copy should say so. + +### Viewer experience (`/share/[token]`) + +A slim, chrome-free version of the private map view: + +``` +┌────────────────────────────────────────────────────────────┐ +│ Map name [view switcher] [search box] │ ← ReadOnlyNavbar +├────────────────────────────────────────────────────────────┤ +│ [Boundary hover info] [Inspector] │ +│ │ +│ (map) [style] │ +│ [zoom +/-] │ +│ [Legend] [timeline control] │ +└────────────────────────────────────────────────────────────┘ +``` + +- No control panel, no visualisation panel, no marker settings, no table, no draw/pin + modes. +- View switching works (client-side URL state, as in the editor). +- If the share has a password and the visitor has no valid grant, the page renders a + centred password form instead of the map. +- If the share is disabled or the token is unknown: `notFound()`. + +## Data model + +New table `map_share` (one share per map): + +``` +map_share + id uuid pk default gen_random_uuid() + map_id uuid fk → map(id) on delete cascade, UNIQUE + token text UNIQUE -- unguessable URL slug, e.g. 24-char nanoid + enabled boolean default true + password_hash text null -- scrypt "salt:hex", null = no password + password_updated_at timestamptz null + created_at timestamptz default now() +``` + +Notes: + +- `passwordHash` as the column/property name means the existing + `hasPasswordHashSerializer` (`src/utils/superjson.ts`, registered in + `src/server/trpc/index.ts:33`) automatically strips it from every tRPC response. +- Reuse `hashPassword` / `verifyPassword` from `src/server/utils/auth.ts:36-61`. +- `password_updated_at` lets us invalidate existing viewer grants when the password + changes (grant issued-at < password_updated_at ⇒ re-prompt). +- Disabling keeps the row (and token) so re-enabling restores the same link; + "Reset link" rotates `token`. +- Migration follows the existing pattern in `migrations/`. + +Repository: `src/server/repositories/MapShare.ts` — `findByToken`, `findByMapId`, +`upsertForMap`, `setPassword`, `regenerateToken`, `setEnabled`. + +## Access control + +### The grant cookie + +Anonymous viewers authenticate to shared maps via an httpOnly cookie +(`SharedMaps`), a `jose`-signed JWT (same `JWT_SECRET` infra as `src/auth/jwt.ts`) +containing a list of grants: + +```ts +{ grants: [{ shareId: string, mapId: string, iat: number }], exp: ... } +``` + +- Visiting `/share/[token]` for a **passwordless** share: the server component sets + the grant cookie directly and renders the map. +- For a **password-protected** share: the page renders the password form; a route + handler (`POST /api/share/[token]/verify`) checks the password with + `verifyPassword`, rate-limited via the existing Redis rate limiter + (`src/server/services/ratelimit.ts`, same 5-attempts/15-min pattern as login, + keyed on IP + token), and sets the grant cookie on success. +- Grants cap at a handful of entries (drop oldest) and expire (e.g. 7 days). +- **The cookie is necessary but not sufficient**: every server-side check re-fetches + the `mapShare` row and verifies `enabled`, and, if `passwordHash` is set, that + `grant.iat >= passwordUpdatedAt`. Disabling a share or changing the password + revokes access immediately. + +### Choke points (all three must change) + +Exploration confirmed exactly three places gate anonymous read access today, each +currently keyed on "a published public map exists": + +1. **`mapReadProcedure`** (`src/server/trpc/index.ts:199-234`) — add a branch: if no + user/org access, check the grant cookie for a valid grant matching `input.mapId` + (with the enabled/password-freshness re-check above). This unlocks `map.byId`, + `dataSource.listForMapView`, and `mapView.inspectorConfigs` for viewers. + `createContext` (`:20-28`) must start reading the request cookies to expose the + parsed grants on `ctx`. +2. **`canReadDataSource`** (`src/server/utils/auth.ts:11-34`) — add a branch: the + data source is visualised by a map for which the caller holds a valid grant + (analogous to `findPublishedPublicMapByDataSourceId` in + `src/server/repositories/PublicMap.ts:56-92`, but checking across **all views** + of the shared map plus `mapConfig.markerDataSourceIds`). This unlocks + `area.stats`, `dataRecord.byId/byAreaCode/byPoint/list/columnStat`. +3. **Markers REST route** (`src/app/api/data-sources/[id]/markers/route.ts`) — reads + the session and calls `canReadDataSource` independently; pass the parsed grants + through the same helper. + +Additionally: + +- **`area.search` / `area.byCode`** are `protectedProcedure` today and the search box + and boundary markers need them. Rather than making them fully public, introduce a + new procedure tier, e.g. **`viewerProcedure`**: allows the request through if there + is an authenticated user **or** the grant cookie contains at least one valid grant + (re-checked against the `mapShare` row: `enabled`, password freshness — same + validation as the other choke points, factored into a shared helper). Ctx becomes + `{ user: User | null, shareGrants: ValidatedGrant[] }`. Move `area.search` and + `area.byCode` onto it. Anonymous visitors with no grant still get `UNAUTHORIZED`, + so boundary search is never open to the public internet. Note these procedures + must not assume `ctx.user` exists (area lookups are not user-scoped, so this + should be a no-op — verify). + +### tRPC router + +New `mapShare` router (`src/server/trpc/routers/mapShare.ts`), all under +`mapWriteProcedure` (org members who can edit the map manage sharing): + +- `get({ mapId })` — current share state (token, enabled, `hasPassword` boolean — + never the hash, which the serializer strips anyway). +- `enable({ mapId })` / `disable({ mapId })` — creates the row on first enable. +- `setPassword({ mapId, password: string | null })` — hash + set `passwordUpdatedAt`. +- `regenerateToken({ mapId })`. + +One `publicProcedure`: `mapShare.getPublicInfo({ token })` — returns +`{ mapId, mapName, requiresPassword }` or null, for the share page shell / password +form. (Alternatively do this fully server-side in the page; no tRPC needed.) + +## Client implementation + +### Route + +`src/app/share/[token]/page.tsx` — **outside `(private)`** (whose layout redirects +unauthenticated visitors to login, `src/app/(private)/layout.tsx:16-19`). Server +component: + +1. Look up the share by token; `notFound()` if missing/disabled. +2. If password required and no valid grant cookie → render `SharePasswordForm`. +3. Else set/refresh the grant cookie and render the map shell: + +``` +MapJotaiProvider mapId viewId readOnly +├ SharedMap → Map (existing components) +├ ReadOnlyNavbar (new: name, view switcher, SearchBox) +└ ReadOnlyMapControls (new: fork of PrivateMapControls) + ├ BoundaryHoverInfo (as-is — client-only state, safe) + ├ InspectorPanel readOnly + ├ LegendDisplay (new: display-only legend overlay) + ├ MapStyleSelector / ZoomControl / TimelineControl + └ MapInfoPopup (already has a ReadOnlyContent renderer) +``` + +Set `X-Robots-Tag: noindex` on this route. + +### Read-only state + +Follow the `isPublicMapRouteAtom` precedent (`atoms/mapStateAtoms.ts:14` and +`useEditable()` in `publish/hooks/usePublicMap.ts:39-45`): + +- Add `isReadOnlyRouteAtom`, hydrated by `MapJotaiProvider` + (`src/providers/MapJotaiProvider.tsx`) via a new `readOnly` prop. +- Add a hook (e.g. `useMapEditable()`) consumed by the components below rather than + prop-drilling. + +### Component work + +- **Legend** (`map/[id]/components/Legend/Legend.tsx`) is currently mounted _inside_ + the control panel (`controls/BoundariesControl/BoundariesControl.tsx:57`) and is + ~90% editing UI; only the `LegendBars` / `BivariateLegend` section (lines ~440-457) + is pure display. Extract a **`LegendDisplay`** component (colour bars + column/ + boundary labels, plus `MarkerLegend`'s display part) that both the existing Legend + and the read-only overlay use. Do not fork the bars themselves. +- **InspectorPanel** (`map/[id]/components/InspectorPanel/InspectorPanel.tsx`): under + read-only, hide: + - "Add to areas" (`:368-375`, writes via `trpc.turf.upsert`) + - "View in table" (`:397-405`, no table in this view) + - the config gear (`InspectorDataTab.tsx:88-90, :246-248, :320`, + `ConfigurableDataRecordsPanel.tsx:21-38`, `InspectorConfigItem.tsx:98`) + - Notes tab (already self-gates on `useOrganisationId()`, which is null for + anonymous viewers — verify, don't assume) + - Keep: Compare, minimise/back, fly-to (client-only state). +- **ReadOnlyMapControls**: fork of `PrivateMapControls.tsx:83-119` keeping + `BoundaryHoverInfo`, `InspectorPanel`, `MapStyleSelector`, `ZoomControl`, + `TimelineControl`; dropping the draw/pin-drop banner (`:121-146`). +- **ReadOnlyNavbar**: new slim navbar. Do **not** mount `MapNavbar` — it runs + `useInitialMapViewEffect()` which _writes_ views if none exist + (`hooks/useInitialMapView.ts`), and `PrivateMapNavbar` auto-uploads thumbnails via + `trpc.map.update` (`PrivateMapNavbar.tsx:112-138`). The view switcher needs a + read-only variant of `MapViews` (list/switch only; no create/rename/delete). +- **Data hooks**: `useDataSources` (`src/hooks/useDataSources.ts:42-53`) branches on + `isPublicMapRouteAtom` to call `dataSource.listForMapView` instead of the + `protectedProcedure` `listReadable`; extend the branch to include the read-only + route. Similarly audit `useMarkerQueries` (`hooks/useMarkerQueries.ts`): in + read-only mode it must use the **full private** `mapConfig.markerDataSourceIds` + (not the public-map subset), with the markers REST call authorised by the grant + cookie. +- **Share dialog**: new `ShareMapDialog` component under + `map/[id]/components/`, driven by the `mapShare` router, mounted from + `PrivateMapNavbar` behind `Feature.SharedMaps`. + +### Feature flag + +Add `SharedMaps` to `Feature` in `src/models/Organisation.ts` and gate the Share +button the same way `Feature.PublicMaps` gates the mode toggle +(`PrivateMapNavbar.tsx:37-41`). The `/share/[token]` route itself is **not** flag +-gated (existing links keep working if a flag is later toggled off — or decide the +opposite; see open questions). + +## Security considerations + +- **Token entropy**: ≥ 128 bits (e.g. 24-char nanoid). The URL itself is a secret + for passwordless shares. +- **Password attempts**: rate-limited per IP + token via the existing Redis limiter. +- **Immediate revocation**: server re-checks the `mapShare` row on every request; + the cookie alone grants nothing. +- **Password rotation invalidates grants** via `passwordUpdatedAt`. +- **No hash leakage**: `passwordHash` naming + existing superjson serializer. +- **Cookie**: httpOnly, `SameSite=Lax`, `Secure` in production. +- **Scope creep check**: `mapReadProcedure`'s public-map branch grants access when + _any_ published public map exists for the mapId, regardless of view — the new + share branch is map-scoped by design (whole-map sharing), but keep the grant + checks strict (`enabled`, password freshness) since this exposes _all_ views and + data sources visualised on the map, which is broader than a public map exposes. +- **No indexing**: `noindex` header on the share route; don't include shared maps in + sitemaps. +- **CSP**: default `frame-ancestors 'self'` applies (the relaxed policy in + `src/proxy.ts:27-30` is only for the public-map host rewrite) — shared maps are + not embeddable, which is fine for this audience. + +## Implementation plan — staged task sequence + +Stages run in order; each ends at a checkpoint where the work is testable (automated +tests, or a manual check by the developer). Tasks within a stage are roughly ordered +but can interleave. + +### Stage 1 — Schema & repository + +1. **Migration**: create `map_share` table (columns as per Data model section), + following the existing `migrations/` pattern (raw SQL = snake_case). +2. **Models**: `src/server/models/MapShare.ts` (Kysely table type, added to the + `Database` interface in `src/server/services/database/index.ts`) and + `src/models/MapShare.ts` (Zod schema / client-safe types). +3. **Repository**: `src/server/repositories/MapShare.ts` — `findByToken`, + `findByMapId`, `upsertForMap`, `setPassword`, `regenerateToken`, `setEnabled` — + with unit tests. Token generation: ≥128-bit URL-safe random. + +**Checkpoint**: `npm run migrate` succeeds; repository unit tests pass. + +### Stage 2 — Share management API + +4. **`mapShare` tRPC router** (`src/server/trpc/routers/mapShare.ts`), all under + `mapWriteProcedure`: `get` (returns token/enabled/`hasPassword`, never the hash), + `enable`, `disable`, `setPassword` (hash + bump `passwordUpdatedAt`), + `regenerateToken`. Tests: org member can manage; non-member cannot; hash never + appears in output. + +**Checkpoint**: router tests pass; a share row can be created end-to-end via tests. + +### Stage 3 — Grant cookie & access control + +5. **Grant cookie helpers** in `src/auth/`: issue/parse/verify a signed `SharedMaps` + JWT holding `{ grants: [{ shareId, mapId, iat }] }`; append-with-cap, expiry. +6. **Validation helper**: given parsed grants + a mapId (or share row), decide + validity — `enabled`, and `grant.iat >= passwordUpdatedAt` when a password is + set. Single shared implementation used by every gate below. +7. **`createContext`** (`src/server/trpc/index.ts`): read request cookies, expose + parsed grants on ctx. +8. **`mapReadProcedure` branch**: valid grant for `input.mapId` ⇒ allow. Tests: + grant works, disabled share 401s, stale-password grant 401s. +9. **`canReadDataSource` branch** + new repository query "data source is visualised + on this shared map" (across all views + `mapConfig.markerDataSourceIds`). Tests + include the negative case: a grant for map A does **not** unlock a data source + only on map B. +10. **Markers REST route** (`src/app/api/data-sources/[id]/markers/route.ts`): pass + parsed grants into the same check. +11. **`viewerProcedure`** (user OR ≥1 valid grant); move `area.search` / + `area.byCode` onto it; verify they don't assume `ctx.user`. Tests: grant-holder + passes, bare anonymous gets `UNAUTHORIZED`, logged-in user unaffected. + +**Checkpoint**: with a share row and a hand-issued cookie, all read procedures +succeed anonymously in tests; without the cookie they 401 as before. + +### Stage 4 — Viewer route (passwordless shares) + +12. **`/share/[token]` page** (`src/app/share/[token]/page.tsx`, outside + `(private)`): resolve token, `notFound()` if missing/disabled, issue grant + cookie, render map shell; `X-Robots-Tag: noindex`. +13. **Read-only state**: `isReadOnlyRouteAtom`, `readOnly` prop on + `MapJotaiProvider`, `useMapEditable()` hook. +14. **`ReadOnlyMapControls`**: fork of `PrivateMapControls` keeping + `BoundaryHoverInfo`, `InspectorPanel`, `MapStyleSelector`, `ZoomControl`, + `TimelineControl`; no draw/pin banner. +15. **`ReadOnlyNavbar`**: map name, read-only view switcher (list/switch only), + `SearchBox`. Must not mount `MapNavbar`/`PrivateMapNavbar` (hidden writes: + initial-view creation, thumbnail upload). +16. **`LegendDisplay`**: extract the display-only legend (colour bars + labels + + marker legend) from `Legend.tsx` / `MarkerLegend.tsx`; reuse in both places. +17. **`InspectorPanel` read-only mode**: hide config gear, "Add to areas", + "View in table", Notes tab; keep Compare/minimise/fly-to. +18. **Data-hook branches**: `useDataSources` → `listForMapView` on the read-only + route; `useMarkerQueries` → full private `markerDataSourceIds` with the markers + stream authorised by the grant cookie. + +**Checkpoint (manual)**: enable a passwordless share via the router, open the link +logged out — map renders read-only with legend, hover info, inspector, zoom, style, +timeline, search; view switching works; no write requests fire (check network tab). + +### Stage 5 — Password gate + +19. **Password form + verify endpoint**: `SharePasswordForm` rendered by the share + page when required; `POST /api/share/[token]/verify` using `verifyPassword`, + Redis rate limiting (5 / 15 min per IP + token), sets grant cookie on success. +20. **Invalidation behaviour**: changing/removing the password mid-session + re-prompts existing viewers (via `passwordUpdatedAt`); tests. + +**Checkpoint (manual)**: set a password, open link in incognito — form appears, +wrong password rejected (and rate-limited), correct password shows the map; change +the password and confirm the open session is booted back to the form. + +### Stage 6 — Share dialog & polish + +21. **Feature flag**: add `SharedMaps` to `Feature` in `src/models/Organisation.ts`. +22. **`ShareMapDialog`** + Share button in `PrivateMapNavbar` behind the flag: + enable switch, password set/change/remove, copy link, reset link. +23. **Toggle rename**: `MapModeToggle` "Share" → "Publish"; audit remaining copy + that calls publishing "share". + +**Checkpoint (manual)**: full end-to-end flow from the dialog — enable, set +password, copy link, share, reset link invalidates the old URL. + +## Open questions + +1. If `Feature.SharedMaps` is later disabled for an org, should existing share links + stop working? (Current plan: links keep working; the org just loses the UI to + manage them.) +2. Should shares support an optional expiry date? (Not in v1; the schema doesn't + preclude adding `expires_at` later.) +3. Should the map's owner see any indication on the dashboard map cards that a map + is shared? (Nice-to-have, not in v1.) diff --git a/migrations/1785888000000_map_share.ts b/migrations/1785888000000_map_share.ts new file mode 100644 index 000000000..7d9a64e0c --- /dev/null +++ b/migrations/1785888000000_map_share.ts @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { sql } from "kysely"; +import type { Kysely } from "kysely"; + +/** + * Read-only private map sharing: one share per map, addressed by an + * unguessable token, optionally protected by a password. + * `password_updated_at` invalidates viewer grants issued before a + * password change. + */ +export async function up(db: Kysely): Promise { + await db.schema + .createTable("mapShare") + .addColumn("id", "uuid", (col) => + col.primaryKey().defaultTo(sql`gen_random_uuid()`), + ) + .addColumn("mapId", "uuid", (col) => col.notNull().unique()) + .addColumn("token", "text", (col) => col.notNull().unique()) + .addColumn("enabled", "boolean", (col) => col.notNull().defaultTo(true)) + .addColumn("passwordHash", "text") + .addColumn("passwordUpdatedAt", "timestamp") + .addColumn("createdAt", "timestamp", (col) => + col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), + ) + .addForeignKeyConstraint( + "mapShareMapIdFKey", + ["mapId"], + "map", + ["id"], + (cb) => cb.onDelete("cascade").onUpdate("cascade"), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("mapShare").execute(); +} diff --git a/src/app/api/data-sources/[id]/markers/route.ts b/src/app/api/data-sources/[id]/markers/route.ts index 046c14473..c07e75b59 100644 --- a/src/app/api/data-sources/[id]/markers/route.ts +++ b/src/app/api/data-sources/[id]/markers/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getServerSession } from "@/auth"; +import { getShareGrants } from "@/auth/shareGrants"; import { MARKER_MATCHED_COLUMN } from "@/constants"; import { streamDataRecordsByDataSource } from "@/server/repositories/DataRecord"; import { findDataSourceById } from "@/server/repositories/DataSource"; @@ -35,7 +36,12 @@ export async function GET( return new NextResponse("Not found", { status: 404 }); } - const canRead = await checkAccess(dataSource, currentUser?.id); + const shareGrants = await getShareGrants(); + const canRead = await checkAccess({ + dataSource, + userId: currentUser?.id, + shareGrants, + }); if (!canRead) { return new NextResponse("Not found", { status: 404 }); } diff --git a/src/auth/shareGrants.ts b/src/auth/shareGrants.ts new file mode 100644 index 000000000..add9b1447 --- /dev/null +++ b/src/auth/shareGrants.ts @@ -0,0 +1,69 @@ +import { SignJWT, jwtVerify } from "jose"; +import { cookies } from "next/headers"; +import { SHARE_GRANT_LIFETIME_SECONDS } from "@/constants"; +import type { ShareGrant } from "@/authTypes"; + +export const SHARE_GRANTS_COOKIE = "SharedMaps"; + +// Cap the cookie size; adding a grant beyond the cap drops the oldest +const MAX_SHARE_GRANTS = 10; + +/** + * Read and verify the share-grant cookie. Returns [] for missing, invalid + * or expired cookies. Grants returned here are only *candidates*: callers + * must validate them against the live mapShare row (see + * `findValidShareGrantForMap` in server/utils/auth.ts). + */ +export async function getShareGrants(): Promise { + const cookieStore = await cookies(); + const cookie = cookieStore.get(SHARE_GRANTS_COOKIE); + if (!cookie?.value) { + return []; + } + try { + const secret = new TextEncoder().encode(process.env.JWT_SECRET || ""); + const { payload } = await jwtVerify<{ grants: ShareGrant[] }>( + cookie.value, + secret, + ); + return Array.isArray(payload.grants) ? payload.grants : []; + } catch { + // Don't bother logging invalid JWTs + return []; + } +} + +/** + * Mint a grant for a share and store it in the signed cookie, replacing any + * previous grant for the same share. Must be called from a route handler or + * server action: Next.js forbids setting cookies during server component + * render. + */ +export async function addShareGrant({ + shareId, + mapId, +}: { + shareId: string; + mapId: string; +}): Promise { + const existing = await getShareGrants(); + const nowSeconds = Math.floor(Date.now() / 1000); + const grants: ShareGrant[] = [ + { shareId, mapId, iat: nowSeconds }, + ...existing.filter((grant) => grant.shareId !== shareId), + ].slice(0, MAX_SHARE_GRANTS); + + const secret = new TextEncoder().encode(process.env.JWT_SECRET || ""); + const token = await new SignJWT({ grants }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(nowSeconds + SHARE_GRANT_LIFETIME_SECONDS) + .sign(secret); + + const cookieStore = await cookies(); + cookieStore.set(SHARE_GRANTS_COOKIE, token, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + }); +} diff --git a/src/authTypes.ts b/src/authTypes.ts index d8eba6cdb..884f37bfc 100644 --- a/src/authTypes.ts +++ b/src/authTypes.ts @@ -13,3 +13,17 @@ export interface ServerSession { jwt: string | null; currentUser: CurrentUser | null; } + +/** + * Grants an anonymous visitor read access to a shared map. Minted after the + * visitor proves possession of the share link (and its password, if set); + * carried in a signed cookie. Never sufficient on its own: every check + * re-validates against the live mapShare row. + */ +export interface ShareGrant { + shareId: string; + mapId: string; + // Unix seconds when the grant was minted. Grants minted before the + // share's passwordUpdatedAt fail validation while a password is set. + iat: number; +} diff --git a/src/constants/index.ts b/src/constants/index.ts index 5eb613982..add4d0391 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -51,6 +51,9 @@ export const GE_DATA_SOURCE_NAME = "2024 General Election Results"; export const JWT_LIFETIME_SECONDS = 24 * 60 * 60; +// Lifetime of a read-only map share grant (see ShareGrant in authTypes.ts) +export const SHARE_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60; + // Different database derived column name because underscores get mangled by camelCase translation export const MARKER_MATCHED_COLUMN = "mappedMatched"; diff --git a/src/models/MapShare.ts b/src/models/MapShare.ts new file mode 100644 index 000000000..d5215ed41 --- /dev/null +++ b/src/models/MapShare.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +// `passwordHash` is stripped from tRPC responses by the +// `hasPasswordHashSerializer` superjson custom serializer. +export const mapShareSchema = z.object({ + id: z.string(), + mapId: z.string(), + token: z.string(), + enabled: z.boolean(), + passwordHash: z.string().nullable(), + passwordUpdatedAt: z.date().nullable(), + createdAt: z.date(), +}); + +export type MapShare = z.infer; diff --git a/src/server/models/MapShare.ts b/src/server/models/MapShare.ts new file mode 100644 index 000000000..30c578ea1 --- /dev/null +++ b/src/server/models/MapShare.ts @@ -0,0 +1,9 @@ +import type { MapShare } from "@/models/MapShare"; +import type { ColumnType, Generated, Insertable, Updateable } from "kysely"; + +export type MapShareTable = MapShare & { + id: Generated; + createdAt: ColumnType; +}; +export type NewMapShare = Insertable; +export type MapShareUpdate = Updateable; diff --git a/src/server/repositories/MapShare.ts b/src/server/repositories/MapShare.ts new file mode 100644 index 000000000..5410a2689 --- /dev/null +++ b/src/server/repositories/MapShare.ts @@ -0,0 +1,142 @@ +import { randomBytes } from "crypto"; + +import { db } from "@/server/services/database"; +import type { MapConfig } from "@/models/Map"; +import type { MapViewConfig } from "@/models/MapView"; +import type { TraversedJSONPathBuilder } from "kysely"; + +// 18 random bytes → 24-char base64url token (144 bits of entropy). +// The URL is itself a secret for passwordless shares. +const generateShareToken = () => randomBytes(18).toString("base64url"); + +export function findMapShareByToken(token: string) { + return db + .selectFrom("mapShare") + .where("token", "=", token) + .selectAll() + .executeTakeFirst(); +} + +export function findMapShareByMapId(mapId: string) { + return db + .selectFrom("mapShare") + .where("mapId", "=", mapId) + .selectAll() + .executeTakeFirst(); +} + +/** + * Enable sharing for a map. The first call creates the share with a fresh + * token; later calls re-enable it, keeping the existing token and password + * so a disabled link is restored rather than replaced. + */ +export function upsertMapShareForMap(mapId: string) { + return db + .insertInto("mapShare") + .values({ mapId, token: generateShareToken(), enabled: true }) + .onConflict((oc) => oc.column("mapId").doUpdateSet({ enabled: true })) + .returningAll() + .executeTakeFirstOrThrow(); +} + +export function setMapShareEnabled({ + mapId, + enabled, +}: { + mapId: string; + enabled: boolean; +}) { + return db + .updateTable("mapShare") + .set({ enabled }) + .where("mapId", "=", mapId) + .returningAll() + .executeTakeFirst(); +} + +/** + * Set (or remove, with `passwordHash: null`) the share password hash. + * Hashing happens in the caller (see the mapShare router) to keep this + * module free of auth imports. Always bumps `passwordUpdatedAt` so viewer + * grants issued before the change stop validating. + */ +export function setMapSharePassword({ + mapId, + passwordHash, +}: { + mapId: string; + passwordHash: string | null; +}) { + return db + .updateTable("mapShare") + .set({ passwordHash, passwordUpdatedAt: new Date() }) + .where("mapId", "=", mapId) + .returningAll() + .executeTakeFirst(); +} + +/** + * Find an enabled share, among the given maps, whose map visualises this + * data source — via the map's membersDataSourceId or markerDataSourceIds, + * or any of the map's views' areaDataSourceId. Used to decide whether a + * share-grant holder may read the data source's records; mirrors + * `findPublishedPublicMapByDataSourceId`, but across all views of the map. + */ +export function findMapShareVisualisingDataSource({ + dataSourceId, + mapIds, +}: { + dataSourceId: string; + mapIds: string[]; +}) { + if (mapIds.length === 0) { + return Promise.resolve(undefined); + } + return db + .selectFrom("mapShare") + .innerJoin("map", "map.id", "mapShare.mapId") + .where("mapShare.mapId", "in", mapIds) + .where("mapShare.enabled", "=", true) + .where(({ eb, exists, ref, selectFrom }) => + eb.or([ + eb( + ref("map.config", "->>").key("membersDataSourceId"), + "=", + dataSourceId, + ), + eb( + ref("map.config", "->").key( + "markerDataSourceIds", + ) as TraversedJSONPathBuilder, + "@>", + JSON.stringify([dataSourceId]), + ), + exists( + selectFrom("mapView") + .select("mapView.id") + .whereRef("mapView.mapId", "=", "map.id") + .where(({ eb: viewEb, ref: viewRef }) => + viewEb( + viewRef("mapView.config", "->>").key( + "areaDataSourceId", + ) as TraversedJSONPathBuilder, + "=", + dataSourceId, + ), + ), + ), + ]), + ) + .selectAll("mapShare") + .executeTakeFirst(); +} + +/** Rotate the token, invalidating the previously shared URL. */ +export function regenerateMapShareToken(mapId: string) { + return db + .updateTable("mapShare") + .set({ token: generateShareToken() }) + .where("mapId", "=", mapId) + .returningAll() + .executeTakeFirst(); +} diff --git a/src/server/services/database/index.ts b/src/server/services/database/index.ts index 8eecb3875..f9d7f4206 100644 --- a/src/server/services/database/index.ts +++ b/src/server/services/database/index.ts @@ -16,6 +16,7 @@ import type { InspectorDataSourceConfigTable } from "@/server/models/InspectorDa import type { InvitationTable } from "@/server/models/Invitation"; import type { JobTable } from "@/server/models/Job"; import type { MapTable } from "@/server/models/Map"; +import type { MapShareTable } from "@/server/models/MapShare"; import type { MapViewTable } from "@/server/models/MapView"; import type { OrganisationTable } from "@/server/models/Organisation"; import type { OrganisationUserTable } from "@/server/models/OrganisationUser"; @@ -68,6 +69,7 @@ export interface Database { geocodeCache: GeocodeCacheTable; invitation: InvitationTable; map: MapTable; + mapShare: MapShareTable; mapView: MapViewTable; inspectorDataSourceConfig: InspectorDataSourceConfigTable; organisation: OrganisationTable; diff --git a/src/server/trpc/index.ts b/src/server/trpc/index.ts index 3d8d204d9..06d4fac43 100644 --- a/src/server/trpc/index.ts +++ b/src/server/trpc/index.ts @@ -2,10 +2,15 @@ import { TRPCError, initTRPC } from "@trpc/server"; import superjson from "superjson"; import z, { ZodError } from "zod"; import { getServerSession } from "@/auth"; +import { getShareGrants } from "@/auth/shareGrants"; import { TRIAL_EXPIRED_MESSAGE } from "@/constants"; import { UserRole } from "@/models/User"; import { getClientIp } from "@/server/services/ratelimit"; -import { canReadDataSource } from "@/server/utils/auth"; +import { + canReadDataSource, + findValidShareGrantForMap, + hasValidShareGrant, +} from "@/server/utils/auth"; import { hasPasswordHashSerializer, serverDataSourceSerializer, @@ -15,6 +20,7 @@ import { findMapById } from "../repositories/Map"; import { findOrganisationForUser } from "../repositories/Organisation"; import { findPublishedPublicMapByMapId } from "../repositories/PublicMap"; import { findUserById } from "../repositories/User"; +import type { ShareGrant } from "@/authTypes"; export async function createContext(opts?: { req?: Request }) { const session = await getServerSession(); @@ -23,10 +29,16 @@ export async function createContext(opts?: { req?: Request }) { user = await findUserById(session.currentUser.id); } const ip = opts?.req ? getClientIp(opts.req) : "unknown"; - return { user, ip }; + const shareGrants = await getShareGrants(); + return { user, ip, shareGrants }; } -export type Context = Awaited>; +// `shareGrants` is optional so server-side callers and tests that construct +// a context by hand can omit it (absent = no grants). +export type Context = Omit< + Awaited>, + "shareGrants" +> & { shareGrants?: ShareGrant[] }; // Prevent sensitive fields being sent to the client superjson.registerCustom(serverDataSourceSerializer, "DataSource"); @@ -65,13 +77,16 @@ const t = initTRPC.context().create({ export const router = t.router; export const publicProcedure = t.procedure; +const isTrialExpired = (user: { trialEndsAt?: Date | null }) => + Boolean(user.trialEndsAt && new Date(user.trialEndsAt) < new Date()); + const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: "You must be logged in to perform this action.", }); - if (ctx.user.trialEndsAt && new Date(ctx.user.trialEndsAt) < new Date()) { + if (isTrialExpired(ctx.user)) { throw new TRPCError({ code: "FORBIDDEN", message: TRIAL_EXPIRED_MESSAGE, @@ -82,6 +97,34 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); +/** + * Allows authenticated users, and anonymous visitors holding at least one + * valid share grant (read-only map share cookie). Use for mildly protected + * reads that shared-map viewers need (e.g. area search) without opening + * them to the public internet. Procedures must not assume `ctx.user`. + */ +const enforceUserOrShareGrant = t.middleware(async ({ ctx, next }) => { + if (ctx.user) { + if (isTrialExpired(ctx.user)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: TRIAL_EXPIRED_MESSAGE, + }); + } + return next({ ctx: { user: ctx.user } }); + } + const hasGrant = await hasValidShareGrant(ctx.shareGrants); + if (!hasGrant) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be logged in to perform this action.", + }); + } + return next({ ctx: { user: null } }); +}); + +export const viewerProcedure = t.procedure.use(enforceUserOrShareGrant); + const enforceUserIsSuperadmin = t.middleware(({ ctx, next }) => { if (ctx.user?.role !== UserRole.Superadmin) throw new TRPCError({ @@ -134,7 +177,11 @@ export const dataSourceReadProcedure = publicProcedure message: "Data source not found", }); - const hasAccess = await canReadDataSource(dataSource, ctx.user?.id); + const hasAccess = await canReadDataSource({ + dataSource, + userId: ctx.user?.id, + shareGrants: ctx.shareGrants, + }); if (!hasAccess) { throw new TRPCError({ code: ctx.user ? "NOT_FOUND" : "UNAUTHORIZED", @@ -213,6 +260,11 @@ export const mapReadProcedure = publicProcedure return next({ ctx: { map } }); } + const shareGrant = await findValidShareGrantForMap(ctx.shareGrants, map.id); + if (shareGrant) { + return next({ ctx: { map } }); + } + if (!ctx.user?.id) { throw new TRPCError({ code: "UNAUTHORIZED", diff --git a/src/server/trpc/router.ts b/src/server/trpc/router.ts index 01d3ed9e0..1c48c7c87 100644 --- a/src/server/trpc/router.ts +++ b/src/server/trpc/router.ts @@ -5,6 +5,7 @@ import { dataSourceRouter } from "./routers/dataSource"; import { folderRouter } from "./routers/folder"; import { invitationRouter } from "./routers/invitation"; import { mapRouter } from "./routers/map"; +import { mapShareRouter } from "./routers/mapShare"; import { mapViewRouter } from "./routers/mapView"; import { oauthRouter } from "./routers/oauth"; import { organisationRouter } from "./routers/organisation"; @@ -24,6 +25,7 @@ export const appRouter = router({ folder: folderRouter, placedMarker: placedMarkerRouter, turf: turfRouter, + mapShare: mapShareRouter, mapView: mapViewRouter, organisation: organisationRouter, publicMap: publicMapRouter, diff --git a/src/server/trpc/routers/area.ts b/src/server/trpc/routers/area.ts index d81a626c7..96248d9d2 100644 --- a/src/server/trpc/routers/area.ts +++ b/src/server/trpc/routers/area.ts @@ -7,10 +7,10 @@ import { searchAreas, } from "@/server/repositories/Area"; import { getAreaStats } from "@/server/stats"; -import { dataSourceReadProcedure, protectedProcedure, router } from "../index"; +import { dataSourceReadProcedure, router, viewerProcedure } from "../index"; export const areaRouter = router({ - byCode: protectedProcedure + byCode: viewerProcedure .input( z.object({ areaSetCode: z.nativeEnum(AreaSetCode), @@ -21,7 +21,7 @@ export const areaRouter = router({ const area = await findAreaByCodeWithGeometry(code, areaSetCode); return area ?? null; }), - search: protectedProcedure + search: viewerProcedure .input( z.object({ search: z.string().min(1).max(200), diff --git a/src/server/trpc/routers/dataSource.ts b/src/server/trpc/routers/dataSource.ts index b8de5fada..3567acb76 100644 --- a/src/server/trpc/routers/dataSource.ts +++ b/src/server/trpc/routers/dataSource.ts @@ -279,7 +279,10 @@ export const dataSourceRouter = router({ referencedDataSourceIds, ); for (const ds of referencedDataSources) { - const hasAccess = await canReadDataSource(ds, ctx.user.id); + const hasAccess = await canReadDataSource({ + dataSource: ds, + userId: ctx.user.id, + }); if (!hasAccess) { throw new TRPCError({ code: "FORBIDDEN", @@ -497,7 +500,10 @@ export const dataSourceRouter = router({ referencedDataSourceIds, ); for (const ds of referencedDataSources) { - const hasAccess = await canReadDataSource(ds, ctx.user.id); + const hasAccess = await canReadDataSource({ + dataSource: ds, + userId: ctx.user.id, + }); if (!hasAccess) { throw new TRPCError({ code: "FORBIDDEN", diff --git a/src/server/trpc/routers/mapShare.ts b/src/server/trpc/routers/mapShare.ts new file mode 100644 index 000000000..179943e3f --- /dev/null +++ b/src/server/trpc/routers/mapShare.ts @@ -0,0 +1,73 @@ +import { TRPCError } from "@trpc/server"; +import z from "zod"; +import { passwordSchema } from "@/models/User"; +import { + findMapShareByMapId, + regenerateMapShareToken, + setMapShareEnabled, + setMapSharePassword, + upsertMapShareForMap, +} from "@/server/repositories/MapShare"; +import { hashPassword } from "@/server/utils/auth"; +import { mapWriteProcedure, router } from "../index"; + +// The share state exposed to the map editor's share dialog: +// never the password hash, only whether one is set. +const toShareState = (share: { + token: string; + enabled: boolean; + passwordHash: string | null; +}) => ({ + token: share.token, + enabled: share.enabled, + hasPassword: Boolean(share.passwordHash), +}); + +const shareNotFoundError = () => + new TRPCError({ + code: "NOT_FOUND", + message: "Map share not found", + }); + +export const mapShareRouter = router({ + get: mapWriteProcedure.query(async ({ ctx }) => { + const share = await findMapShareByMapId(ctx.map.id); + return share ? toShareState(share) : null; + }), + enable: mapWriteProcedure.mutation(async ({ ctx }) => { + const share = await upsertMapShareForMap(ctx.map.id); + return toShareState(share); + }), + disable: mapWriteProcedure.mutation(async ({ ctx }) => { + const share = await setMapShareEnabled({ + mapId: ctx.map.id, + enabled: false, + }); + if (!share) { + throw shareNotFoundError(); + } + return toShareState(share); + }), + setPassword: mapWriteProcedure + .input(z.object({ password: passwordSchema.nullable() })) + .mutation(async ({ ctx, input }) => { + const passwordHash = input.password + ? await hashPassword(input.password) + : null; + const share = await setMapSharePassword({ + mapId: ctx.map.id, + passwordHash, + }); + if (!share) { + throw shareNotFoundError(); + } + return toShareState(share); + }), + regenerateToken: mapWriteProcedure.mutation(async ({ ctx }) => { + const share = await regenerateMapShareToken(ctx.map.id); + if (!share) { + throw shareNotFoundError(); + } + return toShareState(share); + }), +}); diff --git a/src/server/trpc/routers/publicMap.ts b/src/server/trpc/routers/publicMap.ts index 500e641cb..7d7903803 100644 --- a/src/server/trpc/routers/publicMap.ts +++ b/src/server/trpc/routers/publicMap.ts @@ -40,7 +40,10 @@ export const publicMapRouter = router({ message: "Data source not found", }); } - const canRead = await canReadDataSource(dataSource, ctx.user.id); + const canRead = await canReadDataSource({ + dataSource, + userId: ctx.user.id, + }); if (!canRead) { throw new TRPCError({ code: "NOT_FOUND", diff --git a/src/server/utils/auth.ts b/src/server/utils/auth.ts index 063a2a240..9192199a7 100644 --- a/src/server/utils/auth.ts +++ b/src/server/utils/auth.ts @@ -1,18 +1,31 @@ import { randomBytes, scrypt } from "crypto"; +import { SHARE_GRANT_LIFETIME_SECONDS } from "@/constants"; +import { + findMapShareByMapId, + findMapShareVisualisingDataSource, +} from "@/server/repositories/MapShare"; import { findOrganisationForUser } from "@/server/repositories/Organisation"; import { findPublishedPublicMapByDataSourceId } from "@/server/repositories/PublicMap"; +import type { ShareGrant } from "@/authTypes"; +import type { MapShare } from "@/models/MapShare"; /** * Checks whether a user can read a data source. * A data source is readable if: * 1. It is public, or * 2. It appears on a published public map, or - * 3. The user belongs to the data source's organisation. + * 3. It is visualised on a map the caller holds a valid share grant for, or + * 4. The user belongs to the data source's organisation. */ -export async function canReadDataSource( - dataSource: { id: string; public: boolean; organisationId: string }, - userId: string | null | undefined, -): Promise { +export async function canReadDataSource({ + dataSource, + userId, + shareGrants, +}: { + dataSource: { id: string; public: boolean; organisationId: string }; + userId: string | null | undefined; + shareGrants?: ShareGrant[]; +}): Promise { if (dataSource.public) { return true; } @@ -22,6 +35,17 @@ export async function canReadDataSource( return true; } + const sharedMapIds = await getValidShareGrantMapIds(shareGrants); + if (sharedMapIds.length > 0) { + const mapShare = await findMapShareVisualisingDataSource({ + dataSourceId: dataSource.id, + mapIds: sharedMapIds, + }); + if (mapShare) { + return true; + } + } + if (!userId) { return false; } @@ -33,6 +57,69 @@ export async function canReadDataSource( return Boolean(organisation); } +/** + * A grant from the share cookie is never sufficient on its own: it must + * match the live mapShare row, which must be enabled, and — when a password + * is currently set — the grant must have been minted after the last + * password change. Grants also age out independently of the cookie. + */ +function isShareGrantValid(grant: ShareGrant, share: MapShare): boolean { + if (!share.enabled || grant.shareId !== share.id) { + return false; + } + const nowSeconds = Math.floor(Date.now() / 1000); + if (grant.iat + SHARE_GRANT_LIFETIME_SECONDS < nowSeconds) { + return false; + } + if (share.passwordHash && share.passwordUpdatedAt) { + const passwordUpdatedAtSeconds = Math.floor( + share.passwordUpdatedAt.getTime() / 1000, + ); + if (grant.iat < passwordUpdatedAtSeconds) { + return false; + } + } + return true; +} + +/** Find a grant for this map and validate it against the live share row. */ +export async function findValidShareGrantForMap( + shareGrants: ShareGrant[] | undefined, + mapId: string, +): Promise { + const grant = shareGrants?.find((g) => g.mapId === mapId); + if (!grant) { + return null; + } + const share = await findMapShareByMapId(mapId); + if (!share || !isShareGrantValid(grant, share)) { + return null; + } + return grant; +} + +/** The mapIds of all grants that validate against their live share rows. */ +export async function getValidShareGrantMapIds( + shareGrants: ShareGrant[] | undefined, +): Promise { + const mapIds: string[] = []; + for (const grant of shareGrants ?? []) { + const share = await findMapShareByMapId(grant.mapId); + if (share && isShareGrantValid(grant, share)) { + mapIds.push(grant.mapId); + } + } + return mapIds; +} + +/** Whether the caller holds at least one valid share grant. */ +export async function hasValidShareGrant( + shareGrants: ShareGrant[] | undefined, +): Promise { + const mapIds = await getValidShareGrantMapIds(shareGrants); + return mapIds.length > 0; +} + export async function hashPassword(password: string): Promise { const salt = randomBytes(16).toString("hex"); return new Promise((resolve, reject) => { diff --git a/tests/unit/server/repositories/MapShare.test.ts b/tests/unit/server/repositories/MapShare.test.ts new file mode 100644 index 000000000..b2595ae26 --- /dev/null +++ b/tests/unit/server/repositories/MapShare.test.ts @@ -0,0 +1,154 @@ +import { afterAll, describe, expect, test } from "vitest"; +import { createMap, deleteMap } from "@/server/repositories/Map"; +import { + findMapShareByMapId, + findMapShareByToken, + regenerateMapShareToken, + setMapShareEnabled, + setMapSharePassword, + upsertMapShareForMap, +} from "@/server/repositories/MapShare"; +import { upsertOrganisation } from "@/server/repositories/Organisation"; +import { hashPassword, verifyPassword } from "@/server/utils/auth"; + +let orgId: string; +let mapId: string; + +describe("MapShare repository", () => { + afterAll(async () => { + if (mapId) await deleteMap(mapId); + }); + + test("setup: create org and map", async () => { + const org = await upsertOrganisation({ name: "MapShare Test Org" }); + orgId = org.id; + + const map = await createMap(orgId, "MapShare Test Map"); + mapId = map.id; + }); + + test("upsert creates a share with an unguessable token", async () => { + const share = await upsertMapShareForMap(mapId); + expect(share.mapId).toBe(mapId); + expect(share.enabled).toBe(true); + expect(share.passwordHash).toBeNull(); + expect(share.passwordUpdatedAt).toBeNull(); + expect(share.token).toMatch(/^[A-Za-z0-9_-]{24}$/); + }); + + test("share is findable by mapId and by token", async () => { + const byMapId = await findMapShareByMapId(mapId); + expect(byMapId).toBeDefined(); + + const byToken = await findMapShareByToken(byMapId?.token ?? ""); + expect(byToken?.id).toBe(byMapId?.id); + }); + + test("re-upserting keeps the same share and token", async () => { + const before = await findMapShareByMapId(mapId); + const share = await upsertMapShareForMap(mapId); + expect(share.id).toBe(before?.id); + expect(share.token).toBe(before?.token); + }); + + test("disabling and re-enabling restores the same link", async () => { + const disabled = await setMapShareEnabled({ mapId, enabled: false }); + expect(disabled?.enabled).toBe(false); + + const reEnabled = await upsertMapShareForMap(mapId); + expect(reEnabled.enabled).toBe(true); + expect(reEnabled.token).toBe(disabled?.token); + }); + + test("setting a password stores a verifiable hash and bumps passwordUpdatedAt", async () => { + const share = await setMapSharePassword({ + mapId, + passwordHash: await hashPassword("correct horse battery staple"), + }); + expect(share?.passwordHash).toBeDefined(); + expect(share?.passwordHash).not.toContain("correct horse"); + expect(share?.passwordUpdatedAt).toBeInstanceOf(Date); + + const valid = await verifyPassword( + "correct horse battery staple", + share?.passwordHash ?? "", + ); + expect(valid).toBe(true); + + const invalid = await verifyPassword("wrong", share?.passwordHash ?? ""); + expect(invalid).toBe(false); + }); + + test("changing the password replaces the hash", async () => { + const share = await setMapSharePassword({ + mapId, + passwordHash: await hashPassword("new password"), + }); + + const oldPasswordValid = await verifyPassword( + "correct horse battery staple", + share?.passwordHash ?? "", + ); + expect(oldPasswordValid).toBe(false); + + const newPasswordValid = await verifyPassword( + "new password", + share?.passwordHash ?? "", + ); + expect(newPasswordValid).toBe(true); + }); + + test("removing the password clears the hash but keeps passwordUpdatedAt", async () => { + const share = await setMapSharePassword({ mapId, passwordHash: null }); + expect(share?.passwordHash).toBeNull(); + expect(share?.passwordUpdatedAt).toBeInstanceOf(Date); + }); + + test("regenerating the token invalidates the old link", async () => { + const before = await findMapShareByMapId(mapId); + const share = await regenerateMapShareToken(mapId); + + expect(share?.token).toBeDefined(); + expect(share?.token).not.toBe(before?.token); + expect(share?.token).toMatch(/^[A-Za-z0-9_-]{24}$/); + + const byOldToken = await findMapShareByToken(before?.token ?? ""); + expect(byOldToken).toBeUndefined(); + + const byNewToken = await findMapShareByToken(share?.token ?? ""); + expect(byNewToken?.id).toBe(before?.id); + }); + + test("updates on a map with no share return undefined", async () => { + const otherMap = await createMap(orgId, "MapShare Test Map (no share)"); + try { + const enabled = await setMapShareEnabled({ + mapId: otherMap.id, + enabled: true, + }); + expect(enabled).toBeUndefined(); + + const password = await setMapSharePassword({ + mapId: otherMap.id, + passwordHash: await hashPassword("irrelevant"), + }); + expect(password).toBeUndefined(); + + const token = await regenerateMapShareToken(otherMap.id); + expect(token).toBeUndefined(); + } finally { + await deleteMap(otherMap.id); + } + }); + + test("deleting the map cascades to the share", async () => { + const before = await findMapShareByMapId(mapId); + expect(before).toBeDefined(); + + await deleteMap(mapId); + mapId = ""; + + const after = await findMapShareByMapId(before?.mapId ?? ""); + expect(after).toBeUndefined(); + }); +}); diff --git a/tests/unit/server/trpc/routers/mapShare.test.ts b/tests/unit/server/trpc/routers/mapShare.test.ts new file mode 100644 index 000000000..b70ca51c3 --- /dev/null +++ b/tests/unit/server/trpc/routers/mapShare.test.ts @@ -0,0 +1,171 @@ +import { v4 as uuidv4 } from "uuid"; +import { afterAll, describe, expect, test } from "vitest"; +import { createMap, deleteMap } from "@/server/repositories/Map"; +import { findMapShareByMapId } from "@/server/repositories/MapShare"; +import { upsertOrganisation } from "@/server/repositories/Organisation"; +import { upsertOrganisationUser } from "@/server/repositories/OrganisationUser"; +import { deleteUser, upsertUser } from "@/server/repositories/User"; +import { mapShareRouter } from "@/server/trpc/routers/mapShare"; +import { verifyPassword } from "@/server/utils/auth"; + +const userIds: string[] = []; +const mapIds: string[] = []; + +async function createTestUser() { + const user = await upsertUser({ + email: `test-${uuidv4()}@example.com`, + password: "test-password-123", + name: "Test User", + avatarUrl: null, + }); + userIds.push(user.id); + return user; +} + +function makeCaller(user: Awaited> | null) { + return mapShareRouter.createCaller({ user, ip: "127.0.0.1" }); +} + +let mapId: string; +let member: Awaited>; +let outsider: Awaited>; + +describe("mapShare router", () => { + afterAll(async () => { + for (const id of mapIds) { + try { + await deleteMap(id); + } catch { + // already deleted + } + } + for (const id of userIds) { + try { + await deleteUser(id); + } catch { + // already deleted + } + } + }); + + test("setup: create org, member, outsider and map", async () => { + const org = await upsertOrganisation({ + name: `MapShare Router Org ${uuidv4()}`, + }); + member = await createTestUser(); + outsider = await createTestUser(); + await upsertOrganisationUser({ + organisationId: org.id, + userId: member.id, + }); + + const map = await createMap(org.id, "MapShare Router Test Map"); + mapId = map.id; + mapIds.push(map.id); + }); + + test("unauthenticated user cannot read or manage the share", async () => { + const caller = makeCaller(null); + + await expect(caller.get({ mapId })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + await expect(caller.enable({ mapId })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("non-member cannot read or manage the share", async () => { + const caller = makeCaller(outsider); + + await expect(caller.get({ mapId })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect(caller.enable({ mapId })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + test("get returns null before sharing is enabled", async () => { + const caller = makeCaller(member); + const share = await caller.get({ mapId }); + expect(share).toBeNull(); + }); + + test("disable, setPassword and regenerateToken fail before enabling", async () => { + const caller = makeCaller(member); + + await expect(caller.disable({ mapId })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect( + caller.setPassword({ mapId, password: "long enough password" }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + await expect(caller.regenerateToken({ mapId })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + test("member can enable sharing", async () => { + const caller = makeCaller(member); + const share = await caller.enable({ mapId }); + + expect(share.enabled).toBe(true); + expect(share.hasPassword).toBe(false); + expect(share.token).toMatch(/^[A-Za-z0-9_-]{24}$/); + expect("passwordHash" in share).toBe(false); + }); + + test("member can set a password; response never contains the hash", async () => { + const caller = makeCaller(member); + const share = await caller.setPassword({ + mapId, + password: "correct horse battery staple", + }); + + expect(share.hasPassword).toBe(true); + expect("passwordHash" in share).toBe(false); + + // The stored row holds a verifiable scrypt hash, not the plaintext + const row = await findMapShareByMapId(mapId); + expect(row?.passwordHash).not.toContain("correct horse"); + const valid = await verifyPassword( + "correct horse battery staple", + row?.passwordHash ?? "", + ); + expect(valid).toBe(true); + }); + + test("passwords shorter than 8 characters are rejected", async () => { + const caller = makeCaller(member); + await expect( + caller.setPassword({ mapId, password: "short" }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + test("member can remove the password", async () => { + const caller = makeCaller(member); + const share = await caller.setPassword({ mapId, password: null }); + expect(share.hasPassword).toBe(false); + }); + + test("member can regenerate the token", async () => { + const caller = makeCaller(member); + const before = await caller.get({ mapId }); + const share = await caller.regenerateToken({ mapId }); + + expect(share.token).toMatch(/^[A-Za-z0-9_-]{24}$/); + expect(share.token).not.toBe(before?.token); + }); + + test("member can disable and re-enable, keeping the same link", async () => { + const caller = makeCaller(member); + + const disabled = await caller.disable({ mapId }); + expect(disabled.enabled).toBe(false); + + const reEnabled = await caller.enable({ mapId }); + expect(reEnabled.enabled).toBe(true); + expect(reEnabled.token).toBe(disabled.token); + }); +}); diff --git a/tests/unit/server/trpc/shareGrantAccess.test.ts b/tests/unit/server/trpc/shareGrantAccess.test.ts new file mode 100644 index 000000000..58848ee34 --- /dev/null +++ b/tests/unit/server/trpc/shareGrantAccess.test.ts @@ -0,0 +1,343 @@ +import { randomUUID } from "crypto"; +import { v4 as uuidv4 } from "uuid"; +import { afterAll, describe, expect, test } from "vitest"; +import { + DataSourceRecordType, + DataSourceType, + GeocodingType, +} from "@/models/DataSource"; +import { MapStyleName } from "@/models/MapView"; +import { CalculationType } from "@/models/shared"; +import { + createDataSource, + deleteDataSource, +} from "@/server/repositories/DataSource"; +import { createMap, deleteMap, updateMap } from "@/server/repositories/Map"; +import { + setMapShareEnabled, + setMapSharePassword, + upsertMapShareForMap, +} from "@/server/repositories/MapShare"; +import { upsertMapView } from "@/server/repositories/MapView"; +import { upsertOrganisation } from "@/server/repositories/Organisation"; +import { deleteUser, upsertUser } from "@/server/repositories/User"; +import { areaRouter } from "@/server/trpc/routers/area"; +import { mapRouter } from "@/server/trpc/routers/map"; +import { canReadDataSource, hashPassword } from "@/server/utils/auth"; +import type { ShareGrant } from "@/authTypes"; +import type { MapShare } from "@/models/MapShare"; + +const userIds: string[] = []; +const mapIds: string[] = []; +const dataSourceIds: string[] = []; + +// Data sources referenced only from map/view configs (no rows needed +// for canReadDataSource, which takes the data source object directly) +const DS_AREA = randomUUID(); +const DS_ON_MAP_B_ONLY = randomUUID(); + +let orgId: string; +let mapAId: string; +let mapBId: string; +let shareA: MapShare; +let markerDataSourceId: string; + +const nowSeconds = () => Math.floor(Date.now() / 1000); + +function grantFor(share: MapShare, iatOffsetSeconds = 0): ShareGrant { + return { + shareId: share.id, + mapId: share.mapId, + iat: nowSeconds() + iatOffsetSeconds, + }; +} + +function makeMapCaller(shareGrants: ShareGrant[]) { + return mapRouter.createCaller({ user: null, ip: "127.0.0.1", shareGrants }); +} + +function makeAreaCaller(shareGrants: ShareGrant[]) { + return areaRouter.createCaller({ user: null, ip: "127.0.0.1", shareGrants }); +} + +describe("share grant access control", () => { + afterAll(async () => { + for (const id of mapIds) { + try { + await deleteMap(id); + } catch { + // already deleted + } + } + for (const id of dataSourceIds) { + try { + await deleteDataSource(id); + } catch { + // already deleted + } + } + for (const id of userIds) { + try { + await deleteUser(id); + } catch { + // already deleted + } + } + }); + + test("setup: org, maps, share and data sources", async () => { + const org = await upsertOrganisation({ + name: `ShareGrant Test Org ${uuidv4()}`, + }); + orgId = org.id; + + const dataSource = await createDataSource({ + name: `ShareGrant Marker DS ${uuidv4()}`, + organisationId: orgId, + autoEnrich: false, + autoImport: false, + config: { + type: DataSourceType.CSV, + url: `file://tests/resources/stats.csv?${uuidv4()}`, + }, + columnDefs: [], + columnMetadata: [], + columnRoles: { nameColumns: ["Name"] }, + enrichments: [], + geocodingConfig: { type: GeocodingType.None }, + public: false, + recordType: DataSourceRecordType.Data, + }); + dataSourceIds.push(dataSource.id); + markerDataSourceId = dataSource.id; + + const mapA = await createMap(orgId, "ShareGrant Map A"); + mapAId = mapA.id; + mapIds.push(mapA.id); + await updateMap(mapA.id, { + config: { + markerDataSourceIds: [markerDataSourceId], + membersDataSourceId: null, + }, + }); + await upsertMapView({ + id: randomUUID(), + mapId: mapA.id, + name: "Test View", + position: 0, + config: { + areaDataSourceId: DS_AREA, + areaDataColumn: "", + calculationType: CalculationType.Avg, + mapStyleName: MapStyleName.Light, + showLabels: false, + showLocations: false, + showMembers: false, + showTurf: false, + }, + dataSourceViews: [], + }); + + const mapB = await createMap(orgId, "ShareGrant Map B"); + mapBId = mapB.id; + mapIds.push(mapB.id); + await updateMap(mapB.id, { + config: { + markerDataSourceIds: [DS_ON_MAP_B_ONLY], + membersDataSourceId: null, + }, + }); + + shareA = await upsertMapShareForMap(mapAId); + }); + + // ---------- mapReadProcedure (via map.byId) ---------- + + test("anonymous caller without grants cannot read the map", async () => { + const caller = makeMapCaller([]); + await expect(caller.byId({ mapId: mapAId })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("anonymous caller with a valid grant can read the map", async () => { + const caller = makeMapCaller([grantFor(shareA)]); + const result = await caller.byId({ mapId: mapAId }); + expect(result.id).toBe(mapAId); + expect(Array.isArray(result.views)).toBe(true); + }); + + test("a grant does not unlock other maps", async () => { + const caller = makeMapCaller([grantFor(shareA)]); + await expect(caller.byId({ mapId: mapBId })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("a grant with the wrong shareId is rejected", async () => { + const grant = { ...grantFor(shareA), shareId: randomUUID() }; + const caller = makeMapCaller([grant]); + await expect(caller.byId({ mapId: mapAId })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("an aged-out grant is rejected", async () => { + const eightDaysSeconds = 8 * 24 * 60 * 60; + const caller = makeMapCaller([grantFor(shareA, -eightDaysSeconds)]); + await expect(caller.byId({ mapId: mapAId })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("disabling the share revokes existing grants immediately", async () => { + const grant = grantFor(shareA); + await setMapShareEnabled({ mapId: mapAId, enabled: false }); + + const caller = makeMapCaller([grant]); + await expect(caller.byId({ mapId: mapAId })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + + await setMapShareEnabled({ mapId: mapAId, enabled: true }); + const result = await caller.byId({ mapId: mapAId }); + expect(result.id).toBe(mapAId); + }); + + test("setting a password invalidates grants minted before the change", async () => { + const staleGrant = grantFor(shareA, -60); + await setMapSharePassword({ + mapId: mapAId, + passwordHash: await hashPassword("view the map"), + }); + + const staleCaller = makeMapCaller([staleGrant]); + await expect(staleCaller.byId({ mapId: mapAId })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + + // A grant minted after the password change (i.e. after passing the + // password check) is accepted + const freshCaller = makeMapCaller([grantFor(shareA, 5)]); + const result = await freshCaller.byId({ mapId: mapAId }); + expect(result.id).toBe(mapAId); + + // Removing the password restores link-only access for older grants + await setMapSharePassword({ mapId: mapAId, passwordHash: null }); + const restored = await staleCaller.byId({ mapId: mapAId }); + expect(restored.id).toBe(mapAId); + }); + + // ---------- canReadDataSource ---------- + + test("a grant unlocks a data source in the map's markerDataSourceIds", async () => { + const canRead = await canReadDataSource({ + dataSource: { + id: markerDataSourceId, + public: false, + organisationId: orgId, + }, + userId: null, + shareGrants: [grantFor(shareA)], + }); + expect(canRead).toBe(true); + }); + + test("a grant unlocks a data source used as any view's areaDataSourceId", async () => { + const canRead = await canReadDataSource({ + dataSource: { id: DS_AREA, public: false, organisationId: orgId }, + userId: null, + shareGrants: [grantFor(shareA)], + }); + expect(canRead).toBe(true); + }); + + test("a grant for map A does not unlock a data source only on map B", async () => { + const canRead = await canReadDataSource({ + dataSource: { + id: DS_ON_MAP_B_ONLY, + public: false, + organisationId: orgId, + }, + userId: null, + shareGrants: [grantFor(shareA)], + }); + expect(canRead).toBe(false); + }); + + test("without grants an anonymous caller cannot read the data source", async () => { + const canRead = await canReadDataSource({ + dataSource: { + id: markerDataSourceId, + public: false, + organisationId: orgId, + }, + userId: null, + shareGrants: [], + }); + expect(canRead).toBe(false); + }); + + // ---------- dataSourceReadProcedure passthrough (via area.stats) ---------- + + test("area.stats accepts an anonymous caller with a valid grant", async () => { + const caller = makeAreaCaller([grantFor(shareA)]); + const result = await caller.stats({ + dataSourceId: markerDataSourceId, + areaSetCode: null, + calculationType: CalculationType.Avg, + column: "", + }); + expect(result).toBeNull(); + }); + + test("area.stats rejects an anonymous caller without grants", async () => { + const caller = makeAreaCaller([]); + await expect( + caller.stats({ + dataSourceId: markerDataSourceId, + areaSetCode: null, + calculationType: CalculationType.Avg, + column: "", + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); + + // ---------- viewerProcedure (via area.search / area.byCode) ---------- + + test("area.search rejects an anonymous caller without grants", async () => { + const caller = makeAreaCaller([]); + await expect(caller.search({ search: "London" })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + }); + + test("area.search accepts an anonymous caller with a valid grant", async () => { + const caller = makeAreaCaller([grantFor(shareA)]); + const result = await caller.search({ search: "London" }); + expect(Array.isArray(result)).toBe(true); + }); + + test("area.search rejects an anonymous caller whose grant is stale", async () => { + await setMapShareEnabled({ mapId: mapAId, enabled: false }); + const caller = makeAreaCaller([grantFor(shareA)]); + await expect(caller.search({ search: "London" })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + await setMapShareEnabled({ mapId: mapAId, enabled: true }); + }); + + test("area.search still works for authenticated users", async () => { + const user = await upsertUser({ + email: `test-${uuidv4()}@example.com`, + password: "test-password-123", + name: "Test User", + avatarUrl: null, + }); + userIds.push(user.id); + + const caller = areaRouter.createCaller({ user, ip: "127.0.0.1" }); + const result = await caller.search({ search: "London" }); + expect(Array.isArray(result)).toBe(true); + }); +});