interactive intro to open social at-me.zzstoatzz.io

feat: implement browser OAuth for guestbook signing

- Add @atcute/oauth-browser-client integration for ATProto OAuth
- Create oauth.js module with login, callback handling, session management
- Update guestbook-ui.js with login/sign modals and proper auth flow
- Add CSS styling for login and sign forms
- Update oauth-client-metadata.json for wisp.place domain
- Configure Vite to inject OAuth environment variables for dev/prod

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+576 -11
+3 -3
public/oauth-client-metadata.json
··· 1 1 { 2 - "client_id": "https://at-me.fly.dev/oauth-client-metadata.json", 2 + "client_id": "https://at-me.wisp.place/oauth-client-metadata.json", 3 3 "client_name": "at-me", 4 - "client_uri": "https://at-me.fly.dev", 5 - "redirect_uris": ["https://at-me.fly.dev/app.html"], 4 + "client_uri": "https://at-me.wisp.place", 5 + "redirect_uris": ["https://at-me.wisp.place/view/"], 6 6 "scope": "atproto transition:generic", 7 7 "grant_types": ["authorization_code", "refresh_token"], 8 8 "response_types": ["code"],
+189 -6
src/view/guestbook-ui.js
··· 1 1 // ============================================================================ 2 - // GUESTBOOK (simplified - just viewing, no OAuth write for now) 2 + // GUESTBOOK - viewing and signing with OAuth 3 3 // ============================================================================ 4 4 5 5 import { state } from './state.js'; 6 6 import { escapeHtml } from './atproto.js'; 7 7 import { allGuestbookSignatures, fetchAllGuestbookSignatures } from './guestbook-state.js'; 8 + import { 9 + isAuthenticated, 10 + getCurrentDid, 11 + startLogin, 12 + signOut, 13 + createRecord, 14 + listRecords, 15 + deleteRecord, 16 + } from './oauth.js'; 17 + 18 + const GUESTBOOK_COLLECTION = 'app.at-me.visit'; 8 19 9 20 export function initGuestbookUI() { 10 21 document.getElementById('viewGuestbookBtn').addEventListener('click', async () => { ··· 65 76 } 66 77 }); 67 78 68 - // Sign guestbook button - for now just show a message about OAuth 69 - document.getElementById('signGuestbookBtn').addEventListener('click', () => { 70 - if (state.pageOwnerHasSigned) { 79 + // Sign guestbook button 80 + document.getElementById('signGuestbookBtn').addEventListener('click', async () => { 81 + // Check if authenticated first 82 + if (!isAuthenticated()) { 83 + // Show login modal 84 + showLoginModal(); 85 + return; 86 + } 87 + 88 + // Check if current user has already signed 89 + const currentDid = getCurrentDid(); 90 + const userHasSigned = allGuestbookSignatures.some(sig => sig.did === currentDid); 91 + 92 + if (userHasSigned) { 71 93 // Already signed - show the guestbook 72 94 document.getElementById('viewGuestbookBtn').click(); 95 + return; 96 + } 97 + 98 + // Show sign modal 99 + showSignModal(); 100 + }); 101 + 102 + // Update auth UI on load 103 + updateAuthUI(); 104 + } 105 + 106 + // Update UI based on auth state 107 + export function updateAuthUI() { 108 + const signBtn = document.getElementById('signGuestbookBtn'); 109 + if (!signBtn) return; 110 + 111 + if (isAuthenticated()) { 112 + const did = getCurrentDid(); 113 + // Check if current user has already signed 114 + const hasSigned = allGuestbookSignatures.some(sig => sig.did === did); 115 + if (hasSigned) { 116 + signBtn.textContent = 'view signatures'; 117 + signBtn.title = 'you have signed!'; 73 118 } else { 74 - // Not signed - would need OAuth 75 - alert(`OAuth signing coming soon! For now, visit your own PDS to create an app.at-me.visit record.`); 119 + signBtn.textContent = 'sign guestbook'; 120 + signBtn.title = `signed in as ${did}`; 121 + } 122 + } else { 123 + signBtn.textContent = 'sign guestbook'; 124 + signBtn.title = 'sign in to sign'; 125 + } 126 + } 127 + 128 + // Show login modal 129 + function showLoginModal() { 130 + const modal = document.getElementById('guestbookModal'); 131 + const content = document.getElementById('guestbookContent'); 132 + 133 + modal.classList.add('visible'); 134 + content.innerHTML = ` 135 + <div class="guestbook-paper"> 136 + <div class="guestbook-paper-title">sign in</div> 137 + <div class="guestbook-paper-subtitle">enter your handle to sign the guestbook</div> 138 + <form id="loginForm" class="guestbook-login-form"> 139 + <input type="text" id="loginHandle" placeholder="you.bsky.social" class="guestbook-input" autocomplete="off" /> 140 + <button type="submit" class="guestbook-submit-btn">sign in</button> 141 + </form> 142 + <div id="loginError" class="guestbook-error"></div> 143 + </div> 144 + `; 145 + 146 + const form = document.getElementById('loginForm'); 147 + const input = document.getElementById('loginHandle'); 148 + const errorDiv = document.getElementById('loginError'); 149 + 150 + input.focus(); 151 + 152 + form.addEventListener('submit', async (e) => { 153 + e.preventDefault(); 154 + const handle = input.value.trim(); 155 + if (!handle) return; 156 + 157 + errorDiv.textContent = ''; 158 + input.disabled = true; 159 + 160 + try { 161 + await startLogin(handle); 162 + } catch (err) { 163 + errorDiv.textContent = err.message || 'login failed'; 164 + input.disabled = false; 165 + } 166 + }); 167 + } 168 + 169 + // Show sign modal 170 + function showSignModal() { 171 + const modal = document.getElementById('guestbookModal'); 172 + const content = document.getElementById('guestbookContent'); 173 + 174 + modal.classList.add('visible'); 175 + content.innerHTML = ` 176 + <div class="guestbook-paper"> 177 + <div class="guestbook-paper-title">sign guestbook</div> 178 + <div class="guestbook-paper-subtitle">leave your mark (optional message)</div> 179 + <form id="signForm" class="guestbook-sign-form"> 180 + <textarea id="signMessage" placeholder="(optional message)" class="guestbook-textarea" maxlength="280"></textarea> 181 + <div class="guestbook-form-buttons"> 182 + <button type="submit" class="guestbook-submit-btn">sign</button> 183 + <button type="button" id="signOutBtn" class="guestbook-signout-btn">sign out</button> 184 + </div> 185 + </form> 186 + <div id="signError" class="guestbook-error"></div> 187 + </div> 188 + `; 189 + 190 + const form = document.getElementById('signForm'); 191 + const textarea = document.getElementById('signMessage'); 192 + const errorDiv = document.getElementById('signError'); 193 + const signOutBtn = document.getElementById('signOutBtn'); 194 + 195 + form.addEventListener('submit', async (e) => { 196 + e.preventDefault(); 197 + const text = textarea.value.trim(); 198 + 199 + errorDiv.textContent = ''; 200 + textarea.disabled = true; 201 + 202 + try { 203 + const record = { 204 + $type: GUESTBOOK_COLLECTION, 205 + createdAt: new Date().toISOString(), 206 + }; 207 + if (text) { 208 + record.text = text; 209 + } 210 + 211 + await createRecord(GUESTBOOK_COLLECTION, record); 212 + 213 + // Refresh guestbook data 214 + allGuestbookSignatures.length = 0; 215 + await fetchAllGuestbookSignatures(); 216 + 217 + // Close modal and update UI 218 + modal.classList.remove('visible'); 219 + updateAuthUI(); 220 + 221 + // Show success 222 + alert('signed!'); 223 + } catch (err) { 224 + errorDiv.textContent = err.message || 'failed to sign'; 225 + textarea.disabled = false; 76 226 } 77 227 }); 228 + 229 + signOutBtn.addEventListener('click', async () => { 230 + await signOut(); 231 + modal.classList.remove('visible'); 232 + updateAuthUI(); 233 + }); 234 + } 235 + 236 + // Unsign (remove signature) 237 + export async function unsignGuestbook() { 238 + if (!isAuthenticated()) { 239 + throw new Error('not authenticated'); 240 + } 241 + 242 + try { 243 + // Find user's signature records 244 + const records = await listRecords(GUESTBOOK_COLLECTION); 245 + 246 + // Delete all of them 247 + for (const record of records) { 248 + const rkey = record.uri.split('/').pop(); 249 + await deleteRecord(GUESTBOOK_COLLECTION, rkey); 250 + } 251 + 252 + // Refresh guestbook data 253 + allGuestbookSignatures.length = 0; 254 + await fetchAllGuestbookSignatures(); 255 + 256 + updateAuthUI(); 257 + } catch (err) { 258 + console.error('Failed to unsign:', err); 259 + throw err; 260 + } 78 261 }
+150
src/view/guestbook.css
··· 403 403 opacity: 1; 404 404 text-decoration: underline; 405 405 } 406 + 407 + /* Login and Sign Forms */ 408 + .guestbook-login-form, 409 + .guestbook-sign-form { 410 + display: flex; 411 + flex-direction: column; 412 + gap: clamp(1rem, 2.5vmin, 1.5rem); 413 + margin-top: clamp(1.5rem, 4vmin, 2rem); 414 + } 415 + 416 + .guestbook-input, 417 + .guestbook-textarea { 418 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 419 + font-size: clamp(0.9rem, 2vmin, 1rem); 420 + padding: clamp(0.8rem, 2vmin, 1rem); 421 + border: 1px solid #d4c5a8; 422 + background: rgba(255, 255, 255, 0.5); 423 + color: #2a2520; 424 + border-radius: 4px; 425 + width: 100%; 426 + box-sizing: border-box; 427 + transition: all 0.2s ease; 428 + } 429 + 430 + .guestbook-input:focus, 431 + .guestbook-textarea:focus { 432 + outline: none; 433 + border-color: #8a7a6a; 434 + background: rgba(255, 255, 255, 0.8); 435 + } 436 + 437 + .guestbook-input::placeholder, 438 + .guestbook-textarea::placeholder { 439 + color: #8a7a6a; 440 + opacity: 0.7; 441 + } 442 + 443 + @media (prefers-color-scheme: dark) { 444 + .guestbook-input, 445 + .guestbook-textarea { 446 + background: rgba(40, 35, 30, 0.5); 447 + border-color: #3a3530; 448 + color: #d4c5a8; 449 + } 450 + 451 + .guestbook-input:focus, 452 + .guestbook-textarea:focus { 453 + border-color: #6a5a4a; 454 + background: rgba(40, 35, 30, 0.8); 455 + } 456 + 457 + .guestbook-input::placeholder, 458 + .guestbook-textarea::placeholder { 459 + color: #6a5a4a; 460 + } 461 + } 462 + 463 + .guestbook-textarea { 464 + min-height: 100px; 465 + resize: vertical; 466 + } 467 + 468 + .guestbook-submit-btn { 469 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 470 + font-size: clamp(0.85rem, 1.8vmin, 1rem); 471 + padding: clamp(0.8rem, 2vmin, 1rem) clamp(1.5rem, 4vmin, 2rem); 472 + border: 1px solid #d4c5a8; 473 + background: #3a2f25; 474 + color: #f9f7f1; 475 + cursor: pointer; 476 + border-radius: 4px; 477 + transition: all 0.2s ease; 478 + text-transform: lowercase; 479 + letter-spacing: 0.05em; 480 + } 481 + 482 + .guestbook-submit-btn:hover { 483 + background: #4a3f35; 484 + border-color: #8a7a6a; 485 + } 486 + 487 + .guestbook-submit-btn:disabled { 488 + opacity: 0.5; 489 + cursor: not-allowed; 490 + } 491 + 492 + @media (prefers-color-scheme: dark) { 493 + .guestbook-submit-btn { 494 + background: #d4c5a8; 495 + color: #1f1b17; 496 + border-color: #d4c5a8; 497 + } 498 + 499 + .guestbook-submit-btn:hover { 500 + background: #e4d5b8; 501 + border-color: #e4d5b8; 502 + } 503 + } 504 + 505 + .guestbook-form-buttons { 506 + display: flex; 507 + gap: clamp(0.75rem, 2vmin, 1rem); 508 + justify-content: center; 509 + flex-wrap: wrap; 510 + } 511 + 512 + .guestbook-signout-btn { 513 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 514 + font-size: clamp(0.75rem, 1.6vmin, 0.85rem); 515 + padding: clamp(0.6rem, 1.5vmin, 0.8rem) clamp(1rem, 2.5vmin, 1.5rem); 516 + border: 1px solid #d4c5a8; 517 + background: transparent; 518 + color: #6b5d4f; 519 + cursor: pointer; 520 + border-radius: 4px; 521 + transition: all 0.2s ease; 522 + text-transform: lowercase; 523 + letter-spacing: 0.05em; 524 + } 525 + 526 + .guestbook-signout-btn:hover { 527 + background: rgba(212, 197, 168, 0.2); 528 + color: #4a3f35; 529 + } 530 + 531 + @media (prefers-color-scheme: dark) { 532 + .guestbook-signout-btn { 533 + color: #8a7a6a; 534 + border-color: #3a3530; 535 + } 536 + 537 + .guestbook-signout-btn:hover { 538 + background: rgba(60, 50, 40, 0.3); 539 + color: #a89a8a; 540 + } 541 + } 542 + 543 + .guestbook-error { 544 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 545 + font-size: clamp(0.7rem, 1.5vmin, 0.8rem); 546 + color: #b44; 547 + text-align: center; 548 + min-height: 1.5em; 549 + } 550 + 551 + @media (prefers-color-scheme: dark) { 552 + .guestbook-error { 553 + color: #e66; 554 + } 555 + }
+19 -1
src/view/main.js
··· 14 14 } from './atproto.js'; 15 15 import { renderVisualization } from './visualization.js'; 16 16 import { checkGuestbookState } from './guestbook-state.js'; 17 - import { initGuestbookUI } from './guestbook-ui.js'; 17 + import { initGuestbookUI, updateAuthUI } from './guestbook-ui.js'; 18 18 import { initFirehoseUI } from './firehose.js'; 19 + import { initOAuth, handleOAuthCallback, tryResumeSession } from './oauth.js'; 19 20 20 21 // ============================================================================ 21 22 // INITIALIZATION 22 23 // ============================================================================ 23 24 24 25 async function init() { 26 + // Initialize OAuth first 27 + initOAuth(); 28 + 29 + // Check for OAuth callback (returning from authorization) 30 + const callbackDid = await handleOAuthCallback(); 31 + if (callbackDid) { 32 + console.log('OAuth callback successful:', callbackDid); 33 + // Update auth UI after successful login 34 + updateAuthUI(); 35 + } else { 36 + // Try to resume existing session 37 + const resumedDid = await tryResumeSession(); 38 + if (resumedDid) { 39 + console.log('Resumed session:', resumedDid); 40 + updateAuthUI(); 41 + } 42 + } 25 43 const statusEl = document.getElementById('status'); 26 44 27 45 try {
+187
src/view/oauth.js
··· 1 + // ============================================================================ 2 + // OAUTH - ATProto browser OAuth implementation 3 + // ============================================================================ 4 + 5 + import { XRPC } from '@atcute/client'; 6 + import { 7 + configureOAuth, 8 + createAuthorizationUrl, 9 + finalizeAuthorization, 10 + getSession, 11 + resolveFromIdentity, 12 + OAuthUserAgent, 13 + deleteStoredSession, 14 + } from '@atcute/oauth-browser-client'; 15 + 16 + // OAuth state 17 + let agent = null; 18 + let currentDid = null; 19 + 20 + // Initialize OAuth configuration 21 + export function initOAuth() { 22 + configureOAuth({ 23 + metadata: { 24 + client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 25 + redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI, 26 + }, 27 + }); 28 + } 29 + 30 + // Check if we're returning from an OAuth callback 31 + export async function handleOAuthCallback() { 32 + // Check for OAuth callback params in hash (as per atcute docs) 33 + const params = new URLSearchParams(location.hash.slice(1)); 34 + 35 + if (!params.has('state') && !params.has('code')) { 36 + return null; 37 + } 38 + 39 + // Scrub the hash from history 40 + history.replaceState(null, '', location.pathname + location.search); 41 + 42 + try { 43 + const session = await finalizeAuthorization(params); 44 + agent = new OAuthUserAgent(session); 45 + currentDid = session.info.sub; 46 + 47 + // Store DID in localStorage for session persistence 48 + localStorage.setItem('oauth_did', currentDid); 49 + 50 + return currentDid; 51 + } catch (err) { 52 + console.error('OAuth callback failed:', err); 53 + return null; 54 + } 55 + } 56 + 57 + // Try to resume an existing session 58 + export async function tryResumeSession() { 59 + const storedDid = localStorage.getItem('oauth_did'); 60 + if (!storedDid) return null; 61 + 62 + try { 63 + const session = await getSession(storedDid, { allowStale: true }); 64 + agent = new OAuthUserAgent(session); 65 + currentDid = storedDid; 66 + return currentDid; 67 + } catch (err) { 68 + // Session expired or invalid, clear it 69 + localStorage.removeItem('oauth_did'); 70 + return null; 71 + } 72 + } 73 + 74 + // Start OAuth login flow 75 + export async function startLogin(handle) { 76 + try { 77 + const { identity, metadata } = await resolveFromIdentity(handle); 78 + 79 + const authUrl = await createAuthorizationUrl({ 80 + metadata, 81 + identity, 82 + scope: import.meta.env.VITE_OAUTH_SCOPE, 83 + }); 84 + 85 + // Small delay to ensure localStorage is persisted 86 + await new Promise(resolve => setTimeout(resolve, 200)); 87 + 88 + // Redirect to authorization 89 + window.location.assign(authUrl); 90 + 91 + // This promise should never resolve if redirect succeeds 92 + // Only resolves if user navigates back (abort) 93 + await new Promise((_resolve, reject) => { 94 + window.addEventListener('pageshow', () => { 95 + reject(new Error('user aborted login')); 96 + }, { once: true }); 97 + }); 98 + } catch (err) { 99 + console.error('Login failed:', err); 100 + throw err; 101 + } 102 + } 103 + 104 + // Sign out 105 + export async function signOut() { 106 + if (!currentDid) return; 107 + 108 + try { 109 + if (agent) { 110 + await agent.signOut(); 111 + } 112 + } catch (err) { 113 + // Fallback: delete stored session 114 + deleteStoredSession(currentDid); 115 + } 116 + 117 + agent = null; 118 + currentDid = null; 119 + localStorage.removeItem('oauth_did'); 120 + } 121 + 122 + // Get current auth status 123 + export function isAuthenticated() { 124 + return agent !== null && currentDid !== null; 125 + } 126 + 127 + export function getCurrentDid() { 128 + return currentDid; 129 + } 130 + 131 + // Get XRPC client for authenticated requests 132 + export function getXrpc() { 133 + if (!agent) return null; 134 + return new XRPC({ handler: agent }); 135 + } 136 + 137 + // Create a record in the user's repo 138 + export async function createRecord(collection, record) { 139 + const rpc = getXrpc(); 140 + if (!rpc || !currentDid) { 141 + throw new Error('not authenticated'); 142 + } 143 + 144 + const { data } = await rpc.call('com.atproto.repo.createRecord', { 145 + data: { 146 + repo: currentDid, 147 + collection, 148 + record, 149 + }, 150 + }); 151 + 152 + return data; 153 + } 154 + 155 + // List records from the user's repo 156 + export async function listRecords(collection) { 157 + const rpc = getXrpc(); 158 + if (!rpc || !currentDid) { 159 + throw new Error('not authenticated'); 160 + } 161 + 162 + const { data } = await rpc.get('com.atproto.repo.listRecords', { 163 + params: { 164 + repo: currentDid, 165 + collection, 166 + limit: 100, 167 + }, 168 + }); 169 + 170 + return data.records; 171 + } 172 + 173 + // Delete a record from the user's repo 174 + export async function deleteRecord(collection, rkey) { 175 + const rpc = getXrpc(); 176 + if (!rpc || !currentDid) { 177 + throw new Error('not authenticated'); 178 + } 179 + 180 + await rpc.call('com.atproto.repo.deleteRecord', { 181 + data: { 182 + repo: currentDid, 183 + collection, 184 + rkey, 185 + }, 186 + }); 187 + }
+28 -1
vite.config.js
··· 1 1 import { defineConfig } from 'vite'; 2 + import metadata from './public/oauth-client-metadata.json' with { type: 'json' }; 3 + 4 + const SERVER_HOST = '127.0.0.1'; 5 + const SERVER_PORT = 3030; 2 6 3 7 export default defineConfig({ 4 8 root: '.', ··· 15 19 } 16 20 }, 17 21 server: { 18 - port: 3030 22 + host: SERVER_HOST, 23 + port: SERVER_PORT 19 24 }, 20 25 appType: 'mpa', 21 26 plugins: [ 27 + // Inject OAuth environment variables 28 + { 29 + name: 'oauth-env', 30 + config(_conf, { command }) { 31 + if (command === 'build') { 32 + process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 33 + process.env.VITE_OAUTH_REDIRECT_URI = metadata.redirect_uris[0]; 34 + } else { 35 + // Dev mode: use localhost client ID format 36 + const redirectUri = `http://${SERVER_HOST}:${SERVER_PORT}/view.html`; 37 + const clientId = 38 + `http://localhost` + 39 + `?redirect_uri=${encodeURIComponent(redirectUri)}` + 40 + `&scope=${encodeURIComponent(metadata.scope)}`; 41 + 42 + process.env.VITE_OAUTH_CLIENT_ID = clientId; 43 + process.env.VITE_OAUTH_REDIRECT_URI = redirectUri; 44 + } 45 + process.env.VITE_OAUTH_SCOPE = metadata.scope; 46 + } 47 + }, 48 + // Rewrite /view to /view.html for dev server 22 49 { 23 50 name: 'rewrite-view', 24 51 configureServer(server) {