diff --git a/docs/09-security-privacy.md b/docs/09-security-privacy.md
index 8aeffc8..ceb6aff 100644
--- a/docs/09-security-privacy.md
+++ b/docs/09-security-privacy.md
@@ -41,6 +41,7 @@
| Impersonation via display names | The npub is the truth and stays one hover or one click away — the author tooltip, a revision's Details view, `/settings/profile`; the member badge only appears for entries in `39002` |
| Spam in open spaces | Relay rate limits + moderated deletion (`9005`) + a "members only" UI filter |
| Key theft through the app | No handling of nsec at all. NIP-07/NIP-46 only |
+| A link choosing which relay your browser talks to | An unfamiliar relay host is asked about once before anything is sent, and the answer is remembered per host (by default — the reader can answer for this visit only). See "Relay connections follow links" below |
## Images are loaded directly
@@ -64,6 +65,143 @@ server. Bringing the gate back for foreign origins is a small change: the two
places that draw an image are `MarkdownImage` in `src/ui/Markdown.tsx` and
`ImageWidget` in `src/ui/markdown-live.ts`.
+## Relay connections follow links
+
+A space carries its relay inside its own address — `/s/'` — which
+is what makes a link to a space self-contained. It also means the link's author
+picks the host, and until CON-46 the rest was automatic:
+
+1. `AppShell` and `useSpaceRoute` turn the route parameter into a `relayUrl`,
+ with no allowlist and no prompt.
+2. `useRelay(relayUrl)` does two things to that host from its effects:
+ `client.want()` opens a WebSocket to it, and `fetchRelayInfo` fetches
+ `https://` for the NIP-11 document.
+3. `NostrClient` sets `pool.automaticallyAuth`, so a signed-in visitor answers
+ the relay's NIP-42 challenge with no interaction at all.
+
+Following a link was therefore enough to make a signed-in reader's browser
+contact a host a stranger chose and hand it a signed statement of who they are —
+npub, IP address, and the time they clicked. Nothing here was a bug in any one
+of those three steps; the problem is that they compose into a disclosure that
+the reader never agreed to.
+
+### What was considered
+
+1. **Leave it.** A relay in a link is how the addressing works, and a reader who
+ follows a link to a wiki has arguably asked to reach it. Rejected: the thing
+ disclosed is a signed identity, not a page view, and it is disclosed before
+ anything is on screen to judge.
+2. **Ask once per new host** — the host is named, the reader decides, the answer
+ is remembered. **Chosen.**
+3. **Connect, but withhold AUTH** until the reader has confirmed the host: the
+ socket and the NIP-11 fetch happen immediately, only the signed NIP-42 event
+ waits. Rejected, for three reasons that compound. It keeps back the npub but
+ not the IP, the timing, or *which space* is being opened — the NIP-11 fetch
+ to `https://` has already said all of that before the first frame. It
+ does not save the prompt either: a `private` group serves an unauthenticated
+ reader zero events and no error at all (docs/04-permissions-nip29.md, the
+ "Stranger, no AUTH" rows), so the reader still has to be asked — in front of
+ a space that is empty for reasons nothing on screen can explain. And it
+ is the wider change of the two — `signAuth` is not only
+ `pool.automaticallyAuth` (`src/nostr/client.ts:172`), it is passed as
+ `onauth` to every read and publish and called by `refreshAuth`
+ (`client.ts:430, 486, 515, 556, 576, 597`), so a gate that touched only the
+ automatic hook would leave every read signing anyway. Option 2 withholds
+ everything this option withholds, plus the socket and the fetch, from one
+ decision in one place.
+4. **An allowlist fixed at build time.** Strongest, and it is what a
+ single-relay deployment should arguably do. Rejected as the general answer:
+ it makes every legitimate federated link a dead end, and the app's own
+ addressing scheme then only works for addresses the operator foresaw.
+
+### How it works
+
+`src/nostr/known-relays.ts` holds the decision, keyed by the bare host (the
+exact `GroupAddress.host`, e.g. `relay.example:8443`) in `localStorage` under
+`nc-trusted-relays`. The scheme is never part of the key — it is derived from
+the host — so one decision cannot acquire two spellings.
+
+`src/ui/layout/AppShell.tsx` is where it bites. The relay-consuming half of the
+shell — `useRelay`, `useSpace`, and `` — sits in a child component that
+either mounts or does not. That is structural rather than stylistic: both
+disclosures happen in *effects* inside `useRelay`, so withholding them means
+never calling the hook, and a hook cannot be called conditionally.
+
+Two independent reasons reach that one mechanism, kept apart in the code:
+
+- **unreadable** — the address is not `'` at all. It names no
+ relay, so nothing is contacted. This also replaces an older accident: an
+ unparsable address used to fall back to `DEFAULT_RELAY_URL` and connect
+ *there*, so that each view could draw "Invalid group address." over the top
+ of a connection nobody had asked for.
+- **untrusted** — it parses, and names a host with no decision on record.
+
+Two kinds of host are trusted by construction and never reach the prompt:
+loopback (asked through `isLocalRelayHost`, so the dev workflow is unaffected),
+and the host of `DEFAULT_RELAY_URL`.
+
+**Remembering is the default, not the only answer.** "Remember this relay" is
+checked when the prompt opens; unchecking it still connects, but the decision
+then lives only in the shell's own state (`allowedOnce` in `AppShell`) and is
+gone on reload. That set is keyed by host as well, so following a link on from
+an approved relay to a *second* unfamiliar one asks again rather than inheriting
+the first answer.
+
+**The gate stands in front of `/s/:group`, not `/settings/spaces/:group`.**
+Both routes read the same `group` parameter, so a hand-written settings link can
+still carry an unapproved host. It discloses nothing: that route calls no
+`useRelay`, so nobody ever calls `client.want` for that host, and `useSpace`
+alone cannot reach a socket — `SpaceStore.start()` returns at
+`if (!connection.ready)` on a connection nothing asked for. An interstitial
+there would buy no privacy and would sit in front of the settings navigation
+instead. `src/ui/layout/AppShell.trust.test.tsx` pins that, so the day something
+does wire a relay through the settings route, a test says so.
+
+### Not behind the prompt, on purpose
+
+`DEFAULT_RELAY_URL` (`VITE_RELAY_URL`) and the profile relays
+(`VITE_PROFILE_RELAYS`) are **operator configuration**, chosen by whoever built
+and deployed the app. They are not link input, and no visitor's link can change
+them. Putting them behind the same prompt would ask readers to approve their
+own deployment on every fresh browser, which teaches exactly the reflex —
+clicking "Connect" without reading the host — that the prompt exists to avoid.
+
+### Residual risk, stated plainly
+
+- **The prompt is a decision, and decisions get clicked through.** A reader in a
+ hurry approves an unfamiliar host in one click, and the disclosure follows
+ immediately. All this buys is that the host is on screen, in a monospace face,
+ before anything is sent.
+- **It is per browser, not per person.** `localStorage` is cleared by a private
+ window, a new device, or clearing site data — and lost trust means a second
+ prompt, which is the safe direction, but it does mean the prompt recurs.
+- **There is no in-app way to take an approval back.** `forgetRelay` exists in
+ `src/nostr/known-relays.ts` and nothing calls it: today, undoing a "Remember
+ this relay" clicked in haste means clearing the site's data, which drops every
+ other decision with it. A "Trusted relays" list under `/settings/profile` —
+ the screen that already owns per-browser state — is the obvious home for it
+ and is deliberately not in CON-46.
+- **Once approved, the host is approved for everything.** There is no
+ distinction between "let it see my IP" and "let it see my npub"; connecting is
+ one act, and NIP-42 follows from it automatically.
+- **A trusted relay is still just a relay.** It reads everything in the group in
+ plaintext (see "Confidentiality" above). The gate is about *which* relay gets
+ to, not about what it can then see.
+- **A group id can still be a lie.** The gate checks the host, not the space:
+ a link to a relay you have already approved can name any group on it.
+
+### It does not narrow the CSP
+
+CON-46's ticket text claimed a known-hosts list would let CON-44's
+`connect-src` be narrowed. That is wrong, and worth writing down so it is not
+tried. `connect-src` is baked into the document at build time; relay hosts are
+discovered at *runtime*, from links. Unless a deployment has exactly one relay
+host — in which case it should say so directly in its CSP and skip the general
+mechanism — the relay sources have to stay scheme-scoped (`https:` / `wss:`)
+however much the trust gate remembers. The two measures are independent: the
+CSP bounds what the page *can* reach, the gate bounds what it *does* reach
+without asking.
+
## Privacy note for users
An npub is a permanent pseudonym: everything a person posts is linkable across
diff --git a/src/nostr/known-relays.test.ts b/src/nostr/known-relays.test.ts
new file mode 100644
index 0000000..7416ecc
--- /dev/null
+++ b/src/nostr/known-relays.test.ts
@@ -0,0 +1,114 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { forgetRelay, isTrustedRelay, trustRelay } from './known-relays'
+
+/**
+ * The decider behind the trust gate (CON-46). Everything here is about the
+ * question "may the app open a socket to this host without asking", so the
+ * cases that matter are the ones where a wrong answer is a disclosure: a host
+ * nobody approved must not come back trusted, and a storage that is missing,
+ * blocked or full of nonsense must not be able to turn into a yes — or into a
+ * crash, which would take the gate down with it.
+ */
+const STORAGE_KEY = 'nc-trusted-relays'
+
+beforeEach(() => {
+ localStorage.clear()
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ localStorage.clear()
+})
+
+describe('isTrustedRelay / trustRelay / forgetRelay', () => {
+ it('remembers a host until it is forgotten again', () => {
+ expect(isTrustedRelay('relay.example')).toBe(false)
+
+ trustRelay('relay.example')
+ expect(isTrustedRelay('relay.example')).toBe(true)
+ // and only that host — trust is not a blanket yes
+ expect(isTrustedRelay('other.example')).toBe(false)
+ expect(isTrustedRelay('evil.relay.example')).toBe(false)
+
+ forgetRelay('relay.example')
+ expect(isTrustedRelay('relay.example')).toBe(false)
+ })
+
+ it('keys by host including the port, so a second port is a second decision', () => {
+ trustRelay('relay.example:8443')
+ expect(isTrustedRelay('relay.example:8443')).toBe(true)
+ expect(isTrustedRelay('relay.example')).toBe(false)
+ })
+
+ it('is idempotent — StrictMode runs effects twice', () => {
+ trustRelay('relay.example')
+ trustRelay('relay.example')
+ trustRelay('relay.example')
+
+ expect(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')).toEqual(['relay.example'])
+ // and one forget is still enough to undo all three
+ forgetRelay('relay.example')
+ expect(isTrustedRelay('relay.example')).toBe(false)
+ })
+
+ it('treats corrupt storage as nothing remembered, not as a reason to throw', () => {
+ localStorage.setItem(STORAGE_KEY, '{not json at all')
+ expect(isTrustedRelay('relay.example')).toBe(false)
+
+ // the wrong shape counts the same way, and entries that are not strings
+ // are dropped rather than compared
+ localStorage.setItem(STORAGE_KEY, '{"relay.example":true}')
+ expect(isTrustedRelay('relay.example')).toBe(false)
+ localStorage.setItem(STORAGE_KEY, '[17,null,"relay.example"]')
+ expect(isTrustedRelay('relay.example')).toBe(true)
+
+ // and it stays writable afterwards
+ trustRelay('other.example')
+ expect(isTrustedRelay('other.example')).toBe(true)
+ })
+
+ it('stays usable when localStorage itself throws (a private window)', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('access denied')
+ })
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new Error('access denied')
+ })
+
+ expect(() => trustRelay('relay.example')).not.toThrow()
+ // it cannot be remembered, so it is not trusted — failing closed is the
+ // only safe direction for this particular question
+ expect(isTrustedRelay('relay.example')).toBe(false)
+ // but the local relay still is, because that answer needs no storage
+ expect(isTrustedRelay('localhost:8080')).toBe(true)
+ })
+
+ it('trusts loopback without storage, so the dev workflow never meets the prompt', () => {
+ expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
+ expect(isTrustedRelay('localhost:8080')).toBe(true)
+ expect(isTrustedRelay('127.0.0.1:8080')).toBe(true)
+ expect(isTrustedRelay('[::1]:8080')).toBe(true)
+
+ // trusting it writes nothing: isTrustedRelay already knows
+ trustRelay('localhost:8080')
+ expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
+
+ // the CON-45 near-miss stays a near-miss
+ expect(isTrustedRelay('localhost.evil.example')).toBe(false)
+ })
+
+ it('trusts the deployment’s own relay host without storage', async () => {
+ vi.stubEnv('VITE_RELAY_URL', 'wss://relay.operator.example')
+ vi.resetModules()
+ const fresh = await import('./known-relays')
+
+ expect(fresh.isTrustedRelay('relay.operator.example')).toBe(true)
+ expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
+ // a neighbour of it is not
+ expect(fresh.isTrustedRelay('relay.operator.example.evil.example')).toBe(false)
+
+ vi.unstubAllEnvs()
+ vi.resetModules()
+ })
+})
diff --git a/src/nostr/known-relays.ts b/src/nostr/known-relays.ts
new file mode 100644
index 0000000..a185ea0
--- /dev/null
+++ b/src/nostr/known-relays.ts
@@ -0,0 +1,126 @@
+import { isLocalRelayHost } from './group-address'
+import { DEFAULT_RELAY_URL } from './relay-status'
+
+/**
+ * Which relay hosts this browser has agreed to talk to.
+ *
+ * A space carries its relay in its own address — `/s/'` — so a
+ * link decides, on its author's behalf, which host the reader's browser opens
+ * a WebSocket to, fetches a NIP-11 document from over HTTPS, and answers a
+ * NIP-42 challenge from with an event signed by the reader's key. Following a
+ * link is not consent to any of that, so an unfamiliar host is asked about
+ * once and the answer is remembered here.
+ * docs/09-security-privacy.md
+ *
+ * Keyed by the bare host — the exact `GroupAddress.host`, e.g.
+ * `relay.example` or `relay.example:8443` — and never by a relay URL. The
+ * scheme is *derived* from the host (`isLocalRelayHost` decides `ws` vs
+ * `wss`), so carrying it in the key would only give one decision two
+ * spellings, and a `ws://` entry could then look like consent a `wss://`
+ * entry never gave.
+ */
+const STORAGE_KEY = 'nc-trusted-relays'
+
+/**
+ * Hosts reach this module from `GroupAddress.host`, which `normalizeHost`
+ * has already lowercased. Lowercasing again anyway keeps the predicate
+ * correct on its own, the way `isLocalRelayHost` does — a decider whose
+ * answer depends on who called it is the harder kind of bug to see.
+ */
+function key(host: string): string {
+ return host.toLowerCase()
+}
+
+function hostOf(relayUrl: string): string | null {
+ try {
+ return new URL(relayUrl).host.toLowerCase()
+ } catch {
+ return null
+ }
+}
+
+/**
+ * The relay the deployment itself is configured with. It is build-time
+ * operator configuration, not link input, so it is trusted without ever being
+ * stored — see `isTrustedRelay`.
+ */
+const DEFAULT_RELAY_HOST = hostOf(DEFAULT_RELAY_URL)
+
+/**
+ * One defensive read, like `readCollapsed()` in `AppShell` and the theme
+ * bootstrap: `localStorage` throws outright in some private-window
+ * configurations, and the value is editable by hand, so anything that is not
+ * an array of strings counts as "nothing remembered" rather than as a reason
+ * to take the whole app down with it.
+ */
+function readTrusted(): string[] {
+ try {
+ const raw: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')
+ if (!Array.isArray(raw)) return []
+ return raw.filter((entry): entry is string => typeof entry === 'string')
+ } catch {
+ return []
+ }
+}
+
+function writeTrusted(hosts: string[]): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
+ } catch {
+ /* then the decision only holds for as long as this page is open */
+ }
+}
+
+/**
+ * Whether the app may open a connection to this host without asking.
+ *
+ * Two kinds of host are trusted by construction, with nothing in storage:
+ *
+ * - **Loopback.** Asked through `isLocalRelayHost` rather than re-tested
+ * here: CON-45 is what a second, inline copy of that rule costs. It keeps
+ * the dev workflow free of the prompt — `scripts/dev-relay-up.sh` puts a
+ * relay on `localhost:8080` and nobody should have to click through an
+ * interstitial to reach it.
+ * - **The host of `DEFAULT_RELAY_URL`.** That is `VITE_RELAY_URL`, chosen by
+ * whoever built and deployed the app. Operator configuration is not link
+ * input, and asking the reader to approve their own deployment's relay
+ * would teach them to click "Connect" without reading it.
+ */
+export function isTrustedRelay(host: string): boolean {
+ const wanted = key(host)
+ if (isLocalRelayHost(wanted)) return true
+ if (DEFAULT_RELAY_HOST !== null && wanted === DEFAULT_RELAY_HOST) return true
+ return readTrusted().includes(wanted)
+}
+
+/**
+ * Remember the host across reloads.
+ *
+ * Trusting a host twice is trusting it once: React runs effects twice under
+ * StrictMode, and a confirm button can be clicked twice before the view
+ * changes. The early return also keeps the two implicitly-trusted kinds of
+ * host out of storage, so nothing accumulates an entry that says what
+ * `isTrustedRelay` already knows.
+ */
+export function trustRelay(host: string): void {
+ if (isTrustedRelay(host)) return
+ writeTrusted([...readTrusted(), key(host)])
+}
+
+/**
+ * Take a remembered decision back, so the host is asked about again.
+ *
+ * It cannot revoke the two implicit kinds above — those are not decisions
+ * stored here, and a `forgetRelay('localhost:8080')` that appeared to work
+ * would be the worse outcome.
+ *
+ * No screen calls this yet, so revoking an approval currently means clearing
+ * the site's data — which drops every other decision too. The half that is
+ * missing is a "Trusted relays" list under `/settings/profile`, and it is
+ * deliberately not in CON-46; the gap is written down under "Residual risk" in
+ * docs/09-security-privacy.md rather than left for a reader to discover.
+ */
+export function forgetRelay(host: string): void {
+ const wanted = key(host)
+ writeTrusted(readTrusted().filter((entry) => entry !== wanted))
+}
diff --git a/src/nostr/space-store.ts b/src/nostr/space-store.ts
index 8d132c2..c9fe5aa 100644
--- a/src/nostr/space-store.ts
+++ b/src/nostr/space-store.ts
@@ -28,7 +28,12 @@ export type SpaceSnapshot = {
comments: Comment[]
}
-const EMPTY: SpaceSnapshot = {
+/**
+ * A space that holds nothing and is done loading. Exported because the shell
+ * needs something to draw its chrome around while it is deliberately *not*
+ * talking to a relay — see the Gate comment in src/ui/layout/AppShell.tsx.
+ */
+export const EMPTY_SPACE: SpaceSnapshot = {
loading: true,
metadata: null,
admins: [],
@@ -45,7 +50,7 @@ const EMPTY: SpaceSnapshot = {
* docs/02-data-model-events.md
*/
class SpaceStore {
- private snapshot: SpaceSnapshot = EMPTY
+ private snapshot: SpaceSnapshot = EMPTY_SPACE
private listeners = new Set<() => void>()
private revisions = new Map()
/** the winning placement per slug — src/domain/placement.ts */
@@ -123,8 +128,8 @@ class SpaceStore {
this.comments.clear()
this.metadataEvent = null
this.groupEventAt.clear()
- if (this.snapshot === EMPTY) return
- this.snapshot = EMPTY
+ if (this.snapshot === EMPTY_SPACE) return
+ this.snapshot = EMPTY_SPACE
for (const listener of this.listeners) listener()
}
diff --git a/src/routes/space-route.ts b/src/routes/space-route.ts
index 5660d33..2a9bbd5 100644
--- a/src/routes/space-route.ts
+++ b/src/routes/space-route.ts
@@ -3,10 +3,10 @@ import { parseGroupAddress } from '../nostr/group-address'
import type { GroupAddress } from '../nostr/group-address'
import { useSpace } from '../nostr/space-store'
import type { SpaceSnapshot } from '../nostr/space-store'
-import { DEFAULT_RELAY_URL } from '../nostr/relay-status'
export type SpaceRoute = {
group: GroupAddress | null
+ /** empty string when the address does not parse — there is no relay then */
relayUrl: string
space: SpaceSnapshot
/** Base for links inside the space, e.g. /s/host'group */
@@ -22,7 +22,14 @@ export type SpaceRoute = {
export function useSpaceRoute(): SpaceRoute {
const params = useParams<{ group?: string; slug?: string }>()
const group = params.group ? parseGroupAddress(params.group) : null
- const relayUrl = group?.relayUrl ?? DEFAULT_RELAY_URL
+ // Deliberately no fall back to DEFAULT_RELAY_URL. An address that does not
+ // parse names no relay, and standing in the deployment's own relay for it
+ // was a connection nobody asked for — made, in the end, only so the view
+ // above could draw "Invalid group address." on top of it (CON-46). Every
+ // caller guards on `group` before it touches a relay, and `useSpace` with an
+ // empty group id subscribes to nothing, so the empty string never reaches a
+ // socket. docs/09-security-privacy.md
+ const relayUrl = group?.relayUrl ?? ''
const space = useSpace(relayUrl, group?.id ?? '')
return {
group,
diff --git a/src/ui/SpaceGate.tsx b/src/ui/SpaceGate.tsx
new file mode 100644
index 0000000..322f40d
--- /dev/null
+++ b/src/ui/SpaceGate.tsx
@@ -0,0 +1,117 @@
+import { useState } from 'react'
+import { PageFrame, PageTitle } from './layout/PageFrame'
+import { Button, ButtonLink, Callout } from './controls'
+
+/**
+ * What the shell shows *instead of* a space, for the two reasons it may refuse
+ * to open one. They are deliberately two screens rather than one with a
+ * condition in it: a link that does not parse is broken, while a link to an
+ * unfamiliar relay is fine and merely undecided, and telling a reader the
+ * wrong one of those is worse than telling them nothing.
+ * docs/09-security-privacy.md
+ */
+
+/**
+ * The address in the URL is not `'`. There is no host in it to
+ * ask about and nothing to connect to, so this screen names the one thing that
+ * is actually known — the link is unreadable — and offers the way back.
+ *
+ * It replaces a silent fall back to the default relay: until CON-46 an
+ * unparsable address still opened a connection to `DEFAULT_RELAY_URL` behind
+ * an inline "Invalid group address." message, which connected to a relay the
+ * reader had not asked for in order to show them an error.
+ */
+export function InvalidSpaceLink({ address }: { address: string }) {
+ return (
+
+
+ A space link reads host'space — for
+ example relay.example'engineering. This
+ one does not, so there is no relay to ask and no space to open.
+
+ }
+ >
+ This link is not a space address
+
+
+
+ What the address bar carried: {address}
+
+
+ Back to your spaces
+
+ )
+}
+
+/**
+ * The address parses, and names a relay host this browser has no decision on
+ * record for. Asked once per host, before anything is sent.
+ *
+ * The point of the screen is that the reader can read the host before their
+ * browser talks to it, so the host is the largest thing on it and is spelled
+ * out in a monospace face — a name being read to decide whether it is the one
+ * you expected is a name that must not be re-rendered into something friendlier.
+ *
+ * "Remember this relay" is checked by default. Unchecked it still connects;
+ * the difference is only whether the shell asks again after a reload. Making
+ * the reader re-confirm their own team's relay on every visit would train them
+ * to click through it, which is the failure mode this whole screen exists to
+ * avoid.
+ */
+export function RelayTrustPrompt({
+ host,
+ onConnect,
+}: {
+ host: string
+ /** `remember`: also keep the decision across reloads. */
+ onConnect: (remember: boolean) => void
+}) {
+ const [remember, setRemember] = useState(true)
+
+ return (
+
+
+ This space lives on {host}, a relay this
+ browser has not talked to before. The link chose it — Akasha did not.
+
+ }
+ >
+ Connect to {host}?
+
+
+
+
+
Your IP address, and that you opened this link when you did.
+
+ While you are signed in, an event signed with your key naming your npub — the relay
+ asks for it (NIP-42) and the app answers automatically once connected.
+
+
+
+
+
+
+ Kept in this browser only. Clearing the site's data asks again.
+
+
+
+
+ Back to your spaces
+
+
+ )
+}
diff --git a/src/ui/layout/AppShell.trust.test.tsx b/src/ui/layout/AppShell.trust.test.tsx
new file mode 100644
index 0000000..360e576
--- /dev/null
+++ b/src/ui/layout/AppShell.trust.test.tsx
@@ -0,0 +1,276 @@
+// @vitest-environment jsdom
+;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act } from 'react'
+import { createRoot } from 'react-dom/client'
+import type { Root } from 'react-dom/client'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+
+/**
+ * The trust gate, mounted for real (CON-46).
+ *
+ * The load-bearing assertion is the negative one: for a host nobody has
+ * approved, mounting the shell calls **neither** `fetch` **nor**
+ * `client.want`. Those two are the whole disclosure — `client.want` opens the
+ * WebSocket, on which `NostrClient.automaticallyAuth` answers the relay's
+ * NIP-42 challenge with an event signed by the reader's key, and `fetch` asks
+ * `https://` for the NIP-11 document. Both are fired from effects inside
+ * `useRelay`, which is why the gate has to keep the hook from being called at
+ * all rather than ignore what it returns; a test that only looked at the
+ * rendered markup would pass just as happily with the socket open behind it.
+ *
+ * Signed in on purpose: that is the case where following a link costs the
+ * reader a signed statement of who they are.
+ */
+vi.mock('../../session/session', () => ({
+ SessionProvider: ({ children }: { children: unknown }) => children,
+ useSession: () => ({
+ session: {
+ status: 'signed-in',
+ pubkey: 'a'.repeat(64),
+ npub: 'npub1example',
+ profile: null,
+ signer: {},
+ },
+ extension: 'available',
+ error: null,
+ login: vi.fn(),
+ logout: vi.fn(),
+ clearError: vi.fn(),
+ ensureSamePubkey: vi.fn(),
+ applyProfile: vi.fn(),
+ }),
+}))
+
+import { AppShell } from './AppShell'
+import { ThemeProvider } from '../../theme/theme'
+import { client } from '../../nostr/client'
+import { isTrustedRelay, trustRelay } from '../../nostr/known-relays'
+import { useSpaceRoute } from '../../routes/space-route'
+
+const SPACE_MARKER = 'the space itself'
+
+/**
+ * Stands in for `SpaceSettingsRoute` at `/settings/spaces/:group`: that route
+ * reads the same `group` parameter through the same `useSpaceRoute` — and so
+ * the same `useSpace` — which is the only reason the settings exemption needs
+ * testing at all. The real screen is not mounted because its own contents are
+ * beside the point here; what is being measured is what the address alone can
+ * reach.
+ */
+function SettingsSpaceProbe() {
+ const { relayUrl } = useSpaceRoute()
+ return
{`settings route for [${relayUrl}]`}
+}
+
+let root: Root | null = null
+let want: ReturnType
+let fetchMock: ReturnType
+let socket: ReturnType
+
+async function flush() {
+ for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+async function render(path: string) {
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ root = createRoot(host)
+ await act(async () => {
+ root!.render(
+
+
+
+ }>
+ {SPACE_MARKER}} />
+ } />
+ } />
+ not found} />
+
+
+
+ ,
+ )
+ })
+ await act(async () => {
+ await flush()
+ })
+}
+
+function text(): string {
+ return document.body.textContent ?? ''
+}
+
+function buttonSaying(label: string): HTMLButtonElement {
+ const found = [...document.querySelectorAll('button')].find(
+ (button) => button.textContent?.trim() === label,
+ )
+ if (!found) throw new Error(`no button labelled "${label}" — page says: ${text()}`)
+ return found
+}
+
+beforeEach(() => {
+ localStorage.clear()
+ // jsdom implements neither, and ThemeProvider/useRelay want both.
+ vi.stubGlobal('matchMedia', () => ({
+ matches: false,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }))
+ fetchMock = vi.fn().mockResolvedValue({ ok: false })
+ vi.stubGlobal('fetch', fetchMock)
+ // Mocked rather than merely observed: a real want() would start connecting,
+ // and the point of the test is that nothing does.
+ want = vi.spyOn(client, 'want').mockReturnValue(() => {})
+ // The socket itself, one level below `client.want`. `want` is the only path
+ // the shell is *supposed* to open a connection through, so watching it alone
+ // would miss a regression that reaches the pool some other way — a store
+ // subscribing without a hold, for instance.
+ socket = vi.fn()
+ vi.stubGlobal('WebSocket', socket)
+})
+
+/** Every relay url anything tried to reach, by either route. */
+function contacted(): string[] {
+ return [...want.mock.calls, ...socket.mock.calls, ...fetchMock.mock.calls].map((call) =>
+ String(call[0]),
+ )
+}
+
+afterEach(async () => {
+ if (root) {
+ const current = root
+ await act(async () => current.unmount())
+ root = null
+ }
+ document.body.innerHTML = ''
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ localStorage.clear()
+})
+
+describe('AppShell: a space link naming an unfamiliar relay', () => {
+ it('asks first, and until it is answered contacts nothing at all', async () => {
+ await render(`/s/${encodeURIComponent("relay.example'team")}`)
+
+ expect(text()).toContain('Connect to relay.example?')
+ // the space behind the link stays unmounted — it holds relay hooks of its own
+ expect(text()).not.toContain(SPACE_MARKER)
+
+ expect(want, 'no WebSocket may be opened before the reader agrees').not.toHaveBeenCalled()
+ expect(fetchMock, 'no NIP-11 fetch may be made before the reader agrees').not.toHaveBeenCalled()
+ expect(socket, 'and none by any other route either').not.toHaveBeenCalled()
+ })
+
+ it('connects once the reader confirms, and remembers the host', async () => {
+ await render(`/s/${encodeURIComponent("relay.example'team")}`)
+ expect(isTrustedRelay('relay.example')).toBe(false)
+
+ await act(async () => {
+ buttonSaying('Connect').click()
+ })
+ await act(async () => {
+ await flush()
+ })
+
+ expect(text()).toContain(SPACE_MARKER)
+ expect(want).toHaveBeenCalledWith('wss://relay.example')
+ // "Remember this relay" is checked by default, so the next visit is silent
+ expect(isTrustedRelay('relay.example')).toBe(true)
+ })
+
+ it('does not remember the host when the box is unchecked', async () => {
+ await render(`/s/${encodeURIComponent("relay.example'team")}`)
+
+ const box = document.querySelector('input[type="checkbox"]') as HTMLInputElement
+ expect(box.checked, 'remembering is the default').toBe(true)
+ await act(async () => {
+ box.click()
+ })
+ await act(async () => {
+ buttonSaying('Connect').click()
+ })
+ await act(async () => {
+ await flush()
+ })
+
+ expect(text()).toContain(SPACE_MARKER)
+ expect(want).toHaveBeenCalledWith('wss://relay.example')
+ expect(isTrustedRelay('relay.example')).toBe(false)
+ })
+
+ it('asks again for a host already remembered under a different port', async () => {
+ // Remembered first, or this asserts nothing about ports: the decision is
+ // keyed by the exact host, so approving relay.example says nothing about
+ // whatever answers on relay.example:8443.
+ trustRelay('relay.example')
+
+ await render(`/s/${encodeURIComponent("relay.example:8443'team")}`)
+
+ expect(text()).toContain('Connect to relay.example:8443?')
+ expect(text()).not.toContain(SPACE_MARKER)
+ expect(want, 'another port is another host').not.toHaveBeenCalled()
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('AppShell: links that never reach the prompt', () => {
+ it('opens a remembered host straight away — asking once means once', async () => {
+ // The promise the whole feature makes: the answer survives the visit it
+ // was given in. Here it is a fresh mount reading the stored decision, not
+ // the in-memory `allowedOnce` set from a prompt earlier in the same run.
+ trustRelay('relay.example')
+
+ await render(`/s/${encodeURIComponent("relay.example'team")}`)
+
+ expect(text()).not.toContain('Connect to')
+ expect(text()).toContain(SPACE_MARKER)
+ expect(want).toHaveBeenCalledWith('wss://relay.example')
+ })
+
+ it('does not prompt for the local dev relay — that workflow stays frictionless', async () => {
+ await render(`/s/${encodeURIComponent("localhost:8080'team")}`)
+
+ expect(text()).not.toContain('Connect to')
+ expect(text()).toContain(SPACE_MARKER)
+ expect(want).toHaveBeenCalledWith('ws://localhost:8080')
+ })
+
+ it('lets /settings/spaces/:group through ungated — and it still reaches nothing', async () => {
+ // That route reuses the `group` param, so a hand-written settings link can
+ // carry a host nobody approved. It is exempt from the gate on purpose: an
+ // interstitial in front of the settings navigation would buy no privacy,
+ // because nothing there discloses anything.
+ //
+ // Why it discloses nothing — the part that is an invariant rather than a
+ // fact about today's markup: the settings route holds no `useRelay`, so
+ // nobody calls `client.want` for that host, and `useSpace` on its own
+ // cannot reach a socket because `SpaceStore.start()` returns at
+ // `if (!connection.ready)` (src/nostr/space-store.ts) for a connection
+ // nothing ever asked for. A change that wires a relay through the settings
+ // route, or that lets a store subscribe without `want()`, breaks the
+ // exemption — and fails here.
+ await render(`/settings/spaces/${encodeURIComponent("relay.example'team")}`)
+
+ expect(text()).not.toContain('Connect to relay.example?')
+ expect(text()).toContain('settings route for [wss://relay.example]')
+
+ // Host-specific, because the shell does hold the deployment's own relay on
+ // every settings page — that one is operator configuration, not link input.
+ expect(
+ contacted().filter((url) => url.includes('relay.example')),
+ 'nothing may reach the host the link named',
+ ).toEqual([])
+ })
+
+ it('refuses an unreadable address without touching a relay for it', async () => {
+ // No apostrophe, so parseGroupAddress returns null. This used to fall back
+ // to DEFAULT_RELAY_URL and connect there in order to draw an error.
+ await render('/s/not-an-address')
+
+ expect(text()).toContain('This link is not a space address')
+ expect(text()).not.toContain(SPACE_MARKER)
+ expect(want, 'an unreadable address names no relay').not.toHaveBeenCalled()
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/ui/layout/AppShell.tsx b/src/ui/layout/AppShell.tsx
index dcc2e22..614dc32 100644
--- a/src/ui/layout/AppShell.tsx
+++ b/src/ui/layout/AppShell.tsx
@@ -1,13 +1,20 @@
import { useCallback, useEffect, useState } from 'react'
import { Outlet, useLocation, useParams } from 'react-router-dom'
+import type { ReactNode } from 'react'
import { Topbar } from './Topbar'
import { SessionNotice } from '../SessionNotice'
import { Sidebar } from './Sidebar'
import { TableOfContents } from './TableOfContents'
import { TocProvider, useTocMarkdown } from './toc-context'
+import { InvalidSpaceLink, RelayTrustPrompt } from '../SpaceGate'
import { parseGroupAddress } from '../../nostr/group-address'
+import type { GroupAddress } from '../../nostr/group-address'
+import { isTrustedRelay, trustRelay } from '../../nostr/known-relays'
import { DEFAULT_RELAY_URL, useRelay } from '../../nostr/relay-status'
-import { useSpace } from '../../nostr/space-store'
+import { EMPTY_SPACE, useSpace } from '../../nostr/space-store'
+import type { SpaceSnapshot } from '../../nostr/space-store'
+import type { RelayInfo } from '../../nostr/relay-status'
+import type { RelaySnapshot } from '../../nostr/client'
export function AppShell() {
return (
@@ -17,15 +24,37 @@ export function AppShell() {
)
}
-const SIDEBAR_KEY = 'nc-sidebar-collapsed'
-
-function readCollapsed(): boolean {
- try {
- return localStorage.getItem(SIDEBAR_KEY) === '1'
- } catch {
- return false
- }
-}
+/**
+ * Why the shell is split in two.
+ *
+ * The route parameter `/s/'` is chosen by whoever wrote the link,
+ * and everything downstream of it used to be automatic: `useRelay` opens a
+ * WebSocket to that host and fetches its NIP-11 document, and
+ * `NostrClient.automaticallyAuth` answers the relay's NIP-42 challenge with an
+ * event signed by the reader's key. Following a link was therefore enough to
+ * make a signed-in visitor's browser contact a stranger's host and hand it a
+ * signed statement of who they are. CON-46 puts one decision in front of that.
+ *
+ * Both of those happen in *effects* inside `useRelay`, so "do not connect"
+ * cannot mean "call the hook and ignore the answer" — it has to mean the hook
+ * is never called. A hook cannot be called conditionally, so the condition
+ * moves up one level: `Shell` decides, and `SpaceShell` — which holds every
+ * relay-consuming hook, `` included — either mounts or does not.
+ *
+ * Two independent reasons reach that one mechanism, and they stay apart in the
+ * code because they are different things to be told:
+ *
+ * - **unreadable** — `'` did not parse. There is no host here to
+ * ask about. Before CON-46 this connected to `DEFAULT_RELAY_URL` anyway and
+ * each view drew an inline "Invalid group address." over the top of it.
+ * - **untrusted** — it parsed, and names a host this browser has no decision
+ * on record for. `src/nostr/known-relays.ts` holds the answers.
+ *
+ * docs/09-security-privacy.md
+ */
+type Gate =
+ | { kind: 'unreadable'; address: string }
+ | { kind: 'untrusted'; group: GroupAddress }
function Shell() {
const params = useParams<{ group?: string }>()
@@ -33,11 +62,125 @@ function Shell() {
// /settings/spaces/:group reuses the `group` param name (so SpaceSettings
// can share useSpaceRoute with the space's own routes) but is not "inside"
// that space — the sidebar there is the settings nav, not the page tree.
+ //
+ // So a hand-written /settings/spaces/'group skips the gate.
+ // That is deliberate, and it is safe for a reason worth writing down, because
+ // it lives outside this file: the settings route calls no `useRelay`, so
+ // nothing calls `client.want` for that host, and `useSpace` alone never
+ // reaches a socket — `SpaceStore.start()` returns at `if (!connection.ready)`
+ // for a connection nobody asked for. Gating it would therefore buy no
+ // privacy and would only put an interstitial in front of the settings nav.
+ // `AppShell.trust.test.tsx` pins that, so a change which wires a relay
+ // through the settings route fails a test rather than quietly connecting.
const inSettings = location.pathname.startsWith('/settings')
- const group = !inSettings && params.group ? parseGroupAddress(params.group) : null
+ const address = !inSettings && params.group ? params.group : null
+
+ // Hosts cleared for this visit but not written to storage — "Remember this
+ // relay" left unchecked. A set of hosts rather than one flag, so that
+ // following a link on to a *different* relay asks again instead of
+ // inheriting the answer given about the previous one.
+ const [allowedOnce, setAllowedOnce] = useState>(() => new Set())
+
+ const group = address !== null ? parseGroupAddress(address) : null
+ const gate: Gate | null =
+ address === null
+ ? null
+ : group === null
+ ? { kind: 'unreadable', address }
+ : isTrustedRelay(group.host) || allowedOnce.has(group.host)
+ ? null
+ : { kind: 'untrusted', group }
+
+ if (gate !== null) {
+ return (
+
+ {gate.kind === 'unreadable' ? (
+
+ ) : (
+ {
+ const host = gate.group.host
+ if (remember) trustRelay(host)
+ // Recorded here either way: `trustRelay` writes storage, but
+ // storage can be blocked, and re-reading it is not what makes
+ // this render show the space.
+ setAllowedOnce((current) => new Set(current).add(host))
+ }}
+ />
+ )}
+
+ )
+ }
+
+ return
+}
+
+/**
+ * Everything that talks to a relay. Mounted only once the address above has
+ * been read and cleared — see the `Gate` comment for why that has to be a
+ * mounting decision rather than a branch inside one component.
+ *
+ * `DEFAULT_RELAY_URL` is the fallback for the routes that are not inside a
+ * space at all (`/`, `/settings/*`). That is the deployment's own relay,
+ * configured at build time rather than named by a link, and it deliberately
+ * stays outside the gate.
+ */
+function SpaceShell({ group, inSettings }: { group: GroupAddress | null; inSettings: boolean }) {
const relayUrl = group?.relayUrl ?? DEFAULT_RELAY_URL
const { snapshot, info } = useRelay(relayUrl)
const space = useSpace(relayUrl, group?.id ?? '')
+
+ return (
+
+
+
+ )
+}
+
+const SIDEBAR_KEY = 'nc-sidebar-collapsed'
+
+function readCollapsed(): boolean {
+ try {
+ return localStorage.getItem(SIDEBAR_KEY) === '1'
+ } catch {
+ return false
+ }
+}
+
+/**
+ * The chrome: top bar, left bar, canvas, table of contents. Purely
+ * presentational — it holds no relay state of its own, which is what lets the
+ * gate render the same frame around a prompt with `snapshot={null}` (no relay
+ * is being talked to, and a status badge claiming otherwise would be the one
+ * lie on that screen).
+ */
+function ShellFrame({
+ group,
+ space,
+ snapshot,
+ info,
+ inSettings,
+ children,
+}: {
+ group: GroupAddress | null
+ space: SpaceSnapshot
+ snapshot: RelaySnapshot | null
+ info: RelayInfo | null
+ inSettings: boolean
+ children: ReactNode
+}) {
+ const location = useLocation()
const tocMarkdown = useTocMarkdown()
// Two separate states for one button. On a wide screen the bar is a column
@@ -108,7 +251,7 @@ function Shell() {
it by a hairline — the one detail that turns three panels into one
surface. */}
-
+ {children}
diff --git a/src/ui/layout/Sidebar.tsx b/src/ui/layout/Sidebar.tsx
index 88dae45..d0b44c2 100644
--- a/src/ui/layout/Sidebar.tsx
+++ b/src/ui/layout/Sidebar.tsx
@@ -28,7 +28,8 @@ import {
type Props = {
group: GroupAddress | null
space: SpaceSnapshot
- snapshot: RelaySnapshot
+ /** null while no relay is being talked to at all — see Topbar's own prop. */
+ snapshot: RelaySnapshot | null
info: RelayInfo | null
/**
* /settings/* — including /settings/spaces/:group, which reuses the
@@ -369,7 +370,7 @@ export function Sidebar({ group, space, snapshot, info, inSettings }: Props) {
-
+ {snapshot ? : null}
)
}
diff --git a/src/ui/layout/Topbar.tsx b/src/ui/layout/Topbar.tsx
index 6b02b5a..ffe16e6 100644
--- a/src/ui/layout/Topbar.tsx
+++ b/src/ui/layout/Topbar.tsx
@@ -37,7 +37,12 @@ export function Topbar({
onToggleOverlay,
}: {
groupBase: string | null
- snapshot: RelaySnapshot
+ /**
+ * null while the app is deliberately connected to no relay at all — the
+ * trust gate in AppShell. An indicator there could only report on a
+ * connection that does not exist, so the slot stays empty instead.
+ */
+ snapshot: RelaySnapshot | null
info: RelayInfo | null
/** wide screens: the bar is a column that folds away */
columnHidden: boolean
@@ -131,7 +136,7 @@ export function Topbar({