20 KiB
NOSTR_LOGIN_LITE
Objective
- Deliver a minimal, dependency-light replacement for the current auth/UI stack that:
- Preserves all login methods: Nostr Connect (nip46), Extension, Local key, Read-only, OTP/DM.
- Exposes the same window.nostr surface: getPublicKey(), signEvent(event), nip04.encrypt(pubkey, plaintext), nip04.decrypt(pubkey, ciphertext), nip44.encrypt(pubkey, plaintext), nip44.decrypt(pubkey, ciphertext).
- Dispatches identical nlAuth/nlLaunch/nlLogout/nlDarkMode events and accepts setAuth calls for compatibility.
Key differences vs current project
- UI: replace Stencil/Tailwind component library with a single vanilla-JS modal and minimal CSS.
- Transport: remove NDK; implement NIP-46 RPC using nostr-tools SimplePool.
- Crypto: rely on nostr-tools (via CDN global window.NostrTools) for keygen/signing (finalizeEvent), nip04, nip19, and SimplePool; embed a small NIP-44 codec if window.NostrTools.nip44 is not available. The current project’s codec is in packages/auth/src/utils/nip44.ts.
Nostr Tools via CDN (global window.NostrTools)
- Include the bundle once in the page: <script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
- Access APIs via window.NostrTools inside the lite build:
- NostrTools.generateSecretKey() or NostrTools.generatePrivateKey() (depending on CDN version)
- NostrTools.getPublicKey()
- NostrTools.finalizeEvent()
- NostrTools.nip04 encrypt/decrypt
- NostrTools.nip19 npub/nsec encode/decode
- NostrTools.SimplePool relay connectivity
Supported login methods and crypto summary
- Connect (nip46): secp256k1 Schnorr for event signing/hash; nip44 (ChaCha20/HKDF/HMAC) for RPC payloads, with nip04 fallback. Requires relay connectivity (via NostrTools.SimplePool).
- Extension: crypto handled by the extension; we bridge calls.
- Local key: secp256k1 Schnorr signing (NostrTools.finalizeEvent()) and nip04/nip44 encrypt/decrypt locally.
- Read-only: no client crypto.
- OTP/DM: no client crypto beyond state persistence; server sends DM and verifies via HTTP.
Minimal file layout
- Single file drop-in (script tag friendly):
- Optional split during development (bundled later):
External dependencies strategy
- Use the CDN bundle to provide window.NostrTools at runtime. No npm installs required.
- Prefer built-ins from window.NostrTools:
- Keys/sign: generateSecretKey()/generatePrivateKey(), getPublicKey(), finalizeEvent().
- Encoding: nip19.
- Encryption: nip04.
- Relays: SimplePool.
- Nip44 choice:
- If window.NostrTools.nip44 is available, use it directly.
- Otherwise embed the existing lightweight codec adapted from packages/auth/src/utils/nip44.ts as Nip44.
Compatibility requirements
- Provide the same exports: init(opts), launch(startScreen), logout(), setDarkMode(dark), setAuth(o), cancelNeedAuth().
- Dispatch identical events and payload shapes as in onAuth().
Architecture overview
- NostrLite: window.nostr facade mirroring current behavior, invokes auth UI when needed.
- Auth: manages methods (connect/extension/local/readOnly/otp), state, storage, event dispatch.
- NIP46Client: minimal RPC over NostrTools.SimplePool (subscribe, send request, parse/decrypt, dedupe auth_url).
- ExtensionBridge: safely handle window.nostr detection, guard against overwrites, switch to extension mode when requested.
- Store: localStorage helpers for accounts/current/recents and misc values.
- UI: single vanilla modal that lists options and drives flow, with optional inline iframe starter for providers that publish iframe_url.
Function-level TODO checklist (execution order)
Bootstrap and API surface
-
- Verify window.NostrTools presence; throw with actionable message if missing.
-
NostrLite.init(options: NostrLoginOptions)
- Call Deps.ensureNostrToolsLoaded().
- Bind window.nostr to the facade.
- Initialize ExtensionBridge checking loop.
- Mount modal UI, persist options (theme, perms, bunkers, methods, otp URLs, default relays).
- Wire nlLaunch/nlLogout/nlDarkMode/nlSetAuth listeners.
-
NostrLite.launch(startScreen?: string)
- Open modal with selected start screen or switch-account if accounts exist.
-
- Clear current account; dispatch nlAuth logout; reset signer/client state.
-
NostrLite.setDarkMode(dark: boolean)
- Persist and apply theme to modal.
-
NostrLite.setAuth(o: NostrLoginAuthOptions)
- Validate o.type in {login, signup, logout}; o.method in {connect, extension, local, otp, readOnly}.
- Delegate to Auth.switchAccount(); dispatch consistent nlAuth.
-
- Cancel current connect/listen flow and close modal section if waiting.
window.nostr facade
-
- If no user, open modal; await until authenticated or rejected.
-
- Ensure auth, return current pubkey or throw.
-
NostrLite.signEvent(event: NostrEvent)
- Ensure auth; if local key then sign locally via NostrTools.finalizeEvent(); else sign via NIP46Client.sendRequest().
-
NostrLite.nip04.decrypt(pubkey, ciphertext)
- Ensure auth; route to local (NostrTools.nip04) or remote signer via NIP-46 nip04_{encrypt|decrypt}.
-
NostrLite.nip44.decrypt(pubkey, ciphertext)
- Ensure auth; use window.NostrTools.nip44 if present or local Nip44; remote via nip44_{encrypt|decrypt}.
Auth module
-
- Create a promise used by ensureAuth/waitReady; allows Connect flow to resolve or be cancelled.
-
- Resolve session; if iframeUrl present, bind iframe port to NIP46Client before resolving.
-
- Cancel current session safely.
-
Auth.onAuth(type: "login" | "signup" | "logout", info?: Info)
- Update state, persist, dispatch nlAuth; fetch profile asynchronously and update name/picture when fetched; preserve nip05 semantics.
-
Auth.localSignup(name: string, sk?: string)
- Generate sk via NostrTools.generateSecretKey()/generatePrivateKey() if missing; Auth.setLocal(signup=true).
-
Auth.setLocal(info: Info, signup?: boolean)
- Instantiate LocalSigner; call Auth.onAuth().
-
Auth.trySetExtensionForPubkey(pubkey: string)
- Use ExtensionBridge.setExtensionReadPubkey() and reconcile.
-
- Initialize NIP46Client with existing token/relays via NIP46Client.init() and NIP46Client.connect(); call Auth.onAuth(); Auth.endAuthSession().
-
Auth.authNip46(type, { name, bunkerUrl, sk, domain, iframeUrl })
- Parse bunkerUrl/nip05; NIP46Client.init() + NIP46Client.connect(); Auth.onAuth().
-
- Create local keypair and secret; open provider link or show iframe starter; NIP46Client.listen() to learn signerPubkey, then NIP46Client.initUserPubkey(); set bunkerUrl; Auth.onAuth() unless importConnect.
-
Auth.createNostrConnect(relay?: string)
- Build nostrconnect:// URL with meta (icon/url/name/perms).
-
Auth.getNostrConnectServices(): Promise<[string, ConnectionString[]]>
- Return nostrconnect URL and preconfigured providers; query .well-known/nostr.json for relays and iframe_url.
-
Auth.importAndConnect(cs: ConnectionString)
- nostrConnect(..., importConnect=true) → logout keep signer → onAuth(login, connect info).
-
Auth.createAccount(nip05: string) (optional parity)
- Use bunker provider to create account; return bunkerUrl and sk.
NIP46Client (transport over NostrTools.SimplePool)
-
NIP46Client.init(localSk: string, remotePubkey?: string, relays: string[], iframeOrigin?: string)
- Create this.pool = new NostrTools.SimplePool().
- Cache relays; initialize local signer state (seckey, pubkey via NostrTools.getPublicKey()).
- If remotePubkey provided, set it; cache iframeOrigin.
-
NIP46Client.subscribeReplies()
- sub = pool.sub(relays, [{ kinds:[24133], '#p':[localPubkey] }])
- sub.on('event', (ev) => NIP46Client.onEvent(ev)); sub.on('eose', () => {/* keep alive */})
-
NIP46Client.onEvent(ev: NostrEvent)
- Parse/decrypt via NIP46Client.parseEvent(); route to response handlers.
-
NIP46Client.listen(nostrConnectSecret: string): Promise
- Await unsolicited reply to local pubkey; accept "ack" or exact secret; return signer’s pubkey; unsubscribe this one-shot subscription.
-
NIP46Client.connect(token?: string, perms?: string)
- NIP46Client.sendRequest() with method "connect" and params [userPubkey, token||'', perms||'']; resolve on ack.
-
NIP46Client.initUserPubkey(hint?: string): Promise
- If hint present, set and return; else call "get_public_key" RPC and store result.
-
- id = random string
- ev = NIP46Client.createRequestEvent(id, remotePubkey, method, params, kind)
- pool.publish(relays, ev)
- NIP46Client.setResponseHandler(id, cb)
-
NIP46Client.createRequestEvent(id, remotePubkey, method, params, kind)
- content = JSON.stringify({ id, method, params })
- encrypt content using nip44 unless method==='create_account', otherwise nip04 fallback
- tags: [ ['p', remotePubkey] ]
- sign via NostrTools.finalizeEvent()
- return event object
-
- Detect nip04 vs nip44 by ciphertext shape; decrypt using local seckey and remote pubkey (nip04 or Nip44/window.NostrTools.nip44).
- Return { id, method, params, event } or { id, result, error, event }.
-
NIP46Client.setResponseHandler(id, cb)
- Deduplicate 'auth_url' emissions; ensure only one resolution per id; log elapsed; cleanup map entries.
-
NIP46Client.setWorkerIframePort(port: MessagePort)
- When iframe is present, forward signed events via postMessage and map request id↔event.id; send keepalive pings.
-
- Unsubscribe; clear timers/ports; pool.close(relays).
Iframe handshake
-
IframeReadyListener.start(messages: string[], origin: string)
- Listen for ["workerReady","workerError"] and ["starterDone","starterError"]; origin-check by host or subdomain.
ExtensionBridge
-
ExtensionBridge.startChecking(nostrLite: NostrLite)
- Poll window.nostr until found; then call ExtensionBridge.initExtension() once; retry after a delay to capture last extension.
-
ExtensionBridge.initExtension(nostrLite: NostrLite, lastTry?: boolean)
- Cache extension, reassign window.nostr to nostrLite; if currently authed as extension, reconcile; schedule a final late check.
-
ExtensionBridge.setExtensionReadPubkey(expectedPubkey?: string)
- Temporarily set window.nostr = extension; read pubkey; compare to expected; emit extensionLogin/extensionLogout.
Local signer (wrapping window.NostrTools)
-
LocalSigner.constructor(sk: string)
- Cache public key on construct via NostrTools.getPublicKey().
-
LocalSigner.sign(event: NostrEvent): Promise
- Use NostrTools.finalizeEvent(), set id/sig/pubkey.
-
LocalSigner.encrypt04(pubkey: string, plaintext: string): Promise
-
LocalSigner.decrypt04(pubkey: string, ciphertext: string): Promise
-
LocalSigner.encrypt44(pubkey: string, plaintext: string): Promise
- Use window.NostrTools.nip44 if available, else Nip44.encrypt().
-
LocalSigner.decrypt44(pubkey: string, ciphertext: string): Promise
- Use window.NostrTools.nip44 if available, else Nip44.decrypt().
Store (localStorage helpers)
- Store.addAccount(info: Info)
- Store.removeCurrentAccount()
- Store.getCurrent(): Info | null
- Store.getAccounts(): Info[]
- Store.getRecents(): Info[]
- Store.setItem(key: string, value: string)
- Store.getIcon(): Promise
UI (single modal)
-
- Create modal container; inject minimal CSS; set theme and RTL if required.
-
Modal.open(opts: { startScreen?: string })
- Render options: Connect (with provider list/QR), Extension, Local (Create/Import), Read-only, OTP (if server URLs configured), Switch Account if prior accounts.
-
Modal.showAuthUrl(url: string)
- For connect OAuth-like prompt; present link/QR; if iframeUrl is used, show embedded iframe.
-
Modal.onImportConnectionString(cs: ConnectionString)
- Import-and-connect flow: calls Auth.importAndConnect().
Event bus
- Bus.on(event: string, handler: Function)
- Bus.off(event: string, handler: Function)
- Bus.emit(event: string, payload?: any)
Relay configuration helpers
-
Relays.getDefaultRelays(options): string[]
- From options or sensible defaults.
-
Relays.normalize(relays: string[]): string[]
- Ensure proper wss:// and deduping.
Parity acceptance criteria
- Same external API names and events exported as current initializer: init, launch, logout, setDarkMode, setAuth, cancelNeedAuth.
- Same nlAuth payload shape as onAuth().
- window.nostr behavior matches class Nostr.
Notes and implementation tips
- Load order: Deps.ensureNostrToolsLoaded() must guard all usages of window.NostrTools.
- Request de-duplication: mirror setResponseHandler() behavior to avoid auth_url flooding.
- NIP-44 selection: use nip44 for all methods except "create_account".
- Iframe origin checks: follow ReadyListener host/subdomain verification.
- Secret handling in nostrconnect: accept 'ack' or exact secret; see listen.
- Profile fetch is optional; keep it async and non-blocking, update UI/state when complete.
Out of scope for initial lite version
- Complex banners/popups and multi-window flows.
- Full NDK feature parity beyond the minimum for NIP-46 RPC.
- Advanced error telemetry; keep console logs minimal.
Deliverables
- Single distributable lite/nostr-login-lite.js usable via:
- <script src="..."></script> with window.nostr present after [init()](lite/nostr-login-lite.js:1).
- Optional ESM import with same API.
- Minimal HTML example showing each auth method path is functional.