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
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-08-11 22:54:14 -04:00
1 parent 5ae41ed9db
commit b8b19599e0
7 files changed
+245 -5

No files matched your search

+89
View File
@@ -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<string, ((event: unknown) => void)[]> = {};
terminated = false;
responses: Record<string, unknown> = {
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);
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* 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;
}
};
}
+2
View File
@@ -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,