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.
This commit is contained in:
Theseus Dev 2026-10-30 03:45:32 +02:00 • committed by Hermes Dev
parent 9c29331e5b
commit f76ca4f9f4
11 changed files with 453 additions and 217 deletions

View file

@ -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: `<div class="hero">` and `<div class="homepage">` 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
`<p class="ver-line" id="verLine">` 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/<origin>` 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

View file

@ -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=",

View file

@ -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; });

View file

@ -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+",

View file

@ -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"; // { "<origin>": { level, at, kinds: {} } }
const LEVELS = { read: 1, sign: 2 }; // sign implies read
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
@ -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)");
},
};

View file

@ -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" },

View file

@ -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

204
home.html
View file

@ -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 @@
</style>
</head>
<body>
<div class="page">
<aside class="section chromeless sidebar">
<div class="hero">
<div class="hero-id">
<div class="mark">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
<path d="M32 6 L38 32 L26 32 Z" fill="#e04e4e"/>
<path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/>
<path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity="0.55"/>
<circle cx="32" cy="32" r="3" fill="#0b0e14"/>
</svg>
</div>
<h1>Theseus <span class="g">Navigator</span></h1>
</div>
<p class="tag">A browser that follows the thread. Names that live on decentralized blockchain.</p>
<form id="searchForm">
<input id="q" placeholder="Search the web" autofocus spellcheck="false">
<button type="submit">Search</button>
</form>
<p class="hint">Tip: type <b>hello.bch</b> in the address bar above to open a decentralized site.</p>
</div>
<div class="homepage">
<div class="section chromeless left-column">
<div class="slots-list" id="slotsList"></div>
</aside>
<div class="main-col">
<div class="hero">
<div class="hero-id">
<div class="mark">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
<path d="M32 6 L38 32 L26 32 Z" fill="#e04e4e"/>
<path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/>
<path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity="0.55"/>
<circle cx="32" cy="32" r="3" fill="#0b0e14"/>
</svg>
</div>
<h1>Theseus <span class="g">Navigator</span></h1>
</div>
<p class="tag">A browser that follows the thread. Names that live on decentralized blockchain.</p>
<form id="searchForm">
<input id="q" placeholder="Search the web" autofocus spellcheck="false">
<button type="submit">Search</button>
</form>
<p class="hint">Tip: type <b>hello.bch</b> in the address bar above to open a decentralized site.</p>
<p class="ver-line" id="verLine" hidden><a id="verLink" href="https://silentmode.st/releases"></a></p>
</div>
<div class="section cards-wrap">
<div class="cards-grid" id="cardsGrid"></div>
</div>
</div>
<div class="section right-column">
<div class="cards-grid" id="cardsGrid"></div>
</div>
</div>
@ -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);
}
})();
</script>
</body>

View file

@ -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/<origin>")
* 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.<key>. 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:<base64url payload>:<base64url sig>"] — 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);
}

78
main.js
View file

@ -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 = {

View file

@ -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",