diff --git a/README.md b/README.md index c379e01..af4b463 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. - **Author and publish.** Fill in the flyer fields and publish them as a Nostr `30078` parameterized replaceable event. Republishing with the same flyer name replaces the previous version. - **Anonymous by default, your identity if you want it.** The editor signs flyers under one of three identities: an **anonymous** per-device key (default, no setup), a **NIP-07 browser extension** (your secret never touches the page), or an **imported `nsec`**. An imported key signs in-memory for the session; tick "Remember on this device" to keep it, encrypted with a PIN (PBKDF2 → AES-GCM) — the plaintext secret is never stored. A short PIN is a convenience lock, not protection against a targeted attacker (see the [spec](docs/nostr-static-client-spec.md#publishing-identity)); the extension path is stronger. - **Publish confirmation.** After signing and publishing, a modal shows the new flyer's URL to copy, so authors know they're done and where to find it (localized, like everything else). +- **Edit, re-publish, and delete your flyers.** Because flyers are replaceable events, the author can edit one in place (same key + Flyer Name keeps the same URL), re-publish it as a keep-alive so relays don't age it out, or delete it. Delete publishes a replaceable **tombstone** (so VoxVera hides it even where relays ignore deletions) **and** a NIP-09 deletion request — best-effort, author-only. - **Open from a link or ID.** Load a flyer from an `naddr`, `nevent1`, `note1`, raw 64-char event ID, or `nostr:` URL. The lookup field tolerates copy/paste whitespace and newlines, so an event ID copied off a printed tear-off (where it wraps across two lines) still resolves. - **Short, scannable poster URLs.** Each poster URL is a standard NIP-19 `naddr` carrying a single relay hint, placed in the URL `#fragment` (no `?addr=` query). Shorter URLs make the tear-off QR code easier to scan. Older relay-list `naddr`s and `?addr=` URLs still resolve. - **Tear-off tabs.** The printable flyer has ten tear-off tabs. Each tab is a single hyperlinked call to action (the URL is the link, not printed as text) and prints the full event ID so anyone can re-find the flyer by typing it. diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index 414ffd5..6aecd0d 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -162,6 +162,36 @@ but longer PINs are allowed and encouraged. > stronger option; a NIP-46 remote signer is a future addition. 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:`), 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::voxvera:`) 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. + ## Print paper size The flyer supports two print sizes: **US Letter** (8.5×11in) and **A4** diff --git a/docs/roadmap.md b/docs/roadmap.md index 81b70a7..fea249c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -21,6 +21,11 @@ The trust model (#1–#2) has moved from undefined to implemented: - **Editor publishing identity** — authors can publish anonymously (default), via NIP-07, or with an imported `nsec` that is optionally PIN-encrypted at rest (PBKDF2 → AES-GCM; plaintext never stored). +- **Flyer management (edit / re-publish / delete)** — an author can edit a + loaded flyer (re-publish replaces in place via the `d` tag), re-publish as a + keep-alive, or delete it. Delete is belt-and-suspenders: a replaceable + **tombstone** (so the board/viewer hide it even on relays that ignore NIP-09) + plus a **NIP-09** deletion request. Best-effort, author-only. All of the above is localized across the 14 languages and covered by the cross-engine Playwright suite. **What remains** on #1–#2 is polish (degree-2 @@ -204,7 +209,11 @@ of mirrors is unsolved, so in practice users depend on the canonical domain. - Publish the client itself to a content-addressed network so the front door is not a single DNS name. -## 4. Relay permanence is overstated — **Open** +## 4. Relay permanence is overstated — **Partial** + +> Partial mitigation shipped: the editor now has a **Re-publish (keep-alive)** +> action, so an author can refresh a flyer before relays age it out. The durable +> fix (a pinned/dedicated relay) is still open. **Problem.** The app depends on a few general-purpose relays (`relay.damus.io`, `nos.lol`, `relay.primal.net`) that can prune app-data kinds, diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index 2e1f41c..1c86ded 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -636,6 +636,88 @@ test.describe("VoxVera static client", () => { expect(naddrPk).toBe(PK); }); + test("deleting a loaded flyer publishes a tombstone and a NIP-09 deletion", async ({ page }) => { + const PK = "a".repeat(64); + await page.addInitScript((pk) => { + let n = 0; + window.nostr = { + getPublicKey: async () => pk, + signEvent: async (e) => ({ ...e, pubkey: pk, id: (++n).toString(16).padStart(64, "0"), sig: "0".repeat(128) }) + }; + window.__published = []; + class FakeWS { + constructor() { this.readyState = 1; setTimeout(() => this.onopen && this.onopen(), 1); } + send(data) { + try { + const m = JSON.parse(data); + if (m[0] === "EVENT" && m[1] && m[1].id) { + window.__published.push(m[1]); + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", m[1].id, true, ""]) }), 1); + } + } catch (_) {} + } + close() {} + } + window.WebSocket = FakeWS; + }, PK); + await page.goto("/#editor"); + await page.locator("#connect-nip07").click(); + await expect(page.locator("#signer-state")).toHaveText("Publishing as your extension identity"); + // Publish so a flyer is "loaded" and the delete/re-publish actions appear. + await page.locator("#publish-event").click(); + await expect(page.locator("#publish-modal")).toBeVisible(); + await page.locator("#publish-modal-close").click(); + await expect(page.locator("#loaded-flyer-actions")).toBeVisible(); + // Delete → confirm. + await page.locator("#delete-flyer").click(); + await expect(page.locator("#delete-modal")).toBeVisible(); + await page.locator("#delete-modal-confirm").click(); + await expect(page.locator(".flyer-status-message")).toContainText("This flyer was removed"); + const pub = await page.evaluate(() => window.__published); + const tombstone = pub.find((e) => e.kind === 30078 && (e.tags || []).some((t) => t[0] === "deleted")); + const deletion = pub.find((e) => e.kind === 5); + expect(tombstone, "a replaceable tombstone is published").toBeTruthy(); + expect(JSON.parse(tombstone.content).deleted).toBe(true); + expect(deletion, "a NIP-09 deletion request is published").toBeTruthy(); + expect((deletion.tags || []).some((t) => t[0] === "a" && t[1].startsWith(`30078:${PK}:voxvera:`))).toBe(true); + }); + + test("the board hides a flyer that has been tombstoned", async ({ page }) => { + const A = "a".repeat(64); + await stubNip07(page); + await stubRelays(page, [ + flyerEvent("live", A, "Live Flyer"), // d=voxvera:live, created_at 2000 + { + id: "9".repeat(64), kind: 30078, pubkey: A, created_at: 3000, + tags: [["d", "voxvera:live"], ["t", "voxvera"], ["deleted", ""]], + content: JSON.stringify({ type: "voxvera_flyer", version: 1, deleted: true, folder_name: "live", lang: "en" }) + } + ]); + await page.goto("/board.html"); + await page.locator("#board-connect").click(); + await expect(page.locator("#board-content")).toBeVisible(); + // The newer tombstone hides the flyer even though both are returned. + await expect(page.locator("#board-rows")).not.toContainText("Live Flyer"); + }); + + test("viewer shows a removed state for a tombstoned flyer", async ({ page }) => { + const PK = "c".repeat(64); + const IDENT = "voxvera:gone"; + await stubRelays(page, [{ + id: "d".repeat(64), kind: 30078, pubkey: PK, created_at: 5000, + tags: [["d", IDENT], ["t", "voxvera"], ["deleted", ""]], + content: JSON.stringify({ type: "voxvera_flyer", version: 1, deleted: true, folder_name: "gone", lang: "en" }) + }]); + await page.goto("/"); + const naddr = await page.evaluate(({ pk, ident }) => + window.NostrTools.nip19.naddrEncode({ identifier: ident, pubkey: pk, kind: 30078, relays: [] }), + { pk: PK, ident: IDENT }); + await page.goto("about:blank"); + await page.goto(`/#${naddr}`); + await expect(page.locator(".flyer-status-message")).toContainText("This flyer was removed"); + await expect(page.locator(".content h1")).toHaveCount(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/site/board.js b/site/board.js index 9361118..fb17c55 100644 --- a/site/board.js +++ b/site/board.js @@ -157,22 +157,39 @@ }); } + // A tombstone is the deletion marker a flyer is replaced with (same d-tag): + // payload.deleted, or a ["deleted"] tag. The latest version winning means a + // tombstone hides the flyer even on relays that ignore NIP-09 deletions. + function isTombstone(event, payload) { + if (payload && payload.deleted === true) return true; + const tags = (event && event.tags) || []; + return tags.some((x) => x[0] === "deleted"); + } + // Reduce raw events to the latest flyer per author+identifier (replaceable). + // Keep the newest event per key first (including tombstones), then drop any + // key whose newest version is a deletion — so a delete can't be undone by an + // older copy that happens to arrive after it. function parseFlyers(events) { const tools = window.NostrTools; - const byKey = new Map(); + const latest = new Map(); events.forEach((e) => { if (!e || e.kind !== EVENT_KIND) return; const tags = e.tags || []; if (!tags.some((x) => x[0] === "t" && x[1] === "voxvera")) return; + const key = e.pubkey + ":" + dtagOf(tags); + const prev = latest.get(key); + if (!prev || (e.created_at || 0) > (prev.created_at || 0)) latest.set(key, e); + }); + const out = []; + latest.forEach((e) => { let payload; try { payload = JSON.parse(e.content); } catch (_) { return; } - const key = e.pubkey + ":" + dtagOf(tags); - const prev = byKey.get(key); - if (prev && prev.created_at >= (e.created_at || 0)) return; + if (isTombstone(e, payload)) return; + const tags = e.tags || []; let npub = e.pubkey; try { npub = tools.nip19.npubEncode(e.pubkey); } catch (_) {} - byKey.set(key, { + out.push({ title: String(payload.title || "").trim(), link: String(payload.tear_off_link || payload.url || "").trim(), npub: npub, @@ -182,7 +199,7 @@ created_at: e.created_at || 0 }); }); - return Array.from(byKey.values()); + return out; } let rows = []; diff --git a/site/index.html b/site/index.html index cdf6c12..b31a9fa 100644 --- a/site/index.html +++ b/site/index.html @@ -135,6 +135,12 @@ + + +