Files
bitlogin/packages/widget/src/headless.ts
thePR0M3TH3ANandClaude Opus 4.8 b8b19599e0 widget: headless signer — restore + sign without the custom element
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
2026-08-11 22:54:14 -04:00

113 lines
4.4 KiB
TypeScript

/**
* Headless BitLogin signer — session restore + signing with NO custom element
* and no DOM.
*
* The `<bitlogin-auth>` 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
* `<bitlogin-auth>` 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 `<bitlogin-auth>` so the user can sign in).
* Idempotent. */
restore(): Promise<{ pubkey: string } | 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. */
getPublicKey(): Promise<string>;
signEvent(event: SignEventPayload): Promise<NostrEvent>;
nip44Encrypt(peerPublicKey: string, plaintext: string): Promise<string>;
nip44Decrypt(peerPublicKey: string, payload: string): Promise<string>;
/** Clear the cached session for this origin. */
logout(): Promise<void>;
/** 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 <bitlogin-auth>.";
/**
* Create a headless signer and configure its worker. Call `restore()` next to
* pick up an existing session.
*/
export async function createHeadlessSigner(options: HeadlessSignerOptions = {}): Promise<HeadlessSigner> {
const client = new WorkerClient();
await client.configure({
vaultRelayUrls: options.vaultRelays ?? [],
discoveryRelayUrls: options.discoveryRelays ?? []
});
let pubkey: string | null = null;
const requirePubkey = async (): Promise<string> => {
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;
}
};
}