mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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:
1 parent
6fc4ff09a9
commit
f5b2f78da1
20 files changed
+493
-135
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 }));
|
||||
}
|
||||
@@ -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")');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } : {})
|
||||
|
||||
@@ -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 } : {})
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user