mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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:
1 parent
14d993d11a
commit
a7cf55ea84
6 files changed
+171
-27
No files matched your search
@@ -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><bitlogin-auth></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><bitlogin-auth></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><bitlogin-auth></code> element <code>id="bitlogin"</code>.</strong>
|
||||
Browsers automatically expose any element with a matching <code>id</code> or <code>name</code> attribute as
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user