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

+39 -16
View File
@@ -47,27 +47,50 @@
> >
<div class="space-y-2 text-left"> <div class="space-y-2 text-left">
<p class="text-sm text-text-muted"> <p class="text-sm text-text-muted">
Choose how you'd like to sign in. Connect a browser extension, link a Sign in with a login name and password that works the same on every
remote signer, or work directly with your keys when you need to. BitLogin site, or use one of the other options below.
</p> </p>
</div> </div>
<section <!--
class="space-y-5 rounded-3xl border border-border bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5" Permanent, statically-declared mount for <bitlogin-auth> -- never
written by an innerHTML re-render (this markup never changes after
the page loads), so the element is only ever hidden/shown, never
destroyed and recreated. Removing it from the DOM tears down its
crypto Web Worker (see BitLogin's README).
-->
<div
id="bitloginMount"
class="rounded-3xl border border-border bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
> >
<div class="space-y-1"> <bitlogin-auth
<h3 class="text-base font-semibold text-text">Available methods</h3> id="bitloginWidget"
<p class="text-sm text-text-muted"> vault-relays="wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band"
Pick the option that matches how you manage your Nostr identity. discovery-relays="wss://purplepag.es,wss://relay.nostr.band"
</p> ></bitlogin-auth>
</div> </div>
<!-- Login provider buttons are rendered dynamically --> <details class="space-y-3">
<div <summary class="cursor-pointer text-sm font-semibold text-text-muted hover:text-text">
data-login-providers Other login options
class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3" </summary>
></div> <section
</section> class="mt-3 space-y-5 rounded-3xl border border-border bg-overlay-panel-soft/90 p-4 shadow-lg backdrop-blur-sm sm:p-5"
>
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">Available methods</h3>
<p class="text-sm text-text-muted">
Pick the option that matches how you manage your Nostr identity.
</p>
</div>
<!-- Login provider buttons are rendered dynamically -->
<div
data-login-providers
class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3"
></div>
</section>
</details>
<div class="space-y-3"> <div class="space-y-3">
<div class="space-y-1 text-left"> <div class="space-y-1 text-left">
+13
View File
@@ -276,6 +276,19 @@ export const FEATURE_AUDIO_INGEST = false;
*/ */
export const FEATURE_BITCOIN_CONNECT = true; export const FEATURE_BITCOIN_CONNECT = true;
/**
* Whether the login modal offers BitLogin (a portable Nostr identity behind a
* login name/password, https://github.com/PR0M3TH3AN/bitlogin) as the primary
* sign-in method, with the NIP-07 extension, nsec, and NIP-46 options
* collapsed under "Other login options" (js/ui/bitloginModalIntegration.js).
* The vendored widget bundle (vendor/bitlogin/, scripts/build-bitlogin-widget.mjs)
* only lazy-loads once the login modal's HTML is present in the DOM.
*
* Unstable-branch experiment (AGENTS.md §1) -- reset to `false` before this
* reaches main until verified end-to-end against production relays.
*/
export const FEATURE_BITLOGIN = true;
/** /**
* Whether this deployment offers Blossom (nostr-native blob storage) as a * Whether this deployment offers Blossom (nostr-native blob storage) as a
* first-class upload provider alongside Cloudflare R2 / generic S3 / Backblaze B2 * first-class upload provider alongside Cloudflare R2 / generic S3 / Backblaze B2
+23
View File
@@ -880,6 +880,29 @@
position: relative; position: relative;
} }
/* Maps the <bitlogin-auth> widget's own theming hooks onto bitvid's design
tokens (components/login-modal.html), so it reads as part of this site
rather than a foreign embed, and follows the light/dark theme toggle
automatically since every token below is itself theme-aware. */
bitlogin-auth {
display: block;
--bl-accent: var(--color-accent-strong);
--bl-accent-hover: var(--color-accent);
--bl-accent-fg: rgb(255 255 255);
--bl-bg: var(--surface-panel);
--bl-fg: var(--text-primary);
--bl-muted: var(--text-muted);
--bl-border: var(--border-default);
--bl-input-bg: var(--surface-sunken);
--bl-danger: var(--status-danger);
--bl-danger-bg: var(--status-danger-surface);
--bl-warn: var(--status-warning);
--bl-warn-bg: var(--status-warning-surface);
--bl-radius: var(--radius-lg);
--bl-font-family: inherit;
--bl-max-width: none;
}
.btn-ghost[data-state="active"], .btn-ghost[data-state="active"],
.btn-ghost[aria-pressed="true"] { .btn-ghost[aria-pressed="true"] {
@apply border-transparent bg-primary text-white shadow-md; @apply border-transparent bg-primary text-white shadow-md;
+1 -1
View File
@@ -67,7 +67,7 @@ import {
normalizeVideoNotePayload, normalizeVideoNotePayload,
} from "./services/videoNotePayload.js"; } from "./services/videoNotePayload.js";
import getAuthProvider, { import getAuthProvider, {
providers as authProviders, providersForModal as authProviders,
} from "./services/authProviders/index.js"; } from "./services/authProviders/index.js";
import hashtagPreferences, { import hashtagPreferences, {
HASHTAG_PREFERENCES_EVENTS, HASHTAG_PREFERENCES_EVENTS,
+2
View File
@@ -27,6 +27,7 @@ import {
FEATURE_NIP71_INGEST, FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST, FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT, FEATURE_BITCOIN_CONNECT,
FEATURE_BITLOGIN,
FEATURE_BLOSSOM_STORAGE, FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA, FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY, CARD_LIVENESS_POLICY,
@@ -127,6 +128,7 @@ export { ALLOW_NSFW_CONTENT };
export { FEATURE_NIP71_INGEST }; export { FEATURE_NIP71_INGEST };
export { FEATURE_AUDIO_INGEST }; export { FEATURE_AUDIO_INGEST };
export { FEATURE_BITCOIN_CONNECT }; export { FEATURE_BITCOIN_CONNECT };
export { FEATURE_BITLOGIN };
export { FEATURE_BLOSSOM_STORAGE }; export { FEATURE_BLOSSOM_STORAGE };
export { FEATURE_BLOSSOM_TORRENT_METADATA }; export { FEATURE_BLOSSOM_TORRENT_METADATA };
export { CARD_LIVENESS_POLICY }; export { CARD_LIVENESS_POLICY };
+10
View File
@@ -9,6 +9,7 @@ import {
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST, FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST, FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT as CONFIG_FEATURE_BITCOIN_CONNECT, FEATURE_BITCOIN_CONNECT as CONFIG_FEATURE_BITCOIN_CONNECT,
FEATURE_BITLOGIN as CONFIG_FEATURE_BITLOGIN,
FEATURE_BLOSSOM_STORAGE as CONFIG_FEATURE_BLOSSOM_STORAGE, FEATURE_BLOSSOM_STORAGE as CONFIG_FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA as CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA, FEATURE_BLOSSOM_TORRENT_METADATA as CONFIG_FEATURE_BLOSSOM_TORRENT_METADATA,
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY, 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). // Bitcoin Connect NWC "Connect wallet" flow (docs/bitcoin-connect-plan.md, #61).
// Default OFF; only an explicit `true` in the instance config enables it. // Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BITCOIN_CONNECT: CONFIG_FEATURE_BITCOIN_CONNECT === true, 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). // Blossom (nostr-native blob storage) provider (docs/blossom-plan.md, #30).
// Default OFF; only an explicit `true` in the instance config enables it. // Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BLOSSOM_STORAGE: CONFIG_FEATURE_BLOSSOM_STORAGE === true, FEATURE_BLOSSOM_STORAGE: CONFIG_FEATURE_BLOSSOM_STORAGE === true,
@@ -241,6 +245,7 @@ const runtimeFlags = (() => {
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST, FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST, FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT: DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT, FEATURE_BITCOIN_CONNECT: DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT,
FEATURE_BITLOGIN: DEFAULT_FLAGS.FEATURE_BITLOGIN,
FEATURE_BLOSSOM_STORAGE: DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE, FEATURE_BLOSSOM_STORAGE: DEFAULT_FLAGS.FEATURE_BLOSSOM_STORAGE,
FEATURE_BLOSSOM_TORRENT_METADATA: FEATURE_BLOSSOM_TORRENT_METADATA:
DEFAULT_FLAGS.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 DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT
); );
export let FEATURE_BITLOGIN = coerceBoolean(
runtimeFlags.FEATURE_BITLOGIN,
DEFAULT_FLAGS.FEATURE_BITLOGIN
);
export let FEATURE_BLOSSOM_STORAGE = coerceBoolean( export let FEATURE_BLOSSOM_STORAGE = coerceBoolean(
runtimeFlags.FEATURE_BLOSSOM_STORAGE, runtimeFlags.FEATURE_BLOSSOM_STORAGE,
DEFAULT_FLAGS.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 nip46 from "./nip46.js";
import nsec from "./nsec.js"; import nsec from "./nsec.js";
import generate from "./generate.js"; import generate from "./generate.js";
import bitlogin from "./bitlogin.js";
function normalizeProviderId(provider, fallbackId) { function normalizeProviderId(provider, fallbackId) {
if (provider && typeof provider.id === "string") { if (provider && typeof provider.id === "string") {
@@ -39,7 +40,7 @@ function normalizeProviderMetadata(provider, fallbackId) {
return Object.freeze({ id, label, description, badgeVariant }); 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 providerMap = {};
const metadataMap = {}; const metadataMap = {};
@@ -97,6 +98,19 @@ builtInMetadata.forEach((entry) => {
export const providers = Object.freeze({ ...providerMap }); export const providers = Object.freeze({ ...providerMap });
export const providerMetadata = Object.freeze({ ...metadataMap }); 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(); const unknownMetadataCache = new Map();
function getFallbackMetadata(providerId) { function getFallbackMetadata(providerId) {
-1
View File
@@ -49,7 +49,6 @@ export default {
id: PROVIDER_ID, id: PROVIDER_ID,
label: PROVIDER_LABEL, label: PROVIDER_LABEL,
description: PROVIDER_DESCRIPTION, description: PROVIDER_DESCRIPTION,
eyebrow: "Recommended",
tone: "accent", tone: "accent",
badgeVariant: "info", badgeVariant: "info",
capabilities: PROVIDER_CAPABILITIES, capabilities: PROVIDER_CAPABILITIES,
+2
View File
@@ -41,6 +41,7 @@ import ProfileIdentityController from "./profileIdentityController.js";
import VideoListViewController from "./videoListViewController.js"; import VideoListViewController from "./videoListViewController.js";
import ProfileModalController from "./profileModalController.js"; import ProfileModalController from "./profileModalController.js";
import LoginModalController from "./loginModalController.js"; import LoginModalController from "./loginModalController.js";
import { wireBitloginLogin } from "./bitloginModalIntegration.js";
import { VideoListView } from "./views/VideoListView.js"; import { VideoListView } from "./views/VideoListView.js";
import MoreMenuController from "./moreMenuController.js"; import MoreMenuController from "./moreMenuController.js";
import VideoSettingsMenuController from "./videoSettingsMenuController.js"; import VideoSettingsMenuController from "./videoSettingsMenuController.js";
@@ -429,6 +430,7 @@ export default class ApplicationBootstrap {
authProviders: this.services.authProviders || authProviders, authProviders: this.services.authProviders || authProviders,
getAuthProvider: this.services.getAuthProvider || getAuthProvider, getAuthProvider: this.services.getAuthProvider || getAuthProvider,
}); });
wireBitloginLogin(app);
app.authEventUnsubscribes.push( app.authEventUnsubscribes.push(
app.authService.on("auth:login", (detail) => { app.authService.on("auth:login", (detail) => {
const maybePromise = app.handleAuthLogin(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 });
}
+68
View File
@@ -0,0 +1,68 @@
// Vendor the sibling @bitlogin/widget build into a pinned, same-origin bundle.
//
// bitvid's CSP is script-src 'self' (plus a couple of pinned CDN fallbacks for
// nostr-tools) -- the same reasoning as the existing bitcoin-connect/blossom-sdk
// vendor steps. Unlike those, BitLogin ships its own Vite-built ESM output
// directly (bitlogin.js + cryptoWorker.js + a shared chunk they both import),
// so this script copies that output verbatim instead of re-bundling it with
// esbuild -- the worker's relative self-URL resolution depends on the three
// files staying exactly as built and sitting side by side (see BitRoad's and
// BitUnlock's identical vendor steps for this same reasoning).
//
// node scripts/build-bitlogin-widget.mjs
// BITLOGIN_ENTRY=/path/to/bitlogin node scripts/build-bitlogin-widget.mjs
import { createHash } from "node:crypto";
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const bitloginRoot = process.env.BITLOGIN_ENTRY
? path.resolve(process.env.BITLOGIN_ENTRY)
: path.resolve(repoRoot, "../bitlogin");
const widgetDir = path.join(bitloginRoot, "packages/widget");
const widgetDist = path.join(widgetDir, "dist");
const vendorDir = path.join(repoRoot, "vendor/bitlogin");
const integrityFile = path.join(repoRoot, "vendor/bitlogin.integrity.json");
const packageJson = JSON.parse(await readFile(path.join(widgetDir, "package.json"), "utf8"));
if (packageJson.name !== "@bitlogin/widget") {
throw new Error("BITLOGIN_ENTRY must point to a BitLogin checkout (packages/widget/package.json not found there).");
}
if (!existsSync(widgetDist)) {
console.log("[build-bitlogin-widget] dist missing, building @bitlogin/core then @bitlogin/widget first...");
execSync("npm run build -w @bitlogin/core", { cwd: bitloginRoot, stdio: "inherit" });
execSync("npm run build -w @bitlogin/widget", { cwd: bitloginRoot, stdio: "inherit" });
}
await rm(vendorDir, { recursive: true, force: true });
await mkdir(vendorDir, { recursive: true });
const distEntries = (await readdir(widgetDist)).filter((name) => name.endsWith(".js"));
if (!distEntries.includes("bitlogin.js") || !distEntries.includes("cryptoWorker.js")) {
throw new Error("Expected bitlogin.js and cryptoWorker.js in @bitlogin/widget dist -- did the build layout change?");
}
const files = {};
for (const name of distEntries) {
await copyFile(path.join(widgetDist, name), path.join(vendorDir, name));
const bytes = await readFile(path.join(widgetDist, name));
files[name] = { sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.byteLength };
}
await writeFile(
integrityFile,
`${JSON.stringify({ package: packageJson.name, version: packageJson.version, files }, null, 2)}\n`,
);
try {
await copyFile(path.join(bitloginRoot, "LICENSE"), path.join(repoRoot, "vendor/bitlogin.LICENSE"));
} catch (error) {
console.warn("[build-bitlogin-widget] could not copy LICENSE:", error.message);
}
console.log("[build-bitlogin-widget] wrote", path.relative(repoRoot, vendorDir), `(${distEntries.length} files)`);
+146
View File
@@ -0,0 +1,146 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import { describe, it, beforeEach, afterEach } from "node:test";
import {
providers,
providersForModal,
} from "../js/services/authProviders/index.js";
import bitloginProvider, {
setPendingBitloginResult,
} from "../js/services/authProviders/bitlogin.js";
import { createBitloginAdapter } from "../js/nostr/adapters/bitloginAdapter.js";
import { accessControl } from "../js/accessControl.js";
const HEX_PUBKEY = "a".repeat(64);
describe("BitLogin auth provider", () => {
const originalCanAccess = accessControl.canAccess;
const originalIsBlacklisted = accessControl.isBlacklisted;
afterEach(() => {
setPendingBitloginResult(null);
accessControl.canAccess = originalCanAccess;
accessControl.isBlacklisted = originalIsBlacklisted;
});
it("registers in the resolvable provider registry but not the modal grid", () => {
// AuthService.resolveAuthProvider() must be able to find "bitlogin" ...
assert.equal(providers.bitlogin, bitloginProvider);
// ... but the login modal's grid (LoginModalController) must never render
// a redundant button for it -- BitLogin gets its own permanent widget in
// components/login-modal.html instead.
assert.equal(providersForModal.bitlogin, undefined);
assert.equal(providersForModal.nip07, providers.nip07);
});
it("refuses to log in before the widget has actually produced a pubkey", async () => {
accessControl.canAccess = () => true;
setPendingBitloginResult(null);
await assert.rejects(
() => bitloginProvider.login({ nostrClient: {} }),
/hasn't completed yet/i,
);
});
it("activates the signer and resolves once the widget's result is pending", async () => {
accessControl.canAccess = () => true;
let activated = null;
const fakeSigner = { type: "bitlogin", pubkey: HEX_PUBKEY };
const fakeNostrClient = {
signerManager: {
setActiveSigner: (signer) => {
activated = signer;
},
},
};
setPendingBitloginResult({ pubkey: HEX_PUBKEY, signer: fakeSigner });
const result = await bitloginProvider.login({ nostrClient: fakeNostrClient });
assert.equal(result.authType, "bitlogin");
assert.equal(result.pubkey, HEX_PUBKEY);
assert.equal(result.signer, fakeSigner);
assert.equal(activated, fakeSigner);
});
it("consumes the pending result exactly once (no replay across calls)", async () => {
accessControl.canAccess = () => true;
const fakeNostrClient = { signerManager: { setActiveSigner: () => {} } };
setPendingBitloginResult({ pubkey: HEX_PUBKEY, signer: {} });
await bitloginProvider.login({ nostrClient: fakeNostrClient });
await assert.rejects(
() => bitloginProvider.login({ nostrClient: fakeNostrClient }),
/hasn't completed yet/i,
);
});
it("never activates a signer for a blacklisted pubkey", async () => {
accessControl.canAccess = () => false;
accessControl.isBlacklisted = () => true;
let activated = null;
const fakeNostrClient = {
signerManager: {
setActiveSigner: (signer) => {
activated = signer;
},
},
};
setPendingBitloginResult({ pubkey: HEX_PUBKEY, signer: { type: "bitlogin" } });
await assert.rejects(
() => bitloginProvider.login({ nostrClient: fakeNostrClient }),
/blocked/i,
);
assert.equal(activated, null);
});
});
describe("createBitloginAdapter", () => {
it("delegates signing and NIP-44 calls to the live widget element, never window.nostr", async () => {
const calls = [];
const fakeWidget = {
signEvent: async (event) => {
calls.push(["signEvent", event]);
return { ...event, id: "signed" };
},
nip44Encrypt: async (peer, plaintext) => {
calls.push(["nip44Encrypt", peer, plaintext]);
return "ciphertext";
},
nip44Decrypt: async (peer, payload) => {
calls.push(["nip44Decrypt", peer, payload]);
return "plaintext";
},
logout: async () => {
calls.push(["logout"]);
},
};
const adapter = createBitloginAdapter(fakeWidget, HEX_PUBKEY);
assert.equal(adapter.type, "bitlogin");
assert.equal(adapter.pubkey, HEX_PUBKEY);
assert.equal(adapter.canSign(), true);
assert.deepEqual(adapter.capabilities, { sign: true, nip44: true, nip04: false });
const signed = await adapter.signEvent({ kind: 1, content: "hi" });
assert.equal(signed.id, "signed");
await adapter.nip44Encrypt("peer-pubkey", "hello");
await adapter.nip44Decrypt("peer-pubkey", "ciphertext");
await adapter.destroy();
assert.deepEqual(calls, [
["signEvent", { kind: 1, content: "hi" }],
["nip44Encrypt", "peer-pubkey", "hello"],
["nip44Decrypt", "peer-pubkey", "ciphertext"],
["logout"],
]);
});
});
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 PR0M3TH3AN
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+18
View File
@@ -0,0 +1,18 @@
{
"package": "@bitlogin/widget",
"version": "0.1.0",
"files": {
"bitlogin-shared-QIBe5Omw.js": {
"sha256": "7ce4ed0a5c3c7f744e864f55745f7fa78482cbfa19cd30eb551f67a415f55191",
"bytes": 288275
},
"bitlogin.js": {
"sha256": "7bbe34092ff5c110d947ac4cec7500cd32824868e51118387566896660a31228",
"bytes": 65009
},
"cryptoWorker.js": {
"sha256": "31e2b32cbbb8a4223d6664d383b6de49a173406e14a409a4cf192be53ed9dd74",
"bytes": 64803
}
}
}
File diff suppressed because it is too large. Load diff
+1253
View File
File diff suppressed because it is too large. Load diff
+3446
View File
File diff suppressed because it is too large. Load diff