diff --git a/docs/centralized-onramps.md b/docs/centralized-onramps.md
index df5501a..2a6f772 100644
--- a/docs/centralized-onramps.md
+++ b/docs/centralized-onramps.md
@@ -1,8 +1,27 @@
# Centralized On-Ramps — OAuth Sign-In as a Journey Toward Self-Custody
-**Document version:** 0.1 (design proposal)
-**Status:** Proposal only — nothing here is implemented, and the reference
-services it describes live outside this repository by definition. Depends on
+**Document version:** 0.2
+**Status:** The **widget side of §CO5 is implemented** (2026-08-04):
+`onramp-url` / `onramp-name` / `onramp-providers` attributes, the popup
+handshake below, "Continue with " rows under the "Use an account
+you already have" group (§CO11.1 resolved: that heading covers the whole
+existing-account group — extension, remote signer, key import, and
+centralized rails — with the custody statement carried by each row's
+sub-label), client-side registration with optional §SF10 import, and the
+Tier B2 "Secure your account" dashboard card. Reference services live
+outside this repository by definition and do not yet exist.
+
+**Handshake contract as implemented (v1):** the widget opens
+`onramp-url?provider=&state=&origin=` in a popup;
+the popup posts to its opener exactly one
+`{ type: "bitlogin:onramp:unlock", state, unlock: { loginName, password } }`
+(returning user) or `{ …, register: { registrationToken,
+suggestedLoginName? } }` (new user), accepted only from the configured
+origin with the exact state nonce. For new users the widget registers
+client-side, then `POST onramp-url` with
+`{ type: "bitlogin:onramp:store", registrationToken, loginName, password }`;
+a non-OK response forces the phrase ceremony immediately instead of
+deferring it. Hosts must add the service origin to their CSP `connect-src`. Depends on
`docs/spec.md`, `docs/login-methods.md` (the one-login-surface model), and
`docs/second-factor.md` §SF10 (imported identities, implemented), which turns
out to be the graduation mechanism.
@@ -256,6 +275,8 @@ A service a BitLogin deployment points at must:
`{ provider, service, tier }`.
3. Whether Tier B2's first-session phrase window should survive a reload
within some time budget, or end strictly with the worker.
-4. Whether the option-menu group heading "Centralized options" is the right
- label, or something warmer ("Use an account you already have") with the
- custody statement carried entirely by the sub-label.
+4. **Resolved 2026-08-04 (owner decision):** the group heading is "Use an
+ account you already have," covering every existing-account path — Nostr
+ extension, remote signer, key import, and centralized rails alike — with
+ the custody statement carried entirely by each centralized row's
+ sub-label (" manages your sign-in").
diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html
index cf8207c..f260ade 100644
--- a/packages/demo/public/docs.html
+++ b/packages/demo/public/docs.html
@@ -162,6 +162,12 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined
already offers username/password, a NIP-07 extension, and a NIP-46 remote signer as sub-options behind one
surface, and routes every signing call to whichever the user picked. You don't need your own method picker,
and your app code doesn't branch by method.
+
Sites that want a "Continue with Google/GitHub" on-ramp can additionally point the widget at an OAuth
+ unlock service (onramp-url, onramp-name, onramp-providers attributes —
+ nothing centralized ships unless you configure it, and your CSP's connect-src must include the
+ service's origin). The service manages the password of a real, portable Nostr account; users can bring an
+ existing key at signup and are nudged toward claiming their recovery phrase over time. See
+ docs/centralized-onramps.md in the repo for the handshake contract and threat model.
Two different questions have two different APIs, and conflating them is the classic integration bug:
"Is someone signed in through BitLogin?" — use window.bitlogin.activeMethod()
(or the richer activeSession()), or listen for bitlogin-login. This answers correctly
diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt
index e7f4a27..94c9858 100644
--- a/packages/demo/public/llms.txt
+++ b/packages/demo/public/llms.txt
@@ -28,6 +28,17 @@ deployment above.
- A `` custom element (Shadow DOM, no CSS collisions) handling
create account, sign in, forgot-password recovery via a 12-word phrase, and
password rotation.
+- Optional centralized on-ramps ("Continue with Google/GitHub/..."): a host
+ may point the widget at an OAuth unlock service via `onramp-url`,
+ `onramp-name`, and `onramp-providers` attributes -- nothing centralized
+ ships by default. The service acts as the password manager for a REAL
+ capsule account (it holds the unlock secret, never the key); rows render
+ under "Use an account you already have" with the custody statement
+ (" manages your sign-in") as the sub-label, new users can create
+ a fresh identity or import an existing nsec, and the dashboard nudges
+ toward claiming the recovery phrase ("Secure your account"). Hosts must
+ add the service origin to CSP connect-src. Contract and threat model:
+ docs/centralized-onramps.md.
- One login surface, several methods: if the visitor's browser has a NIP-07
signer extension, the welcome screen offers "Use your Nostr extension
instead" below the primary username/password path, and every visitor gets
diff --git a/packages/widget/src/config.ts b/packages/widget/src/config.ts
index 5a5197f..e2a502c 100644
--- a/packages/widget/src/config.ts
+++ b/packages/widget/src/config.ts
@@ -1,7 +1,13 @@
/** Host-page-configurable options for (§19.1, §19.6). */
+import { parseOnrampConfig, type OnrampConfig } from "./onramp.js";
+
export interface BitLoginConfig {
vaultRelayUrls?: string[];
discoveryRelayUrls?: string[];
+ /** Centralized on-ramp (centralized-onramps.md §CO5). Absent unless the
+ * host explicitly configures one -- BitLogin ships no centralized
+ * dependency by default. */
+ onramp?: OnrampConfig;
}
export function readConfigFromElement(el: HTMLElement): BitLoginConfig {
@@ -9,6 +15,11 @@ export function readConfigFromElement(el: HTMLElement): BitLoginConfig {
const discoveryAttr = el.getAttribute("discovery-relays");
return {
vaultRelayUrls: vaultAttr ? vaultAttr.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
- discoveryRelayUrls: discoveryAttr ? discoveryAttr.split(",").map((s) => s.trim()).filter(Boolean) : undefined
+ discoveryRelayUrls: discoveryAttr ? discoveryAttr.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
+ onramp: parseOnrampConfig({
+ url: el.getAttribute("onramp-url"),
+ name: el.getAttribute("onramp-name"),
+ providers: el.getAttribute("onramp-providers")
+ })
};
}
diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts
index 6dee4e0..0cd5462 100644
--- a/packages/widget/src/element.ts
+++ b/packages/widget/src/element.ts
@@ -20,12 +20,22 @@ import { Nip46Signer } from "./signers/nip46.js";
import type { Signer, SignerCapabilities, SignerMethod } from "./signers/types.js";
import { renderQrSvg } from "./qr.js";
import { setActiveSession, clearActiveSession } from "./globalSession.js";
+import {
+ buildOnrampAuthUrl,
+ newOnrampState,
+ onrampProviderLabel,
+ storeOnrampCredential,
+ validateOnrampMessage,
+ type OnrampConfig
+} from "./onramp.js";
+import { randomBytes, bytesToHex } from "@bitlogin/core/crypto";
type Screen =
| "welcome"
| "extension-confirm"
| "bunker-connect"
| "bunker-confirm"
+ | "onramp-create"
| "import-key"
| "create-name"
| "create-credential"
@@ -54,7 +64,8 @@ const OPTION_ICONS = {
extension: ``,
remote: ``,
importKey: ``,
- recover: ``
+ recover: ``,
+ onramp: ``
} as const;
const CHEVRON_DOWN = ``;
@@ -112,6 +123,47 @@ export class BitLoginAuthElement extends HTMLElement {
private bunkerConnectUri = "";
private bunkerAuthUrl = "";
private pendingBunker: { userPubkey: string; npub: string } | null = null;
+
+ // ---- Centralized on-ramp (centralized-onramps.md §CO5, Architecture B) ----
+ // Configured by the host or absent entirely. `attempt` spans one popup round
+ // trip (state nonce + popup handle); `registration` spans a new user's
+ // choice between a fresh identity and importing one (§SF10); `session` marks
+ // the standing for dashboard labeling (the session itself is an ordinary
+ // BitLogin account session). `securePhrasePending` is Tier B2: the phrase is
+ // in memory this session, the ceremony deferred behind a dashboard card.
+ private onramp: OnrampConfig | undefined;
+ private onrampAttempt: { state: string; provider: string; popup: Window | null } | null = null;
+ private onrampRegistration: { registrationToken: string; provider: string; suggestedLoginName?: string } | null = null;
+ private onrampSession = false;
+ private securePhrasePending = false;
+ private readonly onWindowMessage = (event: MessageEvent): void => {
+ const attempt = this.onrampAttempt;
+ if (!attempt || !this.onramp) return;
+ const result = validateOnrampMessage(
+ { origin: event.origin, data: event.data },
+ { origin: this.onramp.origin, state: attempt.state }
+ );
+ if (!result) return;
+ this.onrampAttempt = null;
+ try {
+ attempt.popup?.close();
+ } catch {
+ // Cross-origin popup handles can refuse; it closes itself regardless.
+ }
+ if (result.kind === "unlock") {
+ // Returning user: the service is simply the password manager -- this is
+ // the ordinary password login from here on.
+ this.onrampSession = true;
+ void this.attemptLogin(result.loginName, result.password);
+ return;
+ }
+ this.onrampRegistration = {
+ registrationToken: result.registrationToken,
+ provider: attempt.provider,
+ suggestedLoginName: result.suggestedLoginName
+ };
+ this.goto("onramp-create");
+ };
private sessionWarnings: string[] = [];
private lastSignedEventJson = "";
private exportedNsec = "";
@@ -190,6 +242,8 @@ export class BitLoginAuthElement extends HTMLElement {
const config = readConfigFromElement(this);
this.vaultRelayUrls = config.vaultRelayUrls ?? [];
this.discoveryRelayUrls = config.discoveryRelayUrls ?? [];
+ this.onramp = config.onramp;
+ window.addEventListener("message", this.onWindowMessage);
void this.worker
.configure({ vaultRelayUrls: this.vaultRelayUrls, discoveryRelayUrls: this.discoveryRelayUrls })
.then(() => this.tryRestoreSession());
@@ -216,6 +270,7 @@ export class BitLoginAuthElement extends HTMLElement {
disconnectedCallback(): void {
if (this.successDismissTimer !== null) clearTimeout(this.successDismissTimer);
+ window.removeEventListener("message", this.onWindowMessage);
clearActiveSession(this);
this.releaseSigner();
this.worker.terminate();
@@ -271,6 +326,8 @@ export class BitLoginAuthElement extends HTMLElement {
if (this.altSigner.method === "nip46") void this.worker.nip46Disconnect();
this.altSigner = null;
this.session = null;
+ this.onrampSession = false;
+ this.securePhrasePending = false;
clearActiveSession(this);
this.releaseSigner();
this.dispatchEvent(new CustomEvent("bitlogin-logout"));
@@ -279,6 +336,8 @@ export class BitLoginAuthElement extends HTMLElement {
}
await this.worker.logout();
this.session = null;
+ this.onrampSession = false;
+ this.securePhrasePending = false;
clearActiveSession(this);
this.releaseSigner();
this.dispatchEvent(new CustomEvent("bitlogin-logout"));
@@ -770,6 +829,8 @@ export class BitLoginAuthElement extends HTMLElement {
case "goto-welcome":
this.pendingRollback = null;
this.pendingExtensionSigner = null;
+ this.onrampAttempt = null;
+ this.onrampRegistration = null;
// Backing out of an unfinished bunker connect abandons the worker-side
// attempt -- but never a live session (altSigner) that's merely
// navigating around.
@@ -785,6 +846,18 @@ export class BitLoginAuthElement extends HTMLElement {
this.moreOptionsOpen = !this.moreOptionsOpen;
this.render();
return;
+ case "onramp-signin":
+ this.startOnramp(target.dataset.provider ?? "");
+ return;
+ case "onramp-new":
+ this.importKey = "";
+ this.importPreviewNpub = "";
+ return void this.runOnrampRegistration();
+ case "onramp-import":
+ this.importKey = "";
+ this.importPreviewNpub = "";
+ this.goto("import-key");
+ return;
case "extension-signin":
return this.handleExtensionSignIn();
case "extension-continue":
@@ -976,10 +1049,103 @@ export class BitLoginAuthElement extends HTMLElement {
this.render();
return;
}
+ if (this.onrampRegistration) {
+ // On-ramp signup chose "use my existing key": same §SF10 wrap, with the
+ // service holding the password instead of the user (§CO5).
+ void this.runOnrampRegistration();
+ return;
+ }
this.loginName = "";
this.goto("create-name");
}
+ /** Opens the on-ramp's OAuth popup for one attempt (§CO5). */
+ private startOnramp(provider: string): void {
+ if (!this.onramp) return;
+ const state = newOnrampState();
+ const url = buildOnrampAuthUrl(this.onramp, provider, state, window.location.origin);
+ const popup = window.open(url, "bitlogin-onramp", "popup,width=480,height=640");
+ if (!popup) {
+ this.errorMessage = "Your browser blocked the sign-in window. Allow pop-ups for this site and try again.";
+ this.render();
+ return;
+ }
+ this.onrampAttempt = { state, provider, popup };
+ this.errorMessage = undefined;
+ this.render();
+ }
+
+ /**
+ * New-user registration through the on-ramp (§CO5): standard client-side
+ * registration (§15) -- optionally wrapping an imported key (§SF10) -- with
+ * the generated password handed to the service for safekeeping afterward.
+ * Tier B2 phrase handling: the ceremony is deferred behind the dashboard's
+ * "Secure your account" card, EXCEPT when the store call fails -- an account
+ * whose credential nobody is holding forces the ceremony immediately.
+ */
+ private async runOnrampRegistration(): Promise {
+ const registration = this.onrampRegistration;
+ const onramp = this.onramp;
+ if (!registration || !onramp) return;
+ this.setBusy(true);
+ try {
+ const suggested = registration.suggestedLoginName?.trim().toLowerCase() ?? "";
+ const loginName = isValidLoginName(suggested) ? suggested : `nostr-${bytesToHex(randomBytes(4))}`;
+ const password = generatePassphrase().secret;
+ const result = await this.worker.register({
+ loginName,
+ password,
+ importKey: this.importKey || undefined
+ });
+ this.importKey = "";
+ this.importPreviewNpub = "";
+ this.onrampRegistration = null;
+ this.loginName = loginName;
+ this.recoveryPhrase = result.recoveryPhrase;
+ const words = this.recoveryPhrase.split(" ");
+ const indices = pickRandomIndices(words.length, 3);
+ this.confirmSlots = indices.map((index) => ({ index, value: "" }));
+ this.session = {
+ publicKey: result.everydayPublicKey,
+ npub: encodeNpub(result.everydayPublicKey),
+ accountId: result.accountId,
+ method: "bitlogin"
+ };
+ this.sessionWarnings = [];
+ void this.worker.publishProfileAndRelayLists({
+ name: this.loginName,
+ generalRelays: this.vaultRelayUrls,
+ dmRelays: this.vaultRelayUrls
+ });
+ try {
+ await storeOnrampCredential(onramp, {
+ registrationToken: registration.registrationToken,
+ loginName,
+ password
+ });
+ } catch (storeErr) {
+ // The account is real and live on relays, but the service is not
+ // holding its credential -- the phrase ceremony happens NOW.
+ this.busy = false;
+ this.onrampSession = false;
+ this.securePhrasePending = false;
+ this.errorMessage = storeErr instanceof Error ? storeErr.message : String(storeErr);
+ this.noteSignerClaim(this.claimSigner());
+ this.dispatchLogin();
+ this.goto("confirm-phrase");
+ return;
+ }
+ this.busy = false;
+ this.onrampSession = true;
+ this.securePhrasePending = true;
+ this.noteSignerClaim(this.claimSigner());
+ this.dispatchLogin();
+ this.flashSuccess("dashboard", "Account created");
+ } catch (err) {
+ this.fail(err);
+ }
+ }
+
private handleCreateNameSubmit(): void {
const name = this.field("loginName").trim().toLowerCase();
if (!isValidLoginName(name)) {
@@ -1042,6 +1208,14 @@ export class BitLoginAuthElement extends HTMLElement {
return;
}
}
+ if (this.securePhrasePending) {
+ // Tier B2 deferred ceremony (§CO4): the session already exists and
+ // bitlogin-login already fired at registration -- this just retires
+ // the "Secure your account" card.
+ this.securePhrasePending = false;
+ this.flashSuccess("dashboard", "Recovery phrase secured");
+ return;
+ }
this.sessionWarnings = [];
this.noteSignerClaim(this.claimSigner());
this.dispatchLogin();
@@ -1051,6 +1225,7 @@ export class BitLoginAuthElement extends HTMLElement {
private async handleLoginSubmit(): Promise {
const loginName = this.field("loginName").trim().toLowerCase();
const password = this.field("password");
+ this.onrampSession = false; // typed credentials: the user is their own password manager here
await this.attemptLogin(loginName, password);
}
@@ -1442,11 +1617,28 @@ export class BitLoginAuthElement extends HTMLElement {
this.busy
)
: "";
+ // Centralized rows exist only when the host configured an on-ramp;
+ // the sub-label IS the custody statement (§CO7): who manages access.
+ const onrampRows = (this.onramp?.providers ?? [])
+ .map((provider) => {
+ const label = onrampProviderLabel(provider);
+ return `
+ `;
+ })
+ .join("");
+ const onrampWaiting = this.onrampAttempt
+ ? `
Finish signing in with ${escapeHtml(onrampProviderLabel(this.onrampAttempt.provider))} in the window that opened, then return here.
${extensionRow}
${optionRow("goto-bunker", OPTION_ICONS.remote, "Remote signer", "Scan a code with Amber or another signer app")}
${optionRow("goto-import", OPTION_ICONS.importKey, "Import a Nostr key", "Wrap an existing identity in a login name and password")}
+ ${onrampRows}
${optionRow("goto-recover", OPTION_ICONS.recover, "Recover account", "Sign back in with your 12-word recovery phrase")}
${escapeHtml(this.onramp?.name ?? "The sign-in service")} will manage your sign-in${
+ providerLabel ? ` through ${escapeHtml(providerLabel)}` : ""
+ }. Your account itself lives on the open Nostr network — choose how to set up its identity:
+ ${this.renderError()}
+
+
+
Either way you can later take full control of the account — or leave for any other Nostr app — without losing your identity.
+ `;
+ }
+
case "extension-confirm":
return `
${this.renderScreenHead("Sign in with your extension")}
@@ -1692,10 +1903,21 @@ export class BitLoginAuthElement extends HTMLElement {
`;
+ // Tier B2 (§CO4): the phrase exists only in this session's memory
+ // until the user claims it -- the one nudge that stays until acted on.
+ const securePhraseCard = this.securePhrasePending
+ ? `
Secure your account: your recovery phrase is available only during this session. Save it now and your account stays yours even without ${escapeHtml(this.onramp?.name ?? "the sign-in service")}.
+