diff --git a/PENDING.md b/PENDING.md index 1affcf78..14a188c9 100644 --- a/PENDING.md +++ b/PENDING.md @@ -16,6 +16,100 @@ Releases are no longer recorded here: the live version and hashes are in `../site/releases-manifest.json` and `https://dl.silentmode.st/releases-manifest.json`, and each release commit is `Theseus 0.3.x: ...` in git history. +## Staged for the next release (post-0.3.131) + +- **LANDED: Startpage layout reverted to the pre-0.3.131 default, + plus the user's two preferred tweaks baked into the defaults — the + slot column is chromeless (floats on the page background, no panel + box around it) and default `iconSize` is "large" (60 px tiles).** + Changes in [home.html](home.html): + - HTML: `
` and `
` back as + body siblings (not nested in a `.page` grid with a sidebar aside). + `.homepage` is the 2-col grid `minmax(260, 300) 1fr`, hero padded + `5vh 1rem 0` as the top of the page again. + - CSS: `.sidebar` renamed back to `.left-column`, `.cards-wrap` back + to `.right-column`, the `.page` + `.main-col` grid dropped. + `.section.chromeless` kept (brought back) — the left column uses it + so slots float on the page background like the user had it; the SM + cards keep the soft panel surface. + - `defaultState().iconSize` is `"large"` (60 px slot tiles) — + matches the sizing in the user's screenshot; existing profiles + with their own `iconSize` keep that choice. + - `.slots-list { grid-template-columns: repeat(3, 72px); width: + max-content; margin: 0 auto }` — fixed-cell 232 px grid, centered + in its panel. `layoutSlots` back to `minSlots = 9` (3 × 3 rows, + matches the SM cards grid height). `minSlotsForFill()` and its + `ResizeObserver` are gone. + - `.hero .ver-line` CSS + its `body.light` override removed; the + `

` markup gone; the init-script + block that wired `getVersion()` → `navigate("silentmode.st/releases")` + gone. The user asked for "What's new" to be dropped again after I + restored it for 0.3.131; `window.home.getVersion` exposure in + [home-preload.js](home-preload.js) also removed since nothing calls + it any more. + - `moveItem()` now strips the `slot` property when a q item moves to + sm (link → card). Pin-to-slot is q-only, so leaving the anchor on + an sm card re-pinned it back to a slot cell on the next revert. + This fixes the user-reported "moving a link to card doesn't work" + (actually it moved, but the stale slot anchor flipped it back on + the next render). + +- **Live user state reset.** Backed up `%APPDATA%\Theseus\home-cards.json` + to `.bak-before-reset`, rewrote it to the restored defaults — SM + cards list contains Game.X (replacing SilentMode.X in its slot), + quicklinks strip holds hello.bch / sia.coin / Pithos.sia / + Bergamot.X / SilentMode.X, plus any user-added extras preserved. + Preserved: `background` (indigo preset) and `iconSize` (large). + User must reload the open home tab (Ctrl+R or close/reopen) to pick + up the new file. + +- **LANDED: Claude connector no longer rotates its bearer on every + stop (0.2.2).** The old `stop()` generated a fresh token on every + server stop, so a Theseus quit, a panel toggle-off, or even a 0.3.X + → 0.3.X+1 install silently invalidated every registered Claude. The + Connect button wrote the new token to `~/.claude.json`, but any + already-running Claude Code or Claude Desktop process holds the + previous token in memory and only re-reads config on launch, so + `/mcp` reconnect kept sending the stale bearer and the server + rejected it. Observed symptom from the connector log: after + installing 0.3.131 and clicking Connect, `.claude.json` was + updated (correct new token written at 01:02:53) but the running + Claude Code session's MCP calls stayed 401 forever. Fix in + [bundled-addons/claude-connector/index.js](bundled-addons/claude-connector/index.js): + `stop()` keeps the stored token; only the panel's "Revoke token" + button rotates. Addon version bumped 0.2.1 → 0.2.2 so + `seedBundledAddons` picks up the fix on the first boot of 0.3.132. + +- **LANDED: Nostr per-site identities (0.1.1).** The 0.3.131 Nostr add-on + always signed with Hermes's one `npub`, which gives sites a single + identity they can correlate across the Nostr network. 0.3.132 adds a + per-origin profile picked on first approval: + - `lib/hermes.js` gains `nostrKeyFromRootForOrigin(messengerRoot, origin)` + — HKDF with info `silentmode/nostr-origin/v1/` over the + existing 32-byte messengerRoot, so every site that chooses "site- + specific identity" sees a different `npub` and clients can't cross- + correlate. Deterministic from the mnemonic, no new vault field + needed. + - [main.js](main.js): `_nostrAPI` methods take `{ profileId, origin }`. + `profileId` is `"hermes"` or `"origin"`; the signer cache keys on + both. Per-origin secret keys are hot-cached under + `nostrOriginSkCache` so the async HKDF only runs once per + (vault, origin). Backward-compatible: callers that omit `profileId` + get the Hermes identity (what 0.3.131 did). + - [bundled-addons/nostr/index.js](bundled-addons/nostr/index.js): grant + schema gains `profileId`. First-time approval modal shows BOTH npubs + side-by-side and two Allow buttons ("Use site-specific identity" is + primary, "Use my Hermes identity" is the opt-in to the shared key). + Subsequent calls reuse the stored profile silently. Pre-upgrade + grants (no `profileId` field) default to `"hermes"` so existing + approvals keep behaving the same. + - [bundled-addons/nostr/panel.html](bundled-addons/nostr/panel.html): + each grant row shows which identity the site sees — "Shared Hermes + identity" or "Site-specific identity" with the derived npub under + it. + - Addon version bumped 0.1.0 → 0.1.1 so `seedBundledAddons` reseeds + the fresh-from-0.3.131 copy on the next Theseus start. + ## Staged for the next release (post-0.3.130) Queue of asks for the next cut. Items marked LANDED are edited in the diff --git a/bundled-addons/claude-connector/addon.json b/bundled-addons/claude-connector/addon.json index 40c8206d..4f5fa5f5 100644 --- a/bundled-addons/claude-connector/addon.json +++ b/bundled-addons/claude-connector/addon.json @@ -1,7 +1,7 @@ { "id": "claude-connector", "name": "Claude connector", - "version": "0.2.1", + "version": "0.2.2", "description": "Lets Claude Code or Claude Desktop on this computer work in your Theseus tabs through a local MCP connection: read pages, click, type, paste and upload. Off until you turn it on; asks before each new site; never types into password fields; logs every action.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iMTQiIGZpbGw9IiNkOTc3NTciLz48cGF0aCBkPSJNMzIgMTN2MzhNMTMgMzJoMzhNMTguNiAxOC42bDI2LjggMjYuOE00NS40IDE4LjYgMTguNiA0NS40IiBzdHJva2U9IiNmZmY0ZWUiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+DQo=", diff --git a/bundled-addons/claude-connector/index.js b/bundled-addons/claude-connector/index.js index bf59fbb6..565d31b8 100644 --- a/bundled-addons/claude-connector/index.js +++ b/bundled-addons/claude-connector/index.js @@ -256,14 +256,18 @@ module.exports = { await controller.releaseAll(); policy.reset(); try { fs.rmSync(endpointFile, { force: true }); } catch {} - // Rotate the bearer token on every stop so that re-enabling the - // connector doesn't resurrect a credential a client (or anyone who - // read its config while it was off) still has — matches the mental - // model where "off" means "the keys change". The panel's installed- - // command strings reflect the new token on the next start. + // Token is PERSISTED across stop/start — a Theseus restart or a + // panel toggle-off/on does NOT invalidate connected clients. The + // old behavior rotated here on every stop, which looked tidy but + // broke every registered Claude on every quit: the Connect button + // writes the new token to ~/.claude.json, but any already-running + // Claude Code or Claude Desktop process is holding the previous + // token in memory and only re-reads on launch, so /mcp + // reconnect kept sending the stale bearer. For explicit rotation + // the panel's "Revoke token" button still exists and still + // generates a fresh one. if (s) { - api.storage.set("token", newToken()); - log.add({ tool: "connector", args: `${reason || "stopped"}; token rotated`, ok: true }); + log.add({ tool: "connector", args: reason || "stopped", ok: true }); } pushStatus(); })().finally(() => { stopping = null; }); diff --git a/bundled-addons/nostr/addon.json b/bundled-addons/nostr/addon.json index 892d2bdf..1423d2d3 100644 --- a/bundled-addons/nostr/addon.json +++ b/bundled-addons/nostr/addon.json @@ -1,7 +1,7 @@ { "id": "nostr", "name": "Nostr", - "version": "0.1.0", + "version": "0.1.1", "description": "Sign in to Nostr apps with the identity Theseus already holds. Adds window.nostr (NIP-07): sites can ask who you are and ask you to sign, one approval at a time, and your key never leaves the browser.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPHJlY3QgeD0iMSIgeT0iMSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiByeD0iNS41IiBmaWxsPSIjOGU1Y2Y3Ii8+IDxjaXJjbGUgY3g9IjkuMyIgY3k9IjkuMyIgcj0iMy42IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi40Ii8+IDxwYXRoIGQ9Ik0xMS45IDExLjkgMTkgMTkiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyLjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPiA8cGF0aCBkPSJNMTYuMiAxNi4ybC0xLjkgMS45IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi40IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4gPC9zdmc+", diff --git a/bundled-addons/nostr/index.js b/bundled-addons/nostr/index.js index f81b46cb..da7d182f 100644 --- a/bundled-addons/nostr/index.js +++ b/bundled-addons/nostr/index.js @@ -13,9 +13,22 @@ // The origin is NOT taken from the page. Main derives it from the tab's // committed URL and passes it in `ctx`; a page cannot claim to be someone // else, and only the top-level frame can speak at all. +// +// --- Per-site identities (0.3.132) --------------------------------------- +// Each origin's grant carries a profileId chosen on the first approval: +// "hermes" — the user's one messenger npub (also used by Hermes DMs and +// publicly as the np record on their BNS name). +// "origin" — an HKDF-derived per-origin npub, so sites can't correlate +// posts across Nostr clients. +// Grants with no profileId (migrated from pre-0.3.132) default to "hermes" +// so existing approvals keep behaving the same. -const STORE_KEY = "grants"; // { "": { level, at, kinds: {} } } -const LEVELS = { read: 1, sign: 2 }; // sign implies read +const STORE_KEY = "grants"; // { "": { level, at, profileId, kinds: {} } } +const LEVELS = { read: 1, sign: 2 }; // sign implies read +const PROFILE_LABELS = { + hermes: "Your Hermes identity", + origin: "A site-specific identity (unique to this site)", +}; // Kinds that rewrite or destroy something, where "you already allowed this // site" is not good enough — the user should see what is being signed even @@ -44,9 +57,14 @@ module.exports = { return g && typeof g === "object" ? g : {}; }; const grantFor = (origin) => allGrants()[origin] || null; - function setGrant(origin, level) { + function setGrant(origin, level, profileId) { const g = allGrants(); - g[origin] = { level, at: Date.now() }; + const prev = g[origin] || {}; + g[origin] = { + level, + at: Date.now(), + profileId: profileId || prev.profileId || "hermes", + }; api.storage.set(STORE_KEY, g); } function dropGrant(origin) { @@ -60,30 +78,87 @@ module.exports = { if (!g) return false; return (LEVELS[g.level] || 0) >= (LEVELS[need] || 0); } + // profileId for a known-granted origin, with the pre-0.3.132 default. + function profileFor(origin) { + const g = grantFor(origin); + return (g && g.profileId) || "hermes"; + } // ---- the prompt --------------------------------------------------------- // One shape for every request so the user learns to read it: who is - // asking, what they want, and what exactly gets signed. - async function ask({ origin, title, body, rows, remember }) { - const actions = [{ id: "deny", label: "Deny" }, { id: "allow", label: "Allow", primary: true }]; + // asking, what they want, and what exactly gets signed. On the first + // approval for an origin, chooseProfile=true adds a profile-picker row: + // two distinct "Allow" buttons for the two identities (the user's Hermes + // key vs. a fresh per-site key); subsequent approvals reuse the stored + // profile so the modal stays a single-click yes/no. + async function ask({ origin, title, body, rows, remember, chooseProfile, previews }) { + const actions = chooseProfile + ? [ + { id: "deny", label: "Deny" }, + { id: "allow-origin", label: "Use site-specific identity", primary: true }, + { id: "allow-hermes", label: "Use my Hermes identity" }, + ] + : [{ id: "deny", label: "Deny" }, { id: "allow", label: "Allow", primary: true }]; + // When choosing, show both npubs so the user sees exactly what each + // button will reveal to the site. previews is {hermes:{npub}, origin:{npub}}. + const rowList = (rows || []).slice(); + if (chooseProfile && previews) { + if (previews.hermes && previews.hermes.npub) { + rowList.push({ label: "Your Hermes identity", value: previews.hermes.npub, mono: true }); + } + if (previews.origin && previews.origin.npub) { + rowList.push({ label: `Site-specific for ${origin}`, value: previews.origin.npub, mono: true }); + } + } const r = await api.approvalModal({ - title, body, origin, rows: rows || [], + title, body, origin, rows: rowList, actions, checkbox: remember ? { id: "always", label: `Always allow this on ${origin}` } : null, }); const s = String(r || ""); if (!s.startsWith("allow")) return { ok: false }; - return { ok: true, remember: s.includes("+always") }; + let profileId; + if (s.startsWith("allow-origin")) profileId = "origin"; + else if (s.startsWith("allow-hermes")) profileId = "hermes"; + return { ok: true, remember: s.includes("+always"), profileId }; } + // Compute both npubs so the first-time chooser modal can show them side + // by side. Returns null on either if the vault has no identity. + async function identityPreviews(origin) { + try { + const h = await host().status({ profileId: "hermes" }); + const o = await host().status({ profileId: "origin", origin }); + return { + hermes: h.ready ? { npub: h.npub, pubkey: h.pubkey } : null, + origin: o.ready ? { npub: o.npub, pubkey: o.pubkey } : null, + ready: !!(h.ready && o.ready), + reason: h.ready ? (o.ready ? null : o.reason) : h.reason, + }; + } catch (e) { + return { ready: false, reason: e && e.message || String(e) }; + } + } + + // gate() returns {origin, profileId} so callers know which identity to + // sign with. First-time gates show the profile chooser; subsequent calls + // reuse the stored profileId silently. async function gate(ctx, need, prompt) { const origin = ctx && ctx.origin; if (!origin) throw new Error("this call did not come from a page"); - if (has(origin, need)) return origin; - const r = await ask({ origin, remember: true, ...prompt }); + if (has(origin, need)) return { origin, profileId: profileFor(origin) }; + const previews = await identityPreviews(origin); + if (!previews.ready) throw new Error(previews.reason || "no identity available"); + const r = await ask({ + origin, remember: true, + chooseProfile: !grantFor(origin), + previews, + ...prompt, + }); if (!r.ok) throw new Error("denied"); - if (r.remember) setGrant(origin, need); - return origin; + const profileId = r.profileId || profileFor(origin); + if (r.remember) setGrant(origin, need, profileId); + return { origin, profileId }; } // Readable one-line summary of what a sign request actually is. @@ -103,53 +178,73 @@ module.exports = { // ---- page-facing methods ------------------------------------------------ api.onMessage("getPublicKey", async (_p, ctx) => { - const st = await host().status(); - if (!st.ready) throw new Error(st.reason); - await gate(ctx, "read", { + const { origin, profileId } = await gate(ctx, "read", { title: "Share your Nostr identity?", body: "This site is asking which Nostr account you are. It will see your public key — " - + "the same one your notes are signed with. It cannot post or read messages with this alone.", - rows: [{ label: "Your key", value: st.npub, mono: true }], + + "the same one your notes get signed with. It cannot post or read messages with this alone.\n\n" + + "Pick the Hermes identity if this site is one you want to be publicly linkable to the rest " + + "of your Nostr activity; pick a site-specific identity to keep this site isolated.", }); - return (await host().getPublicKey()).pubkey; + return (await host().getPublicKey({ profileId, origin })).pubkey; }); api.onMessage("signEvent", async (p, ctx) => { - const st = await host().status(); - if (!st.ready) throw new Error(st.reason); const ev = (p && p.event) || {}; const kind = Number(ev.kind); const origin = ctx && ctx.origin; if (!origin) throw new Error("this call did not come from a page"); - // Destructive or identity-rewriting kinds are shown every time, even on - // a site the user already trusts for ordinary posting. - const special = ALWAYS_ASK_KINDS.get(kind); - if (special || !has(origin, "sign")) { + // A sign request promotes the grant to "sign" level. If no grant yet, + // this is also the profile-chooser moment. + let profileId; + if (!has(origin, "sign")) { + const previews = await identityPreviews(origin); + if (!previews.ready) throw new Error(previews.reason || "no identity available"); + const chooseProfile = !grantFor(origin); const rows = [{ label: "Event", value: describeEvent(ev) }]; if (typeof ev.content === "string" && ev.content) rows.push({ label: "Content", value: preview(ev.content) }); if (Array.isArray(ev.tags) && ev.tags.length) rows.push({ label: "Tags", value: preview(JSON.stringify(ev.tags), 120), mono: true }); + const special = ALWAYS_ASK_KINDS.get(kind); const r = await ask({ origin, title: special ? "Sign something that changes your account?" : "Sign as you on Nostr?", body: special ? `This site wants to ${special}. This is published under your name and other clients will follow it.` : "This site wants to publish an event signed with your Nostr key. Anyone can see what you sign.", - // Never offer "always" for the destructive kinds. remember: !special, + chooseProfile, + previews, rows, }); if (!r.ok) throw new Error("denied"); - if (r.remember) setGrant(origin, "sign"); + profileId = r.profileId || profileFor(origin); + if (r.remember) setGrant(origin, "sign", profileId); + } else { + profileId = profileFor(origin); + // Destructive kinds re-prompt every time even on a trusted site, but + // the profile is fixed (same profile this origin has already been + // posting with), so no chooser — just a per-event confirmation. + const special = ALWAYS_ASK_KINDS.get(kind); + if (special) { + const rows = [{ label: "Event", value: describeEvent(ev) }]; + if (typeof ev.content === "string" && ev.content) rows.push({ label: "Content", value: preview(ev.content) }); + if (Array.isArray(ev.tags) && ev.tags.length) rows.push({ label: "Tags", value: preview(JSON.stringify(ev.tags), 120), mono: true }); + const r = await ask({ + origin, + title: "Sign something that changes your account?", + body: `This site wants to ${special}. This is published under your name and other clients will follow it.`, + remember: false, + rows, + }); + if (!r.ok) throw new Error("denied"); + } } - return (await host().signEvent({ event: ev })).event; + return (await host().signEvent({ event: ev, profileId, origin })).event; }); const cryptoCall = (name, need, label, legacy) => api.onMessage(name, async (p, ctx) => { - const st = await host().status(); - if (!st.ready) throw new Error(st.reason); - await gate(ctx, need, { + const { origin, profileId } = await gate(ctx, need, { title: `Let this site ${label} with your key?`, body: (legacy ? "This uses NIP-04, the older scheme, which leaks message length and who you are talking to. " @@ -158,7 +253,7 @@ module.exports = { rows: [{ label: "Other party", value: String((p && p.peer) || "?"), mono: true }], }); const fn = host()[name]; - return (await fn.call(host(), p || {})).result; + return (await fn.call(host(), { ...(p || {}), profileId, origin })).result; }); cryptoCall("nip04Encrypt", "sign", "encrypt", true); @@ -172,16 +267,41 @@ module.exports = { const panelOnly = (ctx) => { if (ctx && ctx.from === "page") throw new Error("not available to pages"); }; - api.onMessage("status", async (_p, ctx) => { panelOnly(ctx); return host().status(); }); + api.onMessage("status", async (p, ctx) => { + panelOnly(ctx); + // The panel's "status" is always the Hermes profile (that's the one it + // shows as "Your identity" at the top). Per-origin keys are listed in + // the grants section with their own derived npubs. + return host().status({ profileId: "hermes" }); + }); api.onMessage("listGrants", async (_p, ctx) => { panelOnly(ctx); const g = allGrants(); - return { - ok: true, - grants: Object.entries(g) - .map(([origin, v]) => ({ origin, level: v.level, at: v.at || 0 })) - .sort((a, b) => b.at - a.at), - }; + const rows = Object.entries(g).map(([origin, v]) => ({ + origin, + level: v.level, + at: v.at || 0, + profileId: v.profileId || "hermes", + profileLabel: PROFILE_LABELS[v.profileId || "hermes"] || "Hermes identity", + })); + // Enrich with the derived npub for site-specific grants so the panel + // can show the user exactly which key the site sees. Skip if the vault + // is locked (status not ready). + const h = await host().status({ profileId: "hermes" }); + if (h.ready) { + for (const r of rows) { + if (r.profileId === "origin") { + try { + const o = await host().status({ profileId: "origin", origin: r.origin }); + if (o.ready) r.npub = o.npub; + } catch {} + } else { + r.npub = h.npub; + } + } + } + rows.sort((a, b) => b.at - a.at); + return { ok: true, grants: rows }; }); api.onMessage("revoke", async (p, ctx) => { panelOnly(ctx); @@ -198,6 +318,6 @@ module.exports = { return { ok: true }; }); - api.log("registered NIP-07 signer"); + api.log("registered NIP-07 signer (per-origin profiles)"); }, }; diff --git a/bundled-addons/nostr/panel.html b/bundled-addons/nostr/panel.html index a232fea7..2f64f95f 100644 --- a/bundled-addons/nostr/panel.html +++ b/bundled-addons/nostr/panel.html @@ -128,15 +128,28 @@ "No site has been allowed yet. The first time one asks, Theseus will show you what it wants.")); return; } - const rows = list.map((g) => el("tr", {}, - el("td", { className: "o" }, - el("div", { textContent: g.origin }), - el("div", { className: "lvl", textContent: g.level === "sign" ? "can sign and encrypt" : "can see your public key" })), - el("td", { style: "text-align:right;width:84px" }, - el("button", { className: "sm", textContent: "Revoke", onclick: async () => { - try { await S.invoke("revoke", { origin: g.origin }); flash("Revoked"); refresh(); } - catch (e) { flash(e?.message || "Failed"); } - } })))); + // Per-origin row: origin + capability + which identity it sees (Hermes + // key vs. a site-specific derived key with its own npub) + revoke. The + // identity label matters — a user glancing down the list should see at + // once which sites share their "public" Hermes npub and which are + // isolated per-site. + const rows = list.map((g) => { + const profileLine = g.profileId === "origin" + ? el("div", { className: "lvl" }, + document.createTextNode("Site-specific identity"), + g.npub ? el("div", { className: "key", style: "margin-top:2px;font-size:10.5px;opacity:.8", textContent: g.npub }) : null) + : el("div", { className: "lvl", textContent: "Shared Hermes identity" }); + return el("tr", {}, + el("td", { className: "o" }, + el("div", { textContent: g.origin }), + el("div", { className: "lvl", textContent: g.level === "sign" ? "can sign and encrypt" : "can see your public key" }), + profileLine), + el("td", { style: "text-align:right;width:84px" }, + el("button", { className: "sm", textContent: "Revoke", onclick: async () => { + try { await S.invoke("revoke", { origin: g.origin }); flash("Revoked"); refresh(); } + catch (e) { flash(e?.message || "Failed"); } + } }))); + }); put($("grants"), el("table", {}, el("tbody", {}, rows)), el("div", { className: "row", style: "margin-top:10px;justify-content:flex-end" }, diff --git a/home-preload.js b/home-preload.js index f387878f..133ec531 100644 --- a/home-preload.js +++ b/home-preload.js @@ -24,10 +24,6 @@ contextBridge.exposeInMainWorld("home", { // Main pushes an updated list here after a successful remote refresh // (only when the user has not customised locally). home.html re-renders. onCards: (cb) => ipcRenderer.on("home-cards", (_e, list) => cb(list)), - // Current Theseus version — the startpage renders a small - // "Theseus v0.3.X — What's new" link beside the hero that points at - // the release notes. IPC is the same one Settings uses; no network. - getVersion: () => ipcRenderer.invoke("app-version"), }); // Same preload also serves the branded error page (error.html). Handlers in // main.js sender-check for error.html so a random page seeing the API shape diff --git a/home.html b/home.html index bcaf8416..b6a253b9 100644 --- a/home.html +++ b/home.html @@ -12,12 +12,12 @@ html, body { margin: 0; padding: 0; min-height: 100vh; font-family: system-ui, sans-serif; color: var(--ink); } body { background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); } - /* Hero (identity + search). Lives inside the right-hand .main-col, - centered within it. The outer .page grid handles the top padding so - the hero's own top padding is 0 — this keeps the sidebar's top edge - flush with the hero's top edge; the slots start at the same height - as the Theseus logo + search box. */ - .hero { text-align: center; padding: 0 1rem; max-width: 820px; margin: 0 auto; } + /* Hero (identity + search). Centered at the top of the page — the + user's most-used thing on this screen. The slot + cards grid lives + beneath. The mark + wordmark share a translucent panel so they read + on any wallpaper; the tag, search box and hint sit outside it, + centered. */ + .hero { text-align: center; padding: 5vh 1rem 0; max-width: 820px; margin: 0 auto; } .hero .hero-id { display: inline-flex; align-items: center; gap: 14px; padding: 12px 24px 12px 14px; border-radius: 22px; background: rgba(11,14,20,.55); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); @@ -39,58 +39,38 @@ .hero .hint { color: var(--mut); font-size: 12.5px; margin-top: .8rem; display: inline-block; padding: 4px 12px; border-radius: 999px; background: rgba(11,14,20,.42); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } - /* "Theseus v0.3.X — What's new ↗" line under the hint. Muted so it does - not pull attention from the search box, but visible enough that a user - who just updated has a one-click path to the release notes. Dynamic: - version comes from the running app via home-preload. */ - .hero .ver-line { margin: .6rem 0 0; font-size: 11.5px; } - .hero .ver-line a { color: var(--mut); text-decoration: none; - padding: 2px 10px; border-radius: 999px; - background: rgba(11,14,20,.3); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } - .hero .ver-line a:hover { color: var(--acid); } - /* Page-level 2-column layout: the sidebar is a left column that starts - at the top of the page (same top edge as the hero's title) and spans - the full height of the content, so the slot grid sits beside the - Theseus logo + search box instead of below them. The main column - stacks the hero (title + search + version link) on top of the Silent - Mode cards. Narrow viewports collapse to one column: sidebar first, - then hero, then cards. */ - .page { display: grid; grid-template-columns: minmax(240px, 280px) minmax(380px, 1fr); - gap: 28px; max-width: 1680px; margin: 0 auto; - padding: 32px 32px 48px; align-items: start; } - .main-col { min-width: 0; display: flex; flex-direction: column; gap: 2rem; } - @media (max-width: 900px) { .page { grid-template-columns: 1fr; gap: 20px; padding: 14px; } - .main-col { gap: 1.4rem; } } + /* Two sections under the hero: a short "Your links" slot grid on the + LEFT (3 cols × 3 rows by default — matches the SM cards grid in + height so the two columns read as a single row of panels) and the + Silent Mode cards on the RIGHT. Each sits in its own soft panel so + they read as grouped collections, not a shared body. Collapses to + one column on narrow viewports. */ + .homepage { display: grid; grid-template-columns: minmax(260px, 300px) minmax(380px, 1fr); + gap: 24px; max-width: 1680px; margin: 2.4rem auto 0; padding: 0 32px 48px; align-items: start; } + @media (max-width: 900px) { .homepage { grid-template-columns: 1fr; gap: 20px; padding: 0 14px 32px; } } - /* The RIGHT column keeps its subtle panel surface — the Silent Mode cards - read as a grouped collection and benefit from the soft tint. The LEFT - column opts out via the chromeless class below, which overrides this. */ .section { background: rgba(255,255,255,.025); border-radius: 16px; padding: 16px 16px 20px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03); width: 100%; min-width: 0; } body.light .section { background: rgba(0,0,0,.03); box-shadow: inset 0 0 0 1px rgba(0,0,0,.03); } + /* The slot column opts out of the section panel chrome — slots float on + the page background so the icons read as "yours, loose on the desktop" + while the SM cards stay in a grouped panel on the right. */ .section.chromeless { background: transparent; box-shadow: none; padding: 4px 0 0; } body.light .section.chromeless { background: transparent; box-shadow: none; } .section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .9px; color: var(--mut); margin: 0 2px 14px; } - /* LEFT AREA — a fixed 3-column grid of slots. Each slot holds one link - tile; empty slots are invisible by default and reveal subtly on - container hover so the user can see where a dragged tile will land. - 3 × 72 px tiles + 2 × 8 px gaps = 232 px — the sidebar column is sized - to that so the grid sits flush with the hero and doesn't float in - half-empty whitespace. */ - /* The sidebar grid cell stretches to match the main column's height, so - the sidebar panel is as tall as the hero + Silent Mode cards combined. - The slots-list fills that full area: 3 columns at 1fr each (stretch to - sidebar width — the slot's inner tile stays 48 px and centers inside - the wider cell), and enough rows to cover the sidebar's vertical span - (row count computed in render() from the main-col height, so a taller - page gets more rows automatically). */ - .sidebar { align-self: stretch; } - .slots-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px; - width: 100%; align-items: start; justify-content: stretch; } + /* LEFT COLUMN — a fixed 3×3 slot grid (72 px cells, 232 px wide). The + grid centers inside its panel so it doesn't lean to one side when + the panel is wider than the slot grid. Empty slots stay invisible + by default and reveal subtly on hover, acting as drop targets AND + click-to-add surfaces. */ + .left-column { align-self: start; } + .slots-list { display: grid; grid-template-columns: repeat(3, 72px); gap: 10px 8px; + width: max-content; align-items: start; justify-content: start; + margin: 0 auto; } .cards-grid.drop-target { outline: 2px dashed var(--acid); outline-offset: 4px; border-radius: 14px; } .slot { position: relative; display: flex; flex-direction: column; align-items: center; @@ -145,11 +125,10 @@ .slot-empty:hover::before { border-color: rgb(from var(--acid) r g b / .35); background: rgb(from var(--acid) r g b / .03); } .slot-empty.drop-hot::before { border-color: var(--acid); background: rgb(from var(--acid) r g b / .14); } - /* Silent Mode cards area — lives under the hero inside .main-col. The - .cards-wrap keeps the subtle panel surface (background, inset border, - rounded corners) so the cards read as a grouped collection; the - sidebar is chromeless so only this side has panel chrome. */ - .cards-wrap { align-self: stretch; } + /* RIGHT COLUMN — the Silent Mode cards. Keeps its subtle panel surface + (background, inset border, rounded corners) same as the slot panel + on the left, so the two columns match. */ + .right-column { display: flex; flex-direction: column; gap: 22px; min-width: 0; } .cards-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-content: start; } @media (max-width: 980px) { .cards-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 560px) { .cards-grid { grid-template-columns: 1fr; } } @@ -266,8 +245,6 @@ body.light .card .card-title, body.light .card .card-sub { color: var(--ink); } body.light .card .card-sub { color: var(--mut); } - body.light .hero .ver-line a { background: rgba(255,255,255,.5); color: var(--mut); } - body.light .hero .ver-line a:hover { color: var(--acid); } body.light .card-add { background: rgba(255,255,255,.35); color: var(--mut); border-color: var(--line2); } body.light .card-add:hover { color: var(--acid); border-color: var(--acid); } body.light .slot .label { color: #0b0e14; text-shadow: 0 1px 2px rgba(255,255,255,.65); } @@ -294,35 +271,33 @@ -

- -
-
-
-
- - - - - - - -
-

Theseus Navigator

-
-

A browser that follows the thread. Names that live on decentralized blockchain.

-
- - -
-

Tip: type hello.bch in the address bar above to open a decentralized site.

- -
-
-
-
+
+
+
@@ -438,7 +413,7 @@ function defaultState() { return { version: 6, - iconSize: "medium", + iconSize: "large", background: { kind: "preset", value: "dark" }, sections: { sm: [ @@ -525,7 +500,7 @@ // hover of the grid. The "+" cell sits at the end so adding is always // one click away. const q = state.sections.q || []; - const slotCells = layoutSlots(q, minSlotsForFill()); + const slotCells = layoutSlots(q); const list = $("slotsList"); list.innerHTML = ""; slotCells.forEach((entry, cellIdx) => { @@ -579,25 +554,13 @@ $("ctxIconSizeVal").textContent = state.iconSize; } - // Compute how many slot rows fit the vertical space the sidebar covers. - // Sidebar height = main-col height (align-self: stretch on the aside), - // so basing the row count on main-col keeps the slot grid filling the - // whole sidebar panel instead of ending halfway down. Falls back to 24 - // (8 rows) when the layout hasn't been measured yet (first render). - function minSlotsForFill() { - const main = document.querySelector(".main-col"); - const h = main ? main.getBoundingClientRect().height : 0; - const rowH = 78; // .slot-empty height (68) + .slots-list row gap (10) - const rows = h > 0 ? Math.max(6, Math.floor(h / rowH)) : 8; - return rows * 3; - } // Lay out the q[] items into a fixed-slot grid: pinned items (with a // `slot: number`) go into that cell; unpinned items flow into the first // free cell, top-to-bottom. The grid always extends to at least one - // trailing empty row for drop space. minSlots defaults to 18 (3 × 6) - // when nothing better is passed; render() passes minSlotsForFill() so - // the grid actually covers the whole sidebar height. - function layoutSlots(items, minSlots = 18) { + // trailing empty row for drop space. minSlots defaults to 9 (3 × 3) — + // matches the SM cards grid height on the right, so the two columns + // read as a single row of panels. + function layoutSlots(items, minSlots = 9) { const pinned = []; const unpinned = []; items.forEach((ic, i) => { const s = Number.isInteger(ic.slot) && ic.slot >= 0 ? ic.slot : null; @@ -793,6 +756,10 @@ function tryParse(s) { try { return JSON.parse(s); } catch { return null; } } function moveItem(fromSec, fromI, toSec, toIdx) { const [m] = state.sections[fromSec].splice(fromI, 1); + // Moving a slot (q) into cards (sm): drop the pinned-slot anchor — + // slot is a q-only concept, and leaving it on an sm card would wire + // the item back to a slot cell if the user ever moved it back. + if (fromSec === "q" && toSec !== "q" && Number.isInteger(m.slot)) delete m.slot; if (fromSec === toSec && toIdx > fromI) toIdx--; toIdx = Math.max(0, Math.min(state.sections[toSec].length, toIdx)); state.sections[toSec].splice(toIdx, 0, m); @@ -1021,21 +988,6 @@ if (name) $("q").placeholder = "Search the web with " + name; }).catch(() => {}); } - // Running-version footer: "Theseus v0.3.X — What's new ↗". Uses - // home.navigate so it opens release notes in this tab, same as any - // other link on the startpage. Hidden until the version resolves. - if (window.home && window.home.getVersion) { - window.home.getVersion().then((v) => { - if (!v) return; - const line = $("verLine"), a = $("verLink"); - a.textContent = "Theseus v" + v + " — What’s new ↗"; - a.addEventListener("click", (e) => { - e.preventDefault(); - window.home.navigate("https://silentmode.st/releases"); - }); - line.hidden = false; - }).catch(() => {}); - } // Container-level drop handlers accept cross-column drops onto empty space. wireContainer($("slotsList"), "q"); wireContainer($("cardsGrid"), "sm"); @@ -1046,33 +998,13 @@ let loaded = null; try { if (window.home && window.home.getCards) loaded = await window.home.getCards(); } catch {} state = ensureShape(loaded) || defaultState(); - applyBg(); - render(); - // The first render runs before the main column has laid out, so the - // row count comes from the fallback (24). Re-render once the layout - // settles so the slot grid fills the sidebar vertically. - requestAnimationFrame(() => render()); + applyBg(); render(); if (window.home && window.home.onCards) { window.home.onCards((data) => { const next = ensureShape(data); if (next) { state = next; applyBg(); render(); } }); } - // Resize the slot grid when the viewport changes so new rows appear - // (or drop off) to match the sidebar's new height. ResizeObserver on - // the main column catches both window resizes and content-driven - // height changes (e.g. new cards being added). - const main = document.querySelector(".main-col"); - if (main && window.ResizeObserver) { - let lastRows = -1, t = null; - const ro = new ResizeObserver(() => { - const rows = Math.floor(minSlotsForFill() / 3); - if (rows === lastRows) return; - lastRows = rows; - clearTimeout(t); t = setTimeout(() => render(), 80); - }); - ro.observe(main); - } })(); diff --git a/lib/hermes.js b/lib/hermes.js index 70f44531..6803dddb 100644 --- a/lib/hermes.js +++ b/lib/hermes.js @@ -89,6 +89,30 @@ export function nostrKeyFromRoot(root) { return { sk, pkHex, npub: nip19.npubEncode(pkHex) }; } +/** Per-origin Nostr identity: HKDF(messengerRoot, info = "nostr-origin/v1/") + * gives each site its own secp256k1 key, so a page at primal.net and one at + * nostrudel.ninja see different npubs and cannot correlate the user across + * Nostr clients. The derivation is deterministic from messengerRoot, so the + * same mnemonic rebuilds every per-origin key. Compromising messengerRoot + * (= compromising the Hermes identity) exposes every per-origin key too — + * domain separation is only against the pages, not against seed theft. */ +export async function nostrKeyFromRootForOrigin(root, origin) { + const rootBytes = typeof root === "string" + ? Uint8Array.from(Buffer.from(root, "hex")) + : root; + if (!(rootBytes instanceof Uint8Array) || rootBytes.length !== 32) { + throw new Error("nostrKeyFromRootForOrigin: expected 32-byte Uint8Array or 64-char hex"); + } + const originStr = String(origin || "").trim().toLowerCase(); + if (!originStr) throw new Error("nostrKeyFromRootForOrigin: origin is required"); + const key = await wc.subtle.importKey("raw", rootBytes, "HKDF", false, ["deriveBits"]); + const info = enc.encode(`silentmode/nostr-origin/v1/${originStr}`); + const sk = new Uint8Array(await wc.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256)); + const pkHex = getPublicKey(sk); + return { sk, pkHex, npub: nip19.npubEncode(pkHex) }; +} + // ---- record parsers -------------------------------------------------------- // The BNS resolver is transparent: whatever the name owner published in `r` // lands as entry.records.. Here we validate and normalise the two Hermes @@ -309,13 +333,20 @@ export async function hashPhone(input) { export const PHONE_DTAG_PREFIX = "hermes.phone/"; -export async function buildPhoneClaimEvent({ sk, phone } = {}) { +export async function buildPhoneClaimEvent({ sk, phone, attestation } = {}) { if (!sk) throw new Error("sk required"); const hash = await hashPhone(phone); + const tags = [["d", PHONE_DTAG_PREFIX + hash]]; + // Embed a Hermes-verify attestation when the caller has one. Format: + // ["att", "tg::"] — resolvers verify + // against the pinned Ed25519 pubkey. See VPS/SilentMode/hermes-verify. + if (attestation && attestation.payload && attestation.sig) { + tags.push(["att", "tg:" + attestation.payload + ":" + attestation.sig]); + } return finalizeEvent({ kind: 30078, content: "{}", - tags: [["d", PHONE_DTAG_PREFIX + hash]], + tags, created_at: Math.floor(Date.now() / 1000), }, sk); } diff --git a/main.js b/main.js index a8ca4a4b..9a9cbdf4 100644 --- a/main.js +++ b/main.js @@ -11397,23 +11397,47 @@ function parseVcardContacts(text) { // Policy — which origin may call, and whether to prompt — is NOT here. The // add-on owns that, because it needs the approval modal and per-origin // storage; this stays a pure crypto surface that fails closed when locked. -let nostrSignerInst = null; -async function nostrSigner() { - if (nostrSignerInst) return nostrSignerInst; +// Signer cache keyed by `${profileId}:${origin||""}`. Each entry wraps one +// derived secret key. profileId "hermes" uses the Hermes identity (same key +// Hermes signs its DMs and public notes with); profileId "origin" uses an +// HKDF derivation per web origin (different npub per site, blocks cross-site +// correlation on Nostr). getSk reads vaultState fresh each call so a lock / +// unlock cycle flips every cached signer to "unavailable" without a refresh. +const nostrSignerCache = new Map(); +async function nostrSignerFor({ profileId, origin }) { + const key = `${profileId || "hermes"}:${origin || ""}`; + const hit = nostrSignerCache.get(key); + if (hit) return hit; const { createSigner } = await import(`file://${path.join(__dirname, "lib", "nostr-signer.js").replace(/\\/g, "/")}`); const H = await loadHermesLib(); - nostrSignerInst = createSigner({ + const inst = createSigner({ getSk: () => { - // Only mnemonic vaults carry messengerRoot; a vault without one has no - // Nostr identity at all, which is a different problem from "locked". if (!vaultState || !vaultState.messengerRoot) return null; + if (profileId === "origin") { + if (!origin) return null; + // nostrKeyFromRootForOrigin is async; the signer's getSk is sync. + // Keep the per-origin sk hot in a module-level cache so the async + // derivation only runs once per (vault, origin) pair. + const ck = `${vaultState.messengerRoot}|${origin}`; + return nostrOriginSkCache.get(ck) || null; + } return H.nostrKeyFromRoot(vaultState.messengerRoot).sk; }, }); - return nostrSignerInst; + if (profileId === "origin" && origin) { + // Prime the per-origin sk cache before the signer is used. + const ck = `${vaultState.messengerRoot}|${origin}`; + if (!nostrOriginSkCache.has(ck)) { + const { sk } = await H.nostrKeyFromRootForOrigin(vaultState.messengerRoot, origin); + nostrOriginSkCache.set(ck, sk); + } + } + nostrSignerCache.set(key, inst); + return inst; } +const nostrOriginSkCache = new Map(); // `${messengerRootHex}|${origin}` -> sk global._nostrAPI = { - async status() { + async status({ profileId, origin } = {}) { const hasVault = !!vaultState; const hasIdentity = !!(vaultState && vaultState.messengerRoot); if (!hasIdentity) { @@ -11424,15 +11448,37 @@ global._nostrAPI = { : "Unlock the password vault to use your Nostr identity.", }; } - const s = await nostrSigner(); - return { ok: true, ready: true, pubkey: s.getPublicKey(), npub: s.npub() }; + const pid = profileId || "hermes"; + if (pid === "origin" && !origin) { + return { ok: true, ready: false, reason: "the per-origin profile needs an origin" }; + } + const s = await nostrSignerFor({ profileId: pid, origin }); + return { ok: true, ready: true, profileId: pid, pubkey: s.getPublicKey(), npub: s.npub() }; + }, + async getPublicKey({ profileId, origin } = {}) { + const s = await nostrSignerFor({ profileId: profileId || "hermes", origin }); + return { ok: true, pubkey: s.getPublicKey() }; + }, + async signEvent({ event, profileId, origin } = {}) { + const s = await nostrSignerFor({ profileId: profileId || "hermes", origin }); + return { ok: true, event: s.signEvent(event) }; + }, + async nip04Encrypt({ peer, plaintext, profileId, origin } = {}) { + const s = await nostrSignerFor({ profileId: profileId || "hermes", origin }); + return { ok: true, result: await s.nip04Encrypt(peer, plaintext) }; + }, + async nip04Decrypt({ peer, ciphertext, profileId, origin } = {}) { + const s = await nostrSignerFor({ profileId: profileId || "hermes", origin }); + return { ok: true, result: await s.nip04Decrypt(peer, ciphertext) }; + }, + async nip44Encrypt({ peer, plaintext, profileId, origin } = {}) { + const s = await nostrSignerFor({ profileId: profileId || "hermes", origin }); + return { ok: true, result: s.nip44Encrypt(peer, plaintext) }; + }, + async nip44Decrypt({ peer, ciphertext, profileId, origin } = {}) { + const s = await nostrSignerFor({ profileId: profileId || "hermes", origin }); + return { ok: true, result: s.nip44Decrypt(peer, ciphertext) }; }, - async getPublicKey() { return { ok: true, pubkey: (await nostrSigner()).getPublicKey() }; }, - async signEvent({ event } = {}) { return { ok: true, event: (await nostrSigner()).signEvent(event) }; }, - async nip04Encrypt({ peer, plaintext } = {}) { return { ok: true, result: await (await nostrSigner()).nip04Encrypt(peer, plaintext) }; }, - async nip04Decrypt({ peer, ciphertext } = {}) { return { ok: true, result: await (await nostrSigner()).nip04Decrypt(peer, ciphertext) }; }, - async nip44Encrypt({ peer, plaintext } = {}) { return { ok: true, result: (await nostrSigner()).nip44Encrypt(peer, plaintext) }; }, - async nip44Decrypt({ peer, ciphertext } = {}) { return { ok: true, result: (await nostrSigner()).nip44Decrypt(peer, ciphertext) }; }, }; global._hermesAPI = { diff --git a/package.json b/package.json index 081bb161..cae07aee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.131", + "version": "0.3.132", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode",