Jason Tudisco fcfaec675a Add NIP-46 signer, optional NIP-07 extension, passkey protection, backups
- NIP-46 remote signer in the page (bunker:// and nostrconnect:// pairing)
- Chrome extension (NIP-07) with per-site permission prompts, unlocked by
  the site via externally_connectable; downloadable from the page
- Passkey (WebAuthn PRF) encryption of the Drive key file
- NIP-49 backups: download or save to Drive; reveal nsec
- Fix: oldest key file wins when two devices sign up at once
- Fix: never overwrite an existing kind-0 because a relay was slow
- Fix: EOSE tracking; relays reconnect with backoff; verify event signatures
- One-click re-sign-in (consent only the first time)
- Vendored, pinned nostr-tools; CSP; no third-party scripts but Google's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:33:02 -06:00

491 lines
16 KiB
JavaScript

import { CLIENT_ID, RELAYS, EXTENSION_ID, EXTENSION_STORE_URL } from "./config.js";
import { signIn, restoreSession, clearSession, isConfigured } from "./goostr.js";
import { pool } from "./relay.js";
import { createBunker } from "./bunker.js";
import { passkeysAvailable } from "./passkey.js";
const $ = (id) => document.getElementById(id);
const VIEWS = {
loading: ["view-loading"],
signin: ["view-signin"],
home: ["view-home", "view-connect", "view-key"],
};
function show(view) {
const on = new Set(VIEWS[view]);
for (const ids of Object.values(VIEWS)) for (const id of ids) $(id).classList.toggle("hidden", !on.has(id));
}
function setStatus(id, msg, kind = "") {
const el = $(id);
el.textContent = msg;
el.className = "status" + (kind ? " " + kind : "");
}
const errMsg = (err) => err?.message || String(err);
const ago = (t) => {
const s = Math.round((Date.now() - t) / 1000);
if (s < 60) return "just now";
if (s < 3600) return `${Math.round(s / 60)}m ago`;
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
return new Date(t).toLocaleDateString();
};
// Opened by the extension's "Unlock" button: hand the key over once signed in.
const extensionUnlockRequested = location.hash === "#extension-unlock";
let session = null;
let signer = null;
let bunker = null;
let publishedProfileTs = 0; // most-recent kind-0 we've published or rendered
const DEFAULT_AVATAR = `data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<rect width='64' height='64' rx='32' fill='#1e2128'/>
<text x='32' y='42' font-size='32' text-anchor='middle' font-family='sans-serif'>👤</text>
</svg>`
)}`;
// ---------- sign-in ----------
$("signin-go").addEventListener("click", async () => {
$("signin-go").disabled = true;
try {
session = await signIn({
clientId: CLIENT_ID,
onStatus: (msg) => setStatus("signin-status", msg),
// Passkey prompts need a fresh click (Safari), so wait for one.
onPasskeyNeeded: () =>
new Promise((resolve) => {
const b = $("signin-passkey");
b.classList.remove("hidden");
b.onclick = () => { b.classList.add("hidden"); resolve(); };
}),
});
signer = session.signer;
setStatus("signin-status", "Connecting to relays…");
const unsubStatus = pool.onStatus((snap) => {
const open = Object.values(snap).filter((s) => s === "open").length;
setStatus("signin-status", `Connecting to relays… ${open}/${Object.keys(snap).length}`);
});
await pool.connectAll({ minRelays: 1 });
unsubStatus();
if (session.isNewIdentity) {
// Brand-new key: nothing can exist on relays yet, so publish the Google
// name + picture right away. Late relays get it via the pool's replay.
publishProfileEvent(session.profile);
}
enterHome();
} catch (err) {
console.error(err);
$("signin-passkey").classList.add("hidden");
setStatus("signin-status", errMsg(err), "error");
$("signin-go").disabled = false;
}
});
$("sign-out").addEventListener("click", () => {
clearSession();
location.replace(location.pathname);
});
// ---------- home / live profile / notes ----------
function publishProfileEvent(profile) {
const event = signer.sign({
kind: 0,
content: JSON.stringify({ name: profile.name, picture: profile.picture }),
tags: [],
});
pool.publish(event);
publishedProfileTs = event.created_at;
}
function enterHome() {
const { profile } = session;
$("me-name").textContent = profile.name || "anonymous";
$("me-pic").src = profile.picture || DEFAULT_AVATAR;
$("me-npub").textContent = signer.npub;
show("home");
pool.onStatus(renderRelayDots);
startLiveProfile(profile);
loadNotes();
initExtension();
initBunker();
renderKey();
}
function renderRelayDots(snap) {
const host = $("relay-dots");
host.innerHTML = "";
for (const [url, state] of Object.entries(snap)) {
const dot = document.createElement("span");
dot.className = `relay-dot ${state}`;
dot.title = `${url} — ${state}`;
host.appendChild(dot);
}
}
function safeJSON(s) { try { return JSON.parse(s); } catch { return {}; } }
function startLiveProfile(googleProfile) {
// Long-lived sub for the latest kind-0. If a relay has a newer event than
// what's on screen, upgrade.
let sawOne = false;
pool.subscribe(
[{ kinds: [0], authors: [signer.pubkey] }],
(ev) => {
sawOne = true;
if (ev.created_at <= publishedProfileTs) return;
const p = safeJSON(ev.content);
publishedProfileTs = ev.created_at;
if (p.name) $("me-name").textContent = p.name;
if (p.picture) $("me-pic").src = p.picture;
},
(answered) => {
// Fill in a profile only when relays positively report there is none.
// A slow relay must never overwrite a profile set in another app.
if (sawOne || publishedProfileTs || !googleProfile?.name) return;
if (answered >= Math.min(2, pool.sockets.size) && answered > 0) publishProfileEvent(googleProfile);
}
);
}
let notesById = new Map();
function loadNotes() {
const tbody = $("notes-table").querySelector("tbody");
tbody.innerHTML = "";
setStatus("notes-status", "Loading…");
notesById = new Map();
pool.subscribe(
[{ kinds: [1], authors: [signer.pubkey], limit: 50 }],
(ev) => {
if (notesById.has(ev.id)) return;
notesById.set(ev.id, ev);
renderRows([...notesById.values()], tbody);
setStatus("notes-status", "");
},
() => {
if (notesById.size === 0) setStatus("notes-status", "No notes yet. Publish one above.");
}
);
}
function renderRows(rows, tbody) {
const sorted = [...rows].sort((a, b) => b.created_at - a.created_at);
tbody.innerHTML = "";
for (const ev of sorted) {
const tr = document.createElement("tr");
const t = document.createElement("td");
t.textContent = new Date(ev.created_at * 1000).toLocaleString();
const c = document.createElement("td");
c.textContent = ev.content;
tr.appendChild(t);
tr.appendChild(c);
tbody.appendChild(tr);
}
}
$("note-send").addEventListener("click", () => {
const text = $("note-text").value.trim();
if (!text) return;
const event = signer.sign({ kind: 1, content: text, tags: [] });
pool.publish(event);
$("note-text").value = "";
notesById.set(event.id, event);
renderRows([...notesById.values()], $("notes-table").querySelector("tbody"));
setStatus("notes-status", "");
});
// ---------- browser extension (NIP-07) ----------
const hasExtensionApi = () => !!globalThis.chrome?.runtime?.sendMessage;
async function extensionCall(msg) {
if (!EXTENSION_ID || !hasExtensionApi()) return null;
try {
const r = await chrome.runtime.sendMessage(EXTENSION_ID, msg);
if (r?.error) throw new Error(r.error);
return r;
} catch (err) {
if (msg.type === "status") return null; // not installed
throw err;
}
}
async function renderExtension() {
for (const id of ["ext-install", "ext-connect", "ext-lock", "ext-store", "ext-download", "ext-steps"]) $(id).classList.add("hidden");
if (!globalThis.chrome) {
setStatus("ext-status", "This browser can't run the extension. Use the remote signer below instead.");
return null;
}
const st = await extensionCall({ type: "status" });
if (!st) {
setStatus("ext-status", "Not installed.");
$("ext-install").classList.remove("hidden");
if (EXTENSION_STORE_URL) {
$("ext-store").href = EXTENSION_STORE_URL;
$("ext-store").classList.remove("hidden");
} else {
$("ext-download").classList.remove("hidden");
$("ext-steps").classList.remove("hidden");
}
} else if (st.pubkey === signer.pubkey) {
setStatus("ext-status", "✓ Installed and signing as you.", "ok");
$("ext-lock").classList.remove("hidden");
} else {
setStatus("ext-status", st.pubkey ? "Installed, but holding a different identity." : "Installed and locked.");
$("ext-connect").classList.remove("hidden");
}
return st;
}
async function unlockExtension() {
await extensionCall({
type: "setKey",
sk: session.keyHex(),
profile: { name: session.profile.name, picture: session.profile.picture },
});
return renderExtension();
}
async function initExtension() {
const st = await renderExtension();
if (extensionUnlockRequested) {
history.replaceState(null, "", location.pathname);
if (!st) return;
try {
await unlockExtension();
const b = $("ext-unlock-banner");
b.textContent = "✓ The Goostr extension is unlocked. You can close this tab.";
b.classList.remove("hidden");
} catch (err) {
setStatus("ext-status", errMsg(err), "error");
}
}
}
$("ext-connect").addEventListener("click", () =>
unlockExtension().catch((err) => setStatus("ext-status", errMsg(err), "error"))
);
$("ext-lock").addEventListener("click", async () => {
await extensionCall({ type: "lock" }).catch(() => {});
renderExtension();
});
// A page only gets chrome.runtime if a matching extension was installed
// before it loaded, so a fresh install needs a reload to be seen.
$("ext-recheck").addEventListener("click", () => (hasExtensionApi() ? renderExtension() : location.reload()));
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && signer) renderExtension();
});
// ---------- remote signer (NIP-46) ----------
function initBunker() {
bunker = createBunker({ signer, pool, relays: RELAYS.slice(0, 3), onChange: renderBunker });
if (localStorage.getItem(bunkerOnKey()) === "1") bunker.start();
renderBunker();
}
const bunkerOnKey = () => `goostr.bunker.on.${signer.pubkey}`;
function setBunkerOn(on) {
try { localStorage.setItem(bunkerOnKey(), on ? "1" : "0"); } catch {}
on ? bunker.start() : bunker.stop();
}
function renderBunker() {
if (!bunker) return;
setStatus("bunker-state", bunker.running ? "● Running in this tab" : "Off", bunker.running ? "ok" : "");
$("bunker-toggle").textContent = bunker.running ? "Turn off" : "Turn on";
const clients = Object.entries(bunker.clients);
$("bunker-no-clients").classList.toggle("hidden", clients.length > 0);
$("bunker-clients").replaceChildren(
...clients.map(([pk, c]) => {
const li = document.createElement("li");
const who = document.createElement("span");
who.className = "who";
who.textContent = c.name;
who.title = pk;
const when = document.createElement("span");
when.className = "when";
when.textContent = `used ${ago(c.lastSeen)}`;
const rm = document.createElement("button");
rm.className = "ghost";
rm.textContent = "Remove";
rm.onclick = () => bunker.revoke(pk);
li.append(who, when, rm);
return li;
})
);
$("bunker-no-log").classList.toggle("hidden", bunker.log.length > 0);
$("bunker-log").replaceChildren(
...bunker.log.map((e) => {
const li = document.createElement("li");
if (!e.ok) li.className = "fail";
const what = document.createElement("span");
what.className = "what";
what.textContent = `${e.who}: ${e.method}${e.detail ? ` (${e.detail})` : ""}`;
const when = document.createElement("span");
when.className = "when";
when.textContent = new Date(e.t).toLocaleTimeString();
li.append(what, when);
return li;
})
);
}
$("bunker-toggle").addEventListener("click", () => setBunkerOn(!bunker.running));
$("bunker-new").addEventListener("click", () => {
setBunkerOn(true);
$("bunker-link").value = bunker.newBunkerUrl();
$("bunker-link-box").classList.remove("hidden");
$("bunker-link").select();
});
$("bunker-copy").addEventListener("click", async () => {
await navigator.clipboard.writeText($("bunker-link").value);
$("bunker-copy").textContent = "Copied";
setTimeout(() => ($("bunker-copy").textContent = "Copy"), 1500);
});
$("nc-go").addEventListener("click", async () => {
try {
setBunkerOn(true);
await bunker.acceptNostrConnect($("nc-link").value);
$("nc-link").value = "";
setStatus("bunker-status", "Connected. Switch back to the app.", "ok");
} catch (err) {
setStatus("bunker-status", errMsg(err), "error");
}
});
// Warn before closing the tab while apps depend on it.
addEventListener("beforeunload", (e) => {
if (bunker?.running && Object.keys(bunker.clients).length) e.preventDefault();
});
// ---------- key protection, backup, export ----------
function renderKey() {
const p = session.protection;
$("key-protection").textContent =
p.type === "passkey"
? `🔒 Encrypted with a passkey for ${p.rpId}.`
: "Stored in your Google Drive app data, not encrypted. Anyone who gets into your Google account can read it.";
$("pk-enable").classList.toggle("hidden", p.type === "passkey" || !passkeysAvailable());
$("pk-disable").classList.toggle("hidden", p.type !== "passkey");
if (!passkeysAvailable()) setStatus("pk-status", "This browser doesn't support passkeys.");
}
$("pk-enable").addEventListener("click", async () => {
$("pk-enable").disabled = true;
setStatus("pk-status", "Follow your browser's passkey prompt…");
try {
await session.enablePasskey();
setStatus("pk-status", "Done. Other devices will ask for this passkey when you sign in.", "ok");
} catch (err) {
setStatus("pk-status", errMsg(err), "error");
}
$("pk-enable").disabled = false;
renderKey();
});
$("pk-disable").addEventListener("click", async () => {
if (!confirm("Store the key in Google Drive without passkey encryption again?")) return;
try {
await session.disablePasskey();
setStatus("pk-status", "Passkey protection removed.", "ok");
} catch (err) {
setStatus("pk-status", errMsg(err), "error");
}
renderKey();
});
function backupPassword() {
const a = $("bk-pw1").value;
if (a.length < 8) throw new Error("Use at least 8 characters.");
if (a !== $("bk-pw2").value) throw new Error("The passwords don't match.");
return a;
}
$("bk-download").addEventListener("click", () => {
try {
const pw = backupPassword();
setStatus("bk-status", "Encrypting…");
// Let the status paint before scrypt blocks the thread for a moment.
setTimeout(() => {
const text = `${session.ncryptsec(pw)}\n\nGoostr backup for ${signer.npub}\nEncrypted with NIP-49. Import it into any NIP-49 signer with your backup password.\n`;
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
a.download = `goostr-backup-${signer.npub.slice(0, 14)}.txt`;
a.click();
URL.revokeObjectURL(a.href);
setStatus("bk-status", "Backup downloaded. Keep the file and the password somewhere safe (separately).", "ok");
}, 30);
} catch (err) {
setStatus("bk-status", errMsg(err), "error");
}
});
$("bk-drive").addEventListener("click", async () => {
try {
const pw = backupPassword();
setStatus("bk-status", "Saving to Google Drive (Google will ask for file access)…");
const link = await session.backupToDrive(pw);
const el = $("bk-status");
el.className = "status ok";
el.textContent = "Saved to your Google Drive. ";
if (link) {
const a = document.createElement("a");
a.href = link;
a.target = "_blank";
a.rel = "noopener";
a.textContent = "Open it";
el.append(a);
}
} catch (err) {
setStatus("bk-status", errMsg(err), "error");
}
});
$("nsec-reveal").addEventListener("click", () => {
const box = $("nsec-box");
const showing = !box.classList.contains("hidden");
$("nsec-value").textContent = showing ? "" : session.nsec();
box.classList.toggle("hidden", showing);
$("nsec-reveal").textContent = showing ? "Reveal nsec" : "Hide nsec";
});
$("nsec-copy").addEventListener("click", async () => {
await navigator.clipboard.writeText(session.nsec());
$("nsec-copy").textContent = "Copied";
setTimeout(() => ($("nsec-copy").textContent = "Copy"), 1500);
});
// ---------- boot ----------
(async function boot() {
if (!isConfigured(CLIENT_ID)) {
$("setup-banner").classList.remove("hidden");
show("signin");
setStatus("signin-status", "Configure CLIENT_ID in public/config.js first.", "error");
$("signin-go").disabled = true;
return;
}
session = restoreSession({ clientId: CLIENT_ID });
if (session) {
signer = session.signer;
show("loading");
$("loading-text").textContent = "Welcome back. Connecting to relays…";
await pool.connectAll({ minRelays: 1 });
enterHome();
return;
}
if (extensionUnlockRequested) {
setStatus("signin-status", "Sign in to unlock the Goostr extension.");
}
show("signin");
})();