Changelog 93bbfcf
Track survey deletion by relay identity and preserve retries (#181)
6 files of code, design or docs +282 −96 lines
Code, design and documents 6 files
docs/DATA-MAINTENANCE.md +10 −0
@@ -1088,4 +1088,14 @@ not to the person. While `open` is false, or no public key and relays are set, n and submissions wait in the browser. +**Deletion retries.** Store relay URLs before publishing, including possible destinations after a +timeout, and keep holders of older versions when editing. Counts are for display only. A deletion +reuses a saved, newer empty replacement and accumulates its ACKs by relay URL across retries and +reloads; an ACK for the NIP-09 request alone cannot confirm replacement. Retired configured relays +are still deletion targets. Count-only legacy submissions have unknown destinations and stay +unconfirmed. Sync reports real end-of-stored-events separately from connection/timeout failures, +and never prunes a pending deletion merely because a query returns nothing. Even a relay ACK +confirms delivery, not physical erasure of historical copies. `npm run survey:test` covers swapped +relay ACKs, offline sync, partial retries, legacy state and older remote versions (#181). + **To try it before opening.** Run a relay of your own, then in the browser's console set `localStorage["cfi-survey-test"] = JSON.stringify({pubkey: "<hex>", relays: ["ws://localhost:7447"]})`.
docs/research/issue-corrections-181-211-214-2026-10-27.md +12 −0
@@ -0,0 +1,12 @@ +# Issue corrections — 27 October 2026 + +Read DATA-MAINTENANCE, WRITING, README and the scoring rubric; kept unrelated +concurrent history/archive work outside these fixes. One commit per issue. + +## #181 — Survey deletion follows relay identities and keeps retries + +Read the survey maintenance notes, browser client, take/me handlers and installed nostr-tools pool/relay implementation. The pool's querySync resolves on close and its pool-level EOSE also covers failures; neither is sufficient evidence of a complete empty snapshot. Queries now use individual relay subscriptions with an outer deadline earlier than the library's synthetic EOSE timeout, reporting completion separately from failure. + +Publishing returns accepted and attempted relay URLs. Before transmission, local storage records possible destinations; timeouts and reloads cannot erase them. Edits retain old holders and unknown identities from legacy count-only records. Deletion saves and reuses one strictly newer empty replacement, requires that replacement's ACK from every known/possible destination, accumulates per-relay progress across retries, and never accepts a NIP-09 request's ACK as replacement delivery. Retired relay URLs remain deletion targets. Count-only legacy records cannot be fully confirmed and remain pending with an explanation. + +Sync uses the tested mergeMine helper in the actual me-page handler: an offline, partial or complete empty query cannot remove pending records. A tombstone on one relay cannot hide a visible old response on another; a newer remote edit invalidates older deletion ACKs. The form's removed-report path uses the same retry state. Same-page mutations are serialised. Existing caveats about relays retaining historical encrypted copies remain; this confirms delivery rather than physical erasure. Tests execute the actual page's sync branch and exercise swapped ACKs, identity migration, persistence, partial retries, query failures, stale copies and strict replacement timestamps. No real survey responses or keys were transmitted during testing.
scripts/survey.test.mjs +100 −11
@@ -9,5 +9,5 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import { generateSecretKey, finalizeEvent, verifyEvent } from "nostr-tools/pure"; -import { relayReason, deletionOutcome } from "../src/lib/survey-client.js"; +import { relayReason, deletionOutcome, publish, withRelayAttempt, withRelayResult, hasRemoteCopy, deletionRelays, applyDeletionOutcome, fetchRelay, mergeMine, mineFromRelays, startDeletion } from "../src/lib/survey-client.js"; const require = createRequire(import.meta.url); @@ -27,14 +27,103 @@ test("a relay's reason is cleaned text, and no survey page puts a relay error in }); -test("a deletion no relay confirmed stays pending, and only confirmed ones are done (#140)", () => { - const gone = [{ id: "a", relays: 3 }, { id: "b", relays: 2 }, { id: "c", relays: 0 }]; - // offline: no relay accepted anything - assert.deepEqual(deletionOutcome(gone, [{ relays: 0 }, { relays: 0 }, { relays: 0 }]), { done: ["c"], pending: [{ id: "a", reached: 0, of: 3 }, { id: "b", reached: 0, of: 2 }] }); - // partial: a reached every relay that held it, b only one of two - assert.deepEqual(deletionOutcome(gone, [{ relays: 3 }, { relays: 1 }, { relays: 0 }]), { done: ["a", "c"], pending: [{ id: "b", reached: 1, of: 2 }] }); - // the page uses the outcome and no longer reports success unconditionally - const me = fs.readFileSync("src/pages/survey/me.astro", "utf8"); - assert.match(me, /client\.deletionOutcome\(/); - assert.doesNotMatch(me, /cannot be brought back|for good/); +const A="wss://a.test/", B="wss://b.test/"; + +test("deletion requires the same relay identities, never an equal ACK count (#140, #181)", () => { + const gone = [{ id: "a", relays: 1, relayUrls: [A] }, { id: "b", relays: 2, relayUrls: [A,B] }, { id: "local", relays: 0 }]; + const swapped=deletionOutcome(gone,[{relays:1,acceptedRelays:[B]}, {relays:1,acceptedRelays:[A]}]); + assert.deepEqual(swapped.done,["local"]); + assert.equal(swapped.pending[0].reached,0); + assert.equal(swapped.pending[1].reached,1); + assert.deepEqual(deletionOutcome([gone[0]],[{acceptedRelays:[A]}]).done,["a"]); + // The last result is a NIP-09 request, not an ACK for a replacement. + assert.deepEqual(deletionOutcome([gone[0]],[{acceptedRelays:[]},{acceptedRelays:[A]}]).done,[]); +}); + +test("legacy count-only records remain unconfirmed even when today's relays ACK (#181)", () => { + const legacy={id:"old",relays:1}; + const result=deletionOutcome([legacy],[{acceptedRelays:[A,B]}],{relays:[A,B]}); + assert.deepEqual(result.done,[]); + assert.equal(result.pending[0].unknown,true); + assert.equal(hasRemoteCopy({relays:[]},legacy),true); +}); + +test("publish records exactly which relay accepted, and normalises duplicate URLs (#181)", async () => { + let closed; + const pool={publish: (destinations) => { + assert.deepEqual(destinations,[A,B]); + return destinations.map(url=>url===A ? Promise.reject(new Error('no')) : Promise.resolve('saved')); + },close: dest=>{closed=dest;}}; + const [sent]=await publish({relays:[A,B,'wss://a.test']},[{id:'event'}],{pool}); + assert.equal(sent.relays,1);assert.deepEqual(sent.acceptedRelays,[B]); + assert.deepEqual(sent.attemptedRelays,[A,B]);assert.deepEqual(closed,[A,B]); +}); + +test("attempted destinations and older holders survive edits, failed writes and reloads (#181)", () => { + const cfg={relays:[B]},old={id:'a',relays:1,relayUrls:[A]}; + const attempted=withRelayAttempt(cfg,{id:'a',relays:0},old); + const saved=JSON.parse(JSON.stringify(withRelayResult(attempted,{relays:0,acceptedRelays:[],attemptedRelays:[B]}))); + assert.equal(hasRemoteCopy(cfg,saved),true); + assert.deepEqual(deletionRelays(cfg,[saved]),[B,A]); + assert.deepEqual(deletionOutcome([saved],[{acceptedRelays:[B]}],cfg).done,[]); +}); + +test("partial deletion ACKs survive reload and a retry to the remaining relay (#181)", () => { + const item={id:'a',relays:2,relayUrls:[A,B],deleting:{replacement:{id:'tombstone'},acceptedRelays:[]}}; + assert.deepEqual(deletionOutcome([item],[{id:'different-event',acceptedRelays:[A,B]}]).done,[]); + const partial=applyDeletionOutcome({items:[item]},deletionOutcome([item],[{id:'tombstone',acceptedRelays:[A]}])); + const reloaded=JSON.parse(JSON.stringify(partial)); + assert.deepEqual(reloaded.items[0].deleting.replacement,{id:'tombstone'}); + assert.deepEqual(deletionOutcome(reloaded.items,[{id:'tombstone',acceptedRelays:[B]}]).done,['a']); +}); + +test("relay snapshots distinguish EOSE from connection failures, CLOSED and timeout (#181)", async () => { + const poolFor=action=>({ensureRelay:async()=>({subscribe:(_,callbacks)=>{queueMicrotask(()=>action(callbacks));}}),close:()=>{}}); + assert.equal((await fetchRelay(A,{}, {pool:poolFor(c=>c.oneose()),timeout:10})).ok,true); + assert.equal((await fetchRelay(A,{}, {pool:poolFor(c=>c.onclose('denied')),timeout:10})).ok,false); + assert.equal((await fetchRelay(A,{}, {pool:poolFor(()=>{}),timeout:5})).ok,false); + assert.equal((await fetchRelay(A,{}, {pool:{ensureRelay:async()=>{throw Error('offline');},close:()=>{}},timeout:10})).ok,false); +}); + +test("offline, partial and empty complete sync keep pending records in the actual page merge (#181)", async () => { + const item={id:'a',relays:1,relayUrls:[A],event:{id:'original',created_at:10},deleting:{replacement:{id:'empty',created_at:11},acceptedRelays:[],relayUrls:[A],reached:0,of:1}}; + const me=fs.readFileSync('src/pages/survey/me.astro','utf8'); + const branch=me.match(/else if \(act === "sync"\) \{([\s\S]*?)\} else if \(act === "backup"\)/)[1]; + const runSync=new (Object.getPrototypeOf(async function(){}).constructor)('client','cfg','draw','say',branch); + for(const relays of [[{url:A,ok:false}],[{url:A,ok:false},{url:B,ok:true}],[{url:A,ok:true}]]) { + let stored=JSON.parse(JSON.stringify({items:[item]})); + const fake={fetchMine:async()=>({items:[],relays}),mine:()=>stored,mergeMine,remember:(_,value)=>{stored=JSON.parse(JSON.stringify(value));return true;}}; + await runSync(fake,{relays:[A,B]},()=>{},()=>{}); + assert.deepEqual(stored.items,[item]); + } +}); + +test("sync keeps holders of old versions, including when a tombstone is only on another relay (#181)", () => { + const cfg={edition:2026},original={id:'a',created_at:10,tags:[['d','test:response']],content:JSON.stringify({edition:2026,type:'response',answers:{residence:'DE'}})}; + const edited={...original,id:'b',created_at:11}; + const open=e=>JSON.parse(e.content); + const snapshot=mineFromRelays(cfg,[{url:A,ok:true,events:[original]},{url:B,ok:true,events:[edited]}],open); + assert.deepEqual(snapshot.items[0].relayUrls,[A,B]); + assert.equal(snapshot.items[0].event.id,'b'); + const tombstone={...edited,id:'deleted',content:JSON.stringify({edition:2026,type:'response',deleted:true})}; + const partial=mineFromRelays(cfg,[{url:A,ok:true,events:[original]},{url:B,ok:true,events:[tombstone]}],open); + assert.equal(partial.items[0].deleting.replacement.id,'deleted'); + assert.deepEqual(partial.items[0].deleting.acceptedRelays,[B]); + assert.deepEqual(deletionOutcome(partial.items,[{id:'deleted',acceptedRelays:[A]}]).done,['response']); +}); + +test("a newer edit invalidates earlier deletion ACKs; replacements are strictly newer (#181)", () => { + const cfg={relays:[A]},item={id:'a',event:{id:'old',created_at:10},relays:1,relayUrls:[A],deleting:{replacement:{id:'tombstone',created_at:11},acceptedRelays:[A]}}; + const found={...item,deleting:undefined,event:{id:'new',created_at:12}}; + const merged=mergeMine(cfg,{items:[item]},{items:[found],relays:[]}); + assert.equal(merged.items[0].deleting.replacement,null); + assert.deepEqual(merged.items[0].deleting.acceptedRelays,[]); + const previousStorage=globalThis.localStorage,storage=new Map(); + globalThis.localStorage={getItem:k=>storage.get(k)??null,setItem:(k,v)=>storage.set(k,v)}; + try { + const future=Math.floor(Date.now()/1000)+1; + const pending=startDeletion({kind:30078,edition:2026,topic:'test',relays:[A]},{type:'response',id:'response',event:{created_at:future},relayUrls:[A]}); + assert.equal(pending.deleting.replacement.created_at,future+1); + assert.equal(startDeletion(cfg,pending).deleting.replacement.id,pending.deleting.replacement.id); + } finally {globalThis.localStorage=previousStorage;} });
src/lib/survey-client.js +112 −48
@@ -13,4 +13,5 @@ import { encrypt, decrypt, getConversationKey } from "nostr-tools/nip44"; import { nsecEncode, npubEncode, naddrEncode, decode } from "nostr-tools/nip19"; import { SimplePool } from "nostr-tools/pool"; +import { normalizeURL } from "nostr-tools/utils"; const KEY = "cfi-survey-key"; @@ -57,5 +58,6 @@ export const forget = () => set(KEY, null); // ── What this browser knows about its own submissions ── -// { items: [{ type: "response" | "observation" | "price", id, answers, updated, event, relays }] } +// `relays` is a display count; `relayUrls` keeps known/possible destinations across versions. +// `deleting` persists the replacement and per-relay ACKs until delivery is confirmed everywhere. const storeKey = (cfg) => `cfi-survey-${cfg.edition}-mine`; export function mine(cfg) { try { return JSON.parse(get(storeKey(cfg)) ?? "null") ?? { items: [] }; } catch { return { items: [] }; } } @@ -73,5 +75,5 @@ export function sign(cfg, item, removed = false) { const tags = [["d", dTag(cfg, item)], ["t", cfg.topic], ["alt", `State of Cannabis ${cfg.edition}: an anonymous survey answer`]]; if (cfg.pubkey) tags.push(["p", cfg.pubkey]); - return finalizeEvent({ kind: cfg.kind, created_at: Math.floor(Date.now() / 1000), tags, content: cfg.pubkey ? encrypt(plain, getConversationKey(sk, cfg.pubkey)) : plain }, sk); + return finalizeEvent({ kind: cfg.kind, created_at: Math.max(Math.floor(Date.now() / 1000), (item.event?.created_at ?? 0) + 1), tags, content: cfg.pubkey ? encrypt(plain, getConversationKey(sk, cfg.pubkey)) : plain }, sk); } // a deletion request (NIP-09) for events of ours, for relays that honour it @@ -93,17 +95,32 @@ export function reference(cfg, item) { // ── Relays ── -const within = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(() => no(new Error("timed out")), ms))]); -// Offers every event to every relay; returns for each event how many relays stored it. -export async function publish(cfg, events) { - const pool = new SimplePool(); +const within = async (p, ms) => { + let timer; + try { return await Promise.race([p, new Promise((_, no) => { timer = setTimeout(() => no(new Error("timed out")), ms); })]); } + finally { clearTimeout(timer); } +}; +const urls = (...lists) => [...new Set(lists.flat().filter(Boolean).map(normalizeURL))]; +const unknownRelays = (item) => !!(item.relayUnknown || item.deleting?.unknown || (item.relays > 0 && !Array.isArray(item.relayUrls))); +export const relayTargets = (cfg, item) => urls(item.relayUrls ?? [], item.deleting?.relayUrls ?? [], unknownRelays(item) ? cfg.relays : []); +export const hasRemoteCopy = (cfg, item) => unknownRelays(item) || relayTargets(cfg, item).length > 0; +// Save possible destinations BEFORE sending: a timeout or a reload can hide a successful write. +// Old count-only records have unknown destinations and cannot be confirmed deleted automatically. +export const withRelayAttempt = (cfg, item, previous = item) => ({ ...item, relayUrls: urls(relayTargets(cfg, previous), cfg.relays), relayUnknown: unknownRelays(previous) }); +export const withRelayResult = (item, sent) => ({ ...item, relays: sent.relays, relayUrls: urls(item.relayUrls ?? [], sent.attemptedRelays, sent.acceptedRelays) }); +export const deletionRelays = (cfg, items) => urls(cfg.relays, ...items.map((i) => relayTargets(cfg, i))); + +// Offers every event to every configured relay. Counts are for display; identities decide deletion. +export async function publish(cfg, events, { pool = new SimplePool(), timeout = 8000 } = {}) { + const destinations = urls(cfg.relays); try { return await Promise.all(events.map(async (event) => { - const results = await Promise.allSettled(pool.publish(cfg.relays, event).map((p) => within(p, 8000))); - return { id: event.id, relays: results.filter((r) => r.status === "fulfilled").length, error: results.find((r) => r.status === "rejected")?.reason?.message ?? null }; + const results = await Promise.allSettled(pool.publish(destinations, event).map((p) => within(p, timeout))); + const acceptedRelays = destinations.filter((_, k) => results[k].status === "fulfilled"); + return { id: event.id, relays: acceptedRelays.length, acceptedRelays, attemptedRelays: destinations, + error: results.find((r) => r.status === "rejected")?.reason?.message ?? null }; })); - } finally { pool.close(cfg.relays); } + } finally { pool.close(destinations); } } -// A relay's reason for refusing an event is free text written by the relay (NIP-01), not by this -// site: shown only as text (never as HTML), on one line and short. +// A relay's reason is untrusted text, never HTML. export const relayReason = (sent) => { const text = sent.find((r) => r.error)?.error; @@ -111,51 +128,98 @@ export const relayReason = (sent) => { }; -// Deleting: an empty replacement is signed for each part, and published with one deletion request -// (NIP-09). A part counts as deleted only when its replacement reached at least as many relays as -// the part was stored on; a part that was never sent has nothing to delete there. Every other part -// stays, marked as a pending deletion, so it can be tried again: nothing is forgotten on the -// strength of a request that no relay took. Relays may still keep older copies, which stay -// encrypted; a deletion request is a request, and the page says so. -// gone: the parts to delete; sent: publish()'s results, the replacements in the same order first. -export function deletionOutcome(gone, sent) { +// Reuse the same replacement across retries; ACKs accumulated across reloads concern this event. +export function startDeletion(cfg, item) { + const relayUrls = relayTargets(cfg, item); + if (item.deleting?.replacement) return { ...item, deleting: { ...item.deleting, relayUrls, of: relayUrls.length } }; + return { ...item, deleting: { relayUrls, unknown: unknownRelays(item), acceptedRelays: [], + reached: 0, of: relayUrls.length, replacement: sign(cfg, item, true) } }; +} +// Only an ACK for the replacement from EACH possible holder counts. NIP-09 ACKs alone never do. +export function deletionOutcome(gone, sent, cfg = { relays: [] }) { const done = [], pending = []; - gone.forEach((i, k) => { - const reached = i.relays > 0 ? sent[k]?.relays ?? 0 : 0; - if (!(i.relays > 0) || reached >= i.relays) done.push(i.id); - else pending.push({ id: i.id, reached, of: i.relays }); + gone.forEach((item, k) => { + const relayUrls = relayTargets(cfg, item), unknown = unknownRelays(item); + const result = sent[k], replacement = item.deleting?.replacement; + const acceptedRelays = urls(item.deleting?.acceptedRelays ?? [], !replacement || result?.id === replacement.id ? result?.acceptedRelays ?? [] : []).filter((u) => relayUrls.includes(u)); + if (!unknown && acceptedRelays.length === relayUrls.length) done.push(item.id); + else pending.push({ ...item.deleting, id: item.id, relayUrls, acceptedRelays, unknown, reached: acceptedRelays.length, of: relayUrls.length }); }); return { done, pending }; } +export function applyDeletionOutcome(store, { done, pending }) { + const waiting = new Map(pending.map(({ id, ...state }) => [id, state])); + return { ...store, items: store.items.filter((i) => !done.includes(i.id)).map((i) => waiting.has(i.id) ? { ...i, deleting: waiting.get(i.id) } : i) }; +} -// What the relays hold under this browser's key, decrypted: the newest version of each part, and -// on how many relays that version sits. Each relay is asked by itself, so that one slow or -// unreachable relay neither hides the others nor inflates the count. +// querySync treats a closed/timed-out subscription like EOSE. Use a single relay subscription, +// with our deadline earlier than the library's synthetic EOSE timeout, to distinguish real EOSE. +export async function fetchRelay(url, filter, { pool = new SimplePool(), timeout = 8000 } = {}) { + const events = []; + try { + const relay = await within(pool.ensureRelay(url, { connectionTimeout: timeout }), timeout); + await within(new Promise((yes, no) => relay.subscribe([filter], { + onevent: (event) => events.push(event), oneose: yes, + onclose: (reason) => no(new Error(reason)), eoseTimeout: timeout * 2, + })), timeout); + return { url: normalizeURL(url), ok: true, events }; + } catch (e) { return { url: normalizeURL(url), ok: false, error: String(e.message ?? e), events }; } + finally { pool.close([url]); } +} +const newer = (a, b) => !b || a.created_at > b.created_at || (a.created_at === b.created_at && a.id < b.id); +// Keep holders of older versions too: a newer response on B does not erase the copy on A. +export function mineFromRelays(cfg, perRelay, open) { + const slots = new Map(); + for (const { url, events } of perRelay) for (const event of events) { + const d = event.tags.find((t) => t[0] === "d")?.[1]; + if (!d) continue; + const slot = slots.get(d) ?? new Map(); + const relayUrl = normalizeURL(url); + if (newer(event, slot.get(relayUrl))) slot.set(relayUrl, event); + slots.set(d, slot); + } + const items = []; + for (const [d, versions] of slots) { + try { + const all = [...versions].map(([url, event]) => ({ url, event, p: open(event) })).filter(({ p }) => p.edition === cfg.edition); + const latest = all.reduce((best, v) => newer(v.event, best?.event) ? v : best, null); + if (!latest) continue; + // A tombstone on B must not hide an older response still visible on A after key restore. + const live = latest.p.deleted ? all.filter(({ p }) => !p.deleted).reduce((best, v) => newer(v.event, best?.event) ? v : best, null) : latest; + if (!live) continue; + const { event, p } = live, relayUrls = urls(all.map((v) => v.url)); + const id = p.type === "response" ? "response" : d.split(/:(?:place|price):/)[1]; + if (!id || !["response", "observation", "price"].includes(p.type)) continue; + const deleting = latest.p.deleted ? { relayUrls, unknown: false, replacement: latest.event, + acceptedRelays: all.filter((v) => v.event.id === latest.event.id).map((v) => v.url), + reached: all.filter((v) => v.event.id === latest.event.id).length, of: relayUrls.length } : null; + items.push({ type: p.type, id, answers: p.answers, updated: event.created_at, event, relays: relayUrls.length, relayUrls, ...(deleting ? { deleting } : {}) }); + } catch {} + } + return { items, relays: perRelay.map(({ events, ...status }) => status) }; +} export async function fetchMine(cfg) { const sk = secret(); - if (!sk || !cfg.open) return []; + if (!sk || !cfg.open) return { items: [], relays: [] }; + const known = mine(cfg).items.flatMap((i) => relayTargets(cfg, i)); const filter = { kinds: [cfg.kind], authors: [getPublicKey(sk)], "#t": [cfg.topic] }; - const perRelay = await Promise.all(cfg.relays.map(async (url) => { - const pool = new SimplePool(); - try { return await within(pool.querySync([url], filter), 8000); } catch { return []; } finally { pool.close([url]); } … 40 more lines
src/pages/survey/me.astro +33 −27
@@ -94,5 +94,5 @@ const regions = Object.fromEntries(countries.filter((c) => subunitsOf(c.id)).map <p class="sv-meta">${where(i)} · changed ${day(i.updated)}</p> ${i.past ? `<p class="sv-small">From the ${i.past} edition, which is closed. It is kept here to read.</p>` - : i.deleting ? `<p class="sv-small">Deleting this was not confirmed: ${i.deleting.reached} of the ${i.deleting.of} relays that held it accepted the deletion. It stays listed here until a deletion goes through.</p><p class="sv-actions"><button type="button" class="go solid" data-do="delete" data-id="${esc(i.id)}">Try deleting again</button></p>` + : i.deleting ? `<p class="sv-small">The empty replacement reached ${i.deleting.reached} of ${i.deleting.of} known or possible relays. ${i.deleting.unknown ? "This older submission did not save relay identities, so deletion cannot be fully confirmed. " : ""}It stays listed here for retrying. Relay acknowledgements do not prove that historical copies were erased.</p><p class="sv-actions"><button type="button" class="go solid" data-do="delete" data-id="${esc(i.id)}">Try deleting again</button></p>` : `<p class="sv-actions"><a class="go solid" href="../take/${i.type === "response" ? "#edit" : `#${KIND[i.type].hash}-${esc(i.id)}`}">Edit ${KIND[i.type].this} →</a> <button type="button" class="go" data-do="delete" data-id="${esc(i.id)}">Delete</button></p>`} <dl class="sv-d-list">${Object.entries(i.answers).map(([id, v]) => `<div><dt>${esc(Q[id]?.text ?? id)}</dt><dd>${words(id, v, i.answers)}</dd></div>`).join("")}</dl> @@ -161,33 +161,35 @@ const regions = Object.fromEntries(countries.filter((c) => subunitsOf(c.id)).map const say = (text) => { const m = document.getElementById("meMsg") ?? status; m.hidden = false; m.textContent = text; }; - // A part is forgotten here only once its deletion is confirmed (client.deletionOutcome); one that - // no relay, or too few, accepted stays listed as a pending deletion, to try again. + // Save retry state before contacting relays; compare destination identities, never totals. async function remove(ids) { - const store = client.mine(cfg), gone = store.items.filter((i) => ids.includes(i.id)); - const online = gone.filter((i) => i.relays > 0); - const done = gone.filter((i) => !(i.relays > 0)).map((i) => i.id); - let pending = []; + let store = client.mine(cfg); + store = { ...store, items: store.items.map((i) => ids.includes(i.id) && client.hasRemoteCopy(cfg, i) ? client.startDeletion(cfg, i) : i) }; + if (!client.remember(cfg, store)) throw new Error("this browser could not save deletion retry state"); + const gone = store.items.filter((i) => ids.includes(i.id)); + const online = gone.filter((i) => client.hasRemoteCopy(cfg, i)); + let outcome = client.deletionOutcome(gone, [], cfg); if (online.length && cfg.open) { say("Deleting on the relays…"); - // an empty replacement overwrites each part; the deletion request is for relays that honour it - const sent = await client.publish(cfg, [...online.map((i) => client.sign(cfg, i, true)), client.deletion(cfg, online.map((i) => i.event))]); - const outcome = client.deletionOutcome(online, sent); - done.push(...outcome.done); - pending = outcome.pending; - } else pending = online.map((i) => ({ id: i.id, reached: 0, of: i.relays })); - const waiting = new Map(pending.map((p) => [p.id, p])); - client.remember(cfg, { items: store.items.filter((i) => !done.includes(i.id)).map((i) => (waiting.has(i.id) ? { ...i, deleting: { reached: waiting.get(i.id).reached, of: waiting.get(i.id).of } } : i)) }); + const sent = await client.publish({ ...cfg, relays: client.deletionRelays(cfg, online) }, [...online.map((i) => i.deleting.replacement), client.deletion(cfg, online.map((i) => i.event))]); + const remote = client.deletionOutcome(online, sent, cfg); + outcome = { done: [...new Set([...outcome.done, ...remote.done])], pending: remote.pending }; + } + const { done, pending } = outcome; + if (!client.remember(cfg, client.applyDeletionOutcome(store, outcome))) throw new Error("this browser could not save the result; the pending retry is kept"); draw(); const parts = (n) => `${n} part${n === 1 ? "" : "s"}`; if (!pending.length) say(`Deleted ${parts(done.length)}. Relays that honour deletion requests remove ${done.length === 1 ? "it" : "them"}; one that does not may keep an encrypted copy.`); - else if (!done.length) say(`Not deleted: no relay confirmed the deletion of ${parts(pending.length)}. Nothing was removed from this list; try again later.`); - else say(`Deleted ${parts(done.length)}. ${parts(pending.length)} could not be confirmed on every relay that held ${pending.length === 1 ? "it" : "them"} and ${pending.length === 1 ? "stays" : "stay"} listed, marked, so you can try again.`); + else if (!done.length) say(`Deletion is not fully confirmed for ${parts(pending.length)}. The pending records are kept; try again later. Older records may have unknown relay identities.`); + else say(`Deleted ${parts(done.length)}. ${parts(pending.length)} remain pending because delivery to all original or possible relays is unconfirmed.`); } root.addEventListener("click", (e) => { const r = e.target.closest("[data-open]"); if (r) open(r.dataset.open); }); + let changing = false; document.addEventListener("click", async (e) => { const b = e.target.closest("[data-do]"); if (!b) return; const act = b.dataset.do; + if (changing) return; + changing = true; try { if (act === "delete") { if (confirm("Delete this? It is replaced with an empty version and every relay is asked to remove it. Relays that honour the request remove it; one that does not may keep an encrypted copy.")) { drawer.close(); await remove([b.dataset.id]); } } @@ -198,7 +200,8 @@ const regions = Object.fromEntries(countries.filter((c) => subunitsOf(c.id)).map say("Sending…"); // sign again, so the message carries today's date and the open survey's settings - for (const i of waiting) { i.event = client.sign(cfg, i); i.updated = i.event.created_at; } + for (const i of waiting) { i.event = client.sign(cfg, i); i.updated = i.event.created_at; Object.assign(i, client.withRelayAttempt(cfg, i)); } + if (!client.remember(cfg, store)) throw new Error("this browser could not save relay destinations"); const sent = await client.publish(cfg, waiting.map((i) => i.event)); - waiting.forEach((i, k) => (i.relays = sent[k].relays)); + waiting.forEach((i, k) => Object.assign(i, client.withRelayResult(i, sent[k]))); client.remember(cfg, store); draw(); @@ -206,12 +209,9 @@ const regions = Object.fromEntries(countries.filter((c) => subunitsOf(c.id)).map } else if (act === "sync") { say("Asking the relays…"); - const found = await client.fetchMine(cfg), store = client.mine(cfg); - // what the relays hold wins over an older local copy; unsent local parts are kept, and so is - // the mark of a deletion still pending (a part no relay holds any more is simply gone) - const pendingOf = new Map(store.items.filter((i) => i.deleting).map((i) => [i.id, i.deleting])); - const merged = [...found.map((f) => (pendingOf.has(f.id) ? { ...f, deleting: pendingOf.get(f.id) } : f)), ...store.items.filter((i) => !i.relays && !found.some((f) => f.id === i.id))]; - client.remember(cfg, { items: merged }); + const snapshot = await client.fetchMine(cfg), store = client.mine(cfg); + client.remember(cfg, client.mergeMine(cfg, store, snapshot)); draw(); - say(`The relays hold ${found.length} part${found.length === 1 ? "" : "s"} under your key.`); + const checked = snapshot.relays.filter((r) => r.ok).length; + say(`Found ${snapshot.items.length} parts; ${checked} of ${snapshot.relays.length} relays completed the check. Local records and pending deletions are kept.`); } else if (act === "backup") { document.getElementById("meBackup").hidden = false; document.getElementById("meNsec").value = client.backup(); } else if (act === "copy") { await navigator.clipboard.writeText(document.getElementById("meNsec").value); b.textContent = "Copied"; } @@ -222,7 +222,13 @@ const regions = Object.fromEntries(countries.filter((c) => subunitsOf(c.id)).map client.remember(cfg, { items: [] }); draw(); - if (cfg.open) root.querySelector('[data-do="sync"]')?.click(); else say("Key replaced."); + if (cfg.open) { + const snapshot = await client.fetchMine(cfg); + client.remember(cfg, client.mergeMine(cfg, client.mine(cfg), snapshot)); + draw(); + say("Key replaced and relays checked."); + } else say("Key replaced."); } } catch (err) { say(`That did not work: ${err.message}`); } + finally { changing = false; } });
src/pages/survey/take.astro +15 −10
@@ -326,14 +326,17 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); const live = client.effective(cfg); const now = Math.floor(Date.now() / 1000); - const items = [{ type: "response", id: "response", answers: personal() }, ...allReports()]; + let items = [{ type: "response", id: "response", answers: personal() }, ...allReports()]; // Reports that were sent before and are no longer in the form are deleted. Until a relay // confirms it, each stays stored as a pending deletion (client.deletionOutcome), so it is // not forgotten on the strength of a request nobody took; /survey/me/ can try it again. const before = client.mine(live).items; - const gone = before.filter((old) => old.relays > 0 && !items.some((i) => i.id === old.id)); - const pendingAll = gone.map((g) => ({ ...g, deleting: { reached: 0, of: g.relays } })); - for (const i of items) { i.event = client.sign(live, i); i.updated = now; i.relays = 0; } - const removals = gone.map((old) => client.sign(live, old, true)); - client.remember(live, { items: [...items, ...pendingAll] }); + const gone = before.filter((old) => client.hasRemoteCopy(live, old) && !items.some((i) => i.id === old.id)).map((i) => client.startDeletion(live, i)); + items = items.map((i) => { + const previous = before.find((old) => old.id === i.id); + i.event = client.sign(live, { ...i, event: previous?.deleting?.replacement ?? previous?.event }); + i.updated = i.event.created_at; i.relays = 0; + return live.open ? client.withRelayAttempt(live, i, previous) : { ...i, relayUrls: previous?.relayUrls ?? [], relayUnknown: previous?.relayUnknown || (previous?.relays > 0 && !previous?.relayUrls) }; + }); + if (!client.remember(live, { items: [...items, ...gone] })) throw new Error("this browser could not save relay and deletion retry state"); try { localStorage.removeItem(DRAFT); } catch {} const parts = `${items.length} part${items.length === 1 ? "" : "s"}`; @@ -344,8 +347,10 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); } result.textContent = "Sending…"; - const sent = await client.publish(live, [...items.map((i) => i.event), ...removals, ...(gone.length ? [client.deletion(live, gone.map((g) => g.event))] : [])]); - items.forEach((i, k) => (i.relays = sent[k].relays)); - const { pending } = client.deletionOutcome(gone, sent.slice(items.length)); - client.remember(live, { items: [...items, ...pendingAll.filter((g) => pending.some((p) => p.id === g.id)).map((g) => ({ ...g, deleting: { reached: pending.find((p) => p.id === g.id).reached, of: g.relays } }))] }); + const sent = await client.publish(live, items.map((i) => i.event)); + items = items.map((i, k) => client.withRelayResult(i, sent[k])); + // Only deletions go to retired relay destinations; new answers go to today's configuration. + const removed = gone.length ? await client.publish({ ...live, relays: client.deletionRelays(live, gone) }, [...gone.map((i) => i.deleting.replacement), client.deletion(live, gone.map((g) => g.event))]) : []; + const outcome = client.deletionOutcome(gone, removed, live); + if (!client.remember(live, client.applyDeletionOutcome({ items: [...items, ...gone] }, outcome))) throw new Error("this browser could not save the result; retry state is kept"); if (items.every((i) => i.relays > 0)) { result.innerHTML = `<p><strong>Thank you.</strong> Your ${parts} ${items.length === 1 ? "is" : "are"} stored on ${Math.min(...items.map((i) => i.relays))} of ${live.relays.length} relay${live.relays.length === 1 ? "" : "s"}. You can change or delete what you sent at any time from this browser.</p><p><a class="go solid" href="../me/">Your submissions →</a> <a class="go" href="../">See the results</a></p>`;