Add pubkey identity alias and colon-style DOM event aliases

Host identity surfaces now always expose pubkey (lowercase hex), with
publicKey kept as a deprecated alias of the same value. Every existing
hyphenated widget event also fires a bubbling, composed bitlogin:<verb>
equivalent immediately after, without changing old dispatch so a listener
on the hyphen name cannot fire twice.

Co-authored-by: thePR0M3TH3AN <[email protected]>
This commit is contained in:
Cursor AgentandthePR0M3TH3AN committed 2026-10-08 19:14:25 +00:00
1 parent 6fc4ff09a9
commit f5b2f78da1
20 files changed
+493 -135

No files matched your search

+24
View File
@@ -6,6 +6,30 @@ site with its own vendored copy (see "Updating your integration" in
sitting on an older vendored copy is missing a fix or behavior change it
would plausibly want. Newest first.
## 2026-10-08 (additive host API aliases)
- **Identity field: `pubkey` everywhere, `publicKey` kept as a deprecated
alias.** Login event detail, `window.bitlogin.activeSession()`,
`readCachedSession()`, and headless `restore()` now all expose `pubkey`
(lowercase 64-hex). Where `publicKey` already existed it is unchanged and
equal to `pubkey`; where only `pubkey` existed, `publicKey` is added as
the same alias. The persisted public-session record is still
`{ pubkey, npub, name? }` — the alias is filled in on read, not written
to disk. Prefer `pubkey` in new code.
- **Colon DOM events, hyphen names kept.** Every existing `bitlogin-*`
event also fires a `bitlogin:<kebab-verb>` alias immediately after
(`bitlogin:login`, `bitlogin:logout`, `bitlogin:connection-granted`,
`bitlogin:signer-claimed`, `bitlogin:signer-released`,
`bitlogin:request-pending`, `bitlogin:offer-pending`). Hyphen events keep
their historical non-bubbling dispatch so an old listener does not fire
twice. Colon events `bubble` and `composed` across shadow DOM and always
carry an object `detail` (empty `{}` when the hyphen event had none);
when there is a payload, both events share the same detail object,
including `pubkey` on login. New colon-only lifecycle events:
`bitlogin:ready` (element connected) and `bitlogin:error`
(`{ code, message, retryable }`; today's code is `widget_error`). Nothing
is removed.
## 2026-08-04 (security-audit response, same day)
- **External signers are now verified, not trusted.** NIP-07 and NIP-46
+32 -21
View File
@@ -62,9 +62,11 @@ The widget is a complete login surface, not just the password flow: behind
detected), a NIP-46 remote signer (bunker:// paste or a scannable
nostrconnect QR for phone signers like Amber), a passkey (WebAuthn PRF —
zero site setup, the passkey deterministically derives the account
credential), §SF10 key import, and phrase recovery. The `bitlogin-login`
event reports `detail.method` (`'bitlogin' | 'nip07' | 'nip46'`) and
`detail.capabilities`, and `window.bitlogin.activeMethod()` /
credential), §SF10 key import, and phrase recovery. The `bitlogin:login`
event (hyphenated `bitlogin-login` still fires, deprecated) reports
`detail.pubkey` (lowercase hex; `detail.publicKey` is a deprecated alias
with the same value), `detail.method` (`'bitlogin' | 'nip07' | 'nip46'`),
and `detail.capabilities`. `window.bitlogin.activeMethod()` /
`activeSession()` answer session state for every method. See
`docs/login-methods.md` and `docs/passkey-login.md`.
@@ -96,9 +98,10 @@ export identity).
**Sessions persist across a page reload.** After a successful sign-in, the
everyday private key is cached in IndexedDB for that origin, so a later
reload restores the session and fires `bitlogin-login` automatically —
your existing listener doesn't need to distinguish "just typed" from
"silently restored." This trades "nothing persists locally" for "the
reload restores the session and fires `bitlogin:login` (and the
deprecated `bitlogin-login`) automatically — your existing listener
doesn't need to distinguish "just typed" from "silently restored." This
trades "nothing persists locally" for "the
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.
@@ -114,14 +117,15 @@ this:
```html
<script type="module">
import { readCachedSession } from "/vendor/bitlogin/bitlogin-session.js";
const who = await readCachedSession(); // { pubkey, npub, name? } | null
const who = await readCachedSession(); // { pubkey, publicKey, 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
and clears on logout, so it can't report a session that has ended. The returned
object also includes `publicKey` as a deprecated alias of `pubkey`. 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
@@ -138,7 +142,7 @@ any DOM:
import { createHeadlessSigner } from "@bitlogin/widget/headless";
const signer = await createHeadlessSigner();
const who = await signer.restore(); // { pubkey } | null (null → no cached session)
const who = await signer.restore(); // { pubkey, publicKey } | null (null → no cached session)
if (who) {
const event = await signer.signEvent({ kind: 1, content: "gm", tags: [] });
const pubkey = await signer.getPublicKey();
@@ -182,8 +186,9 @@ wallet-side budget allows; an embedded same-origin widget cannot broker
(`docs/connection-vault.md` §12.3), so it does not pretend to. Encourage
users toward budget-capped connections — the wallet's own budget is the real
security boundary. Users manage and revoke grants from the dashboard's
"Wallet connections" screen. A `bitlogin-connection-granted` event fires on
the element after each share. Hosts using this flow need one extra CSP line,
"Wallet connections" screen. A `bitlogin:connection-granted` event (and the
deprecated `bitlogin-connection-granted`) fires on the element after each
share. Hosts using this flow need one extra CSP line,
`style-src-attr 'unsafe-inline'` (Bitcoin Connect's components set inline
style attributes; a hash cannot cover those).
@@ -254,7 +259,7 @@ specifically to stop another script from overwriting it. `claimSigner()`
`window.nostr = ...` assignment with no guard, so on a page where such an
extension is active, that line threw `TypeError: Cannot assign to read only
property 'nostr' of object '#<Window>'` — and since it ran synchronously
before the widget dispatched `bitlogin-login` or moved to the dashboard
before the widget dispatched `bitlogin:login` / `bitlogin-login` or moved to the dashboard
screen, this **froze account creation/import at the final "verify your
recovery phrase" step**, with that raw TypeError as the only (confusing)
feedback. `claimSigner()` now catches this, returns `false` instead of
@@ -277,21 +282,27 @@ autocomplete instead of reading the demo:
import "@bitlogin/widget"; // registers <bitlogin-auth> and its types
const el = document.querySelector("bitlogin-auth"); // typed as BitLoginAuthElement
el?.addEventListener("bitlogin-login", (e) => {
// e.detail is typed: { publicKey?: string; method; capabilities }
console.log(e.detail.method, e.detail.capabilities.nip44);
el?.addEventListener("bitlogin:login", (e) => {
// e.detail is typed: { pubkey?: string; publicKey?: string; method; capabilities }
console.log(e.detail.pubkey, e.detail.method, e.detail.capabilities.nip44);
});
await el?.getPublicKey(); // typed element methods
import { readCachedSession } from "@bitlogin/widget/session";
const who = await readCachedSession(); // { pubkey, npub, name? } | null
const who = await readCachedSession(); // { pubkey, publicKey, npub, name? } | null
```
Exported types include `BitLoginAuthElement`, the `bitlogin-*`
`...EventDetail` shapes and `BitLoginEventMap`, `PublicSessionInfo`,
`SignerMethod`/`SignerCapabilities`, `BitLoginConfig`, and the
`window.bitlogin` (`BitLoginGlobal`) interface. Run `npm run build` (or
`build:types`) to emit them into `dist-types/`.
Prefer the colon event names (`bitlogin:login`, `bitlogin:logout`, …): they
bubble and compose across the widget's shadow root, so a host can listen on
`document`. The original hyphenated names (`bitlogin-login`, …) still fire
unchanged (non-bubbling) and are marked `@deprecated`. `publicKey` is a
deprecated alias of `pubkey` with the identical lowercase-hex value.
Exported types include `BitLoginAuthElement`, the `bitlogin-*` /
`bitlogin:*` `...EventDetail` shapes and `BitLoginEventMap`,
`PublicSessionInfo`, `SignerMethod`/`SignerCapabilities`, `BitLoginConfig`,
and the `window.bitlogin` (`BitLoginGlobal`) interface. Run `npm run build`
(or `build:types`) to emit them into `dist-types/`.
### Theming
+10 -3
View File
@@ -211,13 +211,20 @@ build before the standalone build is considered successful.
replacement.
- [ ] Expose a clean additive `window.bitlogin` API, including
`activeSession()`, `login()`, `logout()`, and `getSigner()`.
- [ ] Add and document stable lifecycle event aliases: `bitlogin:ready`,
- [x] Add and document stable lifecycle event aliases: `bitlogin:ready`,
`bitlogin:login`, `bitlogin:logout`, and `bitlogin:error`. Preserve the
existing hyphenated events (`bitlogin-login`, `bitlogin-logout`, and
related current event names) for compatibility; the colon-named events
must be additive and must not replace them silently.
- [ ] Version and test event payloads; preserve existing event names and
must be additive and must not replace them silently. **Shipped 2026-10-08:**
every existing hyphen event also fires its colon alias (bubbles + composed,
object detail); hyphen dispatch is unchanged. `bitlogin:ready` on connect;
`bitlogin:error` from `fail()` with `{ code: "widget_error", message,
retryable: false }`. `pubkey` added beside deprecated `publicKey`.
- [x] Version and test event payloads; preserve existing event names and
payload fields used by current apps while adding new fields additively.
**Shipped 2026-10-08:** login detail and session APIs gained `pubkey`
without removing `publicKey`; covered by `events.test.ts` /
`globalSession.test.ts` / `publicSession.test.ts` / `headless.test.ts`.
- [ ] Expose capability detection, for example `signer.capabilities()`, for
`signEvent`, `nip04`, `nip44`, `nip46`, `nwc`, and `passkey`.
- [ ] Ensure capability reporting describes the active session and never
+3 -3
View File
@@ -1,6 +1,6 @@
const element = document.getElementById("bitlogin-widget");
element?.addEventListener("bitlogin-login", (event) => {
element?.addEventListener("bitlogin:login", (event) => {
const detail = event instanceof CustomEvent ? event.detail : undefined;
console.info("[bitlogin] signed in", detail?.publicKey);
console.info("[bitlogin] signed in", detail?.pubkey ?? detail?.publicKey);
});
element?.addEventListener("bitlogin-logout", () => console.info("[bitlogin] signed out"));
element?.addEventListener("bitlogin:logout", () => console.info("[bitlogin] signed out"));
+16 -11
View File
@@ -61,15 +61,19 @@
<h3>React to sign-in and sign-out</h3>
<pre><code>const bl = document.querySelector('bitlogin-auth');
bl.addEventListener('bitlogin-login', (e) => {
console.log('signed in as', e.detail.publicKey);
bl.addEventListener('bitlogin:login', (e) => {
console.log('signed in as', e.detail.pubkey); // lowercase hex; e.detail.publicKey is a deprecated alias
console.log('via', e.detail.method); // 'bitlogin' | 'nip07' | 'nip46'
console.log('supports', e.detail.capabilities); // { nip44, nip04, getRelays }
});
bl.addEventListener('bitlogin-logout', () => {
bl.addEventListener('bitlogin:logout', () => {
console.log('signed out');
});</code></pre>
<p>Colon names (<code>bitlogin:login</code>, <code>bitlogin:logout</code>, …) bubble and compose across
the widget's shadow root, so you can also listen on <code>document</code>. The original hyphenated
names (<code>bitlogin-login</code>, …) still fire unchanged and are deprecated, not removed.
<code>detail.publicKey</code> remains as a deprecated alias of <code>detail.pubkey</code>.</p>
<div class="callout">
<strong>One login surface, several methods.</strong> If the visitor's browser has a NIP-07 signer
extension (Alby, nos2x, …), the widget's welcome screen offers "Use your Nostr extension instead"
@@ -132,8 +136,9 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined</
<p>The widget owns the whole ceremony: sign-in first if nobody is signed in, a <em>one-tap approval</em>
when the account already has a connection bound to your origin (the connection follows the user to
every new device), or a guided first-time import — Bitcoin Connect's wallet chooser, or a pasted URI —
stored as an encrypted record on the user's account. A <code>bitlogin-connection-granted</code> event
fires on the element after each share, and users manage or revoke grants from the widget dashboard's
stored as an encrypted record on the user's account. A <code>bitlogin:connection-granted</code> event
(hyphenated <code>bitlogin-connection-granted</code> still fires, deprecated) fires on the element after
each share, and users manage or revoke grants from the widget dashboard's
"Wallet connections" screen.</p>
<p>The inverse direction exists too: if your app obtained a connection by its own means (its own
wallet chooser, its own paste box), offer the user a portable copy —</p>
@@ -172,13 +177,13 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined</
model.</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
(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>
// { method, pubkey, publicKey, npub } | null — public data only; publicKey is a deprecated alias of pubkey</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
@@ -218,14 +223,14 @@ if (window.bitlogin?.releaseSigner()) {
the widget itself, so you only need this for a picker UI that lives outside the widget):</p>
<pre><code>document.querySelector('bitlogin-auth').claimSigner();</code></pre>
<p>Both <code>claimSigner()</code>/<code>releaseSigner()</code> on the element and
<code>window.bitlogin.releaseSigner()</code> also fire <code>bitlogin-signer-claimed</code> /
<code>bitlogin-signer-released</code> events (on the element and on <code>window</code>, respectively) if you'd
rather react to the change than check a return value.</p>
<code>window.bitlogin.releaseSigner()</code> also fire <code>bitlogin:signer-claimed</code> /
<code>bitlogin:signer-released</code> events (and the deprecated hyphenated names) on the element and on
<code>window</code>, respectively, if you'd rather react to the change than check a return value.</p>
<div class="callout">
<strong><code>claimSigner()</code> can fail without breaking anything.</strong> Some NIP-07 extensions
install <code>window.nostr</code> as a non-configurable, non-writable property specifically to stop another
script from overwriting it. When that happens, <code>claimSigner()</code> returns <code>false</code> instead
of throwing (and <code>bitlogin-signer-claimed</code>'s <code>event.detail</code> carries
of throwing (and <code>bitlogin:signer-claimed</code>'s <code>event.detail</code> carries
<code>{ windowNostrClaimed: false, error }</code>), and every BitLogin sign-in flow completes normally
regardless — the failure only means <code>window.nostr</code> still points at that other extension. If your
app talks to the element directly (previous step) this never matters at all; only code that reads
+21 -16
View File
@@ -53,10 +53,11 @@ deployment above.
signer-only: no vault (`requestNwcConnection` resolves null), no
persistence (a reload ends the session). NIP-07 sessions leave
`window.nostr` to the extension; NIP-46 sessions claim it, backed by the
worker-held remote-signer client. `bitlogin-login`'s `detail.method`
(`'bitlogin' | 'nip07' | 'nip46'`) and `detail.capabilities`
worker-held remote-signer client. `bitlogin:login`'s `detail.method`
(`'bitlogin' | 'nip07' | 'nip46'`), `detail.pubkey` (lowercase hex;
`detail.publicKey` is a deprecated alias), and `detail.capabilities`
(`{ nip44, nip04, getRelays }`) tell your app which backend is active and
what it supports.
what it supports. The hyphenated `bitlogin-login` still fires.
- A `window.nostr` provider shaped like the de facto NIP-07 interface
(`getPublicKey`, `signEvent`, `getRelays`, `nip44.encrypt`/`decrypt`) —
existing Nostr app code that already calls `window.nostr` works unmodified,
@@ -65,12 +66,12 @@ deployment above.
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
'nip46' | null; `activeSession()` adds pubkey, deprecated 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.
@@ -90,19 +91,23 @@ deployment above.
or first-time import via Bitcoin Connect's chooser / a pasted URI. REVEAL
mode: the origin receives the full bearer credential — wallet-side budgets
are the real security boundary. Requires `style-src-attr 'unsafe-inline'`
in the host CSP. Fires `bitlogin-connection-granted` after each share.
in the host CSP. Fires `bitlogin:connection-granted` after each share
(hyphenated `bitlogin-connection-granted` still fires, deprecated).
- `offerNwcConnection(uri, { appName, label })` on the element — the inverse:
the app obtained an NWC URI by its own means and offers the user a portable
copy. Consent screen in the widget's own UI, never a silent save; resolves
'saved' | 'declined' | 'already-saved' (same wallet+secret exists; origin
binding refreshed, no UI) | 'unavailable' (no session/vault — treat as
no-op). Safe to fire after every successful connect.
- Events on the element: `bitlogin-login` (`detail.publicKey`,
`detail.method`, `detail.capabilities`),
`bitlogin-logout`, `bitlogin-signer-claimed`, `bitlogin-signer-released`
(the last also fires on `window` when triggered via
`window.bitlogin.releaseSigner()`), `bitlogin-connection-granted`
(`detail.origin`).
- Events on the element: prefer colon names (`bitlogin:login` with
`detail.pubkey` / deprecated `detail.publicKey`, `detail.method`,
`detail.capabilities`; `bitlogin:logout`; `bitlogin:signer-claimed`;
`bitlogin:signer-released` — the last also fires on `window` when
triggered via `window.bitlogin.releaseSigner()`;
`bitlogin:connection-granted` with `detail.origin`; plus `bitlogin:ready`
and `bitlogin:error`). Hyphenated names (`bitlogin-login`, …) still fire
unchanged and are deprecated. Colon events bubble/composed; hyphen events
do not.
- Private keys live in a dedicated Web Worker and the signing APIs return only
public keys, signed events, and ciphertext. LIMIT: an embedded widget is
same-origin with its host page, so a malicious or XSS-compromised host can
@@ -206,6 +206,8 @@ describe.skipIf(!chromiumAvailable)("passkey sign-in, real browser ceremony", ()
const session = await page.evaluate(() => window.bitlogin?.activeSession());
expect(session?.method).toBe("bitlogin");
expect(session?.npub).toBe(firstNpub);
expect(session?.pubkey).toBe(session?.publicKey);
expect(session?.pubkey).toMatch(/^[0-9a-f]{64}$/);
// Sign out, then sign IN with the same passkey: derivation must land
// on the same account.
+35 -30
View File
@@ -20,7 +20,12 @@ 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";
import type { BitLoginEventMap } from "./events.js";
import {
dispatchBitLoginColonEvent,
dispatchBitLoginEvent,
loginEventDetail,
type BitLoginEventMap
} from "./events.js";
import {
buildPasskeyCreateOptions,
buildPasskeyGetOptions,
@@ -235,6 +240,7 @@ export class BitLoginAuthElement extends HTMLElement {
// Not fatal -- this element's own API surface doesn't depend on window.nostr.
}
}
dispatchBitLoginColonEvent(this, "bitlogin:ready", {});
}
disconnectedCallback(): void {
@@ -318,7 +324,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.passkeySession = false;
clearActiveSession(this);
this.releaseSigner();
this.dispatchEvent(new CustomEvent("bitlogin-logout"));
dispatchBitLoginEvent(this, "bitlogin-logout");
this.goto("welcome");
return;
}
@@ -327,7 +333,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.passkeySession = false;
clearActiveSession(this);
this.releaseSigner();
this.dispatchEvent(new CustomEvent("bitlogin-logout"));
dispatchBitLoginEvent(this, "bitlogin-logout");
this.goto("welcome");
}
@@ -358,7 +364,7 @@ export class BitLoginAuthElement extends HTMLElement {
// Symmetric with bitlogin-offer-pending. Without it, a host following
// the recommended hidden-mount pattern navigated a HIDDEN element to a
// consent screen nobody could see, and the promise never settled.
this.dispatchEvent(new CustomEvent("bitlogin-request-pending"));
dispatchBitLoginEvent(this, "bitlogin-request-pending");
void (async () => {
try {
const status = await this.worker.getSessionStatus();
@@ -426,7 +432,7 @@ export class BitLoginAuthElement extends HTMLElement {
// Hosts that keep this element hidden until needed (the recommended
// permanent-mount pattern) listen for this to reveal it: the silent
// outcomes above never fire it, so nothing flashes for a duplicate.
this.dispatchEvent(new CustomEvent("bitlogin-offer-pending"));
dispatchBitLoginEvent(this, "bitlogin-offer-pending");
this.goto("vault-offer");
});
}
@@ -449,9 +455,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.setBusy(false);
this.finishVaultOffer("saved");
this.flashSuccess(this.screen, "Wallet saved");
this.dispatchEvent(
new CustomEvent("bitlogin-connection-granted", { detail: { origin: window.location.origin } })
);
dispatchBitLoginEvent(this, "bitlogin-connection-granted", { origin: window.location.origin });
} catch (err) {
this.setBusy(false);
this.fail(err);
@@ -468,9 +472,7 @@ export class BitLoginAuthElement extends HTMLElement {
this.vaultUnsavedReason = undefined;
request.resolve(uri);
if (uri !== null) {
this.dispatchEvent(
new CustomEvent("bitlogin-connection-granted", { detail: { origin: request.origin } })
);
dispatchBitLoginEvent(this, "bitlogin-connection-granted", { origin: request.origin });
}
this.goto(this.session ? "dashboard" : "welcome");
}
@@ -617,11 +619,11 @@ export class BitLoginAuthElement extends HTMLElement {
claimSigner(): boolean {
try {
(window as unknown as { nostr: unknown }).nostr = this.installedProvider;
this.dispatchEvent(new CustomEvent("bitlogin-signer-claimed", { detail: { windowNostrClaimed: true } }));
dispatchBitLoginEvent(this, "bitlogin-signer-claimed", { windowNostrClaimed: true });
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.dispatchEvent(new CustomEvent("bitlogin-signer-claimed", { detail: { windowNostrClaimed: false, error: message } }));
dispatchBitLoginEvent(this, "bitlogin-signer-claimed", { windowNostrClaimed: false, error: message });
return false;
}
}
@@ -632,9 +634,10 @@ export class BitLoginAuthElement extends HTMLElement {
* relay-disagreement warnings -- so a user isn't left wondering why some other app still
* seems to be using a different signer.
*/
/** Single dispatch point for bitlogin-login so every sign-in path reports the same
* detail shape: which method granted the session and what it can do (§LM3). The
* pre-existing `publicKey` field is unchanged for hosts written before `method`. */
/** Single dispatch point for bitlogin-login / bitlogin:login so every sign-in
* path reports the same detail shape: which method granted the session and
* what it can do (§LM3). `pubkey` is canonical; `publicKey` stays as a
* deprecated alias with the identical value. */
private dispatchLogin(): void {
const capabilities: SignerCapabilities = this.altSigner?.capabilities ?? {
nip44: true,
@@ -643,21 +646,17 @@ export class BitLoginAuthElement extends HTMLElement {
};
if (this.session) {
// Keep window.bitlogin.activeMethod()/activeSession() in step with the
// bitlogin-login event -- hosts may use either.
// 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: {
publicKey: this.session?.publicKey,
method: this.session?.method ?? "bitlogin",
capabilities
}
})
dispatchBitLoginEvent(
this,
"bitlogin-login",
loginEventDetail(this.session?.publicKey, this.session?.method ?? "bitlogin", capabilities)
);
}
@@ -688,7 +687,7 @@ export class BitLoginAuthElement extends HTMLElement {
} catch {
return false;
}
this.dispatchEvent(new CustomEvent("bitlogin-signer-released"));
dispatchBitLoginEvent(this, "bitlogin-signer-released");
return true;
}
return false;
@@ -724,6 +723,11 @@ export class BitLoginAuthElement extends HTMLElement {
this.errorMessage = err instanceof Error ? err.message : String(err);
this.busy = false;
this.render();
dispatchBitLoginColonEvent(this, "bitlogin:error", {
code: "widget_error",
message: this.errorMessage,
retryable: false
});
}
private field(name: string): string {
@@ -2220,10 +2224,11 @@ function escapeHtml(input: string): string {
}
// Declaration merge: give BitLoginAuthElement typed addEventListener/
// removeEventListener overloads for its own bitlogin-* events, so a host gets
// `e.detail` typed without a cast. The string overload preserves the base
// signature for every other event. Merging an interface of the same name adds
// these to the class type; the runtime implementation stays HTMLElement's.
// removeEventListener overloads for its own bitlogin-* / bitlogin:* events,
// so a host gets `e.detail` typed without a cast. The string overload
// preserves the base signature for every other event. Merging an interface of
// the same name adds these to the class type; the runtime implementation
// stays HTMLElement's.
export interface BitLoginAuthElement {
addEventListener<K extends keyof BitLoginEventMap>(
type: K,
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import {
BITLOGIN_COLON_ALIASES,
dispatchBitLoginColonEvent,
dispatchBitLoginEvent,
loginEventDetail
} from "./events.js";
import { identityFields } from "./identity.js";
const CAPABILITIES = { nip44: true, nip04: true, getRelays: true };
describe("identityFields", () => {
it("returns lowercase pubkey and an identical publicKey alias", () => {
const hex = "AB".repeat(32);
const ids = identityFields(hex);
expect(ids.pubkey).toBe("ab".repeat(32));
expect(ids.publicKey).toBe(ids.pubkey);
});
});
describe("loginEventDetail", () => {
it("includes pubkey and the deprecated publicKey alias", () => {
const hex = "CD".repeat(32);
const detail = loginEventDetail(hex, "bitlogin", CAPABILITIES);
expect(detail.pubkey).toBe("cd".repeat(32));
expect(detail.publicKey).toBe(detail.pubkey);
expect(detail.method).toBe("bitlogin");
expect(detail.capabilities).toEqual(CAPABILITIES);
});
it("omits identity fields when the hex is unknown", () => {
const detail = loginEventDetail(undefined, "nip07", CAPABILITIES);
expect(detail).not.toHaveProperty("pubkey");
expect(detail).not.toHaveProperty("publicKey");
});
});
describe("dispatchBitLoginEvent", () => {
it("fires the hyphen name once and the colon alias once, sharing detail", () => {
const target = new EventTarget();
const detail = loginEventDetail("ab".repeat(32), "bitlogin", CAPABILITIES);
const seen: { type: string; detail: unknown; bubbles: boolean; composed: boolean }[] = [];
for (const type of ["bitlogin-login", "bitlogin:login"] as const) {
target.addEventListener(type, (event) => {
const custom = event as CustomEvent;
seen.push({
type: custom.type,
detail: custom.detail,
bubbles: custom.bubbles,
composed: custom.composed
});
});
}
dispatchBitLoginEvent(target, "bitlogin-login", detail);
expect(seen).toHaveLength(2);
expect(seen[0]).toMatchObject({ type: "bitlogin-login", bubbles: false, composed: false });
expect(seen[1]).toMatchObject({ type: "bitlogin:login", bubbles: true, composed: true });
expect(seen[0]!.detail).toBe(detail);
expect(seen[1]!.detail).toBe(detail);
});
it("a listener on the old hyphen name fires once, not twice", () => {
const target = new EventTarget();
let count = 0;
target.addEventListener("bitlogin-login", () => {
count += 1;
});
dispatchBitLoginEvent(
target,
"bitlogin-login",
loginEventDetail("ab".repeat(32), "bitlogin", CAPABILITIES)
);
expect(count).toBe(1);
});
it("does not notify a hyphen listener when only the colon alias fires", () => {
const target = new EventTarget();
let hyphenCount = 0;
target.addEventListener("bitlogin-logout", () => {
hyphenCount += 1;
});
dispatchBitLoginColonEvent(target, "bitlogin:ready", {});
expect(hyphenCount).toBe(0);
});
it("keeps empty hyphen detail as null and gives colon events an object", () => {
const target = new EventTarget();
let hyphenDetail: unknown;
let colonDetail: unknown;
target.addEventListener("bitlogin-logout", (event) => {
hyphenDetail = (event as CustomEvent).detail;
});
target.addEventListener("bitlogin:logout", (event) => {
colonDetail = (event as CustomEvent).detail;
});
dispatchBitLoginEvent(target, "bitlogin-logout");
expect(hyphenDetail).toBeNull();
expect(colonDetail).toEqual({});
});
it("maps every hyphenated host event to a colon alias", () => {
expect(BITLOGIN_COLON_ALIASES).toEqual({
"bitlogin-login": "bitlogin:login",
"bitlogin-logout": "bitlogin:logout",
"bitlogin-connection-granted": "bitlogin:connection-granted",
"bitlogin-signer-claimed": "bitlogin:signer-claimed",
"bitlogin-signer-released": "bitlogin:signer-released",
"bitlogin-request-pending": "bitlogin:request-pending",
"bitlogin-offer-pending": "bitlogin:offer-pending"
});
});
});
+119 -13
View File
@@ -1,16 +1,46 @@
/**
* Public event contract for `<bitlogin-auth>`. These are the CustomEvents the
* element dispatches on itself; the detail shapes here match element.ts's
* `dispatchEvent` calls exactly. Integrators previously had to read the demo to
* learn them — the typed `addEventListener` overloads on BitLoginAuthElement
* (see element.ts) now surface them in editors.
* element dispatches on itself; the detail shapes here match the
* `dispatchBitLoginEvent` / colon-only helpers. Integrators previously had to
* read the demo to learn them — the typed `addEventListener` overloads on
* BitLoginAuthElement (see element.ts) now surface them in editors.
*
* Two name families exist, by design (BitBlocks API conventions D0/D2):
* - Hyphenated names (`bitlogin-login`, …) are the original contract. They
* keep their historical non-bubbling, non-composed dispatch so a listener
* registered on the old name fires exactly once.
* - Colon names (`bitlogin:login`, …) are additive aliases. They bubble and
* compose across shadow DOM, and `detail` is always an object. Each hyphen
* event is followed immediately by its colon equivalent, sharing the same
* detail object when there is one.
*/
import type { SignerMethod, SignerCapabilities } from "./signers/types.js";
import { identityFields } from "./identity.js";
/** `bitlogin-login` — a session was established OR silently restored on page
* load (the two are intentionally indistinguishable to a listener). */
/** Hyphenated (legacy) name → colon alias. Old names are never removed. */
export const BITLOGIN_COLON_ALIASES = {
"bitlogin-login": "bitlogin:login",
"bitlogin-logout": "bitlogin:logout",
"bitlogin-connection-granted": "bitlogin:connection-granted",
"bitlogin-signer-claimed": "bitlogin:signer-claimed",
"bitlogin-signer-released": "bitlogin:signer-released",
"bitlogin-request-pending": "bitlogin:request-pending",
"bitlogin-offer-pending": "bitlogin:offer-pending"
} as const;
export type BitLoginHyphenEventName = keyof typeof BITLOGIN_COLON_ALIASES;
export type BitLoginColonAlias = (typeof BITLOGIN_COLON_ALIASES)[BitLoginHyphenEventName];
/** `bitlogin-login` / `bitlogin:login` — a session was established OR silently
* restored on page load (the two are intentionally indistinguishable to a
* listener). */
export interface BitLoginLoginEventDetail {
/** The signed-in account's public key (hex), when known. */
/** Canonical identity: 64-char lowercase hex, when known. */
pubkey?: string;
/**
* @deprecated Use `pubkey`. Identical value; kept so existing listeners
* keep working.
*/
publicKey?: string;
/** How the session was established. */
method: SignerMethod;
@@ -18,27 +48,103 @@ export interface BitLoginLoginEventDetail {
capabilities: SignerCapabilities;
}
/** `bitlogin-connection-granted` — the user shared an NWC wallet connection with
* an origin (Connection Vault, reveal mode). */
/** Build the login detail with `pubkey` and the deprecated `publicKey` alias. */
export function loginEventDetail(
hex: string | undefined,
method: SignerMethod,
capabilities: SignerCapabilities
): BitLoginLoginEventDetail {
return {
...(hex ? identityFields(hex) : {}),
method,
capabilities
};
}
/** `bitlogin-connection-granted` / `bitlogin:connection-granted` — the user
* shared an NWC wallet connection with an origin (Connection Vault, reveal
* mode). */
export interface BitLoginConnectionGrantedEventDetail {
origin: string;
}
/** `bitlogin-signer-claimed` — the element tried to install itself as
* `window.nostr`; `windowNostrClaimed` says whether it won the slot. */
/** `bitlogin-signer-claimed` / `bitlogin:signer-claimed` — the element tried
* to install itself as `window.nostr`; `windowNostrClaimed` says whether it
* won the slot. */
export interface BitLoginSignerClaimedEventDetail {
windowNostrClaimed: boolean;
error?: string;
}
/** Maps each `bitlogin-*` event name to its concrete event type. Events without
* a detail payload are plain `CustomEvent<undefined>`. */
/**
* `bitlogin:error` — a widget flow failed. Failures still render inside the
* widget; this event is additive so a host can react without scraping the UI.
* `code` is a stable lowercase snake_case token (BitBlocks D4); today's
* widget surfaces a generic `widget_error` until per-class codes land.
*/
export interface BitLoginErrorEventDetail {
code: string;
message: string;
retryable: boolean;
}
/** Empty object carried by colon events that have no payload. Hyphen events
* with no payload keep the historical `detail: null`. */
export type BitLoginEmptyDetail = Record<string, never>;
/** Maps each host event name to its concrete event type. Hyphen names without
* a payload stay `CustomEvent<undefined>` (runtime `detail` is `null`).
* Colon names always carry an object `detail`. */
export interface BitLoginEventMap {
/** @deprecated Use `bitlogin:login`. */
"bitlogin-login": CustomEvent<BitLoginLoginEventDetail>;
"bitlogin:login": CustomEvent<BitLoginLoginEventDetail>;
/** @deprecated Use `bitlogin:logout`. */
"bitlogin-logout": CustomEvent<undefined>;
"bitlogin:logout": CustomEvent<BitLoginEmptyDetail>;
/** @deprecated Use `bitlogin:connection-granted`. */
"bitlogin-connection-granted": CustomEvent<BitLoginConnectionGrantedEventDetail>;
"bitlogin:connection-granted": CustomEvent<BitLoginConnectionGrantedEventDetail>;
/** @deprecated Use `bitlogin:signer-claimed`. */
"bitlogin-signer-claimed": CustomEvent<BitLoginSignerClaimedEventDetail>;
"bitlogin:signer-claimed": CustomEvent<BitLoginSignerClaimedEventDetail>;
/** @deprecated Use `bitlogin:signer-released`. */
"bitlogin-signer-released": CustomEvent<undefined>;
"bitlogin:signer-released": CustomEvent<BitLoginEmptyDetail>;
/** @deprecated Use `bitlogin:request-pending`. */
"bitlogin-request-pending": CustomEvent<undefined>;
"bitlogin:request-pending": CustomEvent<BitLoginEmptyDetail>;
/** @deprecated Use `bitlogin:offer-pending`. */
"bitlogin-offer-pending": CustomEvent<undefined>;
"bitlogin:offer-pending": CustomEvent<BitLoginEmptyDetail>;
"bitlogin:ready": CustomEvent<BitLoginEmptyDetail>;
"bitlogin:error": CustomEvent<BitLoginErrorEventDetail>;
}
const COLON_DISPATCH: CustomEventInit = { bubbles: true, composed: true };
/**
* Dispatch a legacy hyphen event, then its colon alias. The hyphen event
* keeps default (non-bubbling, non-composed) CustomEvent behaviour so an
* existing listener on that name cannot fire twice. When `detail` is
* provided, both events receive the same object.
*/
export function dispatchBitLoginEvent(target: EventTarget, hyphenName: BitLoginHyphenEventName, detail?: object): void {
const colonName = BITLOGIN_COLON_ALIASES[hyphenName];
if (detail === undefined) {
target.dispatchEvent(new CustomEvent(hyphenName));
target.dispatchEvent(new CustomEvent(colonName, { ...COLON_DISPATCH, detail: {} }));
return;
}
target.dispatchEvent(new CustomEvent(hyphenName, { detail }));
target.dispatchEvent(new CustomEvent(colonName, { ...COLON_DISPATCH, detail }));
}
/** Colon-only lifecycle events (`bitlogin:ready`, `bitlogin:error`). */
export function dispatchBitLoginColonEvent(
target: EventTarget,
name: "bitlogin:ready" | "bitlogin:error",
detail: object
): void {
target.dispatchEvent(new CustomEvent(name, { ...COLON_DISPATCH, detail }));
}
+27 -6
View File
@@ -2,7 +2,9 @@ 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" };
const HEX = "ab".repeat(32);
const INFO = { method: "nip07" as const, publicKey: HEX, npub: "npub1example" };
const STORED = { method: "nip07" as const, pubkey: HEX, publicKey: HEX, npub: "npub1example" };
describe("global session registry", () => {
beforeEach(() => {
@@ -16,13 +18,22 @@ describe("global session registry", () => {
expect(getActiveSession()).toBeNull();
});
it("records and returns the session, as a copy", () => {
it("records and returns the session, as a copy, with pubkey === publicKey", () => {
const owner = {};
setActiveSession(owner, INFO);
const first = getActiveSession()!;
expect(first).toEqual(INFO);
expect(first).toEqual(STORED);
expect(first.pubkey).toBe(first.publicKey);
first.publicKey = "tampered";
expect(getActiveSession()!.publicKey).toBe(INFO.publicKey);
expect(getActiveSession()!.publicKey).toBe(HEX);
expect(getActiveSession()!.pubkey).toBe(HEX);
});
it("lowercases the identity hex", () => {
const owner = {};
setActiveSession(owner, { ...INFO, publicKey: "AB".repeat(32) });
expect(getActiveSession()!.pubkey).toBe(HEX);
expect(getActiveSession()!.publicKey).toBe(HEX);
});
it("only the owning element can clear its session", () => {
@@ -30,7 +41,7 @@ describe("global session registry", () => {
const elementB = {};
setActiveSession(elementA, INFO);
clearActiveSession(elementB); // a different element tearing down
expect(getActiveSession()).toEqual(INFO);
expect(getActiveSession()).toEqual(STORED);
clearActiveSession(elementA);
expect(getActiveSession()).toBeNull();
});
@@ -49,17 +60,27 @@ describe("global session registry", () => {
describe("element wiring", () => {
const widgetSource = readFileSync(new URL("./element.ts", import.meta.url), "utf8");
const indexSource = readFileSync(new URL("./index.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"),
widgetSource.indexOf("private noteSignerClaim")
);
expect(dispatchBody).toContain("setActiveSession(this");
expect(dispatchBody).toContain("loginEventDetail");
expect(dispatchBody).toContain('dispatchBitLoginEvent');
// Both logout branches and DOM removal clear it.
const clearCount = widgetSource.split("clearActiveSession(this)").length - 1;
expect(clearCount).toBe(3);
});
it("does not dispatch hyphenated host events except through dispatchBitLoginEvent", () => {
expect(widgetSource).not.toMatch(/new CustomEvent\("bitlogin-/);
expect(indexSource).not.toMatch(/new CustomEvent\("bitlogin-/);
expect(widgetSource).toContain("dispatchBitLoginEvent(");
expect(indexSource).toContain('dispatchBitLoginEvent(window, "bitlogin-signer-released")');
});
});
+16 -4
View File
@@ -7,21 +7,33 @@
* 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.
* Public data only (method, pubkey / deprecated publicKey, 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";
import { identityFields } from "./identity.js";
export interface ActiveSessionInfo {
method: SignerMethod;
/** Canonical identity: 64-char lowercase hex. */
pubkey: string;
/**
* @deprecated Use `pubkey`. Identical value; kept as a compatibility alias.
*/
publicKey: string;
npub: string;
}
let current: { owner: object; info: ActiveSessionInfo } | null = null;
export function setActiveSession(owner: object, info: ActiveSessionInfo): void {
current = { owner, info };
/** `publicKey` is accepted as the source when writing; both fields are stored. */
export function setActiveSession(
owner: object,
info: { method: SignerMethod; publicKey: string; npub: string }
): void {
const ids = identityFields(info.publicKey);
current = { owner, info: { method: info.method, npub: info.npub, ...ids } };
}
export function clearActiveSession(owner: object): void {
+1 -1
View File
@@ -46,7 +46,7 @@ describe("createHeadlessSigner", () => {
it("restores a cached session and exposes the identity", async () => {
const signer = await createHeadlessSigner();
await expect(signer.restore()).resolves.toEqual({ pubkey: PK });
await expect(signer.restore()).resolves.toEqual({ pubkey: PK, publicKey: PK });
await expect(signer.getPublicKey()).resolves.toBe(PK);
await expect(signer.isUnlocked()).resolves.toBe(true);
});
+3 -2
View File
@@ -21,6 +21,7 @@
import { WorkerClient } from "./worker/workerClient.js";
import type { SignEventPayload } from "./worker/protocol.js";
import type { NostrEvent } from "@bitlogin/core/nostr";
import { identityFields, type IdentityFields } from "./identity.js";
export interface HeadlessSignerOptions {
/** Relays for the Connection Vault (only needed for wallet features). */
@@ -34,7 +35,7 @@ export interface HeadlessSigner {
/** Restore the cached session for this origin. Returns the identity, or null
* if nothing is cached (mount `<bitlogin-auth>` so the user can sign in).
* Idempotent. */
restore(): Promise<{ pubkey: string } | null>;
restore(): Promise<IdentityFields | null>;
/** Whether a session is unlocked and ready to sign. */
isUnlocked(): Promise<boolean>;
/** The signed-in public key (hex). Rejects if no session is unlocked. */
@@ -77,7 +78,7 @@ export async function createHeadlessSigner(options: HeadlessSignerOptions = {}):
async restore() {
const res = await client.restoreSession();
pubkey = res.restored && res.everydayPublicKey ? res.everydayPublicKey : null;
return pubkey ? { pubkey } : null;
return pubkey ? identityFields(pubkey) : null;
},
async isUnlocked() {
if (pubkey) return true;
+22
View File
@@ -0,0 +1,22 @@
/**
* Host-facing identity fields (BitBlocks API conventions D1).
*
* Canonical name is `pubkey` (lowercase 64-hex). `publicKey` is a deprecated
* alias with the identical value, kept so existing listeners and session
* readers keep working. NIP-07 method names (`getPublicKey`) are a different
* contract and are not renamed.
*/
export interface IdentityFields {
/** Canonical identity: 64-char lowercase hex. */
pubkey: string;
/**
* @deprecated Use `pubkey`. Identical value; kept as a compatibility alias.
*/
publicKey: string;
}
/** Returns `pubkey` and the deprecated `publicKey` alias, always equal. */
export function identityFields(hex: string): IdentityFields {
const pubkey = hex.toLowerCase();
return { pubkey, publicKey: pubkey };
}
+18 -13
View File
@@ -4,6 +4,7 @@ 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";
import { dispatchBitLoginEvent } from "./events.js";
if (!customElements.get("bitlogin-auth")) {
customElements.define("bitlogin-auth", BitLoginAuthElement);
@@ -38,18 +39,20 @@ export interface BitLoginGlobal {
* sign-in.
*/
activeMethod(): SignerMethod | null;
/** The current session's public identity ({ method, publicKey, npub }), or
* 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"). */
/** The current session's public identity ({ method, pubkey, publicKey, npub }),
* or null. `pubkey` is canonical (lowercase hex); `publicKey` is a deprecated
* alias with the identical value. 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. */
/** Reads the persisted public identity for this origin ({ pubkey, publicKey,
* npub, name? }), or null. `pubkey` is canonical; `publicKey` is a deprecated
* alias. 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>;
}
@@ -79,7 +82,7 @@ window.bitlogin = {
const w = window as unknown as { nostr?: { _bitlogin?: boolean } };
if (w.nostr?._bitlogin === true) {
delete (w as { nostr?: unknown }).nostr;
window.dispatchEvent(new CustomEvent("bitlogin-signer-released"));
dispatchBitLoginEvent(window, "bitlogin-signer-released");
return true;
}
return false;
@@ -102,8 +105,10 @@ export type {
BitLoginEventMap,
BitLoginLoginEventDetail,
BitLoginConnectionGrantedEventDetail,
BitLoginSignerClaimedEventDetail
BitLoginSignerClaimedEventDetail,
BitLoginErrorEventDetail
} from "./events.js";
export type { IdentityFields } from "./identity.js";
export type { SignerMethod, SignerCapabilities } from "./signers/types.js";
export { WorkerClient } from "./worker/workerClient.js";
export { createNip07Provider } from "./provider.js";
+9 -5
View File
@@ -21,13 +21,15 @@ class TestStore implements KeyValueStore {
}
}
const info = { pubkey: "a".repeat(64), npub: "npub1example", name: "Ada" };
const hex = "a".repeat(64);
const info = { pubkey: hex, npub: "npub1example", name: "Ada" };
const loadedInfo = { ...info, publicKey: hex };
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);
await expect(loadPublicSession(store)).resolves.toEqual(loadedInfo);
});
it("stores only public data -- never key material", async () => {
@@ -37,6 +39,7 @@ describe("public session record", () => {
// 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.toContain("publicKey");
expect(raw).not.toMatch(/privatekey|nsec|everyday/i);
});
@@ -44,8 +47,9 @@ describe("public session record", () => {
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).toEqual({ pubkey: info.pubkey, publicKey: info.pubkey, npub: info.npub });
expect(loaded).not.toHaveProperty("name");
expect(loaded!.pubkey).toBe(loaded!.publicKey);
});
it("returns null when nothing is stored", async () => {
@@ -62,7 +66,7 @@ describe("public session record", () => {
describe("parsePublicSession", () => {
it("accepts a well-formed record", () => {
expect(parsePublicSession(JSON.stringify(info))).toEqual(info);
expect(parsePublicSession(JSON.stringify(info))).toEqual(loadedInfo);
});
it("treats corrupt or partial records as no session, not an error", () => {
@@ -76,7 +80,7 @@ describe("public session record", () => {
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 });
expect(parsed).toEqual({ pubkey: info.pubkey, publicKey: info.pubkey, npub: info.npub });
});
});
});
+16 -4
View File
@@ -15,14 +15,19 @@
* "signed in" here can never outlive the real session.
*/
import type { KeyValueStore } from "@bitlogin/core/storage";
import { identityFields } from "./identity.js";
/** 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. */
/** Canonical identity: 64-char lowercase hex. */
pubkey: string;
/**
* @deprecated Use `pubkey`. Identical value; not persisted on disk.
*/
publicKey: string;
/** NIP-19 npub encoding of the same key. */
npub: string;
/** Display name, when the widget knows one. Absent otherwise. */
@@ -39,7 +44,7 @@ export function parsePublicSession(raw: string | null | undefined): PublicSessio
const parsed = JSON.parse(raw) as Partial<PublicSessionInfo>;
if (typeof parsed.pubkey !== "string" || typeof parsed.npub !== "string") return null;
return {
pubkey: parsed.pubkey,
...identityFields(parsed.pubkey),
npub: parsed.npub,
...(typeof parsed.name === "string" && parsed.name ? { name: parsed.name } : {})
};
@@ -48,11 +53,18 @@ export function parsePublicSession(raw: string | null | undefined): PublicSessio
}
}
/** On-disk shape. The deprecated `publicKey` alias is filled in on read only. */
type PersistedPublicSession = {
pubkey: string;
npub: string;
name?: string;
};
/** 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> {
export async function savePublicSession(store: KeyValueStore, info: PersistedPublicSession): Promise<void> {
try {
const record: PublicSessionInfo = {
const record: PersistedPublicSession = {
pubkey: info.pubkey,
npub: info.npub,
...(info.name ? { name: info.name } : {})
+6 -2
View File
@@ -10,7 +10,7 @@
* 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
* const who = await readCachedSession(); // { pubkey, publicKey, npub, name? } | null
*
* No build: <script type="module" src="bitlogin-session.js"></script>
* const who = await window.bitlogin.readCachedSession();
@@ -37,8 +37,12 @@ function parsePublicSession(raw: string | null | undefined): PublicSessionInfo |
try {
const parsed = JSON.parse(raw) as Partial<PublicSessionInfo>;
if (typeof parsed.pubkey !== "string" || typeof parsed.npub !== "string") return null;
// Duplicated from identityFields() — this file must not import runtime
// modules or rollup will hoist a shared chunk into the 1 KB reader.
const pubkey = parsed.pubkey.toLowerCase();
return {
pubkey: parsed.pubkey,
pubkey,
publicKey: pubkey,
npub: parsed.npub,
...(typeof parsed.name === "string" && parsed.name ? { name: parsed.name } : {})
};
+1 -1
View File
@@ -14,7 +14,7 @@ export type SignerMethod = "bitlogin" | "nip07" | "nip46";
/** What the active signer actually supports. NIP-07 extensions vary -- some lack nip44,
* older ones lack nip04 -- and the facade reports honestly instead of throwing a
* TypeError off an undefined property (§LM4). Surfaced to the host in the
* bitlogin-login event detail. */
* bitlogin-login / bitlogin:login event detail. */
export interface SignerCapabilities {
nip44: boolean;
nip04: boolean;