The element was the only way to sign, so a host that renders its own UI, a
background job, or a headless test couldn't sign without mounting
<bitlogin-auth>. Add @bitlogin/widget/headless: createHeadlessSigner() → a
DOM-free wrapper over the crypto worker exposing restore, getPublicKey,
signEvent, nip44Encrypt/Decrypt, isUnlocked, logout, destroy.
It restores the cached session and signs with no element and no window.nostr.
It deliberately cannot interactively log in (that needs the password UI):
restore() returns null when nothing is cached, at which point the host mounts
<bitlogin-auth> once; after that the headless signer works across reloads.
New bitlogin-headless.js build entry + @bitlogin/widget/headless export
(also re-exported from @bitlogin/widget). Covered by headless.test.ts with a
scripted worker; README gains "Signing without the widget (headless)".
Addresses the "headless session-restore API" item of the Tessera integration
feedback (docs/TODO.md).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
Integrators had to read the demo to learn the API: the package built via
vite with no .d.ts and no types field, so @bitlogin/widget and
@bitlogin/widget/session resolved to `any`.
Emit declarations from source (tsconfig.types.json -> dist-types,
build:types wired into build) and point the package types/exports
conditions at them, for both the root and the /session entry. Generated,
so they can't drift from the implementation.
Add the type surface integrators actually want:
- HTMLElementTagNameMap["bitlogin-auth"], so querySelector/createElement
return the typed element.
- Typed addEventListener/removeEventListener overloads (declaration-merged
interface) for the bitlogin-* events.
- Exported event-detail types and BitLoginEventMap (src/events.ts), matching
element.ts's dispatchEvent shapes.
Verified with a consumer conformance typecheck (typed detail with no cast,
typed element methods, readCachedSession return type). README gains a
"TypeScript" section.
Addresses the ".d.ts / documented API surface" item of the Tessera
integration feedback (docs/TODO.md).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
The widget renders in a shadow root, so hosts could recolor it via the
:host custom properties but couldn't restyle structure (padding, borders,
box-shadow, button shape) -- which is why an embed can look pasted in.
Stamp a stable set of shadow parts once per render in exposeParts():
card; button (+button-primary/button-secondary); link; input; checkbox;
heading; text; notice (+notice-warn/-info/-error). Mapped by existing
class in one place rather than inline on ~60 rendered elements, so new
buttons/notices stay exposed automatically instead of drifting.
Documented as a contract in the README "Theming" section, alongside the
existing custom-property theming (still the first tool for color/radius/
font; parts are the escape hatch for structure). Verified in-browser that
bitlogin-auth::part(...) matches from light-DOM CSS through the shadow
boundary.
Addresses the "CSS parts" item of the Tessera integration feedback
(docs/TODO.md).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
Removing <bitlogin-auth> from the DOM terminates its crypto worker. A host
that re-renders surrounding markup (framework diff, htmx swap, innerHTML
reset) can do this incidentally, after which signing calls fail -- and the
failure was hard to diagnose.
Two guardrails:
- disconnectedCallback logs a console.error when the element is removed
while a session (BitLogin account or NIP-07/46) is active, naming the
unmount as the cause and pointing at the README "Integration gotcha".
- WorkerClient.terminate(reason) records the reason; both in-flight calls
(via failAll) and any later call (via the dead-flag path) reject with it,
so an unmounted widget produces one legible error message instead of a
bare or mismatched one. The dead flag already prevented the infinite
hang; this makes the cause visible and unifies the wording.
Adds worker/workerClient.test.ts (fake Worker) covering both rejection
paths. Updates the README gotcha paragraphs from "hangs forever with no
error" to the new loud-rejection behavior.
Addresses the "unmount guardrail" item of the Tessera integration
feedback (docs/TODO.md).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
Reading "who is signed in" on a page required mounting the whole widget
(bitlogin.js + crypto worker + wasm, ~1.6MB) because that was the only
thing that restored the per-origin session. Multi-page sites paid the
full load on every page just to render a "Signed in as …" header.
Write a cleartext, public-only record ({ pubkey, npub, name? } — never
key material) beside the encrypted session in IndexedDB, in lockstep with
it: the worker writes it at the single persistSession() choke point,
self-heals it on restoreSession (so sessions cached before this feature
upgrade with no re-login), and clears it on logout, so a reported session
can never outlive the real one.
Add @bitlogin/widget/session -> bitlogin-session.js: a self-contained
~1KB gzip reader (zero imports, no worker/wasm) exposing
readCachedSession(): Promise<{ pubkey, npub, name? } | null>. Mirror the
same call on window.bitlogin.readCachedSession() for pages that already
load the full widget.
The private key stays in the AES-GCM record in sessionCache.ts, exactly
as protected as before. Reader falls through to null for every failure
mode (DB/store absent, IndexedDB blocked), which reads as "not signed in".
Addresses the top item of the Tessera integration feedback (docs/TODO.md).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
README now presents the widget as the complete login surface (four
methods, method-aware events, window.bitlogin session API) and retires
the stale 'NIP-46/NIP-07 interop deferred' line. CHANGELOG gains a
2026-08-04 entry -- leading with the 'wasm-unsafe-eval' CSP requirement
and the service-worker cache:'reload' pattern, since a vendored site
without them has broken password flows. TODO records the shipped
expansion and the owner follow-ups: live-fire Amber QR and real-device
passkey tests, the new passkey derivation compatibility contract, and
the unchanged NIP-46 persistence gate. Removes a dangling on-ramp
fragment my earlier llms.txt edit left fused to the passkey bullet.
Co-Authored-By: Claude Fable 5 <[email protected]>
Authored by ChatGPT/Codex in a separate review pass. Reviewed and verified
here before committing rather than taken on trust; findings below.
The substantive change is that the vault sudo key now lives ONLY in the
phrase-gated recovery capsule. A newly written password credential capsule
carries just connection_vault_root, so an ordinary login restores
connectable-tier records without putting personal-tier material behind a
password capsule that is downloadable and therefore offline-guessable forever.
Pre-hardening capsules stay readable, password login does not return their
sudo key, and the next rotation scrubs it. That closes the gate this project
had explicitly set on personal-tier UI.
Legacy vault-enable migration now derives roots deterministically from
phrase-protected material and account_id, so two concurrent attempts cannot
mint divergent roots -- the failure mode the docs previously mitigated with
"enable the vault once, from one device".
Manual (user-chosen) passwords are REMOVED, not merely discouraged, and a new
securityPolicy.test.ts pins that along with several things tests do not
usually reach: the deployment CSP (default-src 'none', frame-ancestors
'none') plus six baseline headers, demo pages free of inline scripts and
remote fonts, and README documenting same-origin self-hosting only rather
than a remote demo embed. llms.txt was updated to match, which I checked
because that test only reads README.
What I verified, since a passing suite proves less than it appears to:
- All 12 fixes from the 2026-07-30 audit survived the rewrite of recover.ts,
changePassword.ts, validation.ts, relay.ts, session.ts and element.ts.
Checked semantically where grep cannot help: changePassword still checks
publish success BEFORE building the tombstone, and vaultSetBinding still
unbinds on origin === null rather than silently rebinding to the worker
global.
- No dependency changes; the package.json diff is scripts only.
- Argon2id and KDF parameters unchanged. The NIP-44 diff ADDS a base64 length
cap before decoding, a memory-DoS guard.
- Validation increased rather than decreased: 44 throws added, 33 removed.
- No disabled or skipped tests, no secrets in the diff.
typecheck clean, 165/165 tests, build green, and the account manager exercised
in a browser: creation correctly refused when relay quorum could not be met,
and on retry failed at the recovery capsule with "No login credential was
published" -- the §18.1 fail-closed ordering doing its job against a flaky
public relay set.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
The audit's most important finding was not a doc issue at all. In
vaultSetBinding the identifier `origin` resolved to the WORKER GLOBAL
self.origin rather than the intended value, so the dashboard's "Revoke
app access" REBOUND the record to the calling page instead of clearing
it -- the precise cross-origin adoption the binding guard exists to
prevent, wearing the label of the button meant to prevent it. It
typechecked because the DOM lib declares that global. I introduced this
hours ago: a string replace intended for one call site silently matched
two. Fixed, with the reasoning written where the next reader will hit it.
Relay/account audit fixes:
- Phrase recovery permanently tripped the rollback alarm. The mark is
keyed by the everyday pubkey (unchanged by recovery) while recovery
legitimately restarts at generation 0, and raiseHighWaterMark keeps
the maximum -- so anyone who had ever rotated a password met "possibly
revoked credentials" on EVERY later login, forever, with no way to
clear it. The phrase is the master authority; it now resets the mark.
- quorumMet counted TCP connections, not answers: a query timeout
RESOLVED, so a connectable-but-silent relay counted as responding and
every "not enough relays answered" guard was near-vacuous. Only EOSE
is an answer now.
- Relay responses were never checked against the address requested, so
one junk event from one relay blocked registration, rotation, and
recovery for every login name and password a user tried. Events must
now match author, kind, and d-tag.
Website honesty (the surface a stranger reads first, and the one that
had drifted furthest from the spec):
- docs.html and llms.txt still carried "private keys never leave the
worker" -- false on its face, since the widget ships a reveal-nsec
button. Both now state the same limit the README does.
- "you revoke" became honest everywhere: removing a connection deletes
the stored copy; spending authority is revoked at the wallet.
- index.html dropped a pre-softening recovery claim, now says an account
lives only while relays retain its capsule, and says "independently
unaudited" out loud.
- llms.txt gains the two warnings that were missing entirely: losing
BOTH password and phrase is permanent, and removal is not revocation.
- Stale headers: nwc-connections.md no longer claims "Not implemented",
docs/README no longer says the authorization layer is unbuilt, the
test count and spec version are current, and the demo domain points
at bitlogin.network.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
An adversarial audit (five independent reviewers over crypto/protocol,
the vault, the widget, relay flows, and doc honesty) found real bugs in
code this repo shipped. Fixed here, worst first:
CRITICAL - the widget worker ignored application_binding.origin on
vaultRevealNwc, so any caller naming a connection id could pull a
wallet credential the user granted to a DIFFERENT site. The worker now
derives its own origin (self.location) instead of trusting a payload
field -- which also closes binding forgery via vaultSaveNwc /
vaultSetBinding / vaultOfferCheck, where a host could bind another
site's wallet to itself and inherit its one-tap approval.
CRITICAL - VaultSession.destroy() left the vault root live (the root
regenerates every derived key) and, worse, left the session USABLE with
a zeroed prk: deriveRecordKey/deriveVaultSigningKey over 32 zero bytes
are deterministic and world-computable, so a stale reference could seal
a wallet secret under a key any observer can derive and report success.
Now wipes the root and fails loudly.
CRITICAL - changePassword published the tombstone that DESTROYS the old
capsule before checking that the new one reached quorum: a failed
rotation could leave neither password working, phrase-only recovery.
Reordered to fail closed with the old password intact, plus a
regression test that drives it with a partially-hostile relay set.
HIGH - enableConnectionVault never checked quorum on the recovery read,
so one stale relay could make it mint a SECOND vault root and orphan
every stored credential -- the outcome its own header calls
unforgivable.
HIGH - the rollback high-water mark used strict `<` and never compared
the event id it stored, so a same-second sibling of a revocation could
resurrect a revoked credential forever. Ties now break on event id.
HIGH - §11.10 claimed derived secrets are wiped; wipe() had two callers
and neither was on the password or recovery path. The Argon2id output,
both PRKs, and the 64-byte BIP-39 seed now die at their derivation
site.
MEDIUM - one malformed record threw out of the listing loop and made
every OTHER connection unlistable; recovery lacked the
already-registered-at-this-locator guard that registration and rotation
both carry; the recovery chain check silently passed on gaps and forks.
Synthetic (untrusted) clicks and submits no longer drive consent UI.
Docs corrected where they overstated: the README's "private keys never
leave the worker" now says plainly that a same-origin host can reach
exportIdentity, and connection-vault.md gains §19 recording six OPEN
limitations -- no CAS on capsule writes, per-device-only rollback
state, tombstones not destroying prior ciphertext, the sudo key
shipping with the root, fetches not failing closed, and a plaintext
mark store.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
The Phase D surface from vault-ux.md, scoped to NWC and honest about its
boundary: an embedded same-origin widget cannot broker (§CV12.3), so the
API reveals, says so in its consent copy, and leans on the wallet-side
budget as the real security boundary.
One public call owns the whole ceremony. If nobody is signed in, the
sign-in flow runs first and the request resumes via the goto("dashboard")
hook exactly once; if the account has a connection bound to the
requesting origin, the user gets the one-tap approval screen the vault
exists for; otherwise a guided import -- Bitcoin Connect's wallet
chooser or a pasted URI. The BC integration lifts Satisfied's
production discipline verbatim: chooser only, lazy chunks (never
downloaded unless the flow opens), no numeric budget in
authorizationUrlOptions (the unit is not interoperable), disconnect and
clear bc:config so no plaintext copy outlives the ceremony.
The worker holds the vault root (never the sudo key -- wiped on
arrival) and gains vaultStatus/List/SaveNwc/FindForOrigin/RevealNwc/
SetBinding/Delete actions; the root rides the encrypted session cache
so restored sessions keep working, with a vaultEnabled marker so a
stale cache says "sign in again" instead of falsely claiming the
account has no vault. Publishing failures are loud -- a connection that
reached no relay is not saved anywhere and says so. The dashboard gains
Wallet connections management with "revoke app access" and "remove
from BitLogin" honestly separated from wallet-side revocation.
VaultSession gains buildDeletionRequest so the NIP-09 courtesy ask is
signed without exposing the signing key.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
Both discovered via a live bitunlock integration bug this session:
BitUnlock's script-src lacked 'wasm-unsafe-eval' (silently broke every
password-hashing action), and its attempt to relocate the static
#bitloginMount via replaceWith() (to work around the innerHTML gotcha)
turned out to trigger the exact disconnectedCallback problem it was
trying to avoid -- moving a custom element still tears down and
recreates it.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
Vendoring sites had no documented way to learn a fix landed here or how
to pull it in. Adds a changelog (integrator-relevant entries only, each
noting whether it applies to a site's config) and a README section
explaining why vendoring is manual by design -- a shared auto-updating
script would make a bad deploy here an instant multi-site incident for
a widget that handles private key crypto.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DY9UMTuF43Ekc2U4E1eDwX
registerAccount() never checked whether a credential capsule already
existed at the locator address it was about to publish to. That
address is a NIP-33 replaceable event fully determined by login name +
password, so registering with a login name + password combination that
was already registered -- by mistake (double-clicking "create account",
confusing it with "sign in"), or by coincidence with someone else's
account under a shared login name -- silently replaced the existing
account's identity binding. The old everyday identity became
unreachable through that login unless the original recovery phrase was
still on hand, with nothing warning the user beforehand.
changePassword() had the identical gap on the new locator: rotating to
a password that happened to already be registered under the same login
name would silently destroy that other account.
Both flows now query for an existing capsule at the target locator
before publishing, and refuse with AccountAlreadyExistsError if one is
found (or a retry-oriented error if no relay could be reached to check).
Two new end-to-end tests reproduce both scenarios and confirm the
pre-existing account survives untouched.
Bumps docs/spec.md to v0.6 (§15.6, §18.1).
After every successful registration -- including nsec imports -- the
widget unconditionally published a default kind 0 profile ({name:
loginName}) plus kind 10002/10050 relay lists. All three are
NIP-01/NIP-65/NIP-17 replaceable events, so importing an nsec that
already had a real profile (display name, bio, avatar, NIP-05) and/or
relay preferences published elsewhere silently erased all of it,
replacing it with just the chosen BitLogin login name and the vault
relays.
publishInitialProfile() (moved into @bitlogin/core so it can be
covered by the existing MockRelay-based test harness) now queries for
an existing event of each kind before publishing, and only fills in
whichever one is genuinely missing. A fresh registration has nothing
to find, so behavior there is unchanged; an import now leaves
whatever the identity already had untouched.
Bumps docs/spec.md to v0.5 (§15.8, §28.1) and adds two new core tests
covering the fresh-identity and existing-profile-preserved cases.
docs.html was missing everything from this session's work:
- The disconnectedCallback/crypto-worker-teardown gotcha (found via the
BitRoad integration) -- now a callout right where the element is first
mounted, since that's the step where it actually bites.
- Talking to the element directly (getPublicKey/signEvent/nip44Encrypt/
nip44Decrypt as instance methods) as an alternative to window.nostr,
with BitRoad's adapter as the worked example.
- claimSigner() failing gracefully (returns false, doesn't throw) when a
NIP-07 extension has locked window.nostr down -- was previously
undocumented entirely; this is the exact bug just fixed.
- Theming via CSS custom properties, with the full property list and
BitRoad's mapping as a real example.
Also fixed two stale "(v0.3)" references to docs/spec.md in README.md and
CONTRIBUTING.md -- the spec was bumped to v0.4 earlier this session for the
rollback-detection changes but these two mentions were missed.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Some NIP-07 extensions install window.nostr as a non-configurable,
non-writable property to stop another script from overwriting it.
claimSigner() did a plain, unguarded `window.nostr = ...` assignment, so on
a page where such an extension is active, this threw
"Cannot assign to read only property 'nostr' of object '#<Window>'" --
and because it ran synchronously before dispatching bitlogin-login or
moving to the dashboard screen, it froze account creation/import at the
final "verify your recovery phrase" step, with that raw TypeError as the
only feedback shown. Reported from a real BitRoad integration session.
claimSigner() and releaseSigner() now catch this and return false instead
of throwing; connectedCallback's initial claim attempt is guarded the same
way. Every caller (registration, login, recovery, password change) now
completes sign-in regardless -- a host page using this element's own
methods directly (getPublicKey/signEvent/nip44Encrypt/nip44Decrypt) was
never actually affected by a failed window.nostr takeover, only a page that
reads window.nostr itself. The dashboard now shows a plain-language warning
when this happens, reusing the existing rollback/relay-disagreement warning
banner mechanism.
Also fixed while auditing this area: the "Copy" button on the generated-
password screen silently did nothing if the clipboard write failed (e.g.
Firefox's stricter permissions, an unfocused document) -- it now flashes
"Copied" or "Copy failed -- select the text manually" either way.
Verified live in Chrome: reproduced the exact crash by locking down
window.nostr the way a real extension does, confirmed account
creation/import now completes end-to-end (in both the standalone demo and
embedded in BitRoad) with the new warning shown instead of a stuck flow.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
<bitlogin-auth> renders in a shadow root, but every color/radius/font value
already flowed through a CSS custom property on :host -- these inherit
through the shadow boundary, so a host page can already override the
widget's look from ordinary light-DOM CSS. This adds the two properties
that weren't yet exposed:
- --bl-accent-fg (default: white): text color on top of --bl-accent. Was
hardcoded on button.primary, so a host with a light accent color and its
own dark-on-accent convention had no way to match it.
- --bl-max-width (default: 380px): the widget stays a fixed-width card by
default (unchanged for existing embedders), but a host page that wants it
to fill a wider container can now override this instead of being stuck
with the hardcoded cap.
Also routes the hardcoded font-family through --bl-font-family so that's
themeable too. Documented in the README with a worked example (BitRoad,
which maps every one of these onto its own design tokens to render the
widget in its own colors at full width).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Found during a real integration pass (embedding this widget in BitRoad, a
separate Nostr commerce app):
- Password rotation's old-locator tombstone (§18.1) is only advisory -- a
relay that never processes it can keep serving the pre-rotation capsule
indefinitely. loginWithPassword/changePassword previously accepted that
stale capsule with only a non-blocking warning. A device that has already
seen the newer generation now refuses it (RollbackDetectedError) unless the
caller explicitly passes acknowledgeRollback: true for the narrower
relay-lag case. The widget surfaces this as a blocking confirmation screen
before claiming the signer, not a banner shown after the fact.
- NIP-44 capped plaintext at 65535 bytes; added the extended-length prefix
for payloads up to 2^32-1 bytes, verified byte-for-byte against the
nostr-tools reference implementation (conversation keys, padding at every
bucket boundary, round trips in both directions).
- <bitlogin-auth> now exposes nip44Encrypt/nip44Decrypt directly on the
element instance, alongside the existing getPublicKey/signEvent, so a host
page can reach every capability without racing other providers for the
single window.nostr slot.
docs/spec.md bumped to v0.4 with the full writeup (§16.2, §29.7). 75/75
tests passing (14 new NIP-44 tests, 2 new rollback e2e tests).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Preparing to open the project up beyond this repo's own use:
- LICENSE: MIT, and a "license": "MIT" field on the root and all three
workspace package.json files.
- CONTRIBUTING.md: dev workflow, the mock-relay testing setup (most
sandboxes/CI have no route to public Nostr relays, and registration
needs a 2-of-N ack quorum so a single mock relay always fails), the
spec-first process for anything touching wire format or crypto
(docs/spec.md is canonical; speculative ideas go in their own doc, not
the spec), and stricter expectations for crypto/key-handling changes.
- SECURITY.md: private vulnerability disclosure via GitHub's advisory
flow rather than public issues, given this handles private keys and
password-derived secrets client-side; states current audit status
plainly (none yet).
- README: short "Contributing" section pointing at all three.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
Fixes reported from the live Vercel deployment:
- vercel.json: remove cleanUrls, which was breaking every internal .html
link (ERR_FAILED) since links already use explicit extensions
- Recovery export was silently broken after login or password rotation:
the crypto worker's session state only picked up the recovery capsule
event on register/recover, never on login/changePassword, even though
the decrypted payload always carries it. Surfaced recoveryCapsuleEvent
(and recoveryPublicKey for changePassword) through core's LoginResult /
ChangePasswordResult and wired them into the worker session; covered by
a new account.e2e.test.ts case
- New accounts never got a Nostr profile: publishProfileAndRelayLists is
now called with name: loginName after registration, so the npub's
profile defaults to the chosen login name
- Password manager never recognized the fields (shadow DOM + JS-driven
submission defeat the passive save-prompt heuristics, and the
generated-password screen never put the password in a real input at
all). Fixed with an explicit navigator.credentials.store() call after
every register/login/recover/changePassword; documented as
Chromium-only in docs.html
New: optional manual/custom password (packages/core/src/account/manualPassword.ts),
opt-in per spec §9.3's "future release may permit manual passwords behind
an advanced setting" allowance. Enforces a length floor, a common-password
blocklist, a login-name-substring check, a low-variety-pattern check, and
an entropy floor; the widget shows live non-blocking feedback as the user
types. The generated high-entropy passphrase remains the default,
recommended option.
Integration/interop support:
- window.nostr provider now carries a readonly _bitlogin marker
- window.bitlogin ({ version, isActiveSigner() }) is installed
unconditionally as soon as the script loads, independent of login
state or window.nostr's current occupant. Found and fixed a real bug
along the way: an unconditional assignment is required because browsers
auto-expose any element with a matching id/name as a same-named global,
so <bitlogin-auth id="bitlogin"> silently shadowed window.bitlogin
under a naive `if (!window.bitlogin)` guard. Renamed the demo's own
element id to avoid the same trap and documented the gotcha in docs.html
- docs.html: new section on supporting multiple signing methods
(extensions, NIP-46, BitLogin) alongside each other
- Added packages/demo/public/llms.txt for AI-assisted integration
Deployment: GitHub Pages requires a paid plan for private repos, so its
workflow and the .github directory are removed; README documents
Vercel-only deployment and the direct-from-CDN integration path.
Docs: added docs/future-recovery-options.md, laying out passkey-based
2FA/recovery options (pure 2FA, full independent recovery, and a
constrained middle ground) with pros/cons for future consideration —
not a development plan, nothing here is decided.
59/59 tests pass; core and widget typecheck clean; all fixes verified
live in Chromium via Playwright against an in-memory mock relay.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
core:
- registerAccount gains an optional everydayPrivateKey (validated scalar,
used instead of generating); result carries an `imported` flag
- importAccount() + decodeEverydayPrivateKey() convenience helpers accept
nsec (bech32) or 64-char hex, wiring in the existing decodeNsec primitive
- e2e tests: imported nsec round-trips to the same npub through both
clean-device login and phrase recovery; malformed keys rejected
widget:
- worker gains a `previewImportKey` action (validate + preview npub without
publishing) and passes importKey through `register`
- <bitlogin-auth> gets an "Import an existing Nostr key" screen: paste
nsec/hex, check-key preview shows the resulting npub for confirmation,
then the normal name -> password -> phrase flow, with the pasted key
dropped from the main thread after registration (§11.10) and a
risky-paste warning shown up front
docs: mark §SF10 implemented; note backup codes are redundant with the
phrase and unlikely to ship; README implemented-list updated.
Verified end-to-end in real Chromium (preview npub, import, clean-device
login all reproduce the imported identity). 51/51 tests pass.
Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
Builds out the v0.3 spec as an npm workspace monorepo any static site can
consume for login and Nostr signing:
- packages/core: framework-free protocol implementation — Argon2id/HKDF/
ScalarExpand/AES-256-GCM/JCS crypto primitives, NIP-01 event handling and a
quorum-aware relay pool, credential/recovery capsule construction and
validation, and the full account lifecycle (registration, any-device
login with rollback/relay-disagreement detection, phrase recovery with
recovery-capsule refresh, password rotation with mandatory tombstone +
NIP-09 deletion, replica repair, recovery export, NIP-19 encoding, a
NIP-44 v2 implementation, and a NIP-07-shaped signer). 49 tests cover the
spec's Phase 0 byte-level vectors plus full create/login/recover/rotate
end-to-end scenarios against an in-memory mock relay.
- packages/widget: a <bitlogin-auth> custom element with a dedicated crypto
Web Worker (private keys never touch the main thread) and a window.nostr
NIP-07-compatible provider, so existing Nostr web apps work against it
with zero code changes. Built with Vite into two relocatable static files.
- packages/demo: a plain static site (no build step of its own) consuming
the widget — a branding/landing page, an integration guide, and an
account-manager page covering create/login/recover, password rotation,
and identity export.
All flows were verified twice: protocol-level tests against a mock relay,
and the actual built widget driven end-to-end in real Chromium (registration,
phrase confirmation, event signing, clean-device login, window.nostr, and
phrase recovery all reproduce the identical everyday pubkey).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6