diff --git a/CHANGELOG.md b/CHANGELOG.md index aecf43b..e3dd331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:` 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 diff --git a/README.md b/README.md index 137da1e..d81e730 100644 --- a/README.md +++ b/README.md @@ -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 ``` 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 `` 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 '#'` — 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 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 diff --git a/docs/TODO.md b/docs/TODO.md index d2f8ffe..9e59c4f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/packages/demo/public/assets/account.js b/packages/demo/public/assets/account.js index 67bc567..3ad9afc 100644 --- a/packages/demo/public/assets/account.js +++ b/packages/demo/public/assets/account.js @@ -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")); diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html index 6e62279..3783056 100644 --- a/packages/demo/public/docs.html +++ b/packages/demo/public/docs.html @@ -61,15 +61,19 @@

React to sign-in and sign-out

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');
 });
+

Colon names (bitlogin:login, bitlogin:logout, …) bubble and compose across + the widget's shadow root, so you can also listen on document. The original hyphenated + names (bitlogin-login, …) still fire unchanged and are deprecated, not removed. + detail.publicKey remains as a deprecated alias of detail.pubkey.

One login surface, several methods. 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 declinedThe widget owns the whole ceremony: sign-in first if nobody is signed in, a one-tap approval 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 bitlogin-connection-granted 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 bitlogin:connection-granted event + (hyphenated bitlogin-connection-granted still fires, deprecated) fires on the element after + each share, and users manage or revoke grants from the widget dashboard's "Wallet connections" screen.

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 —

@@ -172,13 +177,13 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined

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 + (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
+// { method, pubkey, publicKey, npub } | null — public data only; publicKey is a deprecated alias of pubkey

"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 @@ -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):

document.querySelector('bitlogin-auth').claimSigner();

Both claimSigner()/releaseSigner() on the element and - window.bitlogin.releaseSigner() also fire bitlogin-signer-claimed / - bitlogin-signer-released events (on the element and on window, respectively) if you'd - rather react to the change than check a return value.

+ window.bitlogin.releaseSigner() also fire bitlogin:signer-claimed / + bitlogin:signer-released events (and the deprecated hyphenated names) on the element and on + window, respectively, if you'd rather react to the change than check a return value.

claimSigner() can fail without breaking anything. Some NIP-07 extensions install window.nostr as a non-configurable, non-writable property specifically to stop another script from overwriting it. When that happens, claimSigner() returns false instead - of throwing (and bitlogin-signer-claimed's event.detail carries + of throwing (and bitlogin:signer-claimed's event.detail carries { windowNostrClaimed: false, error }), and every BitLogin sign-in flow completes normally regardless — the failure only means window.nostr 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 diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt index 22bff8e..112cedc 100644 --- a/packages/demo/public/llms.txt +++ b/packages/demo/public/llms.txt @@ -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 diff --git a/packages/widget/src/e2e/passkey.e2e.test.ts b/packages/widget/src/e2e/passkey.e2e.test.ts index 335b476..a1cdd63 100644 --- a/packages/widget/src/e2e/passkey.e2e.test.ts +++ b/packages/widget/src/e2e/passkey.e2e.test.ts @@ -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. diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index 845d1b1..655bf29 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -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( type: K, diff --git a/packages/widget/src/events.test.ts b/packages/widget/src/events.test.ts new file mode 100644 index 0000000..306d00e --- /dev/null +++ b/packages/widget/src/events.test.ts @@ -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" + }); + }); +}); diff --git a/packages/widget/src/events.ts b/packages/widget/src/events.ts index b033212..5ed3608 100644 --- a/packages/widget/src/events.ts +++ b/packages/widget/src/events.ts @@ -1,16 +1,46 @@ /** * Public event contract for ``. 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`. */ +/** + * `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; + +/** Maps each host event name to its concrete event type. Hyphen names without + * a payload stay `CustomEvent` (runtime `detail` is `null`). + * Colon names always carry an object `detail`. */ export interface BitLoginEventMap { + /** @deprecated Use `bitlogin:login`. */ "bitlogin-login": CustomEvent; + "bitlogin:login": CustomEvent; + /** @deprecated Use `bitlogin:logout`. */ "bitlogin-logout": CustomEvent; + "bitlogin:logout": CustomEvent; + /** @deprecated Use `bitlogin:connection-granted`. */ "bitlogin-connection-granted": CustomEvent; + "bitlogin:connection-granted": CustomEvent; + /** @deprecated Use `bitlogin:signer-claimed`. */ "bitlogin-signer-claimed": CustomEvent; + "bitlogin:signer-claimed": CustomEvent; + /** @deprecated Use `bitlogin:signer-released`. */ "bitlogin-signer-released": CustomEvent; + "bitlogin:signer-released": CustomEvent; + /** @deprecated Use `bitlogin:request-pending`. */ "bitlogin-request-pending": CustomEvent; + "bitlogin:request-pending": CustomEvent; + /** @deprecated Use `bitlogin:offer-pending`. */ "bitlogin-offer-pending": CustomEvent; + "bitlogin:offer-pending": CustomEvent; + "bitlogin:ready": CustomEvent; + "bitlogin:error": CustomEvent; +} + +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 })); } diff --git a/packages/widget/src/globalSession.test.ts b/packages/widget/src/globalSession.test.ts index bc90ee1..56526b4 100644 --- a/packages/widget/src/globalSession.test.ts +++ b/packages/widget/src/globalSession.test.ts @@ -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")'); + }); }); diff --git a/packages/widget/src/globalSession.ts b/packages/widget/src/globalSession.ts index fe8efb9..65ee376 100644 --- a/packages/widget/src/globalSession.ts +++ b/packages/widget/src/globalSession.ts @@ -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 { diff --git a/packages/widget/src/headless.test.ts b/packages/widget/src/headless.test.ts index cbd3423..1d1ac8d 100644 --- a/packages/widget/src/headless.test.ts +++ b/packages/widget/src/headless.test.ts @@ -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); }); diff --git a/packages/widget/src/headless.ts b/packages/widget/src/headless.ts index 97312bb..abbed73 100644 --- a/packages/widget/src/headless.ts +++ b/packages/widget/src/headless.ts @@ -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 `` so the user can sign in). * Idempotent. */ - restore(): Promise<{ pubkey: string } | null>; + restore(): Promise; /** Whether a session is unlocked and ready to sign. */ isUnlocked(): Promise; /** 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; diff --git a/packages/widget/src/identity.ts b/packages/widget/src/identity.ts new file mode 100644 index 0000000..a4c8961 --- /dev/null +++ b/packages/widget/src/identity.ts @@ -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 }; +} diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 5fb263f..13549b3 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -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 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 + * for that. */ readCachedSession(): Promise; } @@ -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"; diff --git a/packages/widget/src/publicSession.test.ts b/packages/widget/src/publicSession.test.ts index de0f38f..5ce99c3 100644 --- a/packages/widget/src/publicSession.test.ts +++ b/packages/widget/src/publicSession.test.ts @@ -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 }); }); }); }); diff --git a/packages/widget/src/publicSession.ts b/packages/widget/src/publicSession.ts index 7932f36..aa48867 100644 --- a/packages/widget/src/publicSession.ts +++ b/packages/widget/src/publicSession.ts @@ -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; 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 { +export async function savePublicSession(store: KeyValueStore, info: PersistedPublicSession): Promise { try { - const record: PublicSessionInfo = { + const record: PersistedPublicSession = { pubkey: info.pubkey, npub: info.npub, ...(info.name ? { name: info.name } : {}) diff --git a/packages/widget/src/session.ts b/packages/widget/src/session.ts index 4ca4930..2c2fb4a 100644 --- a/packages/widget/src/session.ts +++ b/packages/widget/src/session.ts @@ -10,7 +10,7 @@ * manages the account, load `` 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: * 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; 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 } : {}) }; diff --git a/packages/widget/src/signers/types.ts b/packages/widget/src/signers/types.ts index f53c9b9..6282ccb 100644 --- a/packages/widget/src/signers/types.ts +++ b/packages/widget/src/signers/types.ts @@ -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;