fix(nostr): reactive extension detection + native Archipelago signer bridge
CI / check (push) Failing after 6m19s

Two fixes found during live signer-login verification:

1. hasExtension was `computed(() => !!window.nostr)` — window.nostr is a
   plain global with no Vue reactivity, so this evaluated once, lazily, on
   first read and cached forever. If the extension's content script hadn't
   injected yet at that moment (common — extensions often inject slightly
   after page scripts start), "SIGN IN WITH EXTENSION" disappeared
   permanently, even once the extension finished injecting moments later.
   Reported live as "no browser extension or signer option ever shows".
   Fixed: hasExtension is now backed by a real ref, seeded from the current
   value and upgraded by a short poll (existing waitForSigner() precedent,
   same 200ms/timeout shape) so the UI reacts when the extension actually
   appears.

2. Added Archipelago's native NIP-07 signer bridge (frontend/public/
   nostr-provider.js, copied verbatim from neode-ui/public/nostr-provider.js
   — the canonical source) via a <script> tag in index.html. This no-ops
   immediately outside an iframe (window === window.top), so a real browser
   extension in a standalone tab is unaffected. Inside the Archipelago node
   dashboard's iframe, it provides window.nostr backed by the node's own
   identity via postMessage to
   neode-ui/src/views/appSession/useNostrBridge.ts (already generic — no
   per-app allowlist needed for the getPublicKey/signEvent bridge itself,
   only for the optional auto-login/identity-picker convenience flow, which
   this app doesn't use). Existing login() flow (buildNip98Token ->
   POST /api/auth/nostr/session) works unchanged through this bridge.

Together: signing in now works reliably both in the dashboard iframe (no
extension needed at all) and in a direct tab (real extension, now reliably
detected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-31 06:58:53 -04:00
co-authored by Claude Fable 5
parent ffd4dfd25f
commit 2c039f2af3
3 changed files with 202 additions and 1 deletions
+30 -1
View File
@@ -102,6 +102,35 @@ let freshlyGenerated = false
// In-memory nsec for current session (never auto-persisted to localStorage)
let sessionNsec: string | null = null
// window.nostr is injected by a browser extension's content script, which
// often runs AFTER this module's own top-level code (extension content
// scripts commonly fire at document_idle, sometimes with an extra delay for
// slower extensions). A plain `computed(() => !!window.nostr)` has no
// reactive dependency to track (window.nostr is a bare global, not a Vue
// ref) — Vue evaluates it once, lazily, on first read and then caches that
// result forever. If the extension hasn't injected yet at that first read,
// the "SIGN IN WITH EXTENSION" button (gated on this value) disappears
// permanently for the rest of the page's life, even once the extension
// finishes injecting moments later — this was a real reported bug: "no
// browser extension or signer option ever shows". Fix: track it in a real
// ref, seeded from the current value, and poll briefly for late injection
// so the UI updates reactively when the extension actually shows up.
const hasExtensionRef = ref(typeof window !== 'undefined' && !!window.nostr)
let extensionPollStarted = (globalThis as any).__bf_extensionPollStarted ?? false
if (typeof window !== 'undefined' && !hasExtensionRef.value && !extensionPollStarted) {
extensionPollStarted = true;
(globalThis as any).__bf_extensionPollStarted = true
const pollStart = Date.now()
const pollTimer = setInterval(() => {
if (window.nostr) {
hasExtensionRef.value = true
clearInterval(pollTimer)
} else if (Date.now() - pollStart > 5000) {
clearInterval(pollTimer)
}
}, 200)
}
// Sync in-memory auth state when tab regains focus (handles external localStorage clearing)
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
@@ -137,7 +166,7 @@ if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpir
export function useNostr() {
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
const hasExtension = computed(() => !!window.nostr)
const hasExtension = computed(() => hasExtensionRef.value)
/** Wait for window.nostr to appear (mobile signers inject late) */
async function waitForSigner(timeoutMs = 3000): Promise<boolean> {