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();
}
};