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