Files
bitlogin/packages/widget/src/worker/workerClient.ts
T
Claude 5ee63ae6b0 Implement BitLogin as a modular protocol library, embeddable widget, and demo site
Builds out the v0.3 spec as an npm workspace monorepo any static site can
consume for login and Nostr signing:

- packages/core: framework-free protocol implementation — Argon2id/HKDF/
  ScalarExpand/AES-256-GCM/JCS crypto primitives, NIP-01 event handling and a
  quorum-aware relay pool, credential/recovery capsule construction and
  validation, and the full account lifecycle (registration, any-device
  login with rollback/relay-disagreement detection, phrase recovery with
  recovery-capsule refresh, password rotation with mandatory tombstone +
  NIP-09 deletion, replica repair, recovery export, NIP-19 encoding, a
  NIP-44 v2 implementation, and a NIP-07-shaped signer). 49 tests cover the
  spec's Phase 0 byte-level vectors plus full create/login/recover/rotate
  end-to-end scenarios against an in-memory mock relay.

- packages/widget: a <bitlogin-auth> custom element with a dedicated crypto
  Web Worker (private keys never touch the main thread) and a window.nostr
  NIP-07-compatible provider, so existing Nostr web apps work against it
  with zero code changes. Built with Vite into two relocatable static files.

- packages/demo: a plain static site (no build step of its own) consuming
  the widget — a branding/landing page, an integration guide, and an
  account-manager page covering create/login/recover, password rotation,
  and identity export.

All flows were verified twice: protocol-level tests against a mock relay,
and the actual built widget driven end-to-end in real Chromium (registration,
phrase confirmation, event signing, clean-device login, window.nostr, and
phrase recovery all reproduce the identical everyday pubkey).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GWJbXXhvTx9wo59jSz57J6
2026-07-21 03:44:10 +00:00

111 lines
3.6 KiB
TypeScript

/** Main-thread proxy for the crypto Web Worker. Every method returns a Promise resolving to public data only. */
import type {
WorkerActionMap,
WorkerAction,
WorkerRequest,
WorkerResponse,
ConfigurePayload,
RegisterPayload,
LoginPayload,
RecoverPayload,
CompleteRecoveryPayload,
ChangePasswordPayload,
PublishProfilePayload,
SignEventPayload,
Nip44EncryptPayload,
Nip44DecryptPayload
} from "./protocol.js";
export class WorkerClient {
private readonly worker: Worker;
private readonly pending = new Map<string, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();
private counter = 0;
constructor() {
// Deliberately NOT the literal `new Worker(new URL("./x.js", import.meta.url))` shape: Vite
// statically pattern-matches that exact expression (string-literal first argument) and
// rewrites/inlines it at build time, which breaks when this widget is served from a
// subdirectory rather than the site root. Building the path in a variable defeats the
// static match, leaving plain runtime URL resolution -- relative to *this* module's actual
// URL, exactly like any other web platform relative import -- untouched by the bundler.
const workerFileName = ["cryptoWorker", ".js"].join("");
const workerUrl = new URL(workerFileName, import.meta.url);
this.worker = new Worker(workerUrl, { type: "module" });
this.worker.addEventListener("message", (event: MessageEvent<WorkerResponse>) => {
const msg = event.data;
const entry = this.pending.get(msg.id);
if (!entry) return;
this.pending.delete(msg.id);
if (msg.ok) {
entry.resolve(msg.result);
} else {
const error = new Error(msg.error);
error.name = msg.errorName ?? "Error";
entry.reject(error);
}
});
}
private call<A extends WorkerAction>(action: A, payload: WorkerActionMap[A][0]): Promise<WorkerActionMap[A][1]> {
const id = `${Date.now().toString(36)}-${(this.counter++).toString(36)}`;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
const request: WorkerRequest = { id, action, payload };
this.worker.postMessage(request);
});
}
configure(payload: ConfigurePayload) {
return this.call("configure", payload);
}
register(payload: RegisterPayload) {
return this.call("register", payload);
}
login(payload: LoginPayload) {
return this.call("login", payload);
}
recover(payload: RecoverPayload) {
return this.call("recover", payload);
}
completeRecovery(payload: CompleteRecoveryPayload) {
return this.call("completeRecovery", payload);
}
changePassword(payload: ChangePasswordPayload) {
return this.call("changePassword", payload);
}
publishProfileAndRelayLists(payload: PublishProfilePayload) {
return this.call("publishProfileAndRelayLists", payload);
}
getPublicKey() {
return this.call("getPublicKey", {});
}
signEvent(payload: SignEventPayload) {
return this.call("signEvent", payload);
}
nip44Encrypt(payload: Nip44EncryptPayload) {
return this.call("nip44Encrypt", payload);
}
nip44Decrypt(payload: Nip44DecryptPayload) {
return this.call("nip44Decrypt", payload);
}
exportIdentity() {
return this.call("exportIdentity", {});
}
buildRecoveryExport() {
return this.call("buildRecoveryExport", {});
}
repairReplicas() {
return this.call("repairReplicas", {});
}
getSessionStatus() {
return this.call("getSessionStatus", {});
}
logout() {
return this.call("logout", {});
}
terminate(): void {
this.worker.terminate();
}
}