From a7cf55ea84ad35d350fa6cdbcebf57d10c5586ec Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:58:14 -0400 Subject: [PATCH] window.bitlogin.activeMethod()/activeSession(): session state for every method Hosts had no method-agnostic session check: isActiveSigner() answers who backs window.nostr, which is deliberately false during a NIP-07 session (the extension IS the signer and rightly owns the slot) even though the user signed in through BitLogin. Sites using it as a login check mis-read extension sessions. A page-level registry (globalSession.ts, public data only -- method, pubkey, npub) is written at the single dispatchLogin point and cleared on logout and DOM removal, owner-scoped so one element's teardown can't clear another's session. window.bitlogin gains activeMethod() and activeSession(); the integration guide and llms.txt now spell out the two-questions distinction and lead with the one-login-surface pattern. Co-Authored-By: Claude Fable 5 --- packages/demo/public/docs.html | 48 +++++++++-------- packages/demo/public/llms.txt | 18 ++++--- packages/widget/src/element.ts | 13 +++++ packages/widget/src/globalSession.test.ts | 65 +++++++++++++++++++++++ packages/widget/src/globalSession.ts | 33 ++++++++++++ packages/widget/src/index.ts | 21 ++++++++ 6 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 packages/widget/src/globalSession.test.ts create mode 100644 packages/widget/src/globalSession.ts diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index 71f2c88..cf8207c 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -157,27 +157,33 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined
  • -

    Supporting multiple signing methods (extensions, NIP-46, BitLogin)

    -

    window.nostr is a single global slot — only one provider can occupy it at a time. If a user also - has a NIP-07 extension (Alby, nos2x, …) installed, BitLogin will not overwrite it: it only installs - window.nostr when nothing is already there. The reverse isn't in BitLogin's control — an extension - that loads after BitLogin can still overwrite it, same as any two NIP-07 providers would collide.

    -

    To know specifically whether BitLogin is the active signer (as opposed to an extension), check - window.bitlogin?.isActiveSigner() rather than just typeof window.nostr !== 'undefined' — - the latter only tells you a signer is present, not which one. window.bitlogin is installed - as soon as the script loads, independent of whether <bitlogin-auth> has signed anyone in yet, - so it's safe to feature-detect immediately.

    -
    if (window.bitlogin) {
    -  console.log('BitLogin is available, version', window.bitlogin.version);
    -}
    -if (window.bitlogin?.isActiveSigner()) {
    -  // window.nostr is currently backed by BitLogin specifically
    -}
    -

    NIP-46 (remote "bunker" signers) is a different transport — it doesn't typically hook - window.nostr directly, so it isn't something BitLogin needs to avoid colliding with. A site can - offer "Sign in with BitLogin," "Connect an extension," and "Connect a bunker" as separate options; whichever - the user picks becomes the active signer, and your app code should call whatever's currently active rather - than assume a specific one.

    +

    One login surface — checking who's signed in, and how

    +

    The recommended integration is to make <bitlogin-auth> your site's only login UI: it + already offers username/password, a NIP-07 extension, and a NIP-46 remote signer as sub-options behind one + surface, and routes every signing call to whichever the user picked. You don't need your own method picker, + and your app code doesn't branch by method.

    +

    Two different questions have two different APIs, and conflating them is the classic integration bug:

    +

    "Is someone signed in through BitLogin?" — use window.bitlogin.activeMethod() + (or the richer activeSession()), or listen for bitlogin-login. This answers correctly + for every method:

    +
    const method = window.bitlogin?.activeMethod();
    +// 'bitlogin' | 'nip07' | 'nip46' | null
    +
    +const session = window.bitlogin?.activeSession();
    +// { method, publicKey, npub } | null — public data only
    +

    "Does BitLogin currently back window.nostr?" — use + window.bitlogin.isActiveSigner(). Note the deliberate asymmetry: during a NIP-07 session the + user's extension rightly owns window.nostr (it is the signer), so + isActiveSigner() is false while activeMethod() is 'nip07'. + Don't use isActiveSigner() as a session check.

    +

    window.bitlogin is installed as soon as the script loads, before anyone signs in, so both are + safe to feature-detect immediately. window.nostr itself works in every case — backed by BitLogin's + provider for password and remote-signer sessions, and by the extension itself for extension sessions — so + existing Nostr app code calling window.nostr needs no changes either way.

    +

    If your site does keep its own signer picker alongside BitLogin: BitLogin never overwrites an + extension's window.nostr on load (it only claims the empty slot, and on sign-in), and + window.bitlogin.releaseSigner() / element.claimSigner() let you hand the slot back + and forth without a reload, as described below.

    Don't give your <bitlogin-auth> element id="bitlogin". Browsers automatically expose any element with a matching id or name attribute as diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt index 7bae64f..e7f4a27 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -47,12 +47,18 @@ deployment above. existing Nostr app code that already calls `window.nostr` works unmodified, same as it would against a browser extension (Alby, nos2x, …). - `window.bitlogin` — installed as soon as the script loads, independent of - login state: `{ version, isActiveSigner(), releaseSigner() }`. Use - `window.bitlogin.isActiveSigner()` to tell whether BitLogin specifically - (not another extension) currently backs `window.nostr`, and - `releaseSigner()` to hand `window.nostr` back (a safe no-op if BitLogin - doesn't hold it) when a user switches to a different signing method in - your own UI. + login state: `{ version, isActiveSigner(), releaseSigner(), activeMethod(), + activeSession() }`. Two distinct questions: `activeMethod()` answers "is + someone signed in through BitLogin, and how" ('bitlogin' | 'nip07' | + 'nip46' | null; `activeSession()` adds publicKey and npub — public data + only), while `isActiveSigner()` answers "does BitLogin currently back + window.nostr". They deliberately diverge during NIP-07 sessions: the + extension rightly owns window.nostr (isActiveSigner() false) while the + user IS signed in through BitLogin (activeMethod() 'nip07'). Use + activeMethod()/the bitlogin-login event for session state, never + isActiveSigner(). `releaseSigner()` hands `window.nostr` back (safe no-op + if BitLogin doesn't hold it) when a user switches signing methods in your + own UI. - The `` element also exposes instance-scoped `claimSigner()` / `releaseSigner()` methods for the same purpose; `claimSigner()` is called automatically on every successful sign-in, diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index b177d22..6dee4e0 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -19,6 +19,7 @@ import { detectForeignNip07Provider, Nip07Signer } from "./signers/nip07.js"; import { Nip46Signer } from "./signers/nip46.js"; import type { Signer, SignerCapabilities, SignerMethod } from "./signers/types.js"; import { renderQrSvg } from "./qr.js"; +import { setActiveSession, clearActiveSession } from "./globalSession.js"; type Screen = | "welcome" @@ -215,6 +216,7 @@ export class BitLoginAuthElement extends HTMLElement { disconnectedCallback(): void { if (this.successDismissTimer !== null) clearTimeout(this.successDismissTimer); + clearActiveSession(this); this.releaseSigner(); this.worker.terminate(); } @@ -269,6 +271,7 @@ export class BitLoginAuthElement extends HTMLElement { if (this.altSigner.method === "nip46") void this.worker.nip46Disconnect(); this.altSigner = null; this.session = null; + clearActiveSession(this); this.releaseSigner(); this.dispatchEvent(new CustomEvent("bitlogin-logout")); this.goto("welcome"); @@ -276,6 +279,7 @@ export class BitLoginAuthElement extends HTMLElement { } await this.worker.logout(); this.session = null; + clearActiveSession(this); this.releaseSigner(); this.dispatchEvent(new CustomEvent("bitlogin-logout")); this.goto("welcome"); @@ -591,6 +595,15 @@ export class BitLoginAuthElement extends HTMLElement { nip04: true, getRelays: true }; + if (this.session) { + // Keep window.bitlogin.activeMethod()/activeSession() in step with the + // bitlogin-login event -- hosts may use either. + setActiveSession(this, { + method: this.session.method, + publicKey: this.session.publicKey, + npub: this.session.npub + }); + } this.dispatchEvent( new CustomEvent("bitlogin-login", { detail: { diff --git a/packages/widget/src/globalSession.test.ts b/packages/widget/src/globalSession.test.ts new file mode 100644 index 0000000..bc90ee1 --- /dev/null +++ b/packages/widget/src/globalSession.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import { beforeEach, describe, expect, it } from "vitest"; +import { clearActiveSession, getActiveSession, setActiveSession } from "./globalSession.js"; + +const INFO = { method: "nip07" as const, publicKey: "ab".repeat(32), npub: "npub1example" }; + +describe("global session registry", () => { + beforeEach(() => { + // Reset by clearing with whatever owner is current -- the module keeps a + // single slot, so clearing under a fresh owner after set covers it. + setActiveSession(globalThis, INFO); + clearActiveSession(globalThis); + }); + + it("returns null when nobody is signed in", () => { + expect(getActiveSession()).toBeNull(); + }); + + it("records and returns the session, as a copy", () => { + const owner = {}; + setActiveSession(owner, INFO); + const first = getActiveSession()!; + expect(first).toEqual(INFO); + first.publicKey = "tampered"; + expect(getActiveSession()!.publicKey).toBe(INFO.publicKey); + }); + + it("only the owning element can clear its session", () => { + const elementA = {}; + const elementB = {}; + setActiveSession(elementA, INFO); + clearActiveSession(elementB); // a different element tearing down + expect(getActiveSession()).toEqual(INFO); + clearActiveSession(elementA); + expect(getActiveSession()).toBeNull(); + }); + + it("the most recent sign-in wins across elements", () => { + const elementA = {}; + const elementB = {}; + setActiveSession(elementA, INFO); + setActiveSession(elementB, { ...INFO, method: "nip46" }); + expect(getActiveSession()!.method).toBe("nip46"); + // A's later logout must not clear B's session. + clearActiveSession(elementA); + expect(getActiveSession()!.method).toBe("nip46"); + }); +}); + +describe("element wiring", () => { + const widgetSource = readFileSync(new URL("./element.ts", import.meta.url), "utf8"); + + it("every login dispatch records the session; every teardown clears it", () => { + // dispatchLogin is the single sign-in dispatch point, so one setActiveSession + // there covers password, extension, and remote-signer sessions alike. + const dispatchBody = widgetSource.slice( + widgetSource.indexOf("private dispatchLogin"), + widgetSource.indexOf("private noteSignerClaim"), + ); + expect(dispatchBody).toContain("setActiveSession(this"); + // Both logout branches and DOM removal clear it. + const clearCount = widgetSource.split("clearActiveSession(this)").length - 1; + expect(clearCount).toBe(3); + }); +}); diff --git a/packages/widget/src/globalSession.ts b/packages/widget/src/globalSession.ts new file mode 100644 index 0000000..fe8efb9 --- /dev/null +++ b/packages/widget/src/globalSession.ts @@ -0,0 +1,33 @@ +/** Page-level record of the widget session, backing window.bitlogin.activeMethod() + * and activeSession() (docs/login-methods.md §LM3). + * + * window.nostr ownership (isActiveSigner) and session state are DIFFERENT + * questions with different answers: during a NIP-07 session the extension owns + * the slot -- correctly -- yet the user very much signed in through BitLogin. + * Hosts that used isActiveSigner() as a session check got `false` there. This + * registry answers the session question directly, for every method. + * + * Public data only (method, public key, npub); never key material. Owner-scoped + * so one element's teardown cannot clear a session another element established. + */ +import type { SignerMethod } from "./signers/types.js"; + +export interface ActiveSessionInfo { + method: SignerMethod; + publicKey: string; + npub: string; +} + +let current: { owner: object; info: ActiveSessionInfo } | null = null; + +export function setActiveSession(owner: object, info: ActiveSessionInfo): void { + current = { owner, info }; +} + +export function clearActiveSession(owner: object): void { + if (current?.owner === owner) current = null; +} + +export function getActiveSession(): ActiveSessionInfo | null { + return current ? { ...current.info } : null; +} diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 1f03014..c0111d7 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -1,5 +1,7 @@ /** Entry point: any static site includes this file and drops in . */ import { BitLoginAuthElement } from "./element.js"; +import { getActiveSession, type ActiveSessionInfo } from "./globalSession.js"; +import type { SignerMethod } from "./signers/types.js"; if (!customElements.get("bitlogin-auth")) { customElements.define("bitlogin-auth", BitLoginAuthElement); @@ -24,6 +26,19 @@ export interface BitLoginGlobal { * holds several widget instances. Returns whether it actually released anything. */ releaseSigner(): boolean; + /** + * How the current widget session was established -- 'bitlogin' (password + * account), 'nip07' (extension), 'nip46' (remote signer) -- or null when + * nobody is signed in. THE session check for hosts. Deliberately distinct + * from isActiveSigner(): during a NIP-07 session the extension rightly owns + * window.nostr, so isActiveSigner() is false while activeMethod() is + * 'nip07'. With several elements, reflects the most recent + * sign-in. + */ + activeMethod(): SignerMethod | null; + /** The current session's public identity ({ method, publicKey, npub }), or + * null. Public data only -- never key material. */ + activeSession(): ActiveSessionInfo | null; } declare global { @@ -56,6 +71,12 @@ window.bitlogin = { return true; } return false; + }, + activeMethod(): SignerMethod | null { + return getActiveSession()?.method ?? null; + }, + activeSession(): ActiveSessionInfo | null { + return getActiveSession(); } };