Add BitLogin as the primary sign-in method (gated, unstable)

The login modal now shows <bitlogin-auth> -- a portable Nostr identity
behind a login name and password -- as the default sign-in path,
styled with bitvid's own brand tokens (accent-strong, surfaces, radii).
The existing NIP-07 extension, nsec, and NIP-46 remote-signer options
still work exactly as before, just collapsed under a new "Other login
options" fold instead of a flat "Available methods" grid; nip07 no
longer carries the stale "Recommended" eyebrow now that BitLogin has
that role.

BitLogin isn't a single button-click-then-await-a-promise flow like
the other providers -- it's a whole multi-screen sign-in/create/recover
UI -- so it gets a permanent, statically-declared widget in
components/login-modal.html (js/ui/bitloginModalIntegration.js relocates
it, never destroys and recreates it, since removal tears down its crypto
Web Worker) instead of a grid button. It still goes through the exact
same session-establishment path as every other signer: a new "bitlogin"
provider (js/services/authProviders/bitlogin.js) lets AuthService
resolve and activate it via the normal requestLogin -> handleAuthLogin
path, while providersForModal excludes it from the grid so it isn't
rendered twice.

Two things this needed beyond the wiring itself:
- js/app.js is one line from its lint/file-size ceiling, so the only
  change there is renaming an import binding (providers -> providersForModal),
  a net-zero line change. The actual widget/event wiring lives in a new,
  unconstrained module instead.
- The login modal's HTML (including the permanent widget element) loads
  asynchronously after bootstrap runs, so wireBitloginLogin() waits via
  MutationObserver rather than assuming a specific call order.

Vendored via scripts/build-bitlogin-widget.mjs (mirrors the existing
Bitcoin Connect/Blossom vendor steps): BitLogin isn't published to npm,
so the widget build is copied verbatim from the sibling ../bitlogin
checkout into committed vendor/bitlogin/, then lazy-imported (like
Bitcoin Connect) so its ~350KB never weighs on visitors who never open
the login modal.

Gated behind FEATURE_BITLOGIN (config/instance-config.js), default on
for this unstable-branch experiment per AGENTS.md §1 -- reset to false
before this reaches main until verified end-to-end against production
relays.

Verified: npm run lint (clean), the full auth/login test suite (43/43,
including new coverage for the provider + adapter), and a manual
Chrome pass against the built dist/ -- widget renders themed correctly,
"Other login options" still shows the original four methods unchanged.
This commit is contained in:
thePR0M3TH3AN committed 2026-07-21 20:56:01 -04:00
1 parent 676844edfc
commit 88c906fd34
19 files changed
+16133 -19

No files matched your search

+1 -1
View File
@@ -67,7 +67,7 @@ import {
normalizeVideoNotePayload,
} from "./services/videoNotePayload.js";
import getAuthProvider, {
providers as authProviders,
providersForModal as authProviders,
} from "./services/authProviders/index.js";
import hashtagPreferences, {
HASHTAG_PREFERENCES_EVENTS,
+2
View File
@@ -27,6 +27,7 @@ import {
FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT,
FEATURE_BITLOGIN,
FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY,
@@ -127,6 +128,7 @@ export { ALLOW_NSFW_CONTENT };
export { FEATURE_NIP71_INGEST };
export { FEATURE_AUDIO_INGEST };
export { FEATURE_BITCOIN_CONNECT };
export { FEATURE_BITLOGIN };
export { FEATURE_BLOSSOM_STORAGE };
export { FEATURE_BLOSSOM_TORRENT_METADATA };
export { CARD_LIVENESS_POLICY };
+10
View File
@@ -9,6 +9,7 @@ import {
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT as CONFIG_FEATURE_BITCOIN_CONNECT,
FEATURE_BITLOGIN as CONFIG_FEATURE_BITLOGIN,
FEATURE_BLOSSOM_STORAGE as CONFIG_FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA as CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY,
@@ -205,6 +206,9 @@ const DEFAULT_FLAGS = Object.freeze({
// Bitcoin Connect NWC "Connect wallet" flow (docs/bitcoin-connect-plan.md, #61).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BITCOIN_CONNECT: CONFIG_FEATURE_BITCOIN_CONNECT === true,
// BitLogin primary sign-in method (js/ui/bitloginModalIntegration.js).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BITLOGIN: CONFIG_FEATURE_BITLOGIN === true,
// Blossom (nostr-native blob storage) provider (docs/blossom-plan.md, #30).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BLOSSOM_STORAGE: CONFIG_FEATURE_BLOSSOM_STORAGE === true,
@@ -241,6 +245,7 @@ const runtimeFlags = (() => {
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT: DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT,
FEATURE_BITLOGIN: DEFAULT_FLAGS.FEATURE_BITLOGIN,
FEATURE_BLOSSOM_STORAGE: DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA:
DEFAULT_FLAGS.FEATURE_BLOSSOM_TORRENT_METADATA,
@@ -299,6 +304,11 @@ export let FEATURE_BITCOIN_CONNECT = coerceBoolean(
DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT
);
export let FEATURE_BITLOGIN = coerceBoolean(
runtimeFlags.FEATURE_BITLOGIN,
DEFAULT_FLAGS.FEATURE_BITLOGIN
);
export let FEATURE_BLOSSOM_STORAGE = coerceBoolean(
runtimeFlags.FEATURE_BLOSSOM_STORAGE,
DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE
+38
View File
@@ -0,0 +1,38 @@
// js/nostr/adapters/bitloginAdapter.js
//
// Wraps a live <bitlogin-auth> element (already signed in) as a signer matching
// the same shape as nip07Adapter/nsecAdapter/nip46Adapter. Never touches
// window.nostr and never sees the private key -- every call is proxied through
// the element's own crypto Web Worker (see BitLogin's README, "Embedding
// BitLogin" -> element-scoped methods).
export function createBitloginAdapter(widgetElement, pubkey) {
const signer = {
type: "bitlogin",
pubkey: typeof pubkey === "string" ? pubkey : "",
metadata: async () => null,
relays: async () => null,
signEvent: async (event) => widgetElement.signEvent(event),
nip44Encrypt: async (peerPubkey, plaintext) =>
widgetElement.nip44Encrypt(peerPubkey, plaintext),
nip44Decrypt: async (peerPubkey, payload) =>
widgetElement.nip44Decrypt(peerPubkey, payload),
requestPermissions: async () => ({ ok: true }),
destroy: async () => {
try {
await widgetElement.logout();
} catch (error) {
// The element is permanently mounted and reused for the next sign-in;
// a failed logout here shouldn't block switching to another signer.
}
},
canSign: () => true,
capabilities: {
sign: true,
nip44: true,
nip04: false,
},
};
return signer;
}
+85
View File
@@ -0,0 +1,85 @@
import { accessControl } from "../../accessControl.js";
const PROVIDER_ID = "bitlogin";
const PROVIDER_LABEL = "BitLogin";
const PROVIDER_DESCRIPTION =
"A portable Nostr identity behind a familiar login name and password.";
const PROVIDER_CAPABILITIES = Object.freeze([
Object.freeze({
id: "signing",
label: "Signs Nostr events",
variant: "info",
}),
Object.freeze({
id: "portable",
label: "Works on every BitLogin site",
variant: "neutral",
}),
]);
const PROVIDER_BUTTON = Object.freeze({
variant: "primary",
});
const PROVIDER_MESSAGES = Object.freeze({
loading: "Signing in with BitLogin…",
slow: "Still waiting on BitLogin…",
error: "Failed to sign in with BitLogin. Please try again.",
});
// Set by the widget's own "bitlogin-login" listener (js/ui/applicationBootstrap.js)
// right before it calls authService.requestLogin({ providerId: "bitlogin" }): by
// the time that reaches this provider's login(), the widget has already run its
// own multi-screen sign-in/create/recover flow and produced a pubkey + signer.
// This provider exists so that result goes through the exact same session-
// establishment path (AuthService.login -> handleAuthLogin) as every other
// signer, without BitLogin also rendering as a redundant button in the login
// grid -- see providersForModal in ./index.js.
let pendingResult = null;
export function setPendingBitloginResult(result) {
pendingResult = result && typeof result === "object" ? result : null;
}
export default {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
description: PROVIDER_DESCRIPTION,
eyebrow: "Recommended",
tone: "accent",
badgeVariant: "info",
capabilities: PROVIDER_CAPABILITIES,
button: PROVIDER_BUTTON,
messages: PROVIDER_MESSAGES,
errorMessage: PROVIDER_MESSAGES.error,
async login({ nostrClient } = {}) {
const result = pendingResult;
pendingResult = null;
if (!result || typeof result.pubkey !== "string" || !result.pubkey.trim()) {
const error = new Error("BitLogin sign-in hasn't completed yet.");
error.code = "provider-unavailable";
throw error;
}
const pubkey = result.pubkey.trim();
if (!accessControl.canAccess(pubkey)) {
if (accessControl.isBlacklisted(pubkey)) {
throw new Error("Your account has been blocked on this platform.");
}
throw new Error("Access restricted to admins and moderators users only.");
}
if (
nostrClient?.signerManager &&
typeof nostrClient.signerManager.setActiveSigner === "function"
) {
nostrClient.signerManager.setActiveSigner(result.signer);
}
return {
authType: PROVIDER_ID,
pubkey,
signer: result.signer,
};
},
};
+15 -1
View File
@@ -2,6 +2,7 @@ import nip07 from "./nip07.js";
import nip46 from "./nip46.js";
import nsec from "./nsec.js";
import generate from "./generate.js";
import bitlogin from "./bitlogin.js";
function normalizeProviderId(provider, fallbackId) {
if (provider && typeof provider.id === "string") {
@@ -39,7 +40,7 @@ function normalizeProviderMetadata(provider, fallbackId) {
return Object.freeze({ id, label, description, badgeVariant });
}
const providerList = [nip07, nip46, nsec, generate].filter(Boolean);
const providerList = [nip07, nip46, nsec, generate, bitlogin].filter(Boolean);
const providerMap = {};
const metadataMap = {};
@@ -97,6 +98,19 @@ builtInMetadata.forEach((entry) => {
export const providers = Object.freeze({ ...providerMap });
export const providerMetadata = Object.freeze({ ...metadataMap });
// BitLogin gets its own permanent, always-visible widget in the login modal
// (components/login-modal.html) rather than a grid button -- it isn't a single
// button-click-then-await-a-promise flow like the others, it's a whole
// multi-screen sign-in/create/recover UI. Excluding it here keeps the modal's
// provider grid ("Other login options") free of a redundant, do-nothing button
// for it, while `providers.bitlogin` above still resolves normally for
// AuthService.requestLogin({ providerId: "bitlogin" }).
export const providersForModal = Object.freeze(
Object.fromEntries(
Object.entries(providerMap).filter(([id]) => id !== "bitlogin"),
),
);
const unknownMetadataCache = new Map();
function getFallbackMetadata(providerId) {
-1
View File
@@ -49,7 +49,6 @@ export default {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
description: PROVIDER_DESCRIPTION,
eyebrow: "Recommended",
tone: "accent",
badgeVariant: "info",
capabilities: PROVIDER_CAPABILITIES,
+2
View File
@@ -41,6 +41,7 @@ import ProfileIdentityController from "./profileIdentityController.js";
import VideoListViewController from "./videoListViewController.js";
import ProfileModalController from "./profileModalController.js";
import LoginModalController from "./loginModalController.js";
import { wireBitloginLogin } from "./bitloginModalIntegration.js";
import { VideoListView } from "./views/VideoListView.js";
import MoreMenuController from "./moreMenuController.js";
import VideoSettingsMenuController from "./videoSettingsMenuController.js";
@@ -429,6 +430,7 @@ export default class ApplicationBootstrap {
authProviders: this.services.authProviders || authProviders,
getAuthProvider: this.services.getAuthProvider || getAuthProvider,
});
wireBitloginLogin(app);
app.authEventUnsubscribes.push(
app.authService.on("auth:login", (detail) => {
const maybePromise = app.handleAuthLogin(detail);
+116
View File
@@ -0,0 +1,116 @@
// js/ui/bitloginModalIntegration.js
//
// Wires the permanent <bitlogin-auth id="bitloginWidget"> element -- declared
// once, statically, in components/login-modal.html, never written by an
// innerHTML re-render -- into the exact same session-establishment path every
// other sign-in method uses (AuthService.requestLogin -> handleAuthLogin).
// BitLogin isn't a single button-click-then-await-a-promise flow like the
// other providers, it's a whole multi-screen sign-in/create/recover UI, so it
// gets its own widget in the modal instead of a grid button (see
// providersForModal in js/services/authProviders/index.js) and its own small
// integration module here rather than special-casing LoginModalController.
import { devLogger } from "../utils/logger.js";
import { closeStaticModal } from "./components/staticModalAccessibility.js";
import { setModalState as setGlobalModalState } from "../state/appState.js";
import { createBitloginAdapter } from "../nostr/adapters/bitloginAdapter.js";
import { setPendingBitloginResult } from "../services/authProviders/bitlogin.js";
import { FEATURE_BITLOGIN } from "../constants.js";
// Vendored, pinned BitLogin widget bundle (scripts/build-bitlogin-widget.mjs).
// Lazy-imported so its ~350KB never weighs on the main bundle for visitors who
// never open the login modal, mirroring the existing Bitcoin Connect vendor
// step (js/ui/profileModal/ProfileWalletController.js). A plain dynamic
// import is enough: the bundle's only job is the customElements.define("bitlogin-auth")
// side effect, which retroactively upgrades the already-present <bitlogin-auth>
// tag in the modal's static HTML once it resolves.
const BITLOGIN_WIDGET_BUNDLE_URL = "../../vendor/bitlogin/bitlogin.js";
let widgetLoadPromise = null;
function loadBitloginWidgetOnce() {
if (!widgetLoadPromise) {
widgetLoadPromise = import(BITLOGIN_WIDGET_BUNDLE_URL).catch((error) => {
widgetLoadPromise = null;
throw error;
});
}
return widgetLoadPromise;
}
function closeLoginModal() {
const modal = document.getElementById("loginModal");
if (modal && closeStaticModal(modal)) {
setGlobalModalState("login", false);
}
}
function attachWidget(app, widget) {
const mount = document.getElementById("bitloginMount");
if (!FEATURE_BITLOGIN) {
// Unstable-branch experiment (AGENTS.md §1, config/instance-config.js) --
// leave the permanent, statically-declared markup in place but never load
// the vendored bundle or show the (otherwise-empty, unupgraded) element.
if (mount instanceof HTMLElement) {
mount.hidden = true;
}
return;
}
if (mount instanceof HTMLElement) {
mount.hidden = false;
}
loadBitloginWidgetOnce().catch((error) => {
devLogger.warn("[BitLogin] Failed to load the widget bundle:", error);
});
widget.addEventListener("bitlogin-login", async (event) => {
const pubkey =
event?.detail && typeof event.detail.publicKey === "string"
? event.detail.publicKey.trim()
: "";
if (!pubkey) {
return;
}
setPendingBitloginResult({
pubkey,
signer: createBitloginAdapter(widget, pubkey),
});
try {
await app.authService.requestLogin({ providerId: "bitlogin" });
closeLoginModal();
} catch (error) {
devLogger.warn("[BitLogin] Sign-in failed to apply:", error);
}
});
}
/**
* Call once during app bootstrap, after `app.authService` exists. The login
* modal's HTML (including the permanent #bitloginWidget element) loads
* asynchronously (js/index.js bootstrapInterface()) and isn't guaranteed to be
* in the DOM yet at this point, so this waits for it rather than requiring a
* specific call order between the two.
* @param {{ authService: { requestLogin: Function } }} app
*/
export function wireBitloginLogin(app) {
if (!app?.authService) {
return;
}
const existing = document.getElementById("bitloginWidget");
if (existing instanceof HTMLElement) {
attachWidget(app, existing);
return;
}
const observer = new MutationObserver(() => {
const widget = document.getElementById("bitloginWidget");
if (widget instanceof HTMLElement) {
observer.disconnect();
attachWidget(app, widget);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}