Files
VoxVera/docs/nostr-static-client-spec.md
T
thePR0M3TH3ANandClaude Opus 4.8 c8c715f438 Document known issues and roadmap
Add docs/roadmap.md capturing product/threat-model gaps that are not code
bugs, in priority order: (1) bulletin board has no moderation/abuse story,
(2) anonymous keys give no authenticity, (3) client distribution is
centralized on one domain, (4) relay permanence is overstated, (5) minimal
content-safety guardrails, plus smaller items. Each entry notes the problem,
current mitigation, why it matters, and possible directions.

Link it from the README and the spec's bulletin board section.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-09 22:21:34 -04:00

9.6 KiB
Raw Blame History

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

site/
  index.html          (the client)
  nostr-client.css
  nostr-client.js
  locales.js
  board.html          (bulletin board page)
  board.js
  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:

{
  "type": "voxvera_flyer",
  "version": 1,
  "folder_name": "voxvera",
  "name": "Vox Vera Printable Flyers",
  "lang": "en",
  "title": "TOP SECRET",
  "subtitle": "DO ~~NOT~~ DISTRIBUTE",
  "headline": "OPERATION VOX VERA",
  "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.

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

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.

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, and posted date. 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).
  • 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.

The board has no moderation and anonymous keys provide no authenticity yet; these and other product/threat-model gaps are tracked in 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

node --check site/nostr-client.js
pytest -q

For UI work, also verify browser rendering and print output from a local static server.