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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-04 10:58:14 -04:00
1 parent 14d993d11a
commit a7cf55ea84
6 files changed
+171 -27

No files matched your search

+27 -21
View File
@@ -157,27 +157,33 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined</
</div>
</li>
<li>
<h3>Supporting multiple signing methods (extensions, NIP-46, BitLogin)</h3>
<p><code>window.nostr</code> 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
<code>window.nostr</code> 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.</p>
<p>To know specifically whether BitLogin is the active signer (as opposed to an extension), check
<code>window.bitlogin?.isActiveSigner()</code> rather than just <code>typeof window.nostr !== 'undefined'</code> —
the latter only tells you <em>a</em> signer is present, not which one. <code>window.bitlogin</code> is installed
as soon as the script loads, independent of whether <code>&lt;bitlogin-auth&gt;</code> has signed anyone in yet,
so it's safe to feature-detect immediately.</p>
<pre><code>if (window.bitlogin) {
console.log('BitLogin is available, version', window.bitlogin.version);
}
if (window.bitlogin?.isActiveSigner()) {
// window.nostr is currently backed by BitLogin specifically
}</code></pre>
<p>NIP-46 (remote "bunker" signers) is a different transport — it doesn't typically hook
<code>window.nostr</code> 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.</p>
<h3>One login surface — checking who's signed in, and how</h3>
<p>The recommended integration is to make <code>&lt;bitlogin-auth&gt;</code> 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.</p>
<p>Two different questions have two different APIs, and conflating them is the classic integration bug:</p>
<p><strong>"Is someone signed in through BitLogin?"</strong> — use <code>window.bitlogin.activeMethod()</code>
(or the richer <code>activeSession()</code>), or listen for <code>bitlogin-login</code>. This answers correctly
for every method:</p>
<pre><code>const method = window.bitlogin?.activeMethod();
// 'bitlogin' | 'nip07' | 'nip46' | null
const session = window.bitlogin?.activeSession();
// { method, publicKey, npub } | null — public data only</code></pre>
<p><strong>"Does BitLogin currently back <code>window.nostr</code>?"</strong> — use
<code>window.bitlogin.isActiveSigner()</code>. Note the deliberate asymmetry: during a NIP-07 session the
user's extension rightly owns <code>window.nostr</code> (it <em>is</em> the signer), so
<code>isActiveSigner()</code> is <code>false</code> while <code>activeMethod()</code> is <code>'nip07'</code>.
Don't use <code>isActiveSigner()</code> as a session check.</p>
<p><code>window.bitlogin</code> is installed as soon as the script loads, before anyone signs in, so both are
safe to feature-detect immediately. <code>window.nostr</code> 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 <code>window.nostr</code> needs no changes either way.</p>
<p>If your site <em>does</em> keep its own signer picker alongside BitLogin: BitLogin never overwrites an
extension's <code>window.nostr</code> on load (it only claims the empty slot, and on sign-in), and
<code>window.bitlogin.releaseSigner()</code> / <code>element.claimSigner()</code> let you hand the slot back
and forth without a reload, as described below.</p>
<div class="callout">
<strong>Don't give your <code>&lt;bitlogin-auth&gt;</code> element <code>id="bitlogin"</code>.</strong>
Browsers automatically expose any element with a matching <code>id</code> or <code>name</code> attribute as
+12 -6
View File
@@ -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 `<bitlogin-auth>` element also exposes instance-scoped
`claimSigner()` / `releaseSigner()` methods for the same purpose;
`claimSigner()` is called automatically on every successful sign-in,
+13
View File
@@ -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: {
+65
View File
@@ -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);
});
});
+33
View File
@@ -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;
}
+21
View File
@@ -1,5 +1,7 @@
/** 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 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 <bitlogin-auth> 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();
}
};