/** * SmirkAuth - Smirk Wallet Authentication Module * * Handles "Login with Smirk" wallet integration for Wownerogue. * Uses the Smirk browser extension API (window.smirk) for wallet interactions. * * Flow: * 1. Acquire explicit WOW permission from Smirk * 2. Get challenge from server * 3. Sign challenge with wallet * 4. Verify signature with backend * 5. Read wallet addresses; update a payout address only in payout-enabled modes */ const SmirkAuth = { /** * Check if Smirk extension is installed */ isAvailable() { return typeof window.smirk !== 'undefined'; }, /** * Current link status */ _isLinked: false, _wowReady: false, _walletAddress: null, _sessionEpoch() { return typeof SocketHandlers !== 'undefined' ? SocketHandlers._paymentSessionEpoch : null; }, async requestWowAccess() { const epoch = this._sessionEpoch(); this._wowReady = false; const keys = await window.smirk.connect(['wow']); if (epoch !== this._sessionEpoch()) throw new Error('Game session changed. Reconnect Smirk for the current account.'); if (!keys || !keys.wow) throw new Error('Smirk did not grant WOW access.'); this._wowReady = true; return keys; }, async checkWowAccess() { const epoch = this._sessionEpoch(); this._wowReady = false; try { const keys = await window.smirk.getPublicKeys(); if (epoch !== this._sessionEpoch()) return false; this._wowReady = !!keys?.wow; } catch (_) { /* Missing permission or provider is not a grant. */ } return this._wowReady; }, /** * Detect whether the connected Smirk wallet supports NIP-98 (nostr) signing. */ _supportsNip98() { return typeof window.smirk?.signNostrEvent === 'function' && typeof window.smirk?.getNostrPublicKey === 'function'; }, /** * Main login flow. * * Keeps NIP-98 identity when supported. Errors never switch credential methods. * WOW asset consent is separate from Nostr signing permission. */ async login() { if (!this.isAvailable()) { throw new Error('Smirk extension not installed. Please install it from your browser extension store.'); } if (!window.socket || !window.socket.id) { throw new Error('Not connected to server. Please wait for connection.'); } const socketId = window.socket.id; let sessionToken = ''; try { sessionToken = localStorage.getItem('wownerogue_token') || ''; } catch (_) {} if (!sessionToken) { throw new Error('Your game session is still being established. Please retry in a moment.'); } // An explicit click grants only the asset this game supports. Nostr-only grants do // not authorize WOW payments. A denial ends this attempt without another popup. const keys = await this.requestWowAccess(); // Step 1: Get challenge from server const challengeRes = await fetch('/api/auth/smirk/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-Token': sessionToken }, body: JSON.stringify({ socketId }) }); if (!challengeRes.ok) { const error = await challengeRes.json(); throw new Error(error.message || 'Failed to get challenge from server'); } const { challenge } = await challengeRes.json(); // Step 2: Feature-detect NIP-98 capability and verify with the backend. let verifyData; let provenPublicKey = null; if (this._supportsNip98()) { const result = await this._verifyNip98(socketId, challenge, sessionToken); verifyData = result.verifyData; provenPublicKey = result.pubkey; } else { const result = await this._verifyLegacy(socketId, challenge, sessionToken, keys); verifyData = result.verifyData; provenPublicKey = result.pubkey; } // Sign-in-with-wallet adoption: if this wallet already owned an account, the server signed // us into it and returned that account's session token. Persist it and reload so the whole // session re-establishes as that account (credits, address, history). if (verifyData && verifyData.adopted && verifyData.sessionToken) { if (typeof SocketHandlers !== 'undefined') { SocketHandlers._invalidatePaymentSession(); } try { localStorage.setItem('wownerogue_token', verifyData.sessionToken); } catch (_) { /* ignore */ } $('#messages').append($('
  • ').text('🔑 Signed in to your wallet-linked account. Reloading…')); setTimeout(function () { window.location.reload(); }, 700); return { success: true, linked: true, adopted: true, address: verifyData.address || null }; } // Receiving addresses are independent of the proven login identity. let walletAddress = null; try { const addresses = await window.smirk.getAddresses(); if (addresses && addresses.wow && window.socket) { // Prestige-only play never needs a payout address. if (typeof SocketHandlers !== 'undefined' && (SocketHandlers._directPayoutsEnabled || SocketHandlers._creditsPayoutsEnabled)) { window.socket.emit('address:update', { address: addresses.wow }); } walletAddress = addresses.wow; } } catch (addrErr) { console.warn('Could not get Smirk addresses:', addrErr.message); // Fall back - user will need to manually enter address } // Update internal state this._isLinked = true; this._walletAddress = walletAddress; this._publicKey = provenPublicKey; return { success: true, linked: verifyData.linked, address: walletAddress }; }, /** * NIP-98 auth path: build a kind:27235 nostr event, sign it with the * Smirk wallet, and POST { socketId, event } to /verify. * * @returns {Promise<{verifyData: object, pubkey: string|null}>} */ async _verifyNip98(socketId, challenge, sessionToken) { // Build the NIP-98 HTTP auth event. The wallet fills in pubkey/id/sig. const evt = { kind: 27235, created_at: Math.floor(Date.now() / 1000), content: '', tags: [ ['u', new URL('/api/auth/smirk/verify', window.location.origin).href], ['method', 'POST'], ['challenge', challenge] ] }; // Sign directly. If this origin doesn't yet hold the Nostr scope, the wallet throws // NOT_AUTHORIZED (no popup); grant it once via getNostrPublicKey() and retry. Signing // first means a RETURNING user (scope already granted) gets a SINGLE approval instead of // two. The wallet still asks per-signature for kind 27235; that can't be silenced. let signed; try { signed = await window.smirk.signNostrEvent(evt); } catch (scopeErr) { const needsScope = scopeErr && scopeErr.code === 'NOT_AUTHORIZED'; if (!needsScope) throw scopeErr; const granted = await window.smirk.getNostrPublicKey(); if (!granted) throw new Error('Smirk Nostr permission was declined.'); signed = await window.smirk.signNostrEvent(evt); } if (!signed || typeof signed !== 'object') { throw new Error('Smirk wallet did not return a signed nostr event'); } const verifyRes = await fetch('/api/auth/smirk/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-Token': sessionToken }, body: JSON.stringify({ socketId, event: signed }) }); if (!verifyRes.ok) { const error = await verifyRes.json().catch(() => ({})); throw new Error(error.message || 'NIP-98 verification failed'); } const verifyData = await verifyRes.json(); return { verifyData, pubkey: signed.pubkey || null }; }, /** * Legacy Ed25519 auth path: connect, sign the challenge string, and POST * { socketId, challenge, publicKey, signature } to /verify. * * @returns {Promise<{verifyData: object, pubkey: string|null}>} */ async _verifyLegacy(socketId, challenge, sessionToken, keys) { if (!keys || !keys.wow) { throw new Error('Smirk wallet did not return WOW public key'); } // Sign the challenge let signResult; try { signResult = await window.smirk.signMessage(challenge); } catch (signError) { const e = new Error('Failed to sign challenge: ' + (signError.message || 'Unknown error')); e.code = signError && signError.code; // preserve 'LOCKED' etc. throw e; } // Find WOW signature const wowSig = signResult.signatures?.find(s => s.asset === 'wow'); if (!wowSig) { throw new Error('WOW signature not found in Smirk response'); } // Verify signature with backend const verifyRes = await fetch('/api/auth/smirk/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-Token': sessionToken }, body: JSON.stringify({ socketId, challenge, publicKey: wowSig.publicKey, signature: wowSig.signature }) }); if (!verifyRes.ok) { const error = await verifyRes.json(); throw new Error(error.message || 'Signature verification failed'); } const verifyData = await verifyRes.json(); return { verifyData, pubkey: wowSig.publicKey }; }, /** * Check current link status */ async checkStatus() { if (!window.socket || !window.socket.id) { return { linked: false, hasPayoutAddress: false }; } const epoch = this._sessionEpoch(); const socketId = window.socket.id; const requestId = this._statusRequestId = (this._statusRequestId || 0) + 1; let token = ''; try { token = localStorage.getItem('wownerogue_token') || ''; } catch (_) {} const current = () => { let activeToken = ''; try { activeToken = localStorage.getItem('wownerogue_token') || ''; } catch (_) {} return epoch === this._sessionEpoch() && socketId === window.socket?.id && token === activeToken && requestId === this._statusRequestId; }; try { // /status is session-gated (BOLA fix): send the session token so a linked user // is recognised; without it the endpoint returns 401 and we fall through to unlinked. const res = await fetch(`/api/auth/smirk/status?socketId=${encodeURIComponent(window.socket.id)}`, { headers: token ? { 'X-Session-Token': token } : {} }); if (res.ok) { const data = await res.json(); if (!current()) return { linked: false, hasPayoutAddress: false }; await this.checkWowAccess(); if (!current()) return { linked: false, hasPayoutAddress: false }; this._isLinked = data.linked === true; this._updateButton($('#smirkLoginBtn'), data.linked ? (this._wowReady ? 'connected' : 'linked') : 'ready'); if (typeof SocketHandlers !== 'undefined' && SocketHandlers._refreshSmirkPaymentButton) { SocketHandlers._refreshSmirkPaymentButton(); } return data; } } catch (e) { console.warn('Failed to check Smirk status:', e.message); } if (current()) { this._isLinked = false; this._wowReady = false; this._updateButton($('#smirkLoginBtn'), 'ready'); } return { linked: false, hasPayoutAddress: false }; }, /** * Create and return the Smirk login button element */ _createButton() { const btn = $('