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

+24
View File
@@ -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 `<bitlogin-auth>` 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 `<bitlogin-auth>` 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
+10 -4
View File
@@ -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 `<bitlogin-auth>` 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
+4
View File
@@ -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": {
+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,
+4 -1
View File
@@ -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`