theseus/bundled-addons/nostr/index.js
Theseus Dev f76ca4f9f4 Theseus 0.3.132: layout revert, Nostr per-site identities, Mail as sidebar quicklink, connector token stays persistent
Startpage layout reverted to the pre-0.3.131 shape. 0.3.131's full-page-
height sidebar was a mistake — slots in a short strip under a hero at
the top, cards and slots sharing one row of panels at the same height,
is what matches the design. .page/.main-col/.sidebar structure dropped,
hero back as a body sibling of .homepage, slots-list back to repeat(3,
72px) with minSlots=9. Two of the user's own tweaks baked into the
defaults: .section.chromeless on the left column so slot icons float
on the page background instead of sitting in a boxed panel, and
defaultState().iconSize = "large" (60 px tiles).

Game.X in the SM cards (replaces SilentMode.X in its slot, stub
description + a built-in gamepad brand mark until game.x goes live).
SilentMode.X moved to the quick-links strip as the 5th slot. moveItem
now strips the pinned-slot anchor when a q item moves to sm — a link
dragged onto the cards used to flip back on the next render because
its slot number was still set.

Nostr NIP-07 signer grows per-site identities (0.1.0 → 0.1.1). First
approval modal picks between the Hermes identity (one npub for DMs
and public notes, the one published as the np record on your BNS
name) and a site-specific HKDF-derived npub (unique per origin, so
Nostr clients can't cross-correlate your activity). The chooser shows
both npubs so the user sees what each button reveals. Grants without
a stored profileId (migrated from 0.3.131) default to the Hermes
identity so existing approvals keep behaving the same.
lib/hermes.js:nostrKeyFromRootForOrigin HKDFs with info
silentmode/nostr-origin/v1/<origin> over the existing 32-byte
messengerRoot. main.js _nostrAPI methods take {profileId, origin};
signer cache per profile.

Silent Mode Mail retired as an extension — now a Sidebar quicklink
(id: silentmail). Its 0.1.0 panel iframed mail.silentmode.st, which
sends X-Frame-Options: DENY; the browser rendered the block page and
STILL fired the iframe's load event, so the 5 s "did it embed?"
watchdog saw it as loaded and the panel stayed black. The whole
extension was just a landing page with an Open Mail button — exactly
what a sidebar quicklink does, without taking a panel slot or an
Extensions row. migrateAegisRename auto-retires any existing
extensions/mail/ to addons-backups/mail-migrated-<stamp>/ on first
boot of this Theseus; "mail" added to the first-party reserved-ids
list so a community add-on can't claim the id.

Claude connector 0.2.1 → 0.2.2: the bearer token no longer rotates on
every server stop. The old stop() generated a fresh token every time
Theseus quit, the panel toggled off, or a 0.3.X install replaced the
app — the Connect button wrote the new token to ~/.claude.json but
any already-running Claude Code or Claude Desktop process held 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
forever. Infinite loop: install Theseus upgrade, click Connect, MCP
still 401, close Claude, open Claude, install another upgrade a day
later, repeat. The explicit Revoke token button still rotates for
users who want fresh keys; everything else is persistent.

Sidebar panel apps blank to about:blank after 2 min of being closed to
free their render tree (iframes, media, listeners). Fast reopen within
the window still reuses the loaded page.

Light theme palette flip under body.light — the full set of
--ink / --mut / --card / --line tokens flip plus hero + card
translucent panels switch to light-glass, so hero titles, SM card
text, slot labels and the ".bch" tld read correctly on the light
background preset instead of as pale ghosts.
2026-10-30 03:45:32 +02:00

323 lines
14 KiB
JavaScript

// Nostr — NIP-07 signer for Theseus.
//
// Splits the same way Hermes does, for the same reason: the identity key is
// derived from the vault and lives in the host (`global._nostrAPI`, backed by
// lib/nostr-signer.js). This file never sees it. What lives HERE is policy —
// which origin may ask for what, when to prompt, and what the prompt says —
// because policy needs UI and storage, and the crypto should stay pure.
//
// Flow for a page call:
// page → nostr-inject.js (window.nostr) → onMessage here → permission check
// → approval modal if needed → global._nostrAPI → signed result back
//
// 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"; // { "<origin>": { 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
// on a trusted origin. Deleting notes or replacing a contact list is not the
// same act as posting.
const ALWAYS_ASK_KINDS = new Map([
[0, "replace your profile"],
[3, "replace your contact list"],
[5, "delete events"],
[10002, "replace your relay list"],
]);
const host = () => {
const api = global._nostrAPI;
if (!api) throw new Error("the Nostr signer is unavailable in this Theseus build");
return api;
};
module.exports = {
activate(api) {
api.registerSidebarPanel({ id: "main", title: "Nostr", page: "panel.html" });
// ---- grant storage ------------------------------------------------------
const allGrants = () => {
const g = api.storage.get(STORE_KEY, {});
return g && typeof g === "object" ? g : {};
};
const grantFor = (origin) => allGrants()[origin] || null;
function setGrant(origin, level, profileId) {
const g = allGrants();
const prev = g[origin] || {};
g[origin] = {
level,
at: Date.now(),
profileId: profileId || prev.profileId || "hermes",
};
api.storage.set(STORE_KEY, g);
}
function dropGrant(origin) {
const g = allGrants();
delete g[origin];
api.storage.set(STORE_KEY, g);
}
function has(origin, need) {
const g = grantFor(origin);
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. 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: rowList,
actions,
checkbox: remember ? { id: "always", label: `Always allow this on ${origin}` } : null,
});
const s = String(r || "");
if (!s.startsWith("allow")) return { ok: false };
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, 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");
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.
function describeEvent(ev) {
const kind = Number(ev && ev.kind);
const special = ALWAYS_ASK_KINDS.get(kind);
if (special) return `kind ${kind} — ${special}`;
if (kind === 1) return "kind 1 — a public note";
if (kind === 7) return "kind 7 — a reaction";
if (kind === 4 || kind === 14) return `kind ${kind} — a direct message`;
return `kind ${kind}`;
}
const preview = (s, n = 180) => {
const t = String(s ?? "");
return t.length > n ? t.slice(0, n) + "…" : t;
};
// ---- page-facing methods ------------------------------------------------
api.onMessage("getPublicKey", async (_p, ctx) => {
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 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({ profileId, origin })).pubkey;
});
api.onMessage("signEvent", async (p, ctx) => {
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");
// 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.",
remember: !special,
chooseProfile,
previews,
rows,
});
if (!r.ok) throw new Error("denied");
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, profileId, origin })).event;
});
const cryptoCall = (name, need, label, legacy) =>
api.onMessage(name, async (p, ctx) => {
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. "
: "This uses NIP-44. ")
+ `The site will be able to ${label} messages between you and the key below for as long as it is allowed.`,
rows: [{ label: "Other party", value: String((p && p.peer) || "?"), mono: true }],
});
const fn = host()[name];
return (await fn.call(host(), { ...(p || {}), profileId, origin })).result;
});
cryptoCall("nip04Encrypt", "sign", "encrypt", true);
cryptoCall("nip04Decrypt", "sign", "read", true);
cryptoCall("nip44Encrypt", "sign", "encrypt", false);
cryptoCall("nip44Decrypt", "sign", "read", false);
// ---- panel methods ------------------------------------------------------
// These come from the add-on's own page (ctx.from === "panel"), never a
// website, so they carry no origin gate — but they must refuse a page.
const panelOnly = (ctx) => {
if (ctx && ctx.from === "page") throw new Error("not available to pages");
};
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();
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);
const origin = String((p && p.origin) || "");
if (!origin) throw new Error("no origin");
dropGrant(origin);
api.log(`revoked ${origin}`);
return { ok: true };
});
api.onMessage("revokeAll", async (_p, ctx) => {
panelOnly(ctx);
api.storage.set(STORE_KEY, {});
api.log("revoked every site");
return { ok: true };
});
api.log("registered NIP-07 signer (per-origin profiles)");
},
};