mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-10-27 09:40:40 +00:00
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:
1 parent
c97624a5e0
commit
bf90f1608b
4 files changed
+89
-15
No files matched your search
+14
-10
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user