diff --git a/README.md b/README.md index 03089fd..098822f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. - **QR codes.** The tear-off QR points at the poster URL (re-open and reprint); the main flyer QR points at the creator's content URL. - **Bulletin board (members-only, trust-filtered).** A small screen-only link below the flyer preview (outside the printed sheet, never printed) opens a spreadsheet-style page of the most recent flyers found on the relays — title, link, posting npub, language, date, and event ID (with a copy button) — sortable by any column. Viewing the board requires connecting a Nostr identity — via a NIP-07 browser extension, by pasting a private key (`nsec`, decoded in-page and never stored), or by creating a new key (revealed so you can save it). The board then shows only flyers from authors in your **web of trust** (your NIP-02 follow graph), with a "Show all" opt-out. A brand-new key with no follows is seeded from a curated trust anchor so the board is never an unmoderated firehose. On your own flyers the board offers inline **Edit / Re-broadcast / Delete**; on others' it offers **Report** (a public, signed NIP-56 report with a category and optional reason, which also hides the flyer for you) and a local **Block** (a per-device hide list). Connecting is not required to author or print flyers, which stay fully anonymous. See the [roadmap](docs/roadmap.md) for the phased trust model. - **Mobile-friendly.** The flyer preview shrinks to fully fit the screen width (no side-scrolling), the editor and viewer are single-column with large tap targets, and the bulletin board collapses to a card per flyer with a per-row **Details** toggle on narrow screens. +- **Honest safety page.** A localized **[Safety & privacy](https://voxvera.org/safety)** page (linked from the editor and board) explains in plain language what VoxVera protects and — just as importantly — what it does *not*: relays see your IP when you publish or browse, the anonymous key is stored unencrypted in your browser, a short PIN won't stop a targeted attacker, and published events are public and effectively permanent. Plus operational tips (Tor/VPN, disposable keys, signer apps). No overpromising to the people most likely to rely on it. - **Self-contained.** No build step and no CDNs — Nostr and QR libraries are vendored under `site/vendor/`. ## What Is In `main` diff --git a/docs/roadmap.md b/docs/roadmap.md index 1a96fe4..fc5d94b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -261,6 +261,17 @@ for events not on the default set. - Reconsider how many relay hints to embed in the poster `naddr` (tradeoff: URL length / QR density vs. fetch robustness — see the QR Target notes in the spec). +- **Self-contained fallback payload in the URL fragment (future).** Because a + printed flyer cannot be edited, a pruned/unreachable event leaves a dead QR. + The flyer payload is small (text only), so it could be compressed and encoded + into the URL `#fragment` alongside the `naddr`, letting the viewer render the + flyer *even when no relay has the event* — relay-independent durability, which + fits the censorship-resistance pitch. **Constraint:** the tear-off QR URL is + already near scannable capacity, so this likely can't be added without + reducing the number of tear-off tabs and/or enlarging the QR (denser code, + harder scan). Best treated as an opt-in "durable QR" mode rather than the + default, and decided alongside the tab-count/QR-size tradeoff. The `naddr` + remains the primary resolver; the embedded payload is a last-resort fallback. ## 5. Content-safety guardrails are minimal — **Open** @@ -284,9 +295,27 @@ should follow the board's trust model. - **Favicon — ✅ done.** A small inline SVG (`site/favicon.svg`) is linked from both pages, so browsers no longer request (and 404 on) `/favicon.ico`. - **i18n key-parity guard — ✅ done.** `e2e/i18n.spec.js` fails if any - `NOSTR_UI` / `BOARD_UI` / locales dictionary is missing (or has an extra) key - in some language. It immediately caught `field_too_long`, which had been - English-only across all 13 other languages. + `NOSTR_UI` / `BOARD_UI` / `SAFETY_UI` / locales dictionary is missing (or has + an extra) key in some language (and that the `SAFETY_UI` bullet lists are the + same length in every language). It immediately caught `field_too_long`, which + had been English-only across all 13 other languages. +- **Safety & privacy page — ✅ done.** A standalone `/safety` page + (`safety.html` / `safety.js`), localized across all 14 languages, in plain + language: what VoxVera protects, what it does **not** (relays see your IP; the + anon key is plaintext in the browser; a short PIN won't stop a targeted + attacker; published events are public/permanent; nsec-paste risk; reports are + public; the host/relays still see requests), and operational tips (Tor/VPN, + disposable keys, signer apps). Linked from the editor and the board. Aimed at + the activist/whistleblower audience the aesthetic invites — being honest about + limits matters more than any feature. +- **Link previews / Open Graph — partial, by design.** Static site-wide OG + + Twitter-card meta are on the home, board, and safety pages, so a shared + `voxvera.org` link renders a real title/description instead of nothing. + **Per-flyer previews are a deliberate non-goal:** the poster `naddr` lives in + the URL `#fragment` (invisible to servers), and a static host can't render + per-flyer `` without server-side code — which is out of scope by choice + (this stays a free, purely static site). Revisit only if that constraint ever + changes. - **Publish transparency — ✅ done.** The publish confirmation modal now reports how many relays accepted the event ("Relays accepted: N / M") and flags a zero-acceptance publish, so a weak publish is visible immediately. diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index f498fe0..954ff5b 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -394,9 +394,10 @@ test.describe("VoxVera static client", () => { test("bulletin board link sits outside the flyer sheet and is not printed", async ({ page }) => { await page.goto("/"); - const link = page.locator(".preview-band > .board-link a"); + const link = page.locator('.preview-band > .board-link a[href="board.html"]'); await expect(link).toHaveText("Bulletin board"); - await expect(link).toHaveAttribute("href", "board.html"); + // The safety link sits alongside it, below the preview. + await expect(page.locator('.preview-band > .board-link a[href="/safety"]')).toHaveText("Safety & privacy"); // It is app chrome below the preview, not part of the printable sheet. await expect(page.locator(".container .board-link")).toHaveCount(0); // Screen-only — it must not appear in print output. @@ -1175,6 +1176,28 @@ test.describe("VoxVera static client", () => { expect(rep.content).toBe("clearly spam"); }); + test("safety page renders all sections, localizes, and is linked from editor + board", async ({ page }) => { + const errors = trackErrors(page); + await page.goto("/safety.html"); + await expect(page.locator("#safety-title")).toHaveText("Safety & privacy"); + await expect(page.locator("#safety-protects li")).toHaveCount(4); + await expect(page.locator("#safety-exposes li")).toHaveCount(7); + await expect(page.locator("#safety-tips li")).toHaveCount(5); + // The headline warning (relays see your IP) must be present. + await expect(page.locator("#safety-exposes")).toContainText("IP address"); + // Switching language localizes the content and flips direction for RTL. + await page.locator("#safety-lang").selectOption("ar"); + await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); + await expect(page.locator("#safety-title")).toHaveText("الأمان والخصوصية"); + await expect(page.locator("#safety-exposes li")).toHaveCount(7); + // Reachable from both the editor and the board (served at /safety on Vercel). + await page.goto("/"); + await expect(page.locator('.board-link a[href="/safety"]')).toHaveCount(1); + await page.goto("/board.html"); + await expect(page.locator("#board-safety")).toHaveAttribute("href", "/safety"); + expect(errors, errors.join("\n")).toHaveLength(0); + }); + test("editor imports an nsec for the session without storing the secret", async ({ page }) => { await page.goto("/#editor"); const { nsec, npub } = await page.evaluate(() => { diff --git a/e2e/i18n.spec.js b/e2e/i18n.spec.js index daf2e02..94616dc 100644 --- a/e2e/i18n.spec.js +++ b/e2e/i18n.spec.js @@ -69,6 +69,20 @@ test.describe("i18n key parity", () => { checkParity("BOARD_UI", dict); }); + test("SAFETY_UI defines every key (and list length) in all languages", () => { + const dict = evalObject(extractObject(readSite("safety.js"), "const SAFETY_UI =")); + expect(Object.keys(dict).length).toBe(14); + checkParity("SAFETY_UI", dict); + // The bullet lists must have the same number of items in every language, or + // some readers silently get fewer warnings than others. + for (const listKey of ["protects", "exposes", "tips"]) { + const refLen = dict.en[listKey].length; + for (const lang of Object.keys(dict)) { + expect(dict[lang][listKey].length, `SAFETY_UI[${lang}].${listKey} length`).toBe(refLen); + } + } + }); + test("locales labels and landing match across all languages", () => { const locales = Function(`var window={}; ${readSite("locales.js")}; return window.VoxVeraLocales;`)(); const labels = {}; diff --git a/site/board.html b/site/board.html index 013b1e7..36cb189 100644 --- a/site/board.html +++ b/site/board.html @@ -9,6 +9,15 @@ /board/... and 404. --> + + + + + + + + +
Bulletin board · Safety & privacy
diff --git a/site/nostr-client.css b/site/nostr-client.css index b1f246a..320d153 100644 --- a/site/nostr-client.css +++ b/site/nostr-client.css @@ -1638,3 +1638,59 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel { #loaded-flyer-status { color: var(--muted); } + +/* --- Safety & privacy page ------------------------------------------------ */ +.safety-main { + max-width: 760px; + margin: 0 auto; + padding: 24px 20px 64px; + line-height: 1.55; +} + +.safety-intro { + font-size: 16px; + margin: 0 0 28px; +} + +.safety-section { + margin: 0 0 28px; + padding: 16px 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--soft); +} + +.safety-section h2 { + margin: 0 0 12px; + font-size: 18px; +} + +.safety-section ul { + margin: 0; + padding-inline-start: 22px; +} + +.safety-section li { + margin: 0 0 10px; +} + +.safety-section li:last-child { + margin-bottom: 0; +} + +/* The "what it does NOT protect" block gets a warning accent so it reads as + the most important section on the page. */ +.safety-exposes { + border-color: #d9a300; + background: #fff8e6; +} + +.safety-closing { + font-style: italic; + color: var(--muted); + margin: 0; +} + +.safety-page .topbar-controls { + gap: 10px; +} diff --git a/site/nostr-client.js b/site/nostr-client.js index df1aa99..746d298 100644 --- a/site/nostr-client.js +++ b/site/nostr-client.js @@ -200,6 +200,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "معاينة", print: "اطبع المنشور", print_preview: "اطبع المعاينة", + safety_link: "الأمان والخصوصية", publish_event: "توقيع ونشر", published_event: "الحدث المنشور", share_reprint: "شارك وأعد الطباعة.", @@ -310,6 +311,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "Vorschau", print: "Flyer drucken", print_preview: "Vorschau drucken", + safety_link: "Sicherheit & Privatsphäre", publish_event: "Signieren und veröffentlichen", published_event: "Veröffentlichtes Event", share_reprint: "Teilen und neu drucken.", @@ -420,6 +422,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "Preview", print: "Print this page", print_preview: "Print preview", + safety_link: "Safety & privacy", publish_event: "Sign and publish", published_event: "Published Event", share_reprint: "Share and reprint.", @@ -530,6 +533,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "Vista previa", print: "Imprimir volante", print_preview: "Imprimir vista previa", + safety_link: "Seguridad y privacidad", publish_event: "Firmar y publicar", published_event: "Evento publicado", share_reprint: "Comparta y reimprima.", @@ -640,6 +644,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "پیشنمایش", print: "چاپ اعلامیه", print_preview: "چاپ پیشنمایش", + safety_link: "ایمنی و حریم خصوصی", publish_event: "امضا و انتشار", published_event: "رویداد منتشرشده", share_reprint: "به اشتراک بگذارید و دوباره چاپ کنید.", @@ -750,6 +755,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "Apercu", print: "Imprimer le flyer", print_preview: "Imprimer l'apercu", + safety_link: "Sécurité et confidentialité", publish_event: "Signer et publier", published_event: "Evenement publie", share_reprint: "Partagez et reimprimez.", @@ -860,6 +866,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "תצוגה מקדימה", print: "הדפס פלייר", print_preview: "הדפס תצוגה מקדימה", + safety_link: "בטיחות ופרטיות", publish_event: "חתום ופרסם", published_event: "אירוע שפורסם", share_reprint: "שתף והדפס מחדש.", @@ -970,6 +977,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "पूर्वावलोकन", print: "फ्लायर प्रिंट करें", print_preview: "पूर्वावलोकन प्रिंट करें", + safety_link: "सुरक्षा और निजता", publish_event: "हस्ताक्षर कर प्रकाशित करें", published_event: "प्रकाशित इवेंट", share_reprint: "साझा करें और फिर से प्रिंट करें।", @@ -1080,6 +1088,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "プレビュー", print: "フライヤーを印刷", print_preview: "プレビューを印刷", + safety_link: "安全とプライバシー", publish_event: "署名して公開", published_event: "公開済みイベント", share_reprint: "共有して再印刷。", @@ -1190,6 +1199,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "Previa", print: "Imprimir Panfleto", print_preview: "Imprimir previa", + safety_link: "Segurança e privacidade", publish_event: "Assinar e publicar", published_event: "Evento publicado", share_reprint: "Compartilhe e reimprima.", @@ -1300,6 +1310,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "Предпросмотр", print: "Печать листовки", print_preview: "Печать предпросмотра", + safety_link: "Безопасность и конфиденциальность", publish_event: "Подписать и опубликовать", published_event: "Опубликованное событие", share_reprint: "Поделитесь и распечатайте снова.", @@ -1410,6 +1421,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "Hakiki", print: "Chapa Kipeperushi", print_preview: "Chapa hakiki", + safety_link: "Usalama na faragha", publish_event: "Saini na chapisha", published_event: "Tukio lililochapishwa", share_reprint: "Shiriki na uchapishe tena.", @@ -1520,6 +1532,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "Önizleme", print: "İlanı Yazdır", print_preview: "Önizlemeyi yazdır", + safety_link: "Güvenlik ve gizlilik", publish_event: "İmzala ve yayınla", published_event: "Yayınlanan olay", share_reprint: "Paylaşın ve yeniden yazdırın.", @@ -1630,6 +1643,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui preview: "预览", print: "打印传单", print_preview: "打印预览", + safety_link: "安全与隐私", publish_event: "签名并发布", published_event: "已发布事件", share_reprint: "分享并重新打印。", diff --git a/site/safety.html b/site/safety.html new file mode 100644 index 0000000..cf78376 --- /dev/null +++ b/site/safety.html @@ -0,0 +1,50 @@ + + + + + +