Changelog 812f73b
Survey: relay text only as text, deletions only when confirmed, one respondent per key (#139, #140, #141)
#139 A relay's reason for refusing an event (free text under NIP-01) was put into innerHTML on the survey's "Not sent" message. It is now cleaned, cut to 160 characters and set with textContent (relayReason). The scanner also escapes event ids and keys from relays. #140 Deleting reported success and forgot the parts whatever the relays answered. deletionOutcome now counts a part deleted only when its empty replacement reached as many relays as held it; the rest stay listed as "Deletion pending" with a retry, are never sent again, and keep the mark through a sync. Removing a report from the form and resubmitting follows the same rule. The confirmations no longer promise permanent erasure: a relay may keep an encrypted copy. #141 The collector trusted the decrypted type, so one key could sign many "responses" in other slots. collect() (survey-lib.cjs) binds each type to its slot: a survey only at <topic>:response, reports only at <topic>:place:<id> or <topic>:price:<id> with the browser's 12-hex id. A key is one respondent; a mismatched slot is dropped and reported, and past 25 reports of a kind per key only the newest count. The survey page says respondents are keys, not verified people. scripts/survey.test.mjs covers all three (hostile reason, zero and partial acknowledgements, several slots under one key, report limit) and runs before every deploy. Checked end to end against a fake relay that rejects with HTML and one that accepts. Claude-Session: https://claude.ai/code/session_015PFhJhHBpXsnUocs8vvYAd
10 files of code, design or docs +217 −60 lines
Code, design and documents 10 files
.github/workflows/deploy.yml +2 −0
@@ -38,4 +38,6 @@ jobs: # the facts earlier issues corrected, which a later rewrite must not lose - run: npm run history:test + # the survey: relay text never as HTML, deletions confirmed, one respondent per key + - run: npm run survey:test # The social preview images (one per page) take minutes to draw and rarely change. Each is # kept under a hash of its own content, so restoring the folder redraws only what is new.
package.json +1 −0
@@ -15,4 +15,5 @@ "seo:audit": "node scripts/seo-audit.mjs", "answers:test": "node --test scripts/answers.test.mjs scripts/profile-claims.test.mjs", + "survey:test": "node --test scripts/survey.test.mjs", "articles:backlog": "node scripts/article-backlog.cjs", "scores:triage": "node scripts/score-triage.cjs",
scripts/survey-collect.cjs +19 −36
@@ -15,5 +15,5 @@ const path = require("path"); const { verifyEvent } = require("nostr-tools/pure"); const { decrypt, getConversationKey } = require("nostr-tools/nip44"); -const { ROOT, loadSurvey, loadRegions, clean, aggregate } = require("./survey-lib.cjs"); +const { ROOT, loadSurvey, loadRegions, clean, aggregate, collect, MAX_REPORTS } = require("./survey-lib.cjs"); const EDITION = 2026; @@ -41,39 +41,22 @@ async function fetchEvents() { (async () => { const responses = [], observations = [], prices = [], texts = []; - const dropped = { invalid: 0, superseded: 0, deleted: 0, unreadable: 0, other: 0 }; - // Each part of a submission is a replaceable event: only the newest version under one key and - // one `d` tag counts, whatever older copies a relay still returns. - const newest = new Map(); - for (const ev of await fetchEvents()) { - const d = ev.tags?.find((t) => t[0] === "d")?.[1]; - if (ev.kind !== kind || !d?.startsWith(`${topic}:`) || !ev.tags.some((t) => t[0] === "t" && t[1] === topic) || !verifyEvent(ev)) { dropped.invalid++; continue; } - const slot = `${ev.pubkey}:${d}`, had = newest.get(slot); - if (had) dropped.superseded++; - if (!had || had.created_at < ev.created_at) newest.set(slot, ev); - } - for (const ev of newest.values()) { - let payload; - try { - const plain = ev.content.startsWith("{") ? ev.content : decrypt(ev.content, getConversationKey(secret, ev.pubkey)); - payload = JSON.parse(plain); - } catch { dropped.unreadable++; continue; } - if (payload?.deleted) { dropped.deleted++; continue; } // the respondent removed this part - if (payload?.edition !== EDITION || typeof payload.answers !== "object") { dropped.other++; continue; } - if (payload.type === "response") { - const r = clean(survey.all, payload.answers, countries); - // the two questions without which an answer cannot be placed in any group - if (!r.residence || !r.relation) { dropped.other++; continue; } - responses.push(r); - } else if (payload.type === "observation") { - const o = clean(survey.territory.questions, payload.answers, countries); - if (!o.country || !o.tie) { dropped.other++; continue; } - observations.push(o); - } else if (payload.type === "price") { - const pr = clean(survey.prices.questions, payload.answers, countries); - if (!pr.p_country || !pr.p_price) { dropped.other++; continue; } - prices.push(pr); - } else { dropped.other++; continue; } - for (const q of [...survey.all, ...survey.territory.questions, ...survey.prices.questions].filter((q) => q.type === "text")) if (payload.answers[q.id]) texts.push([q.id, String(payload.answers[q.id]).slice(0, 4000)]); - } + // which events count, and as what: one survey per key, reports in their own slots (survey-lib.cjs) + const picked = collect(await fetchEvents(), { + kind, topic, edition: EDITION, verify: verifyEvent, + open: (ev) => JSON.parse(ev.content.startsWith("{") ? ev.content : decrypt(ev.content, getConversationKey(secret, ev.pubkey))), + }); + const { dropped } = picked; + const keep = (list, questions, needed, payload) => { + const x = clean(questions, payload.answers, countries); + if (needed.some((id) => !x[id])) { dropped.other++; return; } + list.push(x); + for (const q of questions.filter((q) => q.type === "text")) if (payload.answers[q.id]) texts.push([q.id, String(payload.answers[q.id]).slice(0, 4000)]); + }; + // the questions without which an answer cannot be placed in any group, or a report in any country + for (const p of picked.response) keep(responses, survey.all, ["residence", "relation"], p); + for (const p of picked.observation) keep(observations, survey.territory.questions, ["country", "tie"], p); + for (const p of picked.price) keep(prices, survey.prices.questions, ["p_country", "p_price"], p); + if (dropped.mismatched) console.warn(`warning: ${dropped.mismatched} parts claimed a type their slot does not allow (a second "survey" under one key, for instance) and were not counted`); + if (dropped.excess) console.warn(`warning: ${dropped.excess} reports beyond ${MAX_REPORTS} of a kind under one key were not counted`); const min = args.includes("--min") ? Math.max(1, +args[args.indexOf("--min") + 1] || 1) : null; const out = aggregate(survey, responses, observations, prices, countries, { mock: false, generated: new Date().toISOString().slice(0, 10) }, min ? { segment: min, territory: min } : {});
scripts/survey-lib.cjs +52 −1
@@ -165,3 +165,54 @@ function aggregate(survey, responses, observations, prices, countries, extra = { } -module.exports = { ROOT, loadSurvey, loadRegions, clean, aggregate }; +// ── Which events count, and as what ── +// Every part of a submission is a replaceable event under its own `d` tag, and the tag decides +// what the part is: `<topic>:response` is the one personal survey of a key, `<topic>:place:<id>` +// and `<topic>:price:<id>` its reports (the id as the browser makes it, 12 hex characters). The +// decrypted content must say the same type as its slot. So a key is one respondent, however many +// events it signs: another slot claiming to be a survey is dropped, not counted as a second person. +// A key can file many reports, but past MAX_REPORTS of a kind only the newest count; the rest are +// counted as excess, for the person running the collection to look at. +const REPORT_ID = /^[0-9a-f]{12}$/; +const MAX_REPORTS = 25; +const SLOT_TYPE = { place: "observation", price: "price" }; +function slotType(topic, d) { + if (d === `${topic}:response`) return "response"; + if (!d?.startsWith(`${topic}:`)) return null; + const [slot, id, ...rest] = d.slice(topic.length + 1).split(":"); + return SLOT_TYPE[slot] && REPORT_ID.test(id ?? "") && !rest.length ? SLOT_TYPE[slot] : null; +} +// events: as fetched from the relays. verify(ev): its signature holds. open(ev): its decrypted +// payload (throws when it cannot be read). Returns the payloads to count, by type, and what was +// dropped and why. +function collect(events, { kind, topic, edition, verify, open }) { + const dropped = { invalid: 0, superseded: 0, deleted: 0, unreadable: 0, mismatched: 0, excess: 0, other: 0 }; + // only the newest version under one key and one `d` tag counts, whatever older copies a relay keeps + const newest = new Map(); + for (const ev of events) { + const d = ev.tags?.find((t) => t[0] === "d")?.[1]; + if (ev.kind !== kind || !slotType(topic, d) || !ev.tags.some((t) => t[0] === "t" && t[1] === topic) || !verify(ev)) { dropped.invalid++; continue; } + const slot = `${ev.pubkey}:${d}`, had = newest.get(slot); + if (had) dropped.superseded++; + if (!had || had.created_at < ev.created_at) newest.set(slot, ev); + } + const out = { response: [], observation: [], price: [] }; + const reports = new Map(); // key:type → [{ created_at, payload }] + for (const ev of newest.values()) { + const type = slotType(topic, ev.tags.find((t) => t[0] === "d")[1]); + let payload; + try { payload = open(ev); } catch { dropped.unreadable++; continue; } + if (payload?.deleted) { dropped.deleted++; continue; } // the respondent removed this part + if (payload?.edition !== edition || typeof payload.answers !== "object" || payload.answers === null) { dropped.other++; continue; } + if (payload.type !== type) { dropped.mismatched++; continue; } + if (type === "response") out.response.push(payload); + else { const k = `${ev.pubkey}:${type}`; (reports.get(k) ?? reports.set(k, []).get(k)).push({ created_at: ev.created_at, payload }); } + } + for (const [k, list] of reports) { + list.sort((a, b) => b.created_at - a.created_at); + dropped.excess += Math.max(0, list.length - MAX_REPORTS); + for (const { payload } of list.slice(0, MAX_REPORTS)) out[k.split(":")[1]].push(payload); + } + return { ...out, dropped }; +} + +module.exports = { ROOT, loadSurvey, loadRegions, clean, aggregate, collect, slotType, MAX_REPORTS };
scripts/survey.test.mjs +71 −0
@@ -0,0 +1,71 @@ +// The survey's safety and counting rules, checked without a browser or a relay: +// #139 a relay's free-text reason is shown as text, never put into HTML +// #140 a deletion that no relay (or too few) confirmed is not reported as done, and is kept +// #141 one key counts as one respondent, whatever slots it signs +// node --test scripts/survey.test.mjs +import { test } from "node:test"; +import assert from "node:assert/strict"; +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"; + +const require = createRequire(import.meta.url); +const { collect, MAX_REPORTS } = require("./survey-lib.cjs"); + +test("a relay's reason is cleaned text, and no survey page puts a relay error into HTML (#139)", () => { + const reason = relayReason([{ relays: 0, error: '<img src=x onerror="alert(1)">\n' + "x".repeat(400) }]); + assert.ok(reason.length <= 160); + assert.doesNotMatch(reason, /[\n\r]/); + assert.equal(relayReason([{ relays: 2, error: null }]), null); + for (const file of fs.readdirSync("src/pages/survey").map((f) => `src/pages/survey/${f}`)) { + for (const line of fs.readFileSync(file, "utf8").split("\n").filter((l) => /innerHTML\s*[+]?=/.test(l))) { + // an escaped value, esc(...), is text; anything else from an error must not be there + assert.doesNotMatch(line.replace(/esc\([^)]*\)/g, ""), /\.(error|message)\b/, `${file}: a relay or error message inside innerHTML`); + } + } +}); + +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 TOPIC = "test-survey", KIND = 30078, EDITION = 2026; +const event = (sk, d, payload, at = 1000) => finalizeEvent({ kind: KIND, created_at: at, tags: [["d", d], ["t", TOPIC]], content: JSON.stringify(payload) }, sk); +const run = (events) => collect(events, { kind: KIND, topic: TOPIC, edition: EDITION, verify: verifyEvent, open: (ev) => JSON.parse(ev.content) }); + +test("one key is one respondent, however many slots claim to be a survey (#141)", () => { + const sk = generateSecretKey(); + const survey = { type: "response", edition: EDITION, answers: { residence: "DE" } }; + const got = run([ + event(sk, `${TOPIC}:response`, survey), + event(sk, `${TOPIC}:place:aaaaaaaaaaaa`, survey), // a report slot claiming to be a survey + event(sk, `${TOPIC}:response:2`, survey), // not a slot the survey uses + event(sk, `${TOPIC}:place:not-an-id`, { ...survey, type: "observation" }), + event(sk, `${TOPIC}:response`, survey, 900), // an older version of the same slot + ]); + assert.equal(got.response.length, 1); + assert.equal(got.dropped.mismatched, 1); + assert.equal(got.dropped.invalid, 2); + assert.equal(got.dropped.superseded, 1); + // two keys are two respondents + assert.equal(run([event(sk, `${TOPIC}:response`, survey), event(generateSecretKey(), `${TOPIC}:response`, survey)]).response.length, 2); +}); + +test("reports beyond the limit under one key are not counted (#141)", () => { + const sk = generateSecretKey(); + const many = Array.from({ length: MAX_REPORTS + 5 }, (_, i) => event(sk, `${TOPIC}:place:${String(i).padStart(12, "0")}`, { type: "observation", edition: EDITION, answers: { country: "DE" } }, 1000 + i)); + const got = run(many); + assert.equal(got.observation.length, MAX_REPORTS); + assert.equal(got.dropped.excess, 5); + // the survey page states the same limit + assert.match(fs.readFileSync("src/pages/survey/index.astro", "utf8"), new RegExp(`at most ${MAX_REPORTS} place reports and ${MAX_REPORTS} price reports`)); +});
src/lib/survey-client.js +24 −0
@@ -104,4 +104,28 @@ export async function publish(cfg, events) { } finally { pool.close(cfg.relays); } } +// 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. +export const relayReason = (sent) => { + const text = sent.find((r) => r.error)?.error; + return text ? String(text).replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 160) : null; +}; + +// 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) { + 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 }); + }); + return { done, pending }; +} + // 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
src/pages/survey/index.astro +1 −1
@@ -188,5 +188,5 @@ const waiting = `No country has ${need} ${need === 1 ? "report" : "reports"} yet <div><dt>Pseudonymous</dt><dd>No account, no email, no cookie. Answers are signed with a random key made in your browser and kept there, and encrypted so that only you and the project can read them. That is strong privacy, not a guarantee of anonymity: a rare combination of answers can still narrow down who gave them.</dd></div> <div><dt>Three kinds of contribution</dt><dd>A yearly survey about yourself, reports on places you know, and prices you have paid. Each is stored by itself, and the last two can be added to at any time.</dd></div> - <div><dt>One person, one voice?</dt><dd>Not enforced. Anyone can answer more than once, so the survey is counted by key, place reports are weighed by how well their author knows the place, and prices far from the rest are left out.</dd></div> + <div><dt>One person, one voice?</dt><dd>Not for people: anyone can make several keys and answer more than once. What is enforced is one survey per key: a key counts as one respondent however many messages it signs, and it can file at most 25 place reports and 25 price reports. So "respondents" counts keys, not verified people. Place reports are weighed by how well their author knows the place, and prices far from the rest are left out.</dd></div> <div><dt>Yours to change</dt><dd>The key stays with you, so you can come back to <a href={`${base}survey/me/`}>your submissions</a>, correct them or delete them. Nobody else can.</dd></div> <div><dt>Counted, not listed</dt><dd>Only counts are published, and only for groups large enough to hide in. Free-text answers are read by the researchers and never published as they stand.</dd></div>
src/pages/survey/me.astro +29 −13
@@ -62,5 +62,5 @@ const regions = Object.fromEntries(countries.filter((c) => subunitsOf(c.id)).map return esc(v); } - const where = (i) => (i.relays > 0 ? `<span class="sv-chip ok">${i.relays} relay${i.relays === 1 ? "" : "s"}</span>` : `<span class="sv-chip">This browser</span>`); + const where = (i) => (i.deleting ? `<span class="sv-chip">Deletion pending</span>` : i.relays > 0 ? `<span class="sv-chip ok">${i.relays} relay${i.relays === 1 ? "" : "s"}</span>` : `<span class="sv-chip">This browser</span>`); // what this browser stored for other editions (each edition has its own entry in storage) @@ -94,7 +94,8 @@ 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>` : `<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> - ${r ? `<p class="sv-event"><span>Event</span> <code>${r.id}</code>${i.relays > 0 ? ` <a href="${r.url}" target="_blank" rel="noopener noreferrer">Open on njump ↗</a>` : ` <small>not sent yet</small>`}</p>` : ""}`; + ${r ? `<p class="sv-event"><span>Event</span> <code>${esc(r.id)}</code>${i.relays > 0 ? ` <a href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">Open on njump ↗</a>` : ` <small>not sent yet</small>`}</p>` : ""}`; if (!drawer.open) drawer.showModal(); drawerBody.scrollTop = 0; @@ -118,5 +119,5 @@ const regions = Object.fromEntries(countries.filter((c) => subunitsOf(c.id)).map const all = [...store.items, ...earlier()]; const reports = all.filter((i) => i.type === "response"), places = all.filter((i) => i.type === "observation"), prices = all.filter((i) => i.type === "price"); - const unsent = store.items.filter((i) => !i.relays).length; + const unsent = store.items.filter((i) => !i.relays && !i.deleting).length; // one table row per part; a click opens its answers in the drawer const n = (i) => Object.keys(i.answers).length; @@ -160,14 +161,26 @@ 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. async function remove(ids) { const store = client.mine(cfg), gone = store.items.filter((i) => ids.includes(i.id)); - if (cfg.open && gone.some((i) => i.relays > 0)) { + const online = gone.filter((i) => i.relays > 0); + const done = gone.filter((i) => !(i.relays > 0)).map((i) => i.id); + let pending = []; + 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 - await client.publish(cfg, [...gone.map((i) => client.sign(cfg, i, true)), client.deletion(cfg, gone.map((i) => i.event))]); - } - client.remember(cfg, { items: store.items.filter((i) => !ids.includes(i.id)) }); + 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)) }); draw(); - say(`Deleted ${gone.length} part${gone.length === 1 ? "" : "s"}.`); + 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.`); } @@ -178,8 +191,9 @@ const regions = Object.fromEntries(countries.filter((c) => subunitsOf(c.id)).map const act = b.dataset.do; try { - if (act === "delete") { if (confirm("Delete this for good? It cannot be brought back.")) { drawer.close(); await remove([b.dataset.id]); } } - else if (act === "delete-all") { if (confirm("Delete everything you submitted? It cannot be brought back.")) await remove(client.mine(cfg).items.map((i) => i.id)); } + 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]); } } + else if (act === "delete-all") { if (confirm("Delete everything you submitted? Each part is replaced with an empty version and every relay is asked to remove it. Relays that honour the request remove them; one that does not may keep encrypted copies.")) await remove(client.mine(cfg).items.map((i) => i.id)); } else if (act === "send") { - const store = client.mine(cfg), waiting = store.items.filter((i) => !i.relays); + // never send again a part that is being deleted + const store = client.mine(cfg), waiting = store.items.filter((i) => !i.relays && !i.deleting); say("Sending…"); // sign again, so the message carries today's date and the open survey's settings @@ -193,6 +207,8 @@ const regions = Object.fromEntries(countries.filter((c) => subunitsOf(c.id)).map 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 - const merged = [...found, ...store.items.filter((i) => !i.relays && !found.some((f) => f.id === i.id))]; + // 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 }); draw();
src/pages/survey/scan.astro +2 −2
@@ -72,9 +72,9 @@ const base = import.meta.env.BASE_URL; $("scanEvents").innerHTML = events.map((e) => `<tr${e.pubkey === mine ? ' class="sv-own"' : ""}> <td class="mono">${when(e.created_at)}</td> - <td class="mono"><span class="sv-dot" style="background:${tint(e.pubkey)}"></span><span title="${e.npub}">${e.npub.slice(0, 12)}…${e.npub.slice(-4)}</span>${e.pubkey === mine ? ' <small>you</small>' : ""}</td> + <td class="mono"><span class="sv-dot" style="background:${tint(e.pubkey)}"></span><span title="${esc(e.npub)}">${esc(e.npub.slice(0, 12))}…${esc(e.npub.slice(-4))}</span>${e.pubkey === mine ? ' <small>you</small>' : ""}</td> <td>${esc(PART[e.part] ?? e.part)}</td> <td class="num">${e.encrypted ? `${e.bytes.toLocaleString("en-GB")} B` : '<span class="sv-bad">not encrypted</span>'}</td> <td class="num">${e.relays.length}/${relays.length}</td> - <td class="mono"><a href="${e.url}" target="_blank" rel="noopener noreferrer" title="${e.id}">${e.id.slice(0, 10)}… ↗</a></td> + <td class="mono"><a href="${esc(e.url)}" target="_blank" rel="noopener noreferrer" title="${esc(e.id)}">${esc(e.id.slice(0, 10))}… ↗</a></td> </tr>`).join(""); } catch (err) {
src/pages/survey/take.astro +16 −7
@@ -79,5 +79,5 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); <li>The first time, your browser makes a random key and keeps it in its own storage. It is a pseudonym: it carries no name, email or account, and nobody can tell whose it is.</li> <li>Your survey, each place report and each price are signed with that key and encrypted so that only you and this project can read them. They are sent to public <a href="https://nostr.com/" target="_blank" rel="noopener noreferrer">Nostr</a> relays, which store them. A relay, like any server, can see the address you connect from; use a VPN or Tor if that matters to you.</li> - <li>Because the key stays in this browser, you can come back to <a href={`${base}survey/me/`}>your submissions</a> to change or delete them. Everything signed with one key belongs together, so your places and prices are linked to your survey answers, though not to your name.</li> + <li>Because the key stays in this browser, you can come back to <a href={`${base}survey/me/`}>your submissions</a> to change or delete them. Deleting replaces each message with an empty one and asks every relay to remove it; most do, but a relay that does not may keep an encrypted copy. Everything signed with one key belongs together, so your places and prices are linked to your survey answers, though not to your name.</li> <li>If you clear this browser's data without backing the key up, your answers stay in the survey and you can no longer change them.</li> </ul> @@ -285,5 +285,6 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); } // What was submitted from this browser before, if anything (written by survey-client.js). - function submitted() { try { return JSON.parse(localStorage.getItem(`cfi-survey-${cfg.edition}-mine`) ?? "null")?.items ?? []; } catch { return []; } } + // A part whose deletion is still pending (see /survey/me/) is not brought back into the form. + function submitted() { try { return (JSON.parse(localStorage.getItem(`cfi-survey-${cfg.edition}-mine`) ?? "null")?.items ?? []).filter((i) => !i.deleting); } catch { return []; } } function restore() { try { @@ -326,9 +327,13 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); const now = Math.floor(Date.now() / 1000); const items = [{ type: "response", id: "response", answers: personal() }, ...allReports()]; - // reports that were submitted before and are no longer in the form are removed - const gone = client.mine(live).items.filter((old) => !items.some((i) => i.id === old.id)); + // 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 }); + client.remember(live, { items: [...items, ...pendingAll] }); try { localStorage.removeItem(DRAFT); } catch {} const parts = `${items.length} part${items.length === 1 ? "" : "s"}`; @@ -341,10 +346,14 @@ const cond = (x) => (x.if ? JSON.stringify(x.if) : undefined); 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)); - client.remember(live, { items }); + 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 } }))] }); 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>`; button.hidden = true; } else { - result.innerHTML = `<p><strong>Not sent.</strong> No relay accepted ${items.filter((i) => !i.relays).length} of the ${parts}${sent.find((r) => r.error) ? ` (${sent.find((r) => r.error).error})` : ""}. They are saved in this browser; try again here or from <a href="../me/">your submissions</a>.</p>`; + // The relay's reason is its own free text: set as text, never as HTML. + result.innerHTML = `<p><strong>Not sent.</strong> No relay accepted ${items.filter((i) => !i.relays).length} of the ${parts}<span data-reason></span>. They are saved in this browser; try again here or from <a href="../me/">your submissions</a>.</p>`; + const reason = client.relayReason(sent); + if (reason) result.querySelector("[data-reason]").textContent = ` (the relay said: ${reason})`; button.disabled = false; }