Files
VoxVera/docs/nostr-static-client-spec.md
thePR0M3TH3ANandClaude Opus 4.8 18fa382632 Add NIP-46 nostrconnect (QR) login path; move board safety link to footer
NIP-46 now supports both connect directions on the editor and the board:
- bunker:// (existing): paste a link the signer app generated.
- nostrconnect:// (new): the client generates the link, renders it as a
  QR (+ a copyable link for one-device use), listens on a rendezvous
  relay (relay.nsec.app), and the signer app scans it and connects back —
  proven by echoing the client's random secret. The discovered signer
  pubkey then drives the durable connection (shared adopt path).

Both paths stay session-only with an ephemeral local key; the QR uses
the same hand-rolled NIP-44 client (NIP-04 decrypt fallback). qrcode-
generator is now loaded on the board too. 3 new localized strings
(scan / waiting / copy-link) across all 14 languages in NOSTR_UI and
BOARD_UI; i18n parity passes.

Also per feedback: the board's "Safety & privacy" link moves out of the
topbar (where it ran together with "Back to VoxVera") into a centered
footer link matching the home page, and both pages now link /safety.html
(works on the static host and Vercel alike).

Tests: editor and board each connect via a fake signer that "scans" the
nostrconnect QR (reads the on-screen URI, echoes the secret) — all 6
engines. Full suite 372 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 21:49:37 -04:00

402 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VoxVera Nostr Static Client Spec
Status: `main` is the Nostr static-client branch. The legacy Tor/OnionShare CLI version is preserved on `legacy-tor`.
## Goal
VoxVera should let anyone publish, fetch, and print flyer content without a centralized application server.
The static client is responsible for:
- rendering the flyer
- generating QR codes
- authoring a normalized Nostr flyer payload
- generating anonymous browser-side Nostr keys
- signing and publishing replaceable Nostr events
- fetching events by raw event ID, `note1`, `nevent1`, or `naddr`
- rendering fetched content safely
Nostr relays store the flyer source content. Static hosts only serve the client.
## Active Files
```text
site/
index.html (the client)
nostr-client.css
nostr-client.js
locales.js
board.html (bulletin board page)
board.js
favicon.svg (inline SVG site icon)
CNAME
vendor/
nostr-tools/
qrcode-generator/
nostr/
index.html (redirect stub for legacy /nostr/ URLs)
voxvera/
nostr/
schema.py
tests/
test_nostr_schema.py
```
## Deployment
The deployable static root is `site`.
No build command is required. Vercel should use:
- root directory: repository root
- output directory: `site`
- build command: empty
The client is served from the root (`site/index.html`). `site/nostr/index.html` is a redirect stub that forwards the legacy `/nostr/` path to the root, preserving the query string and `#naddr` fragment, so poster URLs and QR codes printed before the move still resolve.
## Event Model
Recommended event kind: `30078` parameterized replaceable event.
Required tags:
- `["d", "voxvera:<slug>"]`
- `["t", "voxvera"]`
- `["t", "flyer"]`
Recommended language tags:
- `["language", "<lang>"]`
- `["L", "ISO-639-1"]`
- `["l", "<lang>", "ISO-639-1"]`
The payload `lang` field is authoritative. The language tags exist for relay/search metadata and as a fallback for older events whose payload omitted `lang`.
Payload:
```json
{
"type": "voxvera_flyer",
"version": 1,
"folder_name": "voxvera",
"name": "Vox Vera Printable Flyers",
"lang": "en",
"title": "OPERATION VOX VERA",
"subtitle": "DO ~~NOT~~ DISTRIBUTE",
"headline": "TOP SECRET",
"content": "Flyer body text",
"url_message": "Follow this link to learn more.",
"url": "https://creator.example/action",
"tear_off_link": "https://voxvera.org/nostr/#naddr1...",
"footer_message": "0110010",
"qr_target": "flyer_url"
}
```
The UI labels `folder_name` as `Flyer Name`; it acts as the event slug, not a local hosting folder.
## QR Target
Default behavior: `qr_target: "flyer_url"`.
`url` is always the creator-controlled content destination shown in the body of the flyer. The editor must not overwrite this field with generated app URLs.
When publishing, the client computes the replaceable-event `naddr` and writes the generated poster URL to `tear_off_link`. The tear-off tabs and tear-off QR code point at `tear_off_link`, so people can re-open and reprint the flyer. The main flyer QR code points at `url`, so viewers can visit the creator's intended destination.
Each tear-off tab is labeled with the flyer's **title** (`.tear-off-title`, auto-sized by `fitTabTitles()` to be as large as fits the ~1in tab, shrinking rather than overflowing for long titles), a clickable **"Open & reprint this flyer"** link (`tab_link`, the `<a href>` so it stays live in a printed PDF), the tear-off QR, and a **"Reopen at VoxVera.org — Event ID:"** line (`tab_event_id`, the brand stays Latin; the rest is localized) with the full event id. All strings are translated in every supported language.
The `naddr` is encoded with a **single relay hint** (the primary configured relay) instead of the full relay list. This keeps the poster URL short and its tear-off QR code easy to scan — a typical poster URL drops from ~233 characters (full list) to ~171 — while still pointing a fresh viewer at a relay that has the event. Viewers also fall back to `DEFAULT_RELAYS`, which is where flyers are published by default. This stays a standard NIP-19 `naddr`, so any Nostr client can resolve it, and older relay-bearing `naddr` URLs continue to decode and resolve unchanged.
The naddr is carried in the URL **fragment** (`#naddr1...`) rather than a `?addr=` query parameter. The fragment is shorter (less QR clutter), needs no URL-encoding, and never reaches the static host. `getUrlEventReference` reads the naddr from query params, the fragment, or anywhere in the full URL, so older `?addr=` poster URLs and printed QR codes still resolve.
Supported values:
- `flyer_url`: tear-off QR points to the hosted client URL for the poster
- `content_url`: QR points to the payload's content URL
- `nostr_event`: QR points to the Nostr source identifier when available
## Safety
Nostr content is untrusted.
The client and schema helper must:
- reject raw HTML in flyer text fields
- escape rendered flyer content
- reject unsafe URL schemes such as `javascript:` and `data:`
- keep attachments unsupported in V1
- avoid remote frontend dependencies
## Publishing identity
The editor signs flyer events under one of four identities, chosen in the
identity controls (`activeIdentity` / `signActiveEvent` in `nostr-client.js`):
- **Anonymous (default).** The per-device key in `localStorage`
(`voxvera_nostr_anon_secret_hex`); signs locally with `finalizeEvent`.
Unchanged behavior — no connection required. "Generate anonymous npub" rolls a
fresh device key.
- **Browser extension (NIP-07).** Signs via `window.nostr.signEvent`; the secret
never enters the page. The pubkey is fetched with `getPublicKey()`.
- **Private key (nsec).** The user pastes an `nsec`; it is decoded to a secret
key used to sign with `finalizeEvent`. By default the secret lives **only in
memory** for the session. If the user ticks "Remember on this device", the
secret is encrypted with a PIN and stored as `voxvera_identity_nsec_enc`; the
plaintext secret is never written to storage.
- **Remote signer (NIP-46).** The user's real secret never reaches the browser;
the client mints an **ephemeral** local key and exchanges encrypted
kind-`24133` JSON-RPC requests (`connect` → `get_public_key` → `sign_event`)
over a raw WebSocket. Two connect paths are offered:
- **`bunker://` (paste).** The user pastes a link the signer app generated;
the client connects out to that bunker relay.
- **`nostrconnect://` (QR).** The client generates the link itself, renders it
as a QR (`makeQrSvg`) plus a copyable link, and listens on a rendezvous relay
(`relay.nsec.app`); the signer app scans it and connects back, proving the
connection by echoing the client's random `secret`. The discovered signer
pubkey is then used for the durable connection.
Requests are encrypted with **NIP-44** (with a NIP-04 decrypt fallback for
older signers); the vendored nostr-tools bundle has no nip46 module, so this
client is hand-rolled. The connection is **session-only** — nothing is
persisted, so a reload returns to anonymous.
The chosen mode is remembered in `voxvera_identity_mode` (and the npub in
`voxvera_identity_npub` for display). On reload: NIP-07 re-fetches the pubkey;
a remembered nsec shows a **locked** state until the PIN is entered (`unlock`);
a session-only nsec or a remote-signer session falls back to anonymous.
`naddr`/poster-URL/`author npub` outputs all reflect the active identity. A
deliberate identity choice is also mirrored to `voxvera_connected_pubkey` so the
bulletin board reflects the same identity.
**PIN encryption details.** PBKDF2 (`SHA-256`, 600k iterations, random 16-byte
salt) derives an AES-GCM-256 key (random 12-byte IV); the blob stores
`{v, iters, salt, iv, ct}` base64. A minimum 4-digit numeric PIN is enforced,
but longer PINs are allowed and encouraged.
> **Security caveat (documented deliberately).** A short numeric PIN has a tiny
> keyspace (a 4-digit PIN is ~10,000 combinations), so if the encrypted blob is
> exfiltrated (targeted XSS, or devtools access on a shared/stolen device) it is
> brute-forceable offline regardless of PBKDF2 cost. PIN-at-rest protects against
> casual snooping and untargeted localStorage scraping, **not** a determined or
> targeted attacker. The NIP-07 extension path (secret never in the page) and the
> NIP-46 remote signer (secret stays in the signer app; the browser holds only an
> ephemeral key — paste a `bunker://` link) are the stronger options. See
> [`roadmap.md`](roadmap.md).
## Editing, re-publishing, and deleting flyers
Because flyers are addressable replaceable events (`kind 30078`, identified by
`(pubkey, d)` where `d = voxvera:<slug>`), the author can manage a published
flyer by re-publishing under the same key + Flyer Name. When a flyer is loaded
from an event, the editor shows author-only actions (`#loaded-flyer-actions`):
- **Edit** — change fields and publish again; the same `d` tag replaces the
prior version in place, keeping the same `naddr`/poster URL.
- **Re-publish (keep-alive)** — re-sign and re-broadcast the current content so
relays that prune app-data don't age the flyer out (`republishCurrentFlyer`).
- **Delete** (`deleteLoadedFlyer`) — two layers, for robustness across relays:
1. a **replaceable tombstone** (`buildTombstoneEvent`) — same `(kind, d)`, with
`deleted: true` in the payload and a `["deleted"]` tag, so it *overwrites*
the flyer's content; relays honoring replaceable semantics now serve the
tombstone, and
2. a **NIP-09 deletion request** (`buildDeletionEvent`, kind `5`) referencing
the addressable `a` tag (`30078:<pubkey>:voxvera:<slug>`) and the event id,
asking relays to drop it entirely.
All three are **author-only**: the active signing key must match the loaded
flyer's author (`resolveActivePubkey`), and `signAndPublish` refuses to publish
if the signed key diverges from the address. **Tombstone recognition:** the
board's `parseFlyers` keeps the newest event per `(pubkey, d)` and skips it if
that newest version is a tombstone (so a delete can't be undone by an older copy
arriving later); the viewer shows a localized "This flyer was removed." state
(`isFlyerTombstone`). Deletion is **best-effort** — relays that ignore both
replaceable overwrites and NIP-09 may still serve the original — so it is framed
as a request, not a guarantee.
## Importing and exporting a flyer design
The editor can save and load a flyer **design** as a portable `.json` file
(`Export design` / `Import design`), independent of publishing. The file format
is `{ type: "voxvera_flyer_design", version: 1, flyer: <payload>, relays: [] }`,
where `flyer` is exactly `rawPayloadFromForm()` — the content fields plus the
relay list, and **nothing identity-bound**: no npub, `naddr`, event id, or
signature (all of those are derived at publish time by `withPosterUrl` /
`signAndPublish`). That makes a design portable: it can be handed to anyone and
published under *their* key with a fresh address.
- **Export** (`exportDesign`) serializes the current form as-is (draft-friendly,
no strict validation) and downloads `<folder_name>-voxvera-design.json`.
- **Import** (`importDesignFromFile`) reads the picked file (capped at 256 KB),
`JSON.parse`s it, and accepts either the wrapper format or a bare
`voxvera_flyer` payload (`readDesignFile`, lenient). It then shows a **confirm
modal** (`#import-modal`) before overwriting the editor, and on confirm
(`applyPendingImport`) fills the form via `fillEditorFromConfig` and restores
the relay list. Imported content is untrusted, but it flows through the lenient
view path (the renderer escapes every field) and still faces strict
`validatePayload` at publish (raw-HTML rejection, length caps, URL schemes).
This replaced the earlier raw "Export event JSON" button; the published
(signed) event JSON is still shown in `#event-json-output` after a publish.
## Print paper size
The flyer supports two print sizes: **US Letter** (8.5×11in) and **A4**
(210×297mm), covering every country whose language the client supports (Letter
in North America and a few Latin American countries; A4 everywhere else,
including the EU, Russia, China, Japan, and the Middle East).
- Dimensions live in CSS custom properties `--sheet-w` / `--sheet-h`, switched
by a `paper-letter` / `paper-a4` class on `<html>` (`applyPaperSize` in
`nostr-client.js`). The tear-off column (`3.75in`) and QR codes (`1.25in`)
stay fixed physical sizes; the content column flexes to the sheet width.
- The print `@page { size: … }` is injected by JS (an element with id
`voxvera-page-size`), because `@page size` cannot read CSS custom properties
across engines. The static `@media print` block keeps `margin: 0` and a
Letter default for the pre-JS state.
- The default is chosen from the **region subtag** of the browser locale
(`preferredPaperSize` — e.g. `en-GB` → A4, `en-US` → Letter). This is "by
location" without geolocation, using the same signal as language. When no
locale carries a region, it falls back to Letter.
- A user override (the paper-size selector) is stored in `localStorage`
(`voxvera_paper`) and wins over the region default.
- **Paper size is a local print/view preference and is never written into the
Nostr event.** A flyer authored on Letter reprints on A4 for a European
viewer without changing the source event. The live fit-check protects
authoring on whichever size is active; switching to a narrower size after
authoring can ellipsis-truncate a maxed-out single-line field.
Field capacity therefore varies by paper size; see
[`flyer-field-limits.md`](flyer-field-limits.md).
## Bulletin board
`site/board.html` + `site/board.js` are a standalone page that lists the most
recent flyers found on the default relays as a sortable, spreadsheet-style
table: **title**, **link** (the poster URL, rendered as the word "link"),
**posting npub**, **language**, **posted date**, and the **event ID** (shown
shortened, full value in the title attribute, with a per-row copy button). Any
column header sorts (default is date descending); replaceable events are
de-duplicated per author+`d` identifier, keeping the latest.
- The entry point is a small screen-only link below the flyer preview
(`.board-link`, label `bulletin_board`), outside the printable sheet. It must
never print (hidden in the `@media print` block alongside the other app
chrome).
- **The board is gated behind a connected Nostr identity.** On load it shows a
login gate (`#board-gate`) instead of the table, with four ways to connect:
a **NIP-07** extension (`window.nostr.getPublicKey()`), a **NIP-46 remote
signer** (either paste a `bunker://` link or scan a `nostrconnect://` QR; same
hand-rolled client as the editor — it establishes the viewer pubkey via
`get_public_key` and keeps the connection so `getBoardSigner` can sign
reports/deletes remotely this session, then falls back to read-only after a
reload), **pasting an `nsec`** (decoded in-page to
derive the pubkey, then discarded — the secret is never stored or transmitted),
or **creating a new key** (reuses this device's anon key in
`voxvera_nostr_anon_secret_hex` if present, else generates one, and reveals the
`nsec` with a save-it warning before continuing). On success it reveals the
table and surfaces the connected npub (`#board-identity`) with a Disconnect
control. The board only ever needs the *pubkey* to view (it reads to filter),
so the connected pubkey is remembered in `localStorage`
(`voxvera_connected_pubkey`, shared with the main client) and a return visit
reconnects from it directly — no prompt or re-entry, any method. This is
**not access control** — the events are public on the relays regardless — it
establishes the *viewer's* identity for the web-of-trust filter below.
Authoring and printing flyers stays fully anonymous and never requires
connecting.
- **Web-of-trust filter (NIP-02).** Once connected, the board fetches the
viewer's contact list (kind 3) and defaults to showing only flyers whose
author is in the viewer's follow graph (degree 1) plus the viewer themself. A
**"Show all"** checkbox opts out to the full relay firehose. A viewer with no
follow list of their own (a fresh key) is **seeded** from a pinned curator
account's follows (`FALLBACK_CURATOR_PUBKEY` in `board.js`), so a brand-new
viewer still gets a curated board. If no trust data can be fetched at all, the
board falls back to showing everything so it is never mysteriously empty. The
`#board-filter-note` explains which mode is active (following / seeded / all),
and re-localizes with the language selector. See [`roadmap.md`](roadmap.md)
for the phased plan (Phase 1 = the gate; Phase 2 = this filter; Phase 3 =
degree-2 trust, blocklist/report, NIP-05 badges).
- `board.js` is independent of `nostr-client.js` (which exports nothing), so its
UI strings live in `BOARD_UI` (all supported languages); it reuses the
vendored nostr-tools and `locales.js` (for language names + RTL direction).
- The board has its own language selector. It reads/writes the same
`voxvera_nostr_lang` localStorage key as the main client, so the chosen
language carries across both pages, and switching it re-localizes the chrome,
column headers, language labels, and date formatting (and flips RTL).
- Relay content is untrusted: titles and npubs are escaped, and link URLs are
rejected unless `http:`/`https:` (so `javascript:`/`data:` never render as a
link).
- The npub column is forward-looking — anonymous keys today, but a stable
identity per author maps cleanly onto this view later.
- **Per-row actions** (behind a per-row **⋯ menu**). On flyers the connected
viewer authored, the menu offers
**Edit** (a link to `/?edit=1#<naddr>`, which loads the flyer in the editor),
**Re-broadcast** (re-sends the already-signed event to all relays — no signing
needed), and **Delete** (tombstone + NIP-09; the board signs via the device
key or NIP-07, and for a pasted-nsec connection it points to the editor). On
flyers from others it shows **Block** — a per-device blocklist of author
pubkeys (`voxvera_blocked_pubkeys`) that always hides them, with a "Clear
blocked" control. The blocklist is local "hide this," separate from the
web-of-trust filter (roadmap #1).
- **Narrow screens (≤640px):** the table collapses to a card per flyer — the
**title** and the **⋯ menu** stay visible, and the secondary fields (link,
posted-by, language, date, event id) sit behind a per-row **Details**
disclosure (`.board-details-toggle` toggles `tr.expanded`; the columns become
labeled rows via their `data-label`).
On mobile the flyer preview scales to fully fit the screen width
(`updatePreviewScale` / `--flyer-preview-scale`): the sheet keeps its true print
width (`flex: 0 0 auto`, so the viewer-mode flexbox doesn't shrink it) and is
visually scaled with a transform — so it never side-scrolls or clips.
The login gate (Phase 1) and the web-of-trust filter (Phase 2) are in place.
Remaining gaps — degree-2 trust, a blocklist/report flow, NIP-05 badges, and
making the bootstrap curator configurable — are tracked in
[`roadmap.md`](roadmap.md).
## Localization
Flyer defaults come from `site/locales.js`.
Nostr editor/viewer controls and Nostr-specific flyer labels come from `NOSTR_UI` in `site/nostr-client.js`.
Language changes should:
- publish the selected flyer language in payload `lang`
- publish matching Nostr language tags
- infer language from event tags if a fetched payload omits `lang`
- update default flyer content while the flyer is still using default content
- preserve user-authored or fetched event content
- update editor/viewer tool labels
- update printed side-tab labels
- respect RTL direction metadata
### Letter-spacing by script
Flyer text uses tighter letter-spacing than the original design to fit more
characters per line, but `letter-spacing` is not script-neutral, so it is
applied per script via a policy class on the flyer `.container`
(`LETTER_SPACING_POLICY` in `nostr-client.js`; `ls-full` / `ls-cjk` / `ls-none`
rules and `--ls-*` variables in `nostr-client.css`):
- `full` — Latin, Cyrillic, Hebrew: reduced positive tracking on the display
fields plus a slight negative on body/URL to reclaim line width.
- `cjk` — Japanese, Chinese: tracking is clamped to `0` and never negative, so
full-width glyphs do not overlap.
- `none` — Arabic, Persian, Hindi: letter-spacing is turned off entirely. These
are cursive or complex scripts where any tracking breaks the letter joins or
detaches combining marks. (This also corrected the prior behavior, which
applied the design's display tracking to every language unconditionally.)
When adding a language, classify its script in `LETTER_SPACING_POLICY` (default
is `full`) and verify the headline, subtitle, and body render correctly in the
browser.
## Verification
```bash
node --check site/nostr-client.js
pytest -q
```
For UI work, also verify browser rendering and print output from a local static server.