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.
323 lines
14 KiB
JavaScript
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)");
|
|
},
|
|
};
|