theseus/bundled-addons/nostr/index.js
Theseus Dev 9c29331e5b Theseus 0.3.131: Mail promoted to Sidebar quicklink, Nostr signer ships, panels free RAM when idle, Game.X in SM cards
Silent Mode Mail is no longer an extension. The 0.1.0 panel was a black
screen (iframe to 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-second "did it embed?" watchdog saw it as loaded and the
fallback never swapped in). The whole extension existed to put one "Open
Mail" button in a panel — that is exactly what a sidebar quicklink does,
without taking a panel slot or an Extensions row. So:

  - settings.quickLinks default list gains silentmail at the top
    (Silent Mode Mail → https://mail.silentmode.st/).
  - migrateAegisRename grew a mail retirement block: any existing
    extensions/mail/ folder on the user's box moves to
    addons-backups/mail-migrated-<stamp>/ on first boot of this
    Theseus, so the old extension vanishes from Settings instead of
    sitting there with a broken panel.
  - "mail" added to the first-party reserved-ids list so no community
    add-on can claim the id after retirement.

Nostr NIP-07 signer finally ships. The bundled-addons/nostr/ folder was
sitting untracked in the main checkout, so the detached-HEAD build
worktree 0.3.130 used never saw it (same reason build/, tor/, snapshots/
have to be hand-copied into the worktree). This build committed the
folder and copied it in. Puts window.nostr on https + localhost pages;
identity is Hermes's key (one npub for DMs and public notes). Per-origin
read/sign grants; kinds 0/3/5/10002 re-prompt every time with no
remember option.

Sidebar panel apps free their render tree after idle. closeLeftPanel
used to keep the loaded page warm forever — great for fast reopen, bad
for RAM when the user docks a heavy panel (Hermes, Pithos, Aegis) once
and never comes back. A 2-minute idle timer now blanks the panel to
about:blank after close; reopen within the window still reuses the
loaded page (cancelLeftPanelIdleUnload on open), reopen after the
window reloads fresh. LEFT_PANEL_IDLE_MS = 2 * 60 * 1000. Right-side
sidebar view (webapp tabs — Telegram, X, WhatsApp, AI chat) is longer-
lived by design and untouched here.

Startpage layout:
  - Game.X takes SilentMode.X's slot in the Silent Mode cards (stub
    description + a built-in gamepad brand mark until game.x goes
    live). SilentMode.X moves to the quick-links strip as the 5th
    slot. Still TODO: push the swap into the remote home-cards.json
    for existing profiles.
  - Light-theme palette flip under body.light. The @media
    (prefers-color-scheme: light) block only triggered on OS-level
    light preference; picking the "light" background preset left --ink
    white against a white page, so hero titles, card titles, slot
    labels and the ".bch" extension read as pale ghosts. Added a full
    body.light { --ink; --mut; --card; --line; ... } block plus
    overrides for the hero + card translucent panels (dark-glass →
    light-glass on light theme) and the ctx-menu / modal. Dark theme
    untouched.
2026-10-30 00:38:08 +02:00

203 lines
8.4 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.
const STORE_KEY = "grants"; // { "<origin>": { level, at, kinds: {} } }
const LEVELS = { read: 1, sign: 2 }; // sign implies read
// 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) {
const g = allGrants();
g[origin] = { level, at: Date.now() };
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);
}
// ---- 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 }];
const r = await api.approvalModal({
title, body, origin, rows: rows || [],
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") };
}
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 (!r.ok) throw new Error("denied");
if (r.remember) setGrant(origin, need);
return origin;
}
// 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 st = await host().status();
if (!st.ready) throw new Error(st.reason);
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 }],
});
return (await host().getPublicKey()).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")) {
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: 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,
rows,
});
if (!r.ok) throw new Error("denied");
if (r.remember) setGrant(origin, "sign");
}
return (await host().signEvent({ event: ev })).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, {
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 || {})).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); return host().status(); });
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),
};
});
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");
},
};