Degree-2 web-of-trust + editor↔board identity sync

Board trust filter now also includes follows-of-follows (degree 2): on
connect it fetches the viewer's NIP-02 follows, then the kind-3 lists of
those follows (capped at 150 authors), and trusts the union. Brand-new
keys still fall back to the curator seed; no-data still falls back to
show-all.

A deliberate editor identity choice (NIP-07 / imported / generated /
unlocked nsec / anonymous) now writes a shared local pubkey that the
board reads, so the same person sees a consistent identity across pages.

Tests: degree-2 inclusion (A direct, B friend-of-friend shown; stranger
C hidden) and editor→board key sync, across all 6 engines. Full suite
306 passing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-11 10:36:30 -04:00
1 parent c97624a5e0
commit bf90f1608b
4 files changed
+89 -15

No files matched your search

+14 -10
View File
@@ -15,9 +15,13 @@ The trust model (#1–#2) has moved from undefined to implemented:
- **Bulletin board login gate** — viewing the board requires a connected Nostr
identity (NIP-07 extension, a pasted `nsec`, or a generated key).
- **Web-of-trust filter** — the board defaults to flyers from authors in the
viewer's NIP-02 follow graph, seeded from a curator account for fresh keys,
with a "Show all" opt-out (so it is no longer an unmoderated firehose).
- **Web-of-trust filter (degree-2)** — the board defaults to flyers from authors
in the viewer's NIP-02 follow graph *and the people those follows follow*
(follows-of-follows), seeded from a curator account for fresh keys, with a
"Show all" opt-out (so it is no longer an unmoderated firehose).
- **Editor↔board identity sync** — a deliberate editor identity choice (NIP-07,
imported/generated/unlocked nsec, or anonymous) is shared with the board via a
local pubkey, so the same person sees a consistent identity across both pages.
- **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).
@@ -38,8 +42,8 @@ The trust model (#1–#2) has moved from undefined to implemented:
flyers (overrides the trust filter, since your own may not be in your graph).
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
trust, blocklist/report, NIP-05 badges, an identified-vs-anonymous display) plus
cross-engine Playwright suite. **What remains** on #1–#2 is polish (report-to-list,
NIP-05 badges, an identified-vs-anonymous display) plus
the editor follow-ups noted under the *Planned direction* section. **#3–#5 are
still fully open** — distribution centralization (#3) is now the
highest-leverage untouched item.
@@ -52,7 +56,7 @@ highest-leverage untouched item.
> Nostr identity to view, and it now filters to flyers from the viewer's NIP-02
> web of trust (with a bootstrap seed for new keys). See *Planned direction:
> identity + web-of-trust on the board* below (jointly addresses #1 and #2).
> Remaining: degree-2 trust, blocklist/report, NIP-05 badges (Phase 3).
> Remaining: report-to-list, NIP-05 badges (Phase 3). Degree-2 trust shipped.
**Problem.** `board.html` lists every Nostr event tagged `t=voxvera` / `t=flyer`
@@ -161,10 +165,10 @@ board can show them flyers from people they trust.
no trust data can be fetched at all, the board falls back to showing
everything (never mysteriously empty). Covered by the cross-engine e2e suite
(followed-only view + show-all opt-out; curator-seeded fallback).
- **Phase 3 — Optional / next.** Degree-2 trust (follows-of-follows), a local
blocklist / "hide this", report-to-list, and NIP-05 verified badges. Also:
the bootstrap curator is currently a single hardcoded pubkey — consider making
it configurable or a small curated set.
- **Phase 3 — In progress.** Degree-2 trust (follows-of-follows) ✅ shipped; the
local blocklist / "hide this" ✅ shipped. **Remaining:** report-to-list and
NIP-05 verified badges. Also: the bootstrap curator is currently a single
hardcoded pubkey — consider making it configurable or a small curated set.
**Decisions made (2026-06-10).**
1. **Board viewing: login-required.** The board is blank (a Connect prompt)
+34
View File
@@ -567,6 +567,28 @@ test.describe("VoxVera static client", () => {
await expect(page.locator("#board-filter-note")).toContainText(/all flyers/i);
});
test("the web-of-trust filter includes follows-of-follows (degree 2)", async ({ page }) => {
const A = "a".repeat(64);
const B = "b".repeat(64);
const C = "c".repeat(64);
await stubNip07(page); // viewer = ME (1*64)
await stubRelays(page, [
contactList(ME, [A]), // you follow A (degree 1)
contactList(A, [B]), // A follows B (degree 2)
flyerEvent("fa", A, "Direct Follow Flyer"),
flyerEvent("fb", B, "Friend Of Friend Flyer"),
flyerEvent("fc", C, "Stranger Flyer")
]);
await page.goto("/board.html");
await page.locator("#board-connect").click();
await expect(page.locator("#board-content")).toBeVisible();
// Trust-filtered (not show-all): degree-1 and degree-2 appear, stranger hidden.
await expect(page.locator("#board-rows")).toContainText("Direct Follow Flyer");
await expect(page.locator("#board-rows")).toContainText("Friend Of Friend Flyer");
await expect(page.locator("#board-rows")).not.toContainText("Stranger Flyer");
await expect(page.locator("#board-show-all")).not.toBeChecked();
});
test("a viewer with no follow list is seeded from the curator's web of trust", async ({ page }) => {
// The connected viewer has NO contact list; the curator follows TRUSTED, so
// the board seeds its trust set from the curator and still hides UNTRUSTED.
@@ -635,6 +657,18 @@ test.describe("VoxVera static client", () => {
await expect(page.locator("#signer-state")).toHaveText("Publishing anonymously");
});
test("a deliberate editor identity choice syncs to the board's connected key", async ({ page }) => {
const PK = "a".repeat(64);
await page.addInitScript((pk) => {
window.nostr = { getPublicKey: async () => pk, signEvent: async (e) => e };
}, PK);
await page.goto("/#editor");
await page.locator("#connect-nip07").click();
await expect(page.locator("#signer-state")).toHaveText("Publishing as your extension identity");
// The board reads this shared key to reflect the same identity.
expect(await page.evaluate(() => localStorage.getItem("voxvera_connected_pubkey"))).toBe(PK);
});
test("editor can connect a NIP-07 identity and publish under it", async ({ page }) => {
const PK = "a".repeat(64);
await page.addInitScript((pk) => {
+25 -5
View File
@@ -660,25 +660,45 @@
.map((x) => x[1].toLowerCase());
}
// Degree-2: fetch the contact lists of a batch of authors and return the union
// of everyone they follow. Bounded so the board doesn't fan out unboundedly.
async function fetchFollowsOfMany(authors) {
const list = Array.from(new Set(authors.filter((a) => /^[0-9a-f]{64}$/i.test(a)))).slice(0, 150);
if (!list.length) return [];
const { events } = await queryRelays(DEFAULT_RELAYS, { kinds: [3], authors: list, limit: 400 }, 6000);
const latest = new Map();
events.forEach((e) => {
if (!e || e.kind !== 3) return;
const prev = latest.get(e.pubkey);
if (!prev || (e.created_at || 0) > (prev.created_at || 0)) latest.set(e.pubkey, e);
});
const out = new Set();
latest.forEach((e) => (e.tags || []).forEach((x) => {
if (x[0] === "p" && /^[0-9a-f]{64}$/i.test(String(x[1] || ""))) out.add(x[1].toLowerCase());
}));
return Array.from(out);
}
// Build the viewer's trust set: their own follows if they have any, otherwise
// seed from the curator's follows. The viewer always trusts themselves.
async function buildTrustSet(viewerPubkey) {
const viewerFollows = await fetchFollows(viewerPubkey);
let follows;
const set = new Set();
if (viewerFollows.length) {
follows = viewerFollows;
trustSource = "viewer";
viewerFollows.forEach((x) => set.add(x.toLowerCase()));
// Degree-2: also trust the people your follows follow (web of trust).
(await fetchFollowsOfMany(viewerFollows)).forEach((x) => set.add(x));
} else {
const curatorFollows = await fetchFollows(FALLBACK_CURATOR_PUBKEY);
if (curatorFollows.length) {
follows = curatorFollows.concat(FALLBACK_CURATOR_PUBKEY);
trustSource = "seeded";
curatorFollows.forEach((x) => set.add(x.toLowerCase()));
set.add(FALLBACK_CURATOR_PUBKEY);
} else {
follows = [];
trustSource = "unavailable";
}
}
const set = new Set(follows.map((x) => x.toLowerCase()));
if (viewerPubkey) set.add(String(viewerPubkey).toLowerCase());
return set;
}
+16
View File
@@ -12,6 +12,10 @@
// stored for nsec — only this AES-GCM blob, decrypted in memory on unlock.
const IDENTITY_MODE_STORAGE_KEY = "voxvera_identity_mode";
const IDENTITY_ENC_STORAGE_KEY = "voxvera_identity_nsec_enc";
// Shared with the bulletin board: mirror the editor's active pubkey here so a
// deliberate identity choice (connect / import / unlock / generate) carries
// over and the board reflects the same identity (and "My flyers" works).
const BOARD_CONNECTED_PUBKEY_KEY = "voxvera_connected_pubkey";
// Wrong-PIN lockout: after a few bad PINs, refuse unlocking for a cooldown.
// A deterrent against casual on-device guessing (a leaked blob is still
// offline-brute-forceable — see the PIN caveat in docs/roadmap.md). The
@@ -2039,6 +2043,13 @@ Join us in a revolution that values truth and transparency. Together, we can bui
} catch (_) {}
}
// Mirror a deliberately-chosen identity's pubkey to the board's connected-key
// slot, so the editor and board share one identity on this device.
function syncConnectedPubkey(pubkeyHex) {
if (!/^[0-9a-f]{64}$/i.test(String(pubkeyHex || ""))) return;
try { window.localStorage.setItem(BOARD_CONNECTED_PUBKEY_KEY, String(pubkeyHex).toLowerCase()); } catch (_) {}
}
// --- WebCrypto PIN encryption for a remembered nsec (PBKDF2 -> AES-GCM) ---
function b64encode(bytes) {
let bin = "";
@@ -2104,6 +2115,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
const imp = el("nsec-import");
if (imp) imp.hidden = true;
persistMode("anon");
syncConnectedPubkey(activeIdentity().pubkey);
refreshSignerState();
refreshIdentityState();
}
@@ -2122,6 +2134,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
const imp = el("nsec-import");
if (imp) imp.hidden = true;
persistMode("nip07");
syncConnectedPubkey(nip07Pubkey);
refreshSignerState();
refreshIdentityState();
} catch (_) {
@@ -2178,6 +2191,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
// Remembered keys persist mode "nsec" (locked on reload); session-only keys
// persist "anon" so a reload returns cleanly to anonymous, not a dead lock.
persistMode(remember ? "nsec" : "anon");
syncConnectedPubkey(importedPubkey);
refreshSignerState();
refreshIdentityState();
}
@@ -2222,6 +2236,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
if (el("unlock-pin")) el("unlock-pin").value = "";
showLockedRow(false);
persistMode("nsec");
syncConnectedPubkey(importedPubkey);
refreshSignerState();
refreshIdentityState();
}
@@ -3267,6 +3282,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
identityMode = "anon";
nip07Pubkey = null;
persistMode("anon");
syncConnectedPubkey(identity.pubkey);
setRealOutput("author-npub-output", identity.npub);
refreshSignerState();
refreshIdentityState();