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