From c9791b9840e045d9c77418ff92557c1f6c70973a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:22:06 -0400 Subject: [PATCH] widget: lightweight, widget-free session reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading "who is signed in" on a page required mounting the whole widget (bitlogin.js + crypto worker + wasm, ~1.6MB) because that was the only thing that restored the per-origin session. Multi-page sites paid the full load on every page just to render a "Signed in as …" header. Write a cleartext, public-only record ({ pubkey, npub, name? } — never key material) beside the encrypted session in IndexedDB, in lockstep with it: the worker writes it at the single persistSession() choke point, self-heals it on restoreSession (so sessions cached before this feature upgrade with no re-login), and clears it on logout, so a reported session can never outlive the real one. Add @bitlogin/widget/session -> bitlogin-session.js: a self-contained ~1KB gzip reader (zero imports, no worker/wasm) exposing readCachedSession(): Promise<{ pubkey, npub, name? } | null>. Mirror the same call on window.bitlogin.readCachedSession() for pages that already load the full widget. The private key stays in the AES-GCM record in sessionCache.ts, exactly as protected as before. Reader falls through to null for every failure mode (DB/store absent, IndexedDB blocked), which reads as "not signed in". Addresses the top item of the Tessera integration feedback (docs/TODO.md). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB --- README.md | 25 +++++ docs/TODO.md | 51 ++++++++++ packages/widget/package.json | 4 + packages/widget/src/index.ts | 19 +++- packages/widget/src/publicSession.test.ts | 82 +++++++++++++++ packages/widget/src/publicSession.ts | 80 +++++++++++++++ packages/widget/src/session.ts | 110 +++++++++++++++++++++ packages/widget/src/worker/cryptoWorker.ts | 17 ++++ packages/widget/vite.config.ts | 10 +- 9 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 packages/widget/src/publicSession.test.ts create mode 100644 packages/widget/src/publicSession.ts create mode 100644 packages/widget/src/session.ts diff --git a/README.md b/README.md index 3a0cc30..0abe3cb 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,31 @@ decrypted key sits in this browser's IndexedDB until logout," the same tradeoff a NIP-07 extension already makes. Calling `logout()` (or the element's dashboard "Log out" button) clears it. +**Reading "who's signed in" without loading the widget.** A multi-page site +often wants a persistent "Signed in as …" indicator in the header on *every* +page, but only needs the full widget on the pages where a user actually signs +in or manages their account. Loading `bitlogin.js` (plus the worker and wasm) +on every page just to read the pubkey is a lot of bytes for a label. The +`@bitlogin/widget/session` entry is a ~1 KB, dependency-free reader for exactly +this: + +```html + +``` + +It reads a cleartext, **public-only** record (pubkey / npub / optional display +name — never key material) that the widget writes beside its encrypted session +and clears on logout, so it can't report a session that has ended. It cannot +sign: when the user clicks "sign in" or "manage account," load the full +`` then and defer the heavy cost until it's needed. The same +call is also on the full widget as `window.bitlogin.readCachedSession()`, so +pages that already load `bitlogin.js` can use it without a second import. +Deploy `bitlogin-session.js` alongside `bitlogin.js`. + The `` element also mirrors `window.nostr` directly on the element instance (`getPublicKey`, `signEvent`, `nip44Encrypt`, `nip44Decrypt`) — prefer this over `window.nostr` when your host page holds a reference to diff --git a/docs/TODO.md b/docs/TODO.md index 8cf34c4..e9907d7 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -356,3 +356,54 @@ A developer or AI agent can take `bitlogin.js`, `bitgate.js`, `bitfeed.js`, and `bitblocks.js` and build a functional static Nostr application with almost no infrastructure or integration boilerplate, while each individual library remains usable without BitBlocks. + +## Feedback from integrating into Tessera (2026-08-11) + +Notes from embedding `@bitlogin/widget` in the Tessera static site. Grouped by +how much they cost. + +### High-value + +- **[DONE 2026-08-11] Lightweight session-state read without the full widget.** + The single biggest cost: to know *whether a user is signed in* on a page, you + used to have to mount the whole widget (bitlogin.js + crypto worker + wasm, + ~1.6MB) because it was the only thing that restored the per-origin session. + **Shipped:** the widget now writes a cleartext, public-only record + (`{ pubkey, npub, name? }` — never key material) beside its encrypted session + in IndexedDB and clears it on logout, and a new `@bitlogin/widget/session` + entry point (`bitlogin-session.js`, ~1 KB gzip, zero deps, no worker/wasm) + exposes `readCachedSession(): Promise<{ pubkey, npub, name? } | null>`. The + same call is mirrored on `window.bitlogin.readCachedSession()` for pages that + already load the full widget. See `src/publicSession.ts`, `src/session.ts`, + and the README "Reading who's signed in without loading the widget" section. + Remaining polish: have the widget also record the profile display `name` once + fetched (currently `name` is only set when the caller supplies it), and add an + `@bitlogin/widget/session` `.d.ts` (tracked under the Small item below). +- **A headless session-restore API.** Related: expose session restore + + `getPublicKey`/`signEvent` without the custom element, so a host app can wire + its own UI and only load the visual widget for login/management. Right now the + element is the only entry point. + +### Medium + +- **The unmount-kills-the-worker footfooter deserves a guardrail.** The docs + warn that removing `` tears down the crypto worker and hangs + every later signing call with no error. I hit exactly this. Consider: (a) a + console.error when the element is disconnected while a session is active, and + (b) `signEvent` rejecting with a clear "widget was unmounted" error instead of + hanging forever. Silent infinite-hang is the worst failure mode. +- **Styling hooks / CSS parts.** The widget renders in a shadow root, so a host + can only frame it, not theme it. A few `::part()` hooks (button, panel, text) + would let it match a host design system instead of looking pasted in. Tessera + worked around this by wrapping it in a bordered panel with its own header. +- **[RESOLVED — already the behavior] Session-change event on restore.** + Confirmed: `bitlogin-login` already fires on silent session restore across a + page load, not just interactive login, so a host listener needn't distinguish + the two (README "Sessions persist across a page reload"). No new event needed. + For pages that don't mount the widget at all, use `readCachedSession()` above. + +### Small + +- Ship a `.d.ts` (or documented event/method surface) for the element: + `getPublicKey`, `signEvent`, `nip44Encrypt/Decrypt`, and the event + `detail` shapes. Integrators currently read the demo to learn the API. diff --git a/packages/widget/package.json b/packages/widget/package.json index 18629cd..3ecbe0c 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -6,6 +6,10 @@ "type": "module", "description": "BitLogin embeddable login widget: a custom element plus a window.nostr NIP-07-compatible provider, backed by a dedicated crypto Web Worker.", "main": "./dist/bitlogin.js", + "exports": { + ".": "./dist/bitlogin.js", + "./session": "./dist/bitlogin-session.js" + }, "scripts": { "build": "vite build", "dev": "vite build --watch", diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index c0111d7..34462c4 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -1,6 +1,8 @@ /** Entry point: any static site includes this file and drops in . */ import { BitLoginAuthElement } from "./element.js"; import { getActiveSession, type ActiveSessionInfo } from "./globalSession.js"; +import { readCachedSession } from "./session.js"; +import type { PublicSessionInfo } from "./publicSession.js"; import type { SignerMethod } from "./signers/types.js"; if (!customElements.get("bitlogin-auth")) { @@ -37,8 +39,18 @@ export interface BitLoginGlobal { */ activeMethod(): SignerMethod | null; /** The current session's public identity ({ method, publicKey, npub }), or - * null. Public data only -- never key material. */ + * null. Public data only -- never key material. In-memory: reflects a session + * established or restored on THIS page, so it's null until the widget mounts + * and restores. For a persistent "signed in as …" across page loads without + * loading the widget, use readCachedSession() (or import + * "@bitlogin/widget/session"). */ activeSession(): ActiveSessionInfo | null; + /** Reads the persisted public identity for this origin ({ pubkey, npub, + * name? }), or null. Cheap and widget-free: it reads the cleartext + * public-session record rather than restoring the signer, so a header on any + * page can show who's signed in without loading the crypto worker. Cannot + * sign — load for that. */ + readCachedSession(): Promise; } declare global { @@ -77,9 +89,12 @@ window.bitlogin = { }, activeSession(): ActiveSessionInfo | null { return getActiveSession(); - } + }, + readCachedSession }; +export { readCachedSession } from "./session.js"; +export type { PublicSessionInfo } from "./publicSession.js"; export { BitLoginAuthElement } from "./element.js"; export { WorkerClient } from "./worker/workerClient.js"; export { createNip07Provider } from "./provider.js"; diff --git a/packages/widget/src/publicSession.test.ts b/packages/widget/src/publicSession.test.ts new file mode 100644 index 0000000..de0f38f --- /dev/null +++ b/packages/widget/src/publicSession.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { KeyValueStore } from "@bitlogin/core/storage"; +import { + PUBLIC_SESSION_KEY, + clearPublicSession, + loadPublicSession, + parsePublicSession, + savePublicSession +} from "./publicSession.js"; + +class TestStore implements KeyValueStore { + values = new Map(); + async get(key: string): Promise { + return this.values.get(key); + } + async set(key: string, value: string): Promise { + this.values.set(key, value); + } + async delete(key: string): Promise { + this.values.delete(key); + } +} + +const info = { pubkey: "a".repeat(64), npub: "npub1example", name: "Ada" }; + +describe("public session record", () => { + it("round-trips the public identity", async () => { + const store = new TestStore(); + await savePublicSession(store, info); + await expect(loadPublicSession(store)).resolves.toEqual(info); + }); + + it("stores only public data -- never key material", async () => { + const store = new TestStore(); + await savePublicSession(store, info); + const raw = store.values.get(PUBLIC_SESSION_KEY) || ""; + // Sanity: the record is the pubkey/npub/name and nothing that looks like a + // 32-byte hex private key field. + expect(raw).toContain(info.pubkey); + expect(raw).not.toMatch(/privatekey|nsec|everyday/i); + }); + + it("omits name when the widget doesn't know one", async () => { + const store = new TestStore(); + await savePublicSession(store, { pubkey: info.pubkey, npub: info.npub }); + const loaded = await loadPublicSession(store); + expect(loaded).toEqual({ pubkey: info.pubkey, npub: info.npub }); + expect(loaded).not.toHaveProperty("name"); + }); + + it("returns null when nothing is stored", async () => { + await expect(loadPublicSession(new TestStore())).resolves.toBeNull(); + }); + + it("clear removes the record so 'signed in' can't outlive logout", async () => { + const store = new TestStore(); + await savePublicSession(store, info); + await clearPublicSession(store); + expect(store.values.has(PUBLIC_SESSION_KEY)).toBe(false); + await expect(loadPublicSession(store)).resolves.toBeNull(); + }); + + describe("parsePublicSession", () => { + it("accepts a well-formed record", () => { + expect(parsePublicSession(JSON.stringify(info))).toEqual(info); + }); + + it("treats corrupt or partial records as no session, not an error", () => { + expect(parsePublicSession(null)).toBeNull(); + expect(parsePublicSession("")).toBeNull(); + expect(parsePublicSession("not json")).toBeNull(); + expect(parsePublicSession("{}")).toBeNull(); + expect(parsePublicSession(JSON.stringify({ pubkey: "a".repeat(64) }))).toBeNull(); + expect(parsePublicSession(JSON.stringify({ npub: "npub1x" }))).toBeNull(); + }); + + it("drops an empty name rather than surfacing a blank identity", () => { + const parsed = parsePublicSession(JSON.stringify({ ...info, name: "" })); + expect(parsed).toEqual({ pubkey: info.pubkey, npub: info.npub }); + }); + }); +}); diff --git a/packages/widget/src/publicSession.ts b/packages/widget/src/publicSession.ts new file mode 100644 index 0000000..7932f36 --- /dev/null +++ b/packages/widget/src/publicSession.ts @@ -0,0 +1,80 @@ +/** + * Cleartext, public-only session identity — the companion to the AES-GCM + * secret session in `worker/sessionCache.ts`. + * + * It holds ONLY data that is already public: the account's Nostr public key + * (x-only hex), its npub, and an optional display name. None of it is secret — + * the same pubkey appears on every event the user signs and on every relay — + * so it is stored unencrypted, on purpose. That lets a read-only page answer + * "who, if anyone, is signed in?" WITHOUT loading the crypto worker, wasm, or + * the full widget (see `session.ts` for the lightweight reader). + * + * The everyday private key is NOT here and never will be: it stays in the + * encrypted record in `sessionCache.ts`, exactly as protected as before. This + * record is written and cleared in lockstep with that one (crypto worker), so + * "signed in" here can never outlive the real session. + */ +import type { KeyValueStore } from "@bitlogin/core/storage"; + +/** IndexedDB key for the cleartext public-session record (sibling of + * `bitlogin:session:v1`, which stays encrypted). */ +export const PUBLIC_SESSION_KEY = "bitlogin:session-public:v1"; + +export interface PublicSessionInfo { + /** The account's Nostr public key, x-only, lowercase hex. */ + pubkey: string; + /** NIP-19 npub encoding of the same key. */ + npub: string; + /** Display name, when the widget knows one. Absent otherwise. */ + name?: string; +} + +/** Validates and narrows a raw stored string. Returns null for anything that + * isn't a well-formed record, so a corrupt entry reads as "no session," never + * an error. Shared by the store reader and the standalone reader so both agree + * on the shape. */ +export function parsePublicSession(raw: string | null | undefined): PublicSessionInfo | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.pubkey !== "string" || typeof parsed.npub !== "string") return null; + return { + pubkey: parsed.pubkey, + npub: parsed.npub, + ...(typeof parsed.name === "string" && parsed.name ? { name: parsed.name } : {}) + }; + } catch { + return null; + } +} + +/** Best-effort write, mirroring `saveCachedSession`: a full disk or a browser + * blocking IndexedDB must never break the login/rotate call that triggered it. */ +export async function savePublicSession(store: KeyValueStore, info: PublicSessionInfo): Promise { + try { + const record: PublicSessionInfo = { + pubkey: info.pubkey, + npub: info.npub, + ...(info.name ? { name: info.name } : {}) + }; + await store.set(PUBLIC_SESSION_KEY, JSON.stringify(record)); + } catch { + // ignore + } +} + +export async function loadPublicSession(store: KeyValueStore): Promise { + try { + return parsePublicSession(await store.get(PUBLIC_SESSION_KEY)); + } catch { + return null; + } +} + +export async function clearPublicSession(store: KeyValueStore): Promise { + try { + await store.delete(PUBLIC_SESSION_KEY); + } catch { + // ignore + } +} diff --git a/packages/widget/src/session.ts b/packages/widget/src/session.ts new file mode 100644 index 0000000..4ca4930 --- /dev/null +++ b/packages/widget/src/session.ts @@ -0,0 +1,110 @@ +/** + * Lightweight, read-only session reader — the cheap way to ask "who, if anyone, + * is signed in?" on a page that isn't the login/manage surface. + * + * Import this INSTEAD of the full widget (`bitlogin.js`) when you only need to + * show an identity — e.g. a "Signed in as …" chip in the header on every page. + * It opens the same per-origin IndexedDB the widget writes and returns the + * public identity WITHOUT loading the crypto worker, wasm, or bitlogin.js. It + * cannot sign and never touches key material; when the user actually signs or + * manages the account, load `` and pay the heavy cost then. + * + * ESM: import { readCachedSession } from "@bitlogin/widget/session"; + * const who = await readCachedSession(); // { pubkey, npub, name? } | null + * + * No build: + * const who = await window.bitlogin.readCachedSession(); + * + * This file is deliberately dependency-free so it bundles to a few KB with no + * shared chunk pulling in the widget. + */ +import type { PublicSessionInfo } from "./publicSession.js"; + +export type { PublicSessionInfo } from "./publicSession.js"; + +// The two constants and the parser below are duplicated from +// storage/indexedDbStore.ts and publicSession.ts ON PURPOSE. Importing their +// runtime would let rollup hoist a shared chunk, turning this "drop in one file" +// reader into two. The DB names are a stable on-disk contract (indexedDbStore.ts +// is their source of truth); the record shape is validated identically to +// publicSession.parsePublicSession, which owns the write side. +const DB_NAME = "bitlogin"; +const STORE_NAME = "kv"; +const PUBLIC_SESSION_KEY = "bitlogin:session-public:v1"; + +function parsePublicSession(raw: string | null | undefined): PublicSessionInfo | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.pubkey !== "string" || typeof parsed.npub !== "string") return null; + return { + pubkey: parsed.pubkey, + npub: parsed.npub, + ...(typeof parsed.name === "string" && parsed.name ? { name: parsed.name } : {}) + }; + } catch { + return null; + } +} + +/** Opens the widget's DB read-only and reads one string key. Resolves undefined + * for every failure mode — DB absent, store absent (widget never ran here), + * IndexedDB blocked — because "can't tell" is indistinguishable from "not + * signed in" for this reader's purpose. Opened without a version so it never + * triggers an upgrade against whatever version the widget is on. */ +function readRaw(key: string): Promise { + return new Promise((resolve) => { + let request: IDBOpenDBRequest; + try { + request = indexedDB.open(DB_NAME); + } catch { + resolve(undefined); + return; + } + // A brand-new DB (widget never ran on this origin) opens at version 1 with + // no object stores; the contains() check below then returns undefined. + request.onerror = () => resolve(undefined); + request.onblocked = () => resolve(undefined); + request.onsuccess = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.close(); + resolve(undefined); + return; + } + try { + const tx = db.transaction(STORE_NAME, "readonly"); + const getRequest = tx.objectStore(STORE_NAME).get(key); + getRequest.onsuccess = () => resolve(getRequest.result as string | undefined); + getRequest.onerror = () => resolve(undefined); + tx.oncomplete = () => db.close(); + tx.onerror = () => { + resolve(undefined); + db.close(); + }; + } catch { + db.close(); + resolve(undefined); + } + }; + }); +} + +/** The current signed-in identity for this origin, or null. Public data only — + * never key material, and it cannot sign. */ +export async function readCachedSession(): Promise { + if (typeof indexedDB === "undefined") return null; + return parsePublicSession(await readRaw(PUBLIC_SESSION_KEY)); +} + +// No-build convenience: expose on window.bitlogin for the