widget: lightweight, widget-free session reader

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-08-11 19:22:06 -04:00
1 parent 3fa8ea0b61
commit c9791b9840
9 files changed
+394 -4

No files matched your search

+25
View File
@@ -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
<script type="module">
import { readCachedSession } from "/vendor/bitlogin/bitlogin-session.js";
const who = await readCachedSession(); // { pubkey, npub, name? } | null
if (who) header.textContent = `Signed in as ${who.name ?? who.npub.slice(0, 12)}…`;
</script>
```
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
`<bitlogin-auth>` 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 `<bitlogin-auth>` 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
+51
View File
@@ -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 `<bitlogin-auth>` 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.
+4
View File
@@ -6,6 +6,10 @@
"type": "module",
"description": "BitLogin embeddable login widget: a <bitlogin-auth> 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",
+17 -2
View File
@@ -1,6 +1,8 @@
/** Entry point: any static site includes this file and drops in <bitlogin-auth>. */
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 <bitlogin-auth> for that. */
readCachedSession(): Promise<PublicSessionInfo | null>;
}
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";
+82
View File
@@ -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<string, string>();
async get(key: string): Promise<string | undefined> {
return this.values.get(key);
}
async set(key: string, value: string): Promise<void> {
this.values.set(key, value);
}
async delete(key: string): Promise<void> {
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 });
});
});
});
+80
View File
@@ -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<PublicSessionInfo>;
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<void> {
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<PublicSessionInfo | null> {
try {
return parsePublicSession(await store.get(PUBLIC_SESSION_KEY));
} catch {
return null;
}
}
export async function clearPublicSession(store: KeyValueStore): Promise<void> {
try {
await store.delete(PUBLIC_SESSION_KEY);
} catch {
// ignore
}
}
+110
View File
@@ -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 `<bitlogin-auth>` and pay the heavy cost then.
*
* ESM: import { readCachedSession } from "@bitlogin/widget/session";
* const who = await readCachedSession(); // { pubkey, npub, name? } | null
*
* No build: <script type="module" src="bitlogin-session.js"></script>
* 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<PublicSessionInfo>;
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<string | undefined> {
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<PublicSessionInfo | null> {
if (typeof indexedDB === "undefined") return null;
return parsePublicSession(await readRaw(PUBLIC_SESSION_KEY));
}
// No-build convenience: expose on window.bitlogin for the <script> path without
// clobbering the full widget. Cast rather than augmenting the global type here;
// index.ts owns the canonical BitLoginGlobal shape (readCachedSession included),
// so whichever of the two loads, the method is present and identical.
if (typeof window !== "undefined") {
const w = window as unknown as { bitlogin?: Record<string, unknown> };
w.bitlogin ??= {};
if (typeof w.bitlogin.readCachedSession !== "function") {
w.bitlogin.readCachedSession = readCachedSession;
}
}
@@ -42,6 +42,7 @@ import {
} from "@bitlogin/core/vault";
import { IndexedDbKeyValueStore } from "../storage/indexedDbStore.js";
import { saveCachedSession, loadCachedSession, clearCachedSession } from "./sessionCache.js";
import { savePublicSession, clearPublicSession } from "../publicSession.js";
import { SerialQueue } from "./serialQueue.js";
import type { VaultConnectionSummary } from "./protocol.js";
import type {
@@ -162,6 +163,17 @@ async function persistSession(): Promise<void> {
...(session.connectionVaultRoot ? { connectionVaultRoot: session.connectionVaultRoot } : {}),
...(session.vaultKnown ? { vaultEnabled: session.connectionVaultRoot !== null } : {})
});
await persistPublicSession();
}
// Writes the cleartext public-only identity (publicSession.ts) beside the
// encrypted record so a read-only page can show "signed in as …" without
// loading this worker. Public data only -- never key material. Kept in lockstep
// with persistSession()/logout so it can't outlive the real session.
async function persistPublicSession(): Promise<void> {
if (!session.signer) return;
const pubkey = session.signer.getPublicKey();
await savePublicSession(store, { pubkey, npub: encodeNpub(pubkey) });
}
/**
@@ -671,12 +683,17 @@ async function handle(action: string, payload: unknown): Promise<unknown> {
// cache entry leaves it false so requireVault says "sign in again"
// rather than falsely claiming the account has no vault.
session.vaultKnown = cached.vaultEnabled !== undefined;
// Self-heal: a session cached before the public-record feature has an
// encrypted entry but no cleartext one. Writing it here means the very
// next lightweight read sees the identity, no re-login required.
await persistPublicSession();
return { restored: true, everydayPublicKey: session.signer.getPublicKey(), accountId: cached.accountId };
}
case "logout": {
clearSession();
await clearCachedSession(store);
await clearPublicSession(store);
return {};
}
+8 -2
View File
@@ -7,14 +7,20 @@ export default defineConfig({
emptyOutDir: true,
target: "es2022",
lib: {
// Two independent entry points, each a self-contained ES module with a fixed,
// Independent entry points, each a self-contained ES module with a fixed,
// non-hashed filename: bitlogin.js (main thread) and cryptoWorker.js (worker).
// workerClient.ts resolves the worker's URL at runtime relative to its own
// import.meta.url, so both files just need to be deployed side by side --
// at any path, not necessarily the site root (see workerClient.ts comment).
//
// bitlogin-session.js is the lightweight, read-only identity reader
// (session.ts): a few KB with no worker/wasm, for pages that only need to
// show who is signed in. It is dependency-free by design so it does not
// share a chunk with the widget.
entry: {
bitlogin: resolve(__dirname, "src/index.ts"),
cryptoWorker: resolve(__dirname, "src/worker/cryptoWorker.ts")
cryptoWorker: resolve(__dirname, "src/worker/cryptoWorker.ts"),
"bitlogin-session": resolve(__dirname, "src/session.ts")
},
formats: ["es"],
fileName: (_format, entryName) => `${entryName}.js`