Files
regress/frontend/public/nostr-provider.js
T
ssmithx d6ef514c84 Scaffold Regress: Fastify+SQLite server and Vue frontend for the Ingress-style BTC Map capture game
- Server: NIP-98 nostr auth (session cookies), BTC Map sync, claim/link/field
  routes, physical-presence checks via geolocation distance. 31 tests passing.
- Frontend: Leaflet map, team pick, claim/link UI, Archipelago identity
  bridge vendored (nostr-provider.js) for dashboard launch support.
- Verified end-to-end against the live BTC Map API (167 real Madeira places)
  and via genuine NIP-98-signed HTTP requests against the built app.
2026-08-05 13:14:03 +00:00

218 lines
8.9 KiB
JavaScript

/**
* NIP-07 Nostr Provider Shim — Archipelago
*
* Vendored from archy/neode-ui/public/nostr-provider.js (generalized version).
* Provides window.nostr (NIP-07) for iframe apps launched inside the
* Archipelago shell, bridging signing requests via postMessage to the
* parent frame, which relays them to the Archipelago node's identity
* manager. Auto sign-in: does NIP-98 auth against this app's own backend,
* then reloads so the app picks up the valid session.
*
* Not vendored via an Archipelago manifest hook (podsteadr isn't an
* orchestrator-managed package — see neode-ui's EXTERNAL_URLS /
* WEB_ONLY_APP-style "external web app" registration instead), so this
* copy won't auto-update with archy's OTA releases. Re-sync by hand from
* archy/neode-ui/public/nostr-provider.js if that file changes.
*/
(function () {
'use strict';
if (window.__archipelagoNostr) return;
window.__archipelagoNostr = true;
if (window === window.top) return;
var pending = {}, nextId = 1;
function request(method, params) {
return new Promise(function (resolve, reject) {
var id = nextId++;
pending[id] = { resolve: resolve, reject: reject };
window.parent.postMessage({ type: 'nostr-request', id: id, method: method, params: params || {} }, '*');
setTimeout(function () { if (pending[id]) { pending[id].reject(new Error('NIP-07 timeout')); delete pending[id]; } }, 30000);
});
}
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'nostr-response') return;
var h = pending[e.data.id]; if (!h) return; delete pending[e.data.id];
e.data.error ? h.reject(new Error(e.data.error)) : h.resolve(e.data.result);
});
window.nostr = {
getPublicKey: function () { return request('getPublicKey'); },
signEvent: function (ev) { return request('signEvent', { event: ev }); },
sign: function (ev) { return request('signEvent', { event: ev }); },
getRelays: function () { return request('getRelays'); },
nip04: {
encrypt: function (pk, pt) { return request('nip04.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip04.decrypt', { pubkey: pk, ciphertext: ct }); },
},
nip44: {
encrypt: function (pk, pt) { return request('nip44.encrypt', { pubkey: pk, plaintext: pt }); },
decrypt: function (pk, ct) { return request('nip44.decrypt', { pubkey: pk, ciphertext: ct }); },
},
};
// --- Loading Overlay ---
var overlay = null;
function showLoader(message) {
if (overlay) return;
overlay = document.createElement('div');
overlay.id = 'archipelago-auth-overlay';
overlay.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" style="animation:archy-spin 1s linear infinite">' +
'<circle cx="12" cy="12" r="10" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
'<path d="M12 2a10 10 0 019.95 9" stroke="#fb923c" stroke-width="3" stroke-linecap="round"/>' +
'</svg>' +
'<div style="color:rgba(255,255,255,0.9);font:500 14px/1.4 -apple-system,system-ui,sans-serif">' + (message || 'Signing in...') + '</div>' +
'</div>';
overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);backdrop-filter:blur(8px);';
var style = document.createElement('style');
style.textContent = '@keyframes archy-spin{to{transform:rotate(360deg)}}';
document.head.appendChild(style);
document.body.appendChild(overlay);
}
function updateLoader(message) {
if (!overlay) return;
var txt = overlay.querySelector('div > div');
if (txt) txt.textContent = message;
}
function hideLoader() {
if (overlay) { overlay.remove(); overlay = null; }
}
// --- Per-app config (data-* attrs on the injected <script> tag). Defaults
// match indeedhub's original hardcoded values, so apps that don't set any
// overrides keep behaving exactly as before.
var scriptEl = document.currentScript;
var ds = (scriptEl && scriptEl.dataset) || {};
var cfg = {
healthUrl: ds.healthUrl || '/api/nostr-auth/health',
sessionUrl: ds.sessionUrl || '/api/auth/nostr/session',
sessionMethod: ds.sessionMethod || 'POST',
// 'token' (default): login response is JSON {accessToken, refreshToken};
// stored in sessionStorage, matches indeedhub.
// 'cookie': server sets the session cookie directly on the login
// response (Set-Cookie) — nothing to store client-side, just reload.
sessionMode: ds.sessionMode || 'token',
// Optional: for cookie-mode apps, check this endpoint first and skip
// the NIP-98 handshake entirely if it reports already-authenticated
// (401 otherwise) — avoids re-running sign-in on every iframe reload.
meUrl: ds.meUrl || null,
};
// --- Direct NIP-98 Auth ---
var authDone = false;
function performNip98Auth(pubkey) {
var healthUrl = window.location.origin + cfg.healthUrl;
var sessionUrl = window.location.origin + cfg.sessionUrl;
// 1. Check if API backend is reachable (3s timeout)
var hc = new AbortController();
var ht = setTimeout(function () { hc.abort(); }, 3000);
fetch(healthUrl, { signal: hc.signal }).then(function (r) {
clearTimeout(ht);
if (!r.ok) throw new Error('Health ' + r.status);
// 2. API is up — show loader and do NIP-98
showLoader('Signing in with Nostr...');
var now = Math.floor(Date.now() / 1000);
var event = {
kind: 27235, created_at: now, content: '', pubkey: pubkey,
tags: [['u', sessionUrl], ['method', cfg.sessionMethod]]
};
console.log('[nostr-provider] NIP-98: signing for', sessionUrl);
return window.nostr.signEvent(event);
}).then(function (signed) {
updateLoader('Creating session...');
var ac = new AbortController();
setTimeout(function () { ac.abort(); }, 10000);
return fetch(sessionUrl, {
method: cfg.sessionMethod,
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
signal: ac.signal
});
}).then(function (res) {
console.log('[nostr-provider] NIP-98: response', res.status);
if (!res.ok) throw new Error('Auth failed: ' + res.status);
if (cfg.sessionMode === 'cookie') {
// Session cookie already landed via Set-Cookie on this response.
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success (cookie session), reloading...');
setTimeout(function () { window.location.reload(); }, 400);
return null;
}
return res.json();
}).then(function (data) {
if (!data) return; // cookie-mode: handled above, nothing left to do
if (data.accessToken) {
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
updateLoader('Signed in! Loading...');
console.log('[nostr-provider] NIP-98: success, reloading...');
setTimeout(function () { window.location.reload(); }, 400);
} else {
hideLoader(); authDone = false;
}
}).catch(function (err) {
hideLoader(); authDone = false;
var msg = err.message || String(err);
if (msg.indexOf('abort') > -1) msg = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', msg);
});
}
function doNip98Auth(pubkey) {
if (authDone) return;
authDone = true;
if (cfg.meUrl) {
// Already-authenticated check first — avoids re-running the NIP-98
// handshake (and its reload) on every iframe load for cookie-session
// apps, where there's no client-visible token to check locally.
fetch(window.location.origin + cfg.meUrl, { credentials: 'same-origin' })
.then(function (r) {
if (r.ok) {
console.log('[nostr-provider] Already authenticated (meUrl ok), skipping NIP-98');
authDone = false;
return;
}
performNip98Auth(pubkey);
})
.catch(function () { performNip98Auth(pubkey); });
return;
}
performNip98Auth(pubkey);
}
// Listen for identity from parent Archipelago frame
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'archipelago:identity') return;
var pk = e.data.nostr_pubkey;
console.log('[nostr-provider] Identity received:', pk ? pk.slice(0, 12) + '...' : 'none');
if (!pk) return;
// Skip if already signed in with a real token (not mock)
try {
var token = sessionStorage.getItem('nostr_token');
if (token && token.indexOf('mock-') === -1) {
console.log('[nostr-provider] Already signed in with real token');
return;
}
} catch (x) {}
setTimeout(function () { doNip98Auth(pk); }, 1500);
});
})();