diff --git a/docs/09-security-privacy.md b/docs/09-security-privacy.md index 8aeffc8..7778e29 100644 --- a/docs/09-security-privacy.md +++ b/docs/09-security-privacy.md @@ -69,4 +69,28 @@ places that draw an image are `MarkdownImage` in `src/ui/Markdown.tsx` and An npub is a permanent pseudonym: everything a person posts is linkable across relays. For teams where npubs map to real names, that effectively means a public activity history. This belongs on the app's onboarding page, not in the fine -print. **Open:** there is no onboarding page yet. +print. + +**Built (CON-13):** `PseudonymNotice` says it once, as a dialog, on the first +sign-in with a given npub — before anything can be written under it. Three +sentences: what is signed stays linked to the npub forever, publishing cannot +reliably be undone, and a private space restricts access rather than encrypting. +A banner under the top bar would have been cheaper and is exactly what the +paragraph above rules out; a strip that can be scrolled past is the fine print. + +Acknowledgement is stored **per npub**, not per browser (`nc-pseudonym-ack`, see +`src/ui/pseudonym-ack.ts`). A browser is not an identity: a second person on the +same machine, or the same person starting a deliberately unlinked second +pseudonym, has not been told anything. There is still no onboarding *page* — the +app has no sign-in page either, on purpose (docs/06), so the notice goes to the +reader instead of the reader to it. + +Only the button acknowledges. Escape closes the dialog — a dialog the keyboard +cannot leave is its own accessibility problem — but records nothing, so the +notice is back on the next load; and the dialog itself takes the focus rather +than its button, so Enter has nothing to activate. There is one warning per npub +and nothing in the app brings it back, which is the whole reason a reflex must +not be able to spend it. While it is up the rest of the shell is `inert` +(`src/ui/layout/AppShell.tsx`): nothing behind the backdrop is focusable, which +is also what stops the top bar's Ctrl/Cmd+K from putting the caret into a search +field the reader cannot see. diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 560ef88..9c42537 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -226,7 +226,6 @@ As of 2026-09-07, found while comparing the docs against the code: | Hiding a whole page (tombstone) | [05](05-versioning-history.md) | | Writing `previous` timeline references | [02](02-data-model-events.md) | | Sidebar entries "all pages", "recently changed", "space settings" | [06](06-ui-information-architecture.md) | -| Onboarding note that an npub is a permanent pseudonym | [09](09-security-privacy.md) | | Help editing a table — column-aware movement and alignment; the skeleton itself comes from the `/` menu | [13](13-editing.md) | ### Deliberately solved differently than planned diff --git a/src/ui/PseudonymNotice.test.tsx b/src/ui/PseudonymNotice.test.tsx new file mode 100644 index 0000000..0cb1bd2 --- /dev/null +++ b/src/ui/PseudonymNotice.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { PseudonymNotice } from './PseudonymNotice' +import { usePseudonymNotice } from './pseudonym-ack' + +/** + * The three claims the notice has to make — linkable, not undoable, not + * encrypted — and the one rule about when it appears: once per npub, never for + * a reader who has not signed in. Pinned here because a notice that quietly + * stops appearing looks exactly like a notice that was never needed. + * docs/09-security-privacy.md + * + * The second half of the file pins the ways *out*. There is one warning per + * npub and nothing in the app brings it back, so which gestures spend it is + * part of the feature, not a detail of the markup. + */ +const session = vi.hoisted(() => ({ + current: { status: 'anonymous' } as Record, +})) + +vi.mock('../session/session', () => ({ + useSession: () => ({ session: session.current }), +})) + +const PUBKEY = 'a'.repeat(64) +const NPUB = `npub1${'1'.repeat(58)}` + +function signedIn(pubkey = PUBKEY): void { + session.current = { status: 'signed-in', pubkey, npub: NPUB } +} + +/** The shell's wiring in miniature: the hook decides, the dialog draws. */ +function Harness() { + const notice = usePseudonymNotice() + return +} + +function render(): HTMLElement { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + act(() => { + root.render() + }) + return host +} + +function dialogOf(host: HTMLElement): HTMLElement | null { + return host.querySelector('[role="dialog"]') +} + +/** + * Sign in and press the button, checking that it really was there to press. + * `render()` must not be called inside the `act()` that clicks: a nested `act` + * does not commit until the outer one exits, so the button does not exist yet + * and `?.click()` quietly does nothing — a test that then asserts the dialog is + * *shown* passes either way. + */ +function acknowledgeAs(pubkey: string): void { + signedIn(pubkey) + const host = render() + const button = host.querySelector('button') + expect(button).not.toBeNull() + act(() => { + button?.click() + }) + expect(dialogOf(host)).toBeNull() +} + +describe('PseudonymNotice', () => { + beforeEach(() => { + document.body.innerHTML = '' + localStorage.clear() + session.current = { status: 'anonymous' } + }) + + it('says nothing to a reader who is not signed in', () => { + expect(dialogOf(render())).toBeNull() + }) + + it('names what an npub costs: linkable, not undoable, not encrypted', () => { + signedIn() + const text = render().textContent ?? '' + expect(text).toContain('permanent pseudonym') + expect(text).toContain('cannot reliably be undone') + expect(text).toContain('does not encrypt') + expect(text).toContain(NPUB) + }) + + // Akasha has no public space and no read-only visitor (docs/00), so "anyone + // who links the npub can read it all" would be false as written. What is + // true, and what docs/09 actually claims, is linkability: whoever can reach + // a copy can join it all up. + it('scopes the linkability claim to whoever can reach the copies', () => { + signedIn() + expect(render().textContent ?? '').toContain('Anyone who can reach those copies') + }) + + it('describes itself by the three claims, not only by its title', () => { + signedIn() + const host = render() + const describedBy = dialogOf(host)?.getAttribute('aria-describedby') + expect(describedBy).toBeTruthy() + const body = host.querySelector(`#${describedBy ?? ''}`) + expect(body?.textContent).toContain('cannot reliably be undone') + }) + + it('stays away on the next visit once it has been acknowledged', () => { + signedIn() + const host = render() + const button = host.querySelector('button') + expect(button?.textContent).toContain('I understand') + act(() => { + button?.click() + }) + expect(dialogOf(host)).toBeNull() + + expect(dialogOf(render())).toBeNull() + }) + + it('tells a second npub on the same machine, acknowledged or not', () => { + acknowledgeAs(PUBKEY) + + signedIn('b'.repeat(64)) + expect(dialogOf(render())).not.toBeNull() + }) + + it('does not ask the same npub again after a sign-out and back in', () => { + acknowledgeAs(PUBKEY) + + session.current = { status: 'anonymous' } + expect(dialogOf(render())).toBeNull() + + signedIn() + expect(dialogOf(render())).toBeNull() + }) + + // Escape is the reflex for making a dialog go away. It may close this one — + // a dialog the keyboard cannot leave is its own problem — but it must not + // count as having read it, or one keystroke silently spends the only warning + // this npub ever gets. + it('closes on Escape without counting that as having been read', () => { + signedIn() + const host = render() + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + expect(dialogOf(host)).toBeNull() + + expect(dialogOf(render())).not.toBeNull() + }) + + // Same reasoning as Escape: with the button auto-focused, Enter acknowledged + // it in one keystroke. The dialog takes the focus instead, so the label and + // the three sentences are read out and Enter has nothing to activate. + it('focuses the dialog rather than its one button', () => { + signedIn() + const host = render() + expect(document.activeElement).toBe(dialogOf(host)) + expect(document.activeElement).not.toBe(host.querySelector('button')) + }) +}) diff --git a/src/ui/PseudonymNotice.tsx b/src/ui/PseudonymNotice.tsx new file mode 100644 index 0000000..617fd07 --- /dev/null +++ b/src/ui/PseudonymNotice.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef } from 'react' +import { Button } from './controls' +import type { PseudonymNoticeState } from './pseudonym-ack' + +/** + * What an npub costs, said once, before the first revision is written under it. + * + * docs/09 puts this plainly: an npub is a permanent pseudonym, everything + * posted under it is linkable across relays, and in a team where npubs map to + * real names that is a public activity history. It also says where it belongs — + * "on the app's onboarding page, not in the fine print". + * + * There is no onboarding page and deliberately no sign-in page either (see + * `SignInButton`), so the moment has to come to the reader: a dialog on the + * first sign-in with a given npub. A dismissible banner under the top bar was + * the cheaper option and is the wrong one — a strip that can be scrolled past + * *is* the fine print, and this is the one thing the app has to say before + * somebody signs something that cannot be taken back. + * + * It appears on a resumed session too, not only on a fresh `login()` click. + * The question the storage answers is "has this npub been told", and somebody + * who was already signed in when this shipped has not been. + * + * The state comes in from `usePseudonymNotice` rather than being read here, + * because the shell needs the one bit this dialog knows: while it is up, + * everything behind it is `inert`. A dialog that says `aria-modal` while the + * page behind it still takes focus is lying to a screen reader — and + * concretely, the top bar's Ctrl/Cmd+K would otherwise put the caret in a + * search field hidden under the backdrop. + */ +export function PseudonymNotice({ notice }: { notice: PseudonymNoticeState }) { + const { open, npub, confirm, defer } = notice + const dialog = useRef(null) + + // Escape closes it, because a dialog a keyboard cannot leave is its own + // accessibility problem — but it does *not* record the npub as told. Escape + // is the trained reflex for making a dialog go away, and the app has exactly + // one warning per npub to spend; a reflex must not be able to spend it. So + // this way out lasts until the next load, and only the button is final. + useEffect(() => { + if (!open) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') defer() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [open, defer]) + + // The dialog takes the focus, not its button. A screen reader then reads the + // label and the three sentences it is described by, and — the reason it is + // not `autoFocus` on the button any more — Enter has nothing to activate. + // One keystroke on a focused "I understand" is as reflexive as Escape. + useEffect(() => { + if (open) dialog.current?.focus() + }, [open]) + + if (!open) return null + + return ( + // No dismiss on the backdrop. Everything else in the app closes when you + // click beside it; this one asks for the single deliberate click that says + // the sentence above was read. +
+
+

+ Your npub is a permanent pseudonym +

+ +
+

+ Every revision you save is signed with your key and carries your npub. It stays + attached to what you wrote — across every page, every space and every relay that + ever holds a copy. Anyone who can reach those copies and learns once which npub + is you can read back everything it has written. +

+

+ Publishing cannot reliably be undone. Deleting is a request to the relay, not a + guarantee: copies elsewhere may remain. +

+

+ A private space restricts who may read it, it does not encrypt. Its members — and + whoever runs the relay — see everything in plain text. +

+ {npub ? ( +

+ You are writing as {npub}. +

+ ) : null} +
+ +
+ +
+
+
+ ) +} diff --git a/src/ui/layout/AppShell.tsx b/src/ui/layout/AppShell.tsx index dcc2e22..c776ac0 100644 --- a/src/ui/layout/AppShell.tsx +++ b/src/ui/layout/AppShell.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react' import { Outlet, useLocation, useParams } from 'react-router-dom' import { Topbar } from './Topbar' import { SessionNotice } from '../SessionNotice' +import { PseudonymNotice } from '../PseudonymNotice' +import { usePseudonymNotice } from '../pseudonym-ack' import { Sidebar } from './Sidebar' import { TableOfContents } from './TableOfContents' import { TocProvider, useTocMarkdown } from './toc-context' @@ -63,56 +65,70 @@ function Shell() { const base = group ? `/s/${encodeURIComponent(`${group.host}'${group.id}`)}` : null + const notice = usePseudonymNotice() + return (
- setOverlay((open) => !open)} - /> - - -
- {/* Folded away entirely rather than down to a 40px rail of icons. The - rail had one thing in it that could not be reached elsewhere, the - relay status — that now sits in the top bar, where it is visible - whether the bar is open or not. A strip holding a single dot is - not a narrow sidebar, it is a margin. - docs/06-ui-information-architecture.md */} - {collapsed ? null : ( -
- -
- )} + {/* `contents`, so the wrapper adds no box and the layout below is + unchanged — it exists only to carry `inert` for the one dialog that + may not be worked around. `inert` rather than a focus trap because it + does all three jobs at once: nothing behind the backdrop takes focus + (including the top bar's Ctrl/Cmd+K, which would otherwise type into + a search field the reader cannot see), nothing is tabbable, and the + whole shell leaves the accessibility tree while the notice is up. */} +
+ setOverlay((open) => !open)} + /> + - {overlay ? ( - <> -
+ + {/* Outside the `inert` wrapper — it is the only thing that stays live. */} +
) } diff --git a/src/ui/pseudonym-ack.test.ts b/src/ui/pseudonym-ack.test.ts new file mode 100644 index 0000000..e26fbfc --- /dev/null +++ b/src/ui/pseudonym-ack.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { acknowledge, readAcknowledged } from './pseudonym-ack' + +/** + * The fallbacks, which the component tests never reach because they only ever + * drive the happy path. Each one decides whether somebody gets the warning, and + * the direction they fail in is the whole point: a store that cannot be read or + * written has to show the notice again, never skip it. + */ +const STORAGE_KEY = 'nc-pseudonym-ack' +const A = 'a'.repeat(64) +const B = 'b'.repeat(64) + +describe('pseudonym-ack', () => { + beforeEach(() => localStorage.clear()) + afterEach(() => vi.restoreAllMocks()) + + it('treats an unwritten store as nobody having been told', () => { + expect(readAcknowledged().size).toBe(0) + }) + + it('forgets a store it cannot parse rather than trusting it', () => { + localStorage.setItem(STORAGE_KEY, 'not json') + expect(readAcknowledged().size).toBe(0) + }) + + it('forgets a store that is valid JSON but not a list of npubs', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ [A]: true })) + expect(readAcknowledged().size).toBe(0) + }) + + it('keeps the entries it can read and drops the ones it cannot', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify([A, 42, null, { B }])) + expect([...readAcknowledged()]).toEqual([A]) + }) + + it('remembers every npub told so far, not just the last one', () => { + acknowledge(A) + acknowledge(B) + expect([...readAcknowledged()].sort()).toEqual([A, B].sort()) + }) + + // A private window may refuse the write. The notice then reappears next time + // — it costs a click, where skipping it costs the warning. + it('still reports the npub as told for this session when the write fails', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError') + }) + + expect(acknowledge(A).has(A)).toBe(true) + + vi.restoreAllMocks() + expect(readAcknowledged().has(A)).toBe(false) + }) +}) diff --git a/src/ui/pseudonym-ack.ts b/src/ui/pseudonym-ack.ts new file mode 100644 index 0000000..f1d9a65 --- /dev/null +++ b/src/ui/pseudonym-ack.ts @@ -0,0 +1,74 @@ +import { useCallback, useState } from 'react' +import { useSession } from '../session/session' + +const STORAGE_KEY = 'nc-pseudonym-ack' + +/** + * The npubs that have been told what an npub is. + * + * Keyed per pubkey rather than per browser on purpose. The notice is about what + * *this identity* is committing to, and a browser is not an identity: a second + * person signing in on the same machine — or the same person starting a second, + * deliberately unlinked pseudonym — has never been told anything, and a flag + * saying "somebody here has read it" would silently skip them. + * + * Storage is best effort. A private window may refuse it, in which case the + * notice appears once per session instead of once per npub. That is the right + * direction to fail in: showing it twice costs a click, skipping it costs the + * one warning the app gives before somebody writes under their real name. + */ +export function readAcknowledged(): Set { + try { + const raw: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') + return new Set(Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []) + } catch { + return new Set() + } +} + +export function acknowledge(pubkey: string): Set { + const next = readAcknowledged().add(pubkey) + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...next])) + } catch { + /* then it only applies to this session */ + } + return next +} + +export type PseudonymNoticeState = { + /** whether the dialog is up — the shell reads this to go `inert` behind it */ + open: boolean + npub: string | null + /** the deliberate act: records this npub as told, for good */ + confirm: () => void + /** the way out that spends nothing: gone for this session, back on the next load */ + defer: () => void +} + +/** + * Whether this npub still has to be told, and the two ways out. + * + * It lives beside the storage rather than in `PseudonymNotice.tsx` because two + * places need it: the dialog draws it, and the shell has to know it is up in + * order to make everything behind it `inert`. + */ +export function usePseudonymNotice(): PseudonymNoticeState { + const { session } = useSession() + const pubkey = session.status === 'signed-in' ? session.pubkey : null + const npub = session.status === 'signed-in' ? session.npub : null + + const [acknowledged, setAcknowledged] = useState(readAcknowledged) + const [deferred, setDeferred] = useState(null) + + const open = pubkey !== null && !acknowledged.has(pubkey) && deferred !== pubkey + + const confirm = useCallback(() => { + if (!pubkey) return + setAcknowledged(acknowledge(pubkey)) + }, [pubkey]) + + const defer = useCallback(() => setDeferred(pubkey), [pubkey]) + + return { open, npub, confirm, defer } +}