mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 16:41:25 +00:00
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
113 lines
4.4 KiB
TypeScript
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;
|
|
}
|
|
};
|
|
}
|