2727// ?next=<url> — destination after success (default pod root)
2828
2929const DEFAULT_RESOLVER = 'https://solid.social' ;
30+ const FALLBACK_RESOLVER = 'https://nostr.social' ; // did-nostr.com source
3031
3132function readConfig ( ) {
3233 const p = new URLSearchParams ( location . search ) ;
@@ -72,12 +73,40 @@ function podRootFromWebId(webId) {
7273 catch { return null ; }
7374}
7475
76+ // Both "not linked" outcomes (404, and a synthesized DID doc with no
77+ // alsoKnownAs — nostr.social answers 200 for any valid key) get the
78+ // same coaching. Don't offer the fallback when we're already on it.
79+ function linkCoaching ( resolver ) {
80+ const links = [
81+ { label : 'How to link your Nostr key to a Solid pod' , href : 'https://jss.live/docs/' } ,
82+ ] ;
83+ if ( resolver !== FALLBACK_RESOLVER ) {
84+ const alt = new URLSearchParams ( location . search ) ;
85+ alt . set ( 'resolver' , FALLBACK_RESOLVER ) ;
86+ links . push ( { label : 'Try the fallback resolver (nostr.social)' , href : `?${ alt } ` } ) ;
87+ }
88+ return links ;
89+ }
90+
91+ // Signer extensions can inject window.nostr a beat after page load;
92+ // don't declare "no signer" on a fast click without a short grace poll.
93+ function waitForSigner ( ms = 1500 ) {
94+ return new Promise ( ( resolve ) => {
95+ if ( window . nostr ) return resolve ( true ) ;
96+ const started = performance . now ( ) ;
97+ const poll = setInterval ( ( ) => {
98+ if ( window . nostr ) { clearInterval ( poll ) ; resolve ( true ) ; }
99+ else if ( performance . now ( ) - started > ms ) { clearInterval ( poll ) ; resolve ( false ) ; }
100+ } , 100 ) ;
101+ } ) ;
102+ }
103+
75104async function flow ( ) {
76105 const { resolver, next } = readConfig ( ) ;
77106 setStatus ( 'Reading your Nostr identity…' ) ;
78107
79108 // Step 1 — signer extension present?
80- if ( ! window . nostr ) {
109+ if ( ! ( await waitForSigner ( ) ) ) {
81110 setStatus ( help (
82111 [ 'No Nostr signer detected. Install a browser extension that provides ' ,
83112 'window.nostr, then reload this page.' ] ,
@@ -114,17 +143,9 @@ async function flow() {
114143 headers : { Accept : 'application/did+json, application/json' } ,
115144 } ) ;
116145 if ( res . status === 404 ) {
117- // Fallback resolver: nostr.social serves the did-nostr.com
118- // source. Preserve the caller's other params (e.g. ?next=)
119- // when offering the switch.
120- const alt = new URLSearchParams ( location . search ) ;
121- alt . set ( 'resolver' , 'https://nostr.social' ) ;
122146 setStatus ( help (
123147 [ 'Your Nostr key isn’t linked to a Solid pod at ' , resolver , '. ' ] ,
124- [
125- { label : 'How to link your Nostr key to a Solid pod' , href : 'https://jss.live/docs/' } ,
126- { label : 'Try the fallback resolver (nostr.social)' , href : `?${ alt } ` } ,
127- ] ,
148+ linkCoaching ( resolver ) ,
128149 ) , 'error' ) ;
129150 return false ;
130151 }
@@ -145,8 +166,8 @@ async function flow() {
145166 const webId = aka . find ( ( x ) => typeof x === 'string' && / ^ h t t p s ? : \/ \/ / . test ( x ) ) ;
146167 if ( ! webId ) {
147168 setStatus ( help (
148- [ `Your did:nostr document at ${ resolver } doesn’t include an alsoKnownAs WebID. ` ] ,
149- [ { label : 'How to fix this' , href : 'https://jss.live/docs/' } ] ,
169+ [ `Your Nostr key isn’t linked to a Solid pod at ${ resolver } — the DID document has no alsoKnownAs WebID. ` ] ,
170+ linkCoaching ( resolver ) ,
150171 ) , 'error' ) ;
151172 return false ;
152173 }
@@ -163,29 +184,56 @@ async function flow() {
163184 let dest ;
164185 try {
165186 const url = new URL ( base ) ;
187+ if ( ! / ^ h t t p s ? : $ / . test ( url . protocol ) ) {
188+ setStatus ( `Refusing to redirect to a non-HTTP destination: ${ base } ` , 'error' ) ;
189+ return false ;
190+ }
166191 url . searchParams . set ( 'webid' , `did:nostr:${ pubkey } ` ) ;
167192 dest = url . href ;
168193 } catch {
169194 setStatus ( `Could not build redirect URL from: ${ base } ` , 'error' ) ;
170195 return false ;
171196 }
172197 setStatus ( `Found your WebID: ${ webId } . Taking you to ${ base } …` ) ;
173- setTimeout ( ( ) => { location . href = dest ; } , 800 ) ;
198+ // Hopping to the user's own pod is instant; an app-supplied ?next=
199+ // destination stays on screen for a beat so the user sees where
200+ // they're being sent before leaving this origin.
201+ setTimeout ( ( ) => { location . href = dest ; } , next ? 1500 : 0 ) ;
174202 return true ;
175203}
176204
177205function wire ( ) {
178206 const button = document . querySelector ( '#signin' ) ;
179207 if ( ! button ) return ;
180- button . addEventListener ( 'click' , async ( ) => {
208+
209+ async function run ( ) {
181210 button . disabled = true ;
182211 button . setAttribute ( 'aria-busy' , 'true' ) ;
212+ button . textContent = 'Signing in…' ;
183213 const ok = await flow ( ) ;
184214 if ( ! ok ) {
185215 button . disabled = false ;
186216 button . removeAttribute ( 'aria-busy' ) ;
217+ button . textContent = 'Sign in' ;
187218 }
188- } ) ;
219+ }
220+
221+ button . addEventListener ( 'click' , run ) ;
222+
223+ if ( new URLSearchParams ( location . search ) . has ( 'resolver' ) ) {
224+ // Arriving via a "Try the fallback resolver" link (or any explicit
225+ // ?resolver=) carries a click's worth of intent — resume the flow
226+ // instead of asking for a second click.
227+ run ( ) ;
228+ } else {
229+ // Soft pre-check: coach before the first click if no signer
230+ // extension has announced itself.
231+ setTimeout ( ( ) => {
232+ if ( ! window . nostr && ! button . disabled ) {
233+ setStatus ( 'No Nostr signer detected yet — you’ll need a signer extension to sign in.' ) ;
234+ }
235+ } , 1500 ) ;
236+ }
189237}
190238
191239wire ( ) ;
0 commit comments