Skip to content

Commit bc6580d

Browse files
beacon: sign-in-with-did:nostr CTA + /link pod-onboarding
(a) Surface beacon as a did:nostr SSO resolver: topbar with a 'Sign in with did:nostr' CTA -> jss.live/sso/?resolver=https://nostr.social (beacon already serves the endpoint the SSO reads; today's CORS enabled it). (b) /link onboarding for the real gap (most users have no alsoKnownAs): NIP-07 signer -> resolve current did:nostr doc -> if unlinked, enter a WebID, build an updated kind-0 with alsoKnownAs, signEvent, publish to relays over raw WebSocket. Graceful no-signer path (Podkey/xlogin).
1 parent f9879a5 commit bc6580d

2 files changed

Lines changed: 118 additions & 1 deletion

File tree

public/index.html

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,39 @@
8686
.card .counts strong{color:var(--ink);font-weight:600}
8787
.seclabel{font-size:.74rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);margin:.4rem 0 1rem}
8888
.chip strong{font-weight:700}
89+
90+
/* topbar */
91+
.topbar{display:flex;align-items:center;justify-content:space-between;gap:1rem;max-width:1040px;margin:0 auto;padding:1.1rem 1.2rem 0}
92+
.tb-brand{display:inline-flex;align-items:center;gap:.5rem;font-weight:700;letter-spacing:-.01em;color:var(--muted);font-size:.95rem}
93+
.tb-nav{display:flex;align-items:center;gap:1.1rem}
94+
.tb-nav>a{font-size:.88rem;font-weight:500;color:var(--muted)}
95+
.tb-nav>a:hover{color:var(--accent)}
96+
.tb-cta{background:var(--accent);color:#fff!important;padding:.5rem .9rem;border-radius:999px;font-weight:600;box-shadow:0 4px 14px rgba(109,94,252,.3);transition:transform .15s,box-shadow .15s}
97+
.tb-cta:hover{transform:translateY(-1px);box-shadow:0 8px 22px rgba(109,94,252,.4)}
98+
@media(max-width:560px){.tb-nav>a:not(.tb-cta){display:none}}
99+
/* link page */
100+
.link-panel{max-width:620px;margin:0 auto}
101+
.link-panel h2{font-family:var(--serif);font-size:1.8rem;font-weight:600;margin-bottom:.6rem}
102+
.link-panel p{color:var(--muted);margin-bottom:.4rem}
103+
.link-form{display:flex;flex-wrap:wrap;gap:.6rem;margin-top:1.1rem}
104+
.link-form input{flex:1;min-width:240px;font-family:var(--mono);font-size:.85rem;border:1px solid var(--line);border-radius:10px;padding:.7rem .9rem;outline:none}
105+
.link-form input:focus{border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-soft)}
106+
.cta{background:var(--accent);color:#fff;border:none;border-radius:10px;padding:.7rem 1.2rem;font-weight:600;font-size:.9rem;cursor:pointer;font-family:var(--sans)}
107+
.cta:disabled{opacity:.55;cursor:default}
108+
.link-status{width:100%;font-size:.88rem;color:var(--muted);margin-top:.5rem;line-height:1.5}
109+
.link-status.ok{color:#1c8c6b} .link-status.err{color:#c0392b}
89110
</style>
90111
</head>
91112
<body>
113+
<header class="topbar">
114+
<a class="tb-brand" href="/"><span class="dot"></span> nostr.social</a>
115+
<nav class="tb-nav">
116+
<a href="/link">Link your pod</a>
117+
<a class="tb-cta" href="https://jss.live/sso/?resolver=https://nostr.social" target="_blank" rel="noopener">Sign in with did:nostr</a>
118+
</nav>
119+
</header>
92120
<main>
93121
<div class="hero">
94-
<span class="mark"><span class="dot"></span> Beacon</span>
95122
<h1>A directory of <em style="font-style:italic">did:nostr</em> identities</h1>
96123
<p>Profiles, the follow graph, and a verifiable DID document for every key &mdash; indexed live from Nostr.</p>
97124
<div class="searchwrap"><input id="q" type="search" placeholder="Search by name, nip05, npub, or hex pubkey…" autocomplete="off" spellcheck="false"></div>
@@ -206,6 +233,88 @@ <h2>${esc(c.name || short(pk))}</h2>
206233
$('#back').onclick = () => navigate('/');
207234
}
208235

236+
// ---- link your pod (onboarding) ----
237+
const RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
238+
239+
function publishEvent(event) {
240+
return Promise.all(RELAYS.map((url) => new Promise((resolve) => {
241+
let ws, done = false;
242+
const finish = (ok) => { if (!done) { done = true; try { ws.close(); } catch {} resolve({ url, ok }); } };
243+
try { ws = new WebSocket(url); } catch { return finish(false); }
244+
ws.onopen = () => ws.send(JSON.stringify(['EVENT', event]));
245+
ws.onmessage = (m) => { try { const d = JSON.parse(m.data); if (d[0] === 'OK' && d[1] === event.id) finish(!!d[2]); } catch {} };
246+
ws.onerror = () => finish(false);
247+
setTimeout(() => finish(false), 6000);
248+
})));
249+
}
250+
251+
const linkForm = (val) => `<div class="link-form">
252+
<input id="webid" type="url" placeholder="https://you.solid.social/profile/card#me" value="${esc(val)}">
253+
<button id="dolink" class="cta">Publish link</button>
254+
<div id="linkstatus" class="link-status"></div>
255+
</div>`;
256+
257+
async function linkView() {
258+
if ($('#q')) $('#q').value = '';
259+
$('#stat').innerHTML = '';
260+
const panel = (inner) => { view.innerHTML = `<div class="detail"><div class="back" id="back">← all identities</div><div class="panel link-panel">${inner}</div></div>`; $('#back').onclick = () => navigate('/'); };
261+
262+
view.innerHTML = '<div class="loading">checking your signer…</div>';
263+
if (!window.nostr) {
264+
panel(`<h2>Link your pod</h2>
265+
<p>Linking your nostr key to a Solid pod lets <strong>did:nostr</strong> resolve to your WebID — so one-click <em>Sign in with did:nostr</em> works.</p>
266+
<p>First you need a NIP-07 signer extension:</p>
267+
<div class="chips" style="margin-top:.8rem">
268+
<a class="chip" href="https://github.com/JavaScriptSolidServer/podkey" target="_blank" rel="noopener">Podkey ↗</a>
269+
<a class="chip" href="https://github.com/melvincarvalho/xlogin" target="_blank" rel="noopener">xlogin ↗</a>
270+
</div>`);
271+
return;
272+
}
273+
let pubkey;
274+
try { pubkey = (await window.nostr.getPublicKey()).toLowerCase(); }
275+
catch { panel('<h2>Link your pod</h2><p>Your signer declined to share your public key. Reload and try again.</p>'); return; }
276+
277+
const doc = await fetch(`/api/did/${pubkey}`).then((r) => r.json()).catch(() => null);
278+
const aka = (doc?.alsoKnownAs || []).find((x) => /^https?:\/\//.test(x));
279+
if (aka) {
280+
panel(`<h2>You're linked ✓</h2>
281+
<p><code>did:nostr:${short(pubkey)}</code> resolves to your WebID:</p>
282+
<div class="didline"><span>${esc(aka)}</span></div>
283+
<p style="margin-top:1.1rem">Test it: <a href="https://jss.live/sso/?resolver=https://nostr.social" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Sign in with did:nostr →</a></p>
284+
<details style="margin-top:1.2rem"><summary style="cursor:pointer;color:var(--muted)">Change linked pod</summary>${linkForm(aka)}</details>`);
285+
} else {
286+
panel(`<h2>Link your pod</h2>
287+
<p><code>did:nostr:${short(pubkey)}</code> isn't linked to a Solid pod yet. Enter your WebID (your pod's profile URL) and we'll publish an <code>alsoKnownAs</code> to your nostr profile (kind&nbsp;0), signed by your key.</p>
288+
${linkForm('')}`);
289+
}
290+
wireLink(pubkey);
291+
}
292+
293+
function wireLink(pubkey) {
294+
const btn = $('#dolink'); if (!btn) return;
295+
const status = $('#linkstatus');
296+
const say = (msg, kind = '') => { status.textContent = msg; status.className = 'link-status' + (kind ? ' ' + kind : ''); };
297+
btn.onclick = async () => {
298+
const webid = $('#webid').value.trim();
299+
if (!/^https?:\/\/.+/.test(webid)) return say('Enter a valid WebID URL (https://…).', 'err');
300+
btn.disabled = true; say('Building your updated profile…');
301+
try {
302+
const raw = await fetch(`/api/profile/${pubkey}`).then((r) => r.json()).catch(() => null);
303+
let c = {}; try { c = JSON.parse(raw?.content || '{}'); } catch {}
304+
c.alsoKnownAs = [webid];
305+
const unsigned = { kind: 0, pubkey, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify(c) };
306+
say('Requesting your signature…');
307+
const signed = await window.nostr.signEvent(unsigned);
308+
say('Publishing to relays…');
309+
const results = await publishEvent(signed);
310+
const ok = results.filter((r) => r.ok).length;
311+
if (ok) say(`Published to ${ok}/${results.length} relays. did:nostr:${short(pubkey)} now points to ${webid}. beacon will re-index shortly, then Sign in with did:nostr works.`, 'ok');
312+
else say('No relay accepted the event — try again or check your relays.', 'err');
313+
} catch (err) { say('Signing/publishing failed: ' + (err?.message || err), 'err'); }
314+
finally { btn.disabled = false; }
315+
};
316+
}
317+
209318
function navigate(path) { if (location.pathname !== path) history.pushState({}, '', path); route(); }
210319

211320
let searchTimer;
@@ -216,6 +325,7 @@ <h2>${esc(c.name || short(pk))}</h2>
216325
}
217326

218327
function route() {
328+
if (location.pathname === '/link') return linkView();
219329
const m = location.pathname.match(/^\/([0-9a-f]{64})$/i);
220330
if (m) return profile(m[1].toLowerCase());
221331
const h = location.hash.slice(1); // legacy #<hex> links still resolve

src/server.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ export async function startServer(port = process.env.PORT || 3000) {
5151
// home shell (default OGP)
5252
app.get('/', (_req, res) => res.type('html').send(renderShell()));
5353

54+
// "link your pod" onboarding shell
55+
app.get('/link', (_req, res) => res.type('html').send(renderShell({
56+
title: 'Link your pod · nostr.social',
57+
description: 'Link your nostr key to a Solid pod so did:nostr resolves to your WebID — one-click sign-in via jss.live SSO.',
58+
url: `${SITE}/link`,
59+
})));
60+
5461
// per-profile shell at /<pubkey> with that identity's OGP (crawlable canonical
5562
// URL). RegExp route constrained to 64-hex, so it never collides with /api,
5663
// /healthz, /.well-known, or static assets.

0 commit comments

Comments
 (0)