Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion docs/09-security-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 0 additions & 1 deletion docs/10-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
164 changes: 164 additions & 0 deletions src/ui/PseudonymNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
}))

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 <PseudonymNotice notice={notice} />
}

function render(): HTMLElement {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
act(() => {
root.render(<Harness />)
})
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'))
})
})
107 changes: 107 additions & 0 deletions src/ui/PseudonymNotice.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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.
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-4">
<div
ref={dialog}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="pseudonym-notice-title"
aria-describedby="pseudonym-notice-body"
className="max-h-full w-full max-w-lg overflow-auto scroll-slim rounded-lg border border-line bg-surface-2 p-6 shadow-lg outline-none"
>
<h2 id="pseudonym-notice-title" className="text-base font-semibold text-fg">
Your npub is a permanent pseudonym
</h2>

<div id="pseudonym-notice-body" className="mt-3 space-y-3 text-sm text-fg-muted">
<p>
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.
</p>
<p>
Publishing cannot reliably be undone. Deleting is a request to the relay, not a
guarantee: copies elsewhere may remain.
</p>
<p>
A private space restricts who may read it, it does not encrypt. Its members — and
whoever runs the relay — see everything in plain text.
</p>
{npub ? (
<p className="text-fg-subtle">
You are writing as <span className="font-mono break-all">{npub}</span>.
</p>
) : null}
</div>

<div className="mt-5 flex justify-end">
<Button variant="primary" onClick={confirm}>
I understand
</Button>
</div>
</div>
</div>
)
}
Loading
Loading