mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
676844edfc
commit
88c906fd34
19 files changed
+16133
-19
No files matched your search
+39
-16
@@ -47,27 +47,50 @@
|
||||
>
|
||||
<div class="space-y-2 text-left">
|
||||
<p class="text-sm text-text-muted">
|
||||
Choose how you'd like to sign in. Connect a browser extension, link a
|
||||
remote signer, or work directly with your keys when you need to.
|
||||
Sign in with a login name and password that works the same on every
|
||||
BitLogin site, or use one of the other options below.
|
||||
</p>
|
||||
</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">
|
||||
<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>
|
||||
<bitlogin-auth
|
||||
id="bitloginWidget"
|
||||
vault-relays="wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band"
|
||||
discovery-relays="wss://purplepag.es,wss://relay.nostr.band"
|
||||
></bitlogin-auth>
|
||||
</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 class="space-y-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-text-muted hover:text-text">
|
||||
Other login options
|
||||
</summary>
|
||||
<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-1 text-left">
|
||||
|
||||
@@ -276,6 +276,19 @@ export const FEATURE_AUDIO_INGEST = false;
|
||||
*/
|
||||
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
|
||||
* first-class upload provider alongside Cloudflare R2 / generic S3 / Backblaze B2
|
||||
|
||||
@@ -880,6 +880,29 @@
|
||||
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[aria-pressed="true"] {
|
||||
@apply border-transparent bg-primary text-white shadow-md;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -49,7 +49,6 @@ export default {
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
description: PROVIDER_DESCRIPTION,
|
||||
eyebrow: "Recommended",
|
||||
tone: "accent",
|
||||
badgeVariant: "info",
|
||||
capabilities: PROVIDER_CAPABILITIES,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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)`);
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Vendored
+21
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+10837
File diff suppressed because it is too large.
Load diff
Vendored
+1253
File diff suppressed because it is too large.
Load diff
Vendored
+3446
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user