- 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>
491 lines
16 KiB
JavaScript
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");
|
|
})();
|