diff --git a/README.md b/README.md index a27f38a..137da1e 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,30 @@ call is also on the full widget as `window.bitlogin.readCachedSession()`, so pages that already load `bitlogin.js` can use it without a second import. Deploy `bitlogin-session.js` alongside `bitlogin.js`. +**Signing without the widget (headless).** `readCachedSession` reads identity +but can't sign. When a host needs to *sign* programmatically — a background job, +a framework rendering its own account UI, a headless test — and doesn't want the +custom element, use the headless signer. It runs the same crypto worker without +any DOM: + +```javascript +import { createHeadlessSigner } from "@bitlogin/widget/headless"; + +const signer = await createHeadlessSigner(); +const who = await signer.restore(); // { pubkey } | null (null → no cached session) +if (who) { + const event = await signer.signEvent({ kind: 1, content: "gm", tags: [] }); + const pubkey = await signer.getPublicKey(); + // also: nip44Encrypt / nip44Decrypt / isUnlocked / logout / destroy +} +``` + +It deliberately can't *log in* (that needs the password UI): `restore()` returns +null when no session is cached, at which point mount `` once so +the user signs in — after that the headless signer works across reloads from the +cached session. Deploy `bitlogin-headless.js` next to `cryptoWorker.js` and the +shared chunks, same as `bitlogin.js`. + The `` element also mirrors `window.nostr` directly on the element instance (`getPublicKey`, `signEvent`, `nip44Encrypt`, `nip44Decrypt`) — prefer this over `window.nostr` when your host page holds a reference to diff --git a/docs/TODO.md b/docs/TODO.md index d8845e1..44f279b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -379,10 +379,16 @@ how much they cost. Remaining polish: have the widget also record the profile display `name` once fetched (currently `name` is only set when the caller supplies it), and add an `@bitlogin/widget/session` `.d.ts` (tracked under the Small item below). -- **A headless session-restore API.** Related: expose session restore + - `getPublicKey`/`signEvent` without the custom element, so a host app can wire - its own UI and only load the visual widget for login/management. Right now the - element is the only entry point. +- **[DONE 2026-08-11] A headless session-restore API.** Shipped + `@bitlogin/widget/headless` (`bitlogin-headless.js`): `createHeadlessSigner()` + → `restore()` / `getPublicKey()` / `signEvent()` / `nip44Encrypt|Decrypt` / + `isUnlocked()` / `logout()` / `destroy()`, a DOM-free wrapper over the crypto + worker. It restores the cached session and signs with no custom element and no + `window.nostr`; it deliberately can't interactively log in (`restore()` + returns null when nothing is cached → mount `` once). Covered by + `headless.test.ts` (scripted-worker); also re-exported from `@bitlogin/widget`; + README "Signing without the widget (headless)". Deploy alongside + `cryptoWorker.js` + shared chunks. ### Medium diff --git a/packages/widget/package.json b/packages/widget/package.json index 275edef..f9bbef5 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -15,6 +15,10 @@ "./session": { "types": "./dist-types/session.d.ts", "default": "./dist/bitlogin-session.js" + }, + "./headless": { + "types": "./dist-types/headless.d.ts", + "default": "./dist/bitlogin-headless.js" } }, "scripts": { diff --git a/packages/widget/src/headless.test.ts b/packages/widget/src/headless.test.ts new file mode 100644 index 0000000..cbd3423 --- /dev/null +++ b/packages/widget/src/headless.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createHeadlessSigner } from "./headless.js"; + +const PK = "ab".repeat(32); + +/** A crypto-worker stand-in that answers the protocol with canned results, so + * the headless signer's wiring can be tested without the real worker/wasm. */ +class ScriptedWorker { + static last: ScriptedWorker | null = null; + listeners: Record void)[]> = {}; + terminated = false; + responses: Record = { + configure: {}, + restoreSession: { restored: true, everydayPublicKey: PK, accountId: "acct" }, + getSessionStatus: { unlocked: true, everydayPublicKey: PK }, + signEvent: { id: "e".repeat(64), pubkey: PK, kind: 1, content: "hi", tags: [], created_at: 1, sig: "s".repeat(128) }, + nip44Encrypt: { ciphertext: "CIPHER" }, + nip44Decrypt: { plaintext: "PLAIN" }, + logout: {} + }; + constructor(_url: URL, _opts?: unknown) { + ScriptedWorker.last = this; + } + addEventListener(type: string, cb: (event: unknown) => void): void { + (this.listeners[type] ??= []).push(cb); + } + postMessage(msg: unknown): void { + const { id, action } = msg as { id: string; action: string }; + const result = this.responses[action]; + queueMicrotask(() => { + for (const cb of this.listeners.message ?? []) cb({ data: { id, ok: true, result } }); + }); + } + terminate(): void { + this.terminated = true; + } +} + +describe("createHeadlessSigner", () => { + beforeEach(() => { + (globalThis as unknown as { Worker: unknown }).Worker = ScriptedWorker; + }); + afterEach(() => { + delete (globalThis as unknown as { Worker?: unknown }).Worker; + }); + + 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.getPublicKey()).resolves.toBe(PK); + await expect(signer.isUnlocked()).resolves.toBe(true); + }); + + it("signs an event once a session is present", async () => { + const signer = await createHeadlessSigner(); + await signer.restore(); + const signed = await signer.signEvent({ kind: 1, content: "hi", tags: [] }); + expect(signed.pubkey).toBe(PK); + expect(signed.sig).toHaveLength(128); + }); + + it("encrypts/decrypts via nip44 after restore", async () => { + const signer = await createHeadlessSigner(); + await signer.restore(); + await expect(signer.nip44Encrypt("cd".repeat(32), "secret")).resolves.toBe("CIPHER"); + await expect(signer.nip44Decrypt("cd".repeat(32), "CIPHER")).resolves.toBe("PLAIN"); + }); + + it("falls back to getSessionStatus when getPublicKey is called before restore", async () => { + const signer = await createHeadlessSigner(); + // No restore() — getPublicKey should consult getSessionStatus and succeed. + await expect(signer.getPublicKey()).resolves.toBe(PK); + }); + + it("returns null from restore and rejects signing when no session is cached", async () => { + const signer = await createHeadlessSigner(); + ScriptedWorker.last!.responses.restoreSession = { restored: false }; + ScriptedWorker.last!.responses.getSessionStatus = { unlocked: false }; + await expect(signer.restore()).resolves.toBeNull(); + await expect(signer.getPublicKey()).rejects.toThrow(/no session/i); + await expect(signer.signEvent({ kind: 1, content: "x", tags: [] })).rejects.toThrow(/no session/i); + }); + + it("destroy() terminates the worker", async () => { + const signer = await createHeadlessSigner(); + signer.destroy(); + expect(ScriptedWorker.last?.terminated).toBe(true); + }); +}); diff --git a/packages/widget/src/headless.ts b/packages/widget/src/headless.ts new file mode 100644 index 0000000..97312bb --- /dev/null +++ b/packages/widget/src/headless.ts @@ -0,0 +1,112 @@ +/** + * Headless BitLogin signer — session restore + signing with NO custom element + * and no DOM. + * + * The `` widget owns the interactive surface (create / recover / + * password entry). This is the other half: a host that already has a session + * established on this origin — set up once, via the widget — and wants to sign + * programmatically without mounting anything. A background job, a framework that + * renders its own account UI, a headless test. It spins up the same crypto Web + * Worker the widget uses, restores the cached session, and exposes + * getPublicKey / signEvent / nip44 — no `window.nostr`, no element, no styles. + * + * It deliberately cannot LOG IN (that needs the password UI): `restore()` + * returns null when no session is cached, at which point the host should mount + * `` once so the user can sign in. After that, this signer works + * across reloads from the cached session. + * + * Deploy `cryptoWorker.js` and the shared chunks alongside the headless build, + * exactly as for `bitlogin.js` — the worker is where the keys live. + */ +import { WorkerClient } from "./worker/workerClient.js"; +import type { SignEventPayload } from "./worker/protocol.js"; +import type { NostrEvent } from "@bitlogin/core/nostr"; + +export interface HeadlessSignerOptions { + /** Relays for the Connection Vault (only needed for wallet features). */ + vaultRelays?: string[]; + /** Relays for account discovery (only needed for interactive login, which + * this signer doesn't do — safe to omit). */ + discoveryRelays?: string[]; +} + +export interface HeadlessSigner { + /** Restore the cached session for this origin. Returns the identity, or null + * if nothing is cached (mount `` so the user can sign in). + * Idempotent. */ + restore(): Promise<{ pubkey: string } | null>; + /** Whether a session is unlocked and ready to sign. */ + isUnlocked(): Promise; + /** The signed-in public key (hex). Rejects if no session is unlocked. */ + getPublicKey(): Promise; + signEvent(event: SignEventPayload): Promise; + nip44Encrypt(peerPublicKey: string, plaintext: string): Promise; + nip44Decrypt(peerPublicKey: string, payload: string): Promise; + /** Clear the cached session for this origin. */ + logout(): Promise; + /** Tear down the crypto worker. The signer is unusable afterward; create a + * new one to sign again. */ + destroy(): void; +} + +const NO_SESSION = + "BitLogin: no session is unlocked. Call restore() first, or have the user sign in once via ."; + +/** + * Create a headless signer and configure its worker. Call `restore()` next to + * pick up an existing session. + */ +export async function createHeadlessSigner(options: HeadlessSignerOptions = {}): Promise { + const client = new WorkerClient(); + await client.configure({ + vaultRelayUrls: options.vaultRelays ?? [], + discoveryRelayUrls: options.discoveryRelays ?? [] + }); + + let pubkey: string | null = null; + + const requirePubkey = async (): Promise => { + if (pubkey) return pubkey; + const status = await client.getSessionStatus(); + if (!status.unlocked || !status.everydayPublicKey) throw new Error(NO_SESSION); + pubkey = status.everydayPublicKey; + return pubkey; + }; + + return { + async restore() { + const res = await client.restoreSession(); + pubkey = res.restored && res.everydayPublicKey ? res.everydayPublicKey : null; + return pubkey ? { pubkey } : null; + }, + async isUnlocked() { + if (pubkey) return true; + const status = await client.getSessionStatus(); + if (status.unlocked && status.everydayPublicKey) pubkey = status.everydayPublicKey; + return Boolean(status.unlocked); + }, + getPublicKey() { + return requirePubkey(); + }, + async signEvent(event) { + await requirePubkey(); + return client.signEvent(event); + }, + async nip44Encrypt(peerPublicKey, plaintext) { + await requirePubkey(); + return (await client.nip44Encrypt({ peerPublicKey, plaintext })).ciphertext; + }, + async nip44Decrypt(peerPublicKey, payload) { + await requirePubkey(); + return (await client.nip44Decrypt({ peerPublicKey, payload })).plaintext; + }, + async logout() { + await client.logout(); + pubkey = null; + }, + destroy() { + client.terminate("BitLogin headless signer was destroyed."); + pubkey = null; + } + }; +} diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index f979ff9..5fb263f 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -95,6 +95,8 @@ window.bitlogin = { export { readCachedSession } from "./session.js"; export type { PublicSessionInfo } from "./publicSession.js"; +export { createHeadlessSigner } from "./headless.js"; +export type { HeadlessSigner, HeadlessSignerOptions } from "./headless.js"; export { BitLoginAuthElement } from "./element.js"; export type { BitLoginEventMap, diff --git a/packages/widget/vite.config.ts b/packages/widget/vite.config.ts index c313bdc..2a2382d 100644 --- a/packages/widget/vite.config.ts +++ b/packages/widget/vite.config.ts @@ -20,7 +20,10 @@ export default defineConfig({ entry: { bitlogin: resolve(__dirname, "src/index.ts"), cryptoWorker: resolve(__dirname, "src/worker/cryptoWorker.ts"), - "bitlogin-session": resolve(__dirname, "src/session.ts") + "bitlogin-session": resolve(__dirname, "src/session.ts"), + // Headless signer (restore + sign, no element/DOM). Loads cryptoWorker.js + // at runtime like bitlogin.js, so deploy them together. + "bitlogin-headless": resolve(__dirname, "src/headless.ts") }, formats: ["es"], fileName: (_format, entryName) => `${entryName}.js`