mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
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:
1 parent
5ae41ed9db
commit
b8b19599e0
7 files changed
+245
-5
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in new issue
Block a user