Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add statusphere HTML example and viewer query

Adds a complete standalone HTML example demonstrating:
- OAuth/PKCE authentication with AT Protocol
- GraphQL queries and mutations for status updates
- Real-time UI rendering of user statuses

Also adds viewer query to the GraphQL schema, allowing authenticated
users to query their own data with DID join fields that return
connection types (with edges/nodes, pagination, sortBy, where) for
collections with multiple records per DID.

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

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

+3174 -4
+1339
docs/plans/2025-11-30-statusphere-html-example.md
··· 1 + # Statusphere HTML Example Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Create a single-file HTML example app that mirrors the statusphere-example-app functionality using quickslice's GraphQL API. 6 + 7 + **Architecture:** A self-contained `index.html` with embedded CSS and JavaScript. Implements OAuth PKCE flow for authentication, GraphQL queries for fetching statuses/profiles, and mutations for posting statuses. No external dependencies. 8 + 9 + **Tech Stack:** Vanilla HTML/CSS/JavaScript, Web Crypto API for PKCE, Fetch API for GraphQL, sessionStorage for tokens. 10 + 11 + --- 12 + 13 + ## Prerequisites 14 + 15 + Before using this example, users must: 16 + 1. Have quickslice server running at `http://localhost:8080` 17 + 2. Register an OAuth client via quickslice admin UI or GraphQL mutation 18 + 3. Set the client's redirect URI to match where they serve the HTML file 19 + 20 + --- 21 + 22 + ## Task 1: Create Directory Structure and Base HTML 23 + 24 + **Files:** 25 + - Create: `examples/01-statusphere-html/index.html` 26 + 27 + **Step 1: Create the examples directory** 28 + 29 + ```bash 30 + mkdir -p examples/01-statusphere-html 31 + ``` 32 + 33 + **Step 2: Create base HTML structure** 34 + 35 + Create `examples/01-statusphere-html/index.html`: 36 + 37 + ```html 38 + <!DOCTYPE html> 39 + <html lang="en"> 40 + <head> 41 + <meta charset="UTF-8"> 42 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 43 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src http://localhost:8080;"> 44 + <title>Statusphere</title> 45 + <style> 46 + /* CSS will be added in Task 2 */ 47 + </style> 48 + </head> 49 + <body> 50 + <div id="app"> 51 + <header> 52 + <h1>Statusphere</h1> 53 + <p class="tagline">Set your status on the Atmosphere</p> 54 + </header> 55 + <main> 56 + <div id="auth-section"></div> 57 + <div id="emoji-picker"></div> 58 + <div id="status-feed"></div> 59 + </main> 60 + <div id="error-banner" class="hidden"></div> 61 + </div> 62 + <script> 63 + // JavaScript will be added in Tasks 3-7 64 + </script> 65 + </body> 66 + </html> 67 + ``` 68 + 69 + **Step 3: Verify file exists** 70 + 71 + Run: `ls -la examples/01-statusphere-html/` 72 + Expected: `index.html` listed 73 + 74 + **Step 4: Commit** 75 + 76 + ```bash 77 + git add examples/01-statusphere-html/index.html 78 + git commit -m "feat(examples): add base HTML structure for statusphere example" 79 + ``` 80 + 81 + --- 82 + 83 + ## Task 2: Add CSS Styling 84 + 85 + **Files:** 86 + - Modify: `examples/01-statusphere-html/index.html` (inside `<style>` tag) 87 + 88 + **Step 1: Add CSS variables and reset** 89 + 90 + Replace the `<style>` section with: 91 + 92 + ```css 93 + /* CSS Reset */ 94 + *, *::before, *::after { 95 + box-sizing: border-box; 96 + } 97 + * { 98 + margin: 0; 99 + } 100 + body { 101 + line-height: 1.5; 102 + -webkit-font-smoothing: antialiased; 103 + } 104 + input, button { 105 + font: inherit; 106 + } 107 + 108 + /* CSS Variables */ 109 + :root { 110 + --primary-500: #0078ff; 111 + --primary-400: #339dff; 112 + --primary-600: #0060cc; 113 + --gray-100: #f5f5f5; 114 + --gray-200: #e5e5e5; 115 + --gray-500: #737373; 116 + --gray-700: #404040; 117 + --gray-900: #171717; 118 + --border-color: #e5e5e5; 119 + --error-bg: #fef2f2; 120 + --error-border: #fecaca; 121 + --error-text: #dc2626; 122 + } 123 + 124 + /* Layout */ 125 + body { 126 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 127 + background: var(--gray-100); 128 + color: var(--gray-900); 129 + min-height: 100vh; 130 + padding: 2rem 1rem; 131 + } 132 + 133 + #app { 134 + max-width: 600px; 135 + margin: 0 auto; 136 + } 137 + 138 + /* Header */ 139 + header { 140 + text-align: center; 141 + margin-bottom: 2rem; 142 + } 143 + 144 + header h1 { 145 + font-size: 2.5rem; 146 + color: var(--primary-500); 147 + margin-bottom: 0.25rem; 148 + } 149 + 150 + .tagline { 151 + color: var(--gray-500); 152 + font-size: 1rem; 153 + } 154 + 155 + /* Cards */ 156 + .card { 157 + background: white; 158 + border-radius: 0.5rem; 159 + padding: 1.5rem; 160 + margin-bottom: 1rem; 161 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 162 + } 163 + 164 + /* Auth Section */ 165 + .login-form { 166 + display: flex; 167 + flex-direction: column; 168 + gap: 1rem; 169 + } 170 + 171 + .form-group { 172 + display: flex; 173 + flex-direction: column; 174 + gap: 0.25rem; 175 + } 176 + 177 + .form-group label { 178 + font-size: 0.875rem; 179 + font-weight: 500; 180 + color: var(--gray-700); 181 + } 182 + 183 + .form-group input { 184 + padding: 0.75rem; 185 + border: 1px solid var(--border-color); 186 + border-radius: 0.375rem; 187 + font-size: 1rem; 188 + } 189 + 190 + .form-group input:focus { 191 + outline: none; 192 + border-color: var(--primary-500); 193 + box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1); 194 + } 195 + 196 + .btn { 197 + padding: 0.75rem 1.5rem; 198 + border: none; 199 + border-radius: 0.375rem; 200 + font-size: 1rem; 201 + font-weight: 500; 202 + cursor: pointer; 203 + transition: background-color 0.15s; 204 + } 205 + 206 + .btn-primary { 207 + background: var(--primary-500); 208 + color: white; 209 + } 210 + 211 + .btn-primary:hover { 212 + background: var(--primary-600); 213 + } 214 + 215 + .btn-primary:disabled { 216 + background: var(--gray-200); 217 + color: var(--gray-500); 218 + cursor: not-allowed; 219 + } 220 + 221 + .btn-secondary { 222 + background: var(--gray-200); 223 + color: var(--gray-700); 224 + } 225 + 226 + .btn-secondary:hover { 227 + background: var(--border-color); 228 + } 229 + 230 + /* User Card */ 231 + .user-card { 232 + display: flex; 233 + align-items: center; 234 + justify-content: space-between; 235 + } 236 + 237 + .user-info { 238 + display: flex; 239 + align-items: center; 240 + gap: 0.75rem; 241 + } 242 + 243 + .user-avatar { 244 + width: 48px; 245 + height: 48px; 246 + border-radius: 50%; 247 + background: var(--gray-200); 248 + display: flex; 249 + align-items: center; 250 + justify-content: center; 251 + font-size: 1.5rem; 252 + } 253 + 254 + .user-avatar img { 255 + width: 100%; 256 + height: 100%; 257 + border-radius: 50%; 258 + object-fit: cover; 259 + } 260 + 261 + .user-name { 262 + font-weight: 600; 263 + } 264 + 265 + .user-handle { 266 + font-size: 0.875rem; 267 + color: var(--gray-500); 268 + } 269 + 270 + /* Emoji Picker */ 271 + .emoji-grid { 272 + display: grid; 273 + grid-template-columns: repeat(9, 1fr); 274 + gap: 0.5rem; 275 + } 276 + 277 + .emoji-btn { 278 + width: 100%; 279 + aspect-ratio: 1; 280 + font-size: 1.5rem; 281 + border: 2px solid var(--border-color); 282 + border-radius: 50%; 283 + background: white; 284 + cursor: pointer; 285 + transition: all 0.15s; 286 + display: flex; 287 + align-items: center; 288 + justify-content: center; 289 + } 290 + 291 + .emoji-btn:hover { 292 + background: rgba(0, 120, 255, 0.1); 293 + border-color: var(--primary-400); 294 + } 295 + 296 + .emoji-btn.selected { 297 + border-color: var(--primary-500); 298 + box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.2); 299 + } 300 + 301 + .emoji-btn:disabled { 302 + opacity: 0.5; 303 + cursor: not-allowed; 304 + } 305 + 306 + .emoji-btn:disabled:hover { 307 + background: white; 308 + border-color: var(--border-color); 309 + } 310 + 311 + /* Status Feed */ 312 + .feed-title { 313 + font-size: 1.125rem; 314 + font-weight: 600; 315 + margin-bottom: 1rem; 316 + color: var(--gray-700); 317 + } 318 + 319 + .status-list { 320 + list-style: none; 321 + padding: 0; 322 + } 323 + 324 + .status-item { 325 + position: relative; 326 + padding-left: 2rem; 327 + padding-bottom: 1.5rem; 328 + } 329 + 330 + .status-item::before { 331 + content: ""; 332 + position: absolute; 333 + left: 0.75rem; 334 + top: 1.5rem; 335 + bottom: 0; 336 + width: 2px; 337 + background: var(--border-color); 338 + } 339 + 340 + .status-item:last-child::before { 341 + display: none; 342 + } 343 + 344 + .status-item:last-child { 345 + padding-bottom: 0; 346 + } 347 + 348 + .status-emoji { 349 + position: absolute; 350 + left: 0; 351 + top: 0; 352 + font-size: 1.5rem; 353 + } 354 + 355 + .status-content { 356 + padding-top: 0.25rem; 357 + } 358 + 359 + .status-author { 360 + color: var(--primary-500); 361 + text-decoration: none; 362 + font-weight: 500; 363 + } 364 + 365 + .status-author:hover { 366 + text-decoration: underline; 367 + } 368 + 369 + .status-text { 370 + color: var(--gray-700); 371 + } 372 + 373 + .status-date { 374 + font-size: 0.875rem; 375 + color: var(--gray-500); 376 + } 377 + 378 + /* Error Banner */ 379 + #error-banner { 380 + position: fixed; 381 + top: 1rem; 382 + left: 50%; 383 + transform: translateX(-50%); 384 + background: var(--error-bg); 385 + border: 1px solid var(--error-border); 386 + color: var(--error-text); 387 + padding: 0.75rem 1rem; 388 + border-radius: 0.375rem; 389 + display: flex; 390 + align-items: center; 391 + gap: 0.75rem; 392 + max-width: 90%; 393 + z-index: 100; 394 + } 395 + 396 + #error-banner.hidden { 397 + display: none; 398 + } 399 + 400 + #error-banner button { 401 + background: none; 402 + border: none; 403 + color: var(--error-text); 404 + cursor: pointer; 405 + font-size: 1.25rem; 406 + line-height: 1; 407 + } 408 + 409 + /* Loading State */ 410 + .loading { 411 + text-align: center; 412 + color: var(--gray-500); 413 + padding: 2rem; 414 + } 415 + 416 + /* Responsive */ 417 + @media (max-width: 480px) { 418 + .emoji-grid { 419 + grid-template-columns: repeat(6, 1fr); 420 + } 421 + 422 + .emoji-btn { 423 + font-size: 1.25rem; 424 + } 425 + } 426 + 427 + /* Hidden utility */ 428 + .hidden { 429 + display: none !important; 430 + } 431 + ``` 432 + 433 + **Step 2: Open in browser to verify styling** 434 + 435 + Run: `open examples/01-statusphere-html/index.html` (macOS) 436 + Expected: Page displays with blue header "Statusphere" and gray background 437 + 438 + **Step 3: Commit** 439 + 440 + ```bash 441 + git add examples/01-statusphere-html/index.html 442 + git commit -m "feat(examples): add CSS styling matching statusphere design" 443 + ``` 444 + 445 + --- 446 + 447 + ## Task 3: Add Constants and Storage Utilities 448 + 449 + **Files:** 450 + - Modify: `examples/01-statusphere-html/index.html` (inside `<script>` tag) 451 + 452 + **Step 1: Add constants and storage helpers** 453 + 454 + Replace the `<script>` section with: 455 + 456 + ```javascript 457 + // ============================================================================= 458 + // CONSTANTS 459 + // ============================================================================= 460 + 461 + const GRAPHQL_URL = 'http://localhost:8080/graphql'; 462 + const OAUTH_AUTHORIZE_URL = 'http://localhost:8080/oauth/authorize'; 463 + const OAUTH_TOKEN_URL = 'http://localhost:8080/oauth/token'; 464 + 465 + const EMOJIS = [ 466 + '👍', '👎', '💙', '😧', '😤', '🙃', '😉', '😎', '🤩', 467 + '🥳', '😭', '😱', '🥺', '😡', '💀', '🤖', '👻', '👽', 468 + '🎃', '🤡', '💩', '🔥', '⭐', '🌈', '🍕', '🎉', '💯' 469 + ]; 470 + 471 + const STORAGE_KEYS = { 472 + accessToken: 'qs_access_token', 473 + refreshToken: 'qs_refresh_token', 474 + userDid: 'qs_user_did', 475 + codeVerifier: 'qs_code_verifier', 476 + oauthState: 'qs_oauth_state', 477 + clientId: 'qs_client_id' 478 + }; 479 + 480 + // ============================================================================= 481 + // STORAGE UTILITIES 482 + // ============================================================================= 483 + 484 + const storage = { 485 + get(key) { 486 + return sessionStorage.getItem(key); 487 + }, 488 + set(key, value) { 489 + sessionStorage.setItem(key, value); 490 + }, 491 + remove(key) { 492 + sessionStorage.removeItem(key); 493 + }, 494 + clear() { 495 + Object.values(STORAGE_KEYS).forEach(key => sessionStorage.removeItem(key)); 496 + } 497 + }; 498 + 499 + // ============================================================================= 500 + // INITIALIZATION (more code to be added in subsequent tasks) 501 + // ============================================================================= 502 + 503 + console.log('Statusphere loaded. EMOJIS:', EMOJIS.length); 504 + ``` 505 + 506 + **Step 2: Open in browser and check console** 507 + 508 + Open browser DevTools console. 509 + Expected: "Statusphere loaded. EMOJIS: 27" 510 + 511 + **Step 3: Commit** 512 + 513 + ```bash 514 + git add examples/01-statusphere-html/index.html 515 + git commit -m "feat(examples): add constants and storage utilities" 516 + ``` 517 + 518 + --- 519 + 520 + ## Task 4: Add PKCE and OAuth Functions 521 + 522 + **Files:** 523 + - Modify: `examples/01-statusphere-html/index.html` (inside `<script>` tag, after storage utilities) 524 + 525 + **Step 1: Add PKCE helper functions** 526 + 527 + Add after the storage utilities section: 528 + 529 + ```javascript 530 + // ============================================================================= 531 + // PKCE UTILITIES 532 + // ============================================================================= 533 + 534 + function base64UrlEncode(buffer) { 535 + const bytes = new Uint8Array(buffer); 536 + let binary = ''; 537 + for (let i = 0; i < bytes.length; i++) { 538 + binary += String.fromCharCode(bytes[i]); 539 + } 540 + return btoa(binary) 541 + .replace(/\+/g, '-') 542 + .replace(/\//g, '_') 543 + .replace(/=+$/, ''); 544 + } 545 + 546 + async function generateCodeVerifier() { 547 + const randomBytes = new Uint8Array(32); 548 + crypto.getRandomValues(randomBytes); 549 + return base64UrlEncode(randomBytes); 550 + } 551 + 552 + async function generateCodeChallenge(verifier) { 553 + const encoder = new TextEncoder(); 554 + const data = encoder.encode(verifier); 555 + const hash = await crypto.subtle.digest('SHA-256', data); 556 + return base64UrlEncode(hash); 557 + } 558 + 559 + function generateState() { 560 + const randomBytes = new Uint8Array(16); 561 + crypto.getRandomValues(randomBytes); 562 + return base64UrlEncode(randomBytes); 563 + } 564 + 565 + // ============================================================================= 566 + // OAUTH FUNCTIONS 567 + // ============================================================================= 568 + 569 + async function initiateLogin(clientId, handle) { 570 + const codeVerifier = await generateCodeVerifier(); 571 + const codeChallenge = await generateCodeChallenge(codeVerifier); 572 + const state = generateState(); 573 + 574 + // Store for callback 575 + storage.set(STORAGE_KEYS.codeVerifier, codeVerifier); 576 + storage.set(STORAGE_KEYS.oauthState, state); 577 + storage.set(STORAGE_KEYS.clientId, clientId); 578 + 579 + // Build redirect URI (current page without query params) 580 + const redirectUri = window.location.origin + window.location.pathname; 581 + 582 + // Build authorization URL 583 + const params = new URLSearchParams({ 584 + client_id: clientId, 585 + redirect_uri: redirectUri, 586 + response_type: 'code', 587 + code_challenge: codeChallenge, 588 + code_challenge_method: 'S256', 589 + state: state, 590 + login_hint: handle 591 + }); 592 + 593 + window.location.href = `${OAUTH_AUTHORIZE_URL}?${params.toString()}`; 594 + } 595 + 596 + async function handleOAuthCallback() { 597 + const params = new URLSearchParams(window.location.search); 598 + const code = params.get('code'); 599 + const state = params.get('state'); 600 + const error = params.get('error'); 601 + 602 + if (error) { 603 + throw new Error(`OAuth error: ${error} - ${params.get('error_description') || ''}`); 604 + } 605 + 606 + if (!code || !state) { 607 + return false; // Not a callback 608 + } 609 + 610 + // Verify state 611 + const storedState = storage.get(STORAGE_KEYS.oauthState); 612 + if (state !== storedState) { 613 + throw new Error('OAuth state mismatch - possible CSRF attack'); 614 + } 615 + 616 + // Get stored values 617 + const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier); 618 + const clientId = storage.get(STORAGE_KEYS.clientId); 619 + const redirectUri = window.location.origin + window.location.pathname; 620 + 621 + if (!codeVerifier || !clientId) { 622 + throw new Error('Missing OAuth session data'); 623 + } 624 + 625 + // Exchange code for tokens 626 + const tokenResponse = await fetch(OAUTH_TOKEN_URL, { 627 + method: 'POST', 628 + headers: { 629 + 'Content-Type': 'application/x-www-form-urlencoded' 630 + }, 631 + body: new URLSearchParams({ 632 + grant_type: 'authorization_code', 633 + code: code, 634 + redirect_uri: redirectUri, 635 + client_id: clientId, 636 + code_verifier: codeVerifier 637 + }) 638 + }); 639 + 640 + if (!tokenResponse.ok) { 641 + const errorData = await tokenResponse.json().catch(() => ({})); 642 + throw new Error(`Token exchange failed: ${errorData.error_description || tokenResponse.statusText}`); 643 + } 644 + 645 + const tokens = await tokenResponse.json(); 646 + 647 + // Store tokens 648 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 649 + if (tokens.refresh_token) { 650 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 651 + } 652 + 653 + // Extract DID from token response (sub claim) or we'll fetch it later 654 + if (tokens.sub) { 655 + storage.set(STORAGE_KEYS.userDid, tokens.sub); 656 + } 657 + 658 + // Clean up OAuth state 659 + storage.remove(STORAGE_KEYS.codeVerifier); 660 + storage.remove(STORAGE_KEYS.oauthState); 661 + 662 + // Clear URL params 663 + window.history.replaceState({}, document.title, window.location.pathname); 664 + 665 + return true; 666 + } 667 + 668 + function logout() { 669 + storage.clear(); 670 + window.location.reload(); 671 + } 672 + 673 + function isLoggedIn() { 674 + return !!storage.get(STORAGE_KEYS.accessToken); 675 + } 676 + 677 + function getAccessToken() { 678 + return storage.get(STORAGE_KEYS.accessToken); 679 + } 680 + 681 + function getUserDid() { 682 + return storage.get(STORAGE_KEYS.userDid); 683 + } 684 + ``` 685 + 686 + **Step 2: Test PKCE generation in console** 687 + 688 + Open browser DevTools and run: 689 + ```javascript 690 + generateCodeVerifier().then(v => console.log('Verifier:', v)); 691 + ``` 692 + Expected: Random base64url string ~43 characters 693 + 694 + **Step 3: Commit** 695 + 696 + ```bash 697 + git add examples/01-statusphere-html/index.html 698 + git commit -m "feat(examples): add PKCE and OAuth authentication functions" 699 + ``` 700 + 701 + --- 702 + 703 + ## Task 5: Add GraphQL Query Functions 704 + 705 + **Files:** 706 + - Modify: `examples/01-statusphere-html/index.html` (inside `<script>` tag, after OAuth functions) 707 + 708 + **Step 1: Add GraphQL utility and queries** 709 + 710 + Add after the OAuth functions section: 711 + 712 + ```javascript 713 + // ============================================================================= 714 + // GRAPHQL UTILITIES 715 + // ============================================================================= 716 + 717 + async function graphqlQuery(query, variables = {}, requireAuth = false) { 718 + const headers = { 719 + 'Content-Type': 'application/json' 720 + }; 721 + 722 + if (requireAuth) { 723 + const token = getAccessToken(); 724 + if (!token) { 725 + throw new Error('Not authenticated'); 726 + } 727 + headers['Authorization'] = `Bearer ${token}`; 728 + } 729 + 730 + const response = await fetch(GRAPHQL_URL, { 731 + method: 'POST', 732 + headers, 733 + body: JSON.stringify({ query, variables }) 734 + }); 735 + 736 + if (!response.ok) { 737 + throw new Error(`GraphQL request failed: ${response.statusText}`); 738 + } 739 + 740 + const result = await response.json(); 741 + 742 + if (result.errors && result.errors.length > 0) { 743 + throw new Error(`GraphQL error: ${result.errors[0].message}`); 744 + } 745 + 746 + return result.data; 747 + } 748 + 749 + // ============================================================================= 750 + // DATA FETCHING 751 + // ============================================================================= 752 + 753 + async function fetchStatuses() { 754 + const query = ` 755 + query GetStatuses { 756 + xyzStatusphereStatus( 757 + first: 20 758 + sortBy: [{ field: "createdAt", direction: DESC }] 759 + ) { 760 + edges { 761 + node { 762 + uri 763 + did 764 + status 765 + createdAt 766 + appBskyActorProfileByDid { 767 + actorHandle 768 + displayName 769 + } 770 + } 771 + } 772 + } 773 + } 774 + `; 775 + 776 + const data = await graphqlQuery(query); 777 + return data.xyzStatusphereStatus?.edges?.map(e => e.node) || []; 778 + } 779 + 780 + async function fetchUserProfile(did) { 781 + const query = ` 782 + query GetProfile($did: String!) { 783 + appBskyActorProfile( 784 + where: { did: { eq: $did } } 785 + first: 1 786 + ) { 787 + edges { 788 + node { 789 + did 790 + actorHandle 791 + displayName 792 + avatar { 793 + url 794 + } 795 + } 796 + } 797 + } 798 + } 799 + `; 800 + 801 + const data = await graphqlQuery(query, { did }); 802 + const edges = data.appBskyActorProfile?.edges || []; 803 + return edges.length > 0 ? edges[0].node : null; 804 + } 805 + 806 + async function postStatus(emoji) { 807 + const mutation = ` 808 + mutation CreateStatus($status: String!, $createdAt: DateTime!) { 809 + createXyzStatusphereStatus( 810 + input: { status: $status, createdAt: $createdAt } 811 + ) { 812 + uri 813 + status 814 + createdAt 815 + } 816 + } 817 + `; 818 + 819 + const variables = { 820 + status: emoji, 821 + createdAt: new Date().toISOString() 822 + }; 823 + 824 + const data = await graphqlQuery(mutation, variables, true); 825 + return data.createXyzStatusphereStatus; 826 + } 827 + ``` 828 + 829 + **Step 2: Verify syntax by checking console for errors** 830 + 831 + Refresh the page, check DevTools console. 832 + Expected: No JavaScript errors 833 + 834 + **Step 3: Commit** 835 + 836 + ```bash 837 + git add examples/01-statusphere-html/index.html 838 + git commit -m "feat(examples): add GraphQL query and mutation functions" 839 + ``` 840 + 841 + --- 842 + 843 + ## Task 6: Add UI Rendering Functions 844 + 845 + **Files:** 846 + - Modify: `examples/01-statusphere-html/index.html` (inside `<script>` tag, after GraphQL functions) 847 + 848 + **Step 1: Add UI rendering functions** 849 + 850 + Add after the data fetching section: 851 + 852 + ```javascript 853 + // ============================================================================= 854 + // UI RENDERING 855 + // ============================================================================= 856 + 857 + function showError(message) { 858 + const banner = document.getElementById('error-banner'); 859 + banner.innerHTML = ` 860 + <span>${escapeHtml(message)}</span> 861 + <button onclick="hideError()">&times;</button> 862 + `; 863 + banner.classList.remove('hidden'); 864 + } 865 + 866 + function hideError() { 867 + document.getElementById('error-banner').classList.add('hidden'); 868 + } 869 + 870 + function escapeHtml(text) { 871 + const div = document.createElement('div'); 872 + div.textContent = text; 873 + return div.innerHTML; 874 + } 875 + 876 + function formatDate(dateString) { 877 + const date = new Date(dateString); 878 + const now = new Date(); 879 + const isToday = date.toDateString() === now.toDateString(); 880 + 881 + if (isToday) { 882 + return 'today'; 883 + } 884 + 885 + return date.toLocaleDateString('en-US', { 886 + month: 'short', 887 + day: 'numeric', 888 + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined 889 + }); 890 + } 891 + 892 + function renderLoginForm() { 893 + const container = document.getElementById('auth-section'); 894 + const savedClientId = storage.get(STORAGE_KEYS.clientId) || ''; 895 + 896 + container.innerHTML = ` 897 + <div class="card"> 898 + <form class="login-form" onsubmit="handleLogin(event)"> 899 + <div class="form-group"> 900 + <label for="client-id">OAuth Client ID</label> 901 + <input 902 + type="text" 903 + id="client-id" 904 + placeholder="your-client-id" 905 + value="${escapeHtml(savedClientId)}" 906 + required 907 + > 908 + </div> 909 + <div class="form-group"> 910 + <label for="handle">Bluesky Handle</label> 911 + <input 912 + type="text" 913 + id="handle" 914 + placeholder="you.bsky.social" 915 + required 916 + > 917 + </div> 918 + <button type="submit" class="btn btn-primary">Login with Bluesky</button> 919 + </form> 920 + <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;"> 921 + Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a> 922 + </p> 923 + </div> 924 + `; 925 + } 926 + 927 + function renderUserCard(profile) { 928 + const container = document.getElementById('auth-section'); 929 + const displayName = profile?.displayName || 'User'; 930 + const handle = profile?.actorHandle || 'unknown'; 931 + const avatarUrl = profile?.avatar?.url; 932 + 933 + container.innerHTML = ` 934 + <div class="card user-card"> 935 + <div class="user-info"> 936 + <div class="user-avatar"> 937 + ${avatarUrl 938 + ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` 939 + : '👤'} 940 + </div> 941 + <div> 942 + <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> 943 + <div class="user-handle">@${escapeHtml(handle)}</div> 944 + </div> 945 + </div> 946 + <button class="btn btn-secondary" onclick="logout()">Logout</button> 947 + </div> 948 + `; 949 + } 950 + 951 + function renderEmojiPicker(currentStatus, enabled = true) { 952 + const container = document.getElementById('emoji-picker'); 953 + 954 + container.innerHTML = ` 955 + <div class="card"> 956 + <div class="emoji-grid"> 957 + ${EMOJIS.map(emoji => ` 958 + <button 959 + class="emoji-btn ${emoji === currentStatus ? 'selected' : ''}" 960 + onclick="selectStatus('${emoji}')" 961 + ${!enabled ? 'disabled' : ''} 962 + title="${enabled ? 'Set status' : 'Login to set status'}" 963 + > 964 + ${emoji} 965 + </button> 966 + `).join('')} 967 + </div> 968 + </div> 969 + `; 970 + } 971 + 972 + function renderStatusFeed(statuses) { 973 + const container = document.getElementById('status-feed'); 974 + 975 + if (statuses.length === 0) { 976 + container.innerHTML = ` 977 + <div class="card"> 978 + <p class="loading">No statuses yet. Be the first to post!</p> 979 + </div> 980 + `; 981 + return; 982 + } 983 + 984 + container.innerHTML = ` 985 + <div class="card"> 986 + <h2 class="feed-title">Recent Statuses</h2> 987 + <ul class="status-list"> 988 + ${statuses.map(status => { 989 + const handle = status.appBskyActorProfileByDid?.actorHandle || status.did; 990 + const displayHandle = handle.startsWith('did:') ? handle.substring(0, 20) + '...' : handle; 991 + const profileUrl = handle.startsWith('did:') 992 + ? `https://bsky.app/profile/${status.did}` 993 + : `https://bsky.app/profile/${handle}`; 994 + 995 + return ` 996 + <li class="status-item"> 997 + <span class="status-emoji">${status.status}</span> 998 + <div class="status-content"> 999 + <span class="status-text"> 1000 + <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(displayHandle)}</a> 1001 + is feeling ${status.status} 1002 + </span> 1003 + <div class="status-date">${formatDate(status.createdAt)}</div> 1004 + </div> 1005 + </li> 1006 + `; 1007 + }).join('')} 1008 + </ul> 1009 + </div> 1010 + `; 1011 + } 1012 + 1013 + function renderLoading(container) { 1014 + document.getElementById(container).innerHTML = ` 1015 + <div class="card"> 1016 + <p class="loading">Loading...</p> 1017 + </div> 1018 + `; 1019 + } 1020 + ``` 1021 + 1022 + **Step 2: Verify syntax by checking console** 1023 + 1024 + Refresh page, check DevTools console. 1025 + Expected: No JavaScript errors 1026 + 1027 + **Step 3: Commit** 1028 + 1029 + ```bash 1030 + git add examples/01-statusphere-html/index.html 1031 + git commit -m "feat(examples): add UI rendering functions" 1032 + ``` 1033 + 1034 + --- 1035 + 1036 + ## Task 7: Add Event Handlers and Main Initialization 1037 + 1038 + **Files:** 1039 + - Modify: `examples/01-statusphere-html/index.html` (inside `<script>` tag, replace the console.log at the end) 1040 + 1041 + **Step 1: Add event handlers and main function** 1042 + 1043 + Replace the `console.log('Statusphere loaded...')` line with: 1044 + 1045 + ```javascript 1046 + // ============================================================================= 1047 + // EVENT HANDLERS 1048 + // ============================================================================= 1049 + 1050 + async function handleLogin(event) { 1051 + event.preventDefault(); 1052 + 1053 + const clientId = document.getElementById('client-id').value.trim(); 1054 + const handle = document.getElementById('handle').value.trim(); 1055 + 1056 + if (!clientId || !handle) { 1057 + showError('Please enter both Client ID and Handle'); 1058 + return; 1059 + } 1060 + 1061 + try { 1062 + await initiateLogin(clientId, handle); 1063 + } catch (error) { 1064 + showError(`Login failed: ${error.message}`); 1065 + } 1066 + } 1067 + 1068 + async function selectStatus(emoji) { 1069 + if (!isLoggedIn()) { 1070 + showError('Please login to set your status'); 1071 + return; 1072 + } 1073 + 1074 + try { 1075 + // Disable buttons while posting 1076 + document.querySelectorAll('.emoji-btn').forEach(btn => btn.disabled = true); 1077 + 1078 + await postStatus(emoji); 1079 + 1080 + // Refresh the page to show new status 1081 + window.location.reload(); 1082 + } catch (error) { 1083 + showError(`Failed to post status: ${error.message}`); 1084 + // Re-enable buttons 1085 + document.querySelectorAll('.emoji-btn').forEach(btn => btn.disabled = false); 1086 + } 1087 + } 1088 + 1089 + // ============================================================================= 1090 + // MAIN INITIALIZATION 1091 + // ============================================================================= 1092 + 1093 + async function main() { 1094 + try { 1095 + // Check if this is an OAuth callback 1096 + const isCallback = await handleOAuthCallback(); 1097 + if (isCallback) { 1098 + console.log('OAuth callback handled successfully'); 1099 + } 1100 + } catch (error) { 1101 + showError(`Authentication failed: ${error.message}`); 1102 + storage.clear(); 1103 + } 1104 + 1105 + // Render auth section 1106 + if (isLoggedIn()) { 1107 + const did = getUserDid(); 1108 + if (did) { 1109 + try { 1110 + const profile = await fetchUserProfile(did); 1111 + renderUserCard(profile); 1112 + } catch (error) { 1113 + console.error('Failed to fetch profile:', error); 1114 + renderUserCard(null); 1115 + } 1116 + } else { 1117 + renderUserCard(null); 1118 + } 1119 + } else { 1120 + renderLoginForm(); 1121 + } 1122 + 1123 + // Render emoji picker (enabled only if logged in) 1124 + renderEmojiPicker(null, isLoggedIn()); 1125 + 1126 + // Fetch and render statuses 1127 + renderLoading('status-feed'); 1128 + try { 1129 + const statuses = await fetchStatuses(); 1130 + renderStatusFeed(statuses); 1131 + } catch (error) { 1132 + console.error('Failed to fetch statuses:', error); 1133 + document.getElementById('status-feed').innerHTML = ` 1134 + <div class="card"> 1135 + <p class="loading" style="color: var(--error-text);"> 1136 + Failed to load statuses. Is the quickslice server running at localhost:8080? 1137 + </p> 1138 + </div> 1139 + `; 1140 + } 1141 + } 1142 + 1143 + // Run on page load 1144 + main(); 1145 + ``` 1146 + 1147 + **Step 2: Test the complete page** 1148 + 1149 + 1. Open `examples/01-statusphere-html/index.html` in browser 1150 + 2. Expected: Login form visible, emoji picker visible but disabled, status feed shows loading then either statuses or error message 1151 + 1152 + **Step 3: Commit** 1153 + 1154 + ```bash 1155 + git add examples/01-statusphere-html/index.html 1156 + git commit -m "feat(examples): add event handlers and main initialization" 1157 + ``` 1158 + 1159 + --- 1160 + 1161 + ## Task 8: Add README for the Example 1162 + 1163 + **Files:** 1164 + - Create: `examples/01-statusphere-html/README.md` 1165 + 1166 + **Step 1: Create README** 1167 + 1168 + Create `examples/01-statusphere-html/README.md`: 1169 + 1170 + ```markdown 1171 + # Statusphere HTML Example 1172 + 1173 + A single-file HTML example demonstrating quickslice's GraphQL API with OAuth authentication. 1174 + 1175 + ## Features 1176 + 1177 + - OAuth PKCE authentication flow 1178 + - Post status updates (emoji) 1179 + - View recent statuses from the network 1180 + - Display user profiles 1181 + 1182 + ## Prerequisites 1183 + 1184 + 1. Quickslice server running at `http://localhost:8080` 1185 + 2. A registered OAuth client 1186 + 1187 + ## Setup 1188 + 1189 + ### 1. Start Quickslice 1190 + 1191 + ```bash 1192 + cd /path/to/quickslice 1193 + make run 1194 + ``` 1195 + 1196 + ### 2. Register an OAuth Client 1197 + 1198 + Via GraphQL mutation: 1199 + 1200 + ```graphql 1201 + mutation { 1202 + createOAuthClient(input: { 1203 + name: "Statusphere HTML Example" 1204 + redirectUris: ["http://localhost:3000/index.html"] 1205 + tokenEndpointAuthMethod: "none" 1206 + }) { 1207 + clientId 1208 + } 1209 + } 1210 + ``` 1211 + 1212 + Or use the quickslice admin UI. 1213 + 1214 + **Important:** Set the redirect URI to match where you'll serve this HTML file. 1215 + 1216 + ### 3. Serve the HTML File 1217 + 1218 + Option A - Python: 1219 + ```bash 1220 + cd examples/01-statusphere-html 1221 + python -m http.server 3000 1222 + # Open http://localhost:3000/index.html 1223 + ``` 1224 + 1225 + Option B - Node.js: 1226 + ```bash 1227 + npx serve examples/01-statusphere-html -p 3000 1228 + # Open http://localhost:3000/index.html 1229 + ``` 1230 + 1231 + ### 4. Login 1232 + 1233 + 1. Enter your OAuth Client ID 1234 + 2. Enter your Bluesky handle (e.g., `you.bsky.social`) 1235 + 3. Click "Login with Bluesky" 1236 + 4. Authorize the app on your AT Protocol PDS 1237 + 5. You'll be redirected back and logged in 1238 + 1239 + ## Usage 1240 + 1241 + - Click any emoji to set your status 1242 + - View recent statuses from the network 1243 + - Click "Logout" to clear your session 1244 + 1245 + ## Security Notes 1246 + 1247 + - Tokens are stored in `sessionStorage` (cleared when tab closes) 1248 + - No external dependencies - all code is inline 1249 + - Uses PKCE for secure OAuth flow 1250 + - CSP header restricts connections to localhost:8080 1251 + 1252 + ## Troubleshooting 1253 + 1254 + **"Failed to load statuses"** 1255 + - Ensure quickslice server is running at localhost:8080 1256 + - Check browser console for CORS errors 1257 + 1258 + **OAuth redirect fails** 1259 + - Verify redirect URI matches exactly in OAuth client config 1260 + - Check that the client ID is correct 1261 + 1262 + **Can't post status** 1263 + - Ensure you're logged in (session may have expired) 1264 + - Check browser console for error details 1265 + ``` 1266 + 1267 + **Step 2: Verify README renders correctly** 1268 + 1269 + View in a markdown viewer or GitHub. 1270 + Expected: Formatted documentation with code blocks 1271 + 1272 + **Step 3: Commit** 1273 + 1274 + ```bash 1275 + git add examples/01-statusphere-html/README.md 1276 + git commit -m "docs(examples): add README for statusphere HTML example" 1277 + ``` 1278 + 1279 + --- 1280 + 1281 + ## Task 9: Final Testing and Verification 1282 + 1283 + **Step 1: Verify file structure** 1284 + 1285 + Run: `ls -la examples/01-statusphere-html/` 1286 + Expected: 1287 + ``` 1288 + index.html 1289 + README.md 1290 + ``` 1291 + 1292 + **Step 2: Verify HTML is valid** 1293 + 1294 + Run: `head -50 examples/01-statusphere-html/index.html` 1295 + Expected: Valid HTML structure starting with `<!DOCTYPE html>` 1296 + 1297 + **Step 3: Test in browser without server** 1298 + 1299 + Open `examples/01-statusphere-html/index.html` directly in browser. 1300 + Expected: 1301 + - Page loads with "Statusphere" header 1302 + - Login form displays 1303 + - Emoji picker shows (disabled) 1304 + - Error message about server connection (expected without quickslice running) 1305 + 1306 + **Step 4: Test with quickslice server (if available)** 1307 + 1308 + 1. Start quickslice: `make run` 1309 + 2. Open `http://localhost:8080` to verify server is running 1310 + 3. Serve the example: `python -m http.server 3000 -d examples/01-statusphere-html` 1311 + 4. Open `http://localhost:3000/index.html` 1312 + 5. Expected: Statuses load from the server 1313 + 1314 + **Step 5: Final commit** 1315 + 1316 + ```bash 1317 + git add -A 1318 + git status 1319 + # If any uncommitted changes: 1320 + git commit -m "chore(examples): finalize statusphere HTML example" 1321 + ``` 1322 + 1323 + --- 1324 + 1325 + ## Summary 1326 + 1327 + | Task | Description | Files | 1328 + |------|-------------|-------| 1329 + | 1 | Base HTML structure | `examples/01-statusphere-html/index.html` | 1330 + | 2 | CSS styling | Same file (style section) | 1331 + | 3 | Constants and storage | Same file (script section) | 1332 + | 4 | PKCE and OAuth | Same file (script section) | 1333 + | 5 | GraphQL functions | Same file (script section) | 1334 + | 6 | UI rendering | Same file (script section) | 1335 + | 7 | Event handlers and init | Same file (script section) | 1336 + | 8 | README documentation | `examples/01-statusphere-html/README.md` | 1337 + | 9 | Final testing | Verification only | 1338 + 1339 + **Total commits:** 8 focused commits following conventional commit format.
+495
docs/plans/2025-11-30-viewer-query.md
··· 1 + # Viewer Query Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add a `viewer` GraphQL query that returns the authenticated user's DID, handle, and profile via reverse join. 6 + 7 + **Architecture:** The viewer query extracts the user DID from the Bearer token, queries the actors table for the handle, and returns a Viewer object. The Viewer type includes `appBskyActorProfileByDid` using the same reverse join pattern as other record types. 8 + 9 + **Tech Stack:** Gleam, swell/schema, sqlight, existing batch fetcher infrastructure. 10 + 11 + --- 12 + 13 + ## Task 1: Add Viewer Type and Query to Schema Builder 14 + 15 + **Files:** 16 + - Modify: `lexicon_graphql/src/lexicon_graphql/schema/database.gleam` 17 + 18 + **Step 1: Add viewer_fetcher type alias after existing type definitions (around line 80)** 19 + 20 + Add after the `AggregateFetcher` type: 21 + 22 + ```gleam 23 + /// Fetches viewer info (did, handle) from auth token 24 + pub type ViewerFetcher = 25 + fn(String) -> Result(#(String, option.Option(String)), String) 26 + ``` 27 + 28 + **Step 2: Update build_schema_with_subscriptions signature to accept viewer_fetcher** 29 + 30 + Modify the function signature (line 183) to add the viewer_fetcher parameter: 31 + 32 + ```gleam 33 + pub fn build_schema_with_subscriptions( 34 + lexicons: List(types.Lexicon), 35 + fetcher: RecordFetcher, 36 + batch_fetcher: option.Option(dataloader.BatchFetcher), 37 + paginated_batch_fetcher: option.Option(dataloader.PaginatedBatchFetcher), 38 + create_factory: option.Option(mutation_builder.ResolverFactory), 39 + update_factory: option.Option(mutation_builder.ResolverFactory), 40 + delete_factory: option.Option(mutation_builder.ResolverFactory), 41 + upload_blob_factory: option.Option(mutation_builder.UploadBlobResolverFactory), 42 + aggregate_fetcher: option.Option(AggregateFetcher), 43 + viewer_fetcher: option.Option(ViewerFetcher), 44 + ) -> Result(schema.Schema, String) { 45 + ``` 46 + 47 + **Step 3: Pass viewer_fetcher to build_query_type** 48 + 49 + Update the call to build_query_type (around line 206): 50 + 51 + ```gleam 52 + let query_type = 53 + build_query_type( 54 + record_types, 55 + object_types, 56 + fetcher, 57 + aggregate_fetcher, 58 + field_type_registry, 59 + batch_fetcher, 60 + viewer_fetcher, 61 + ) 62 + ``` 63 + 64 + **Step 4: Update build_query_type to accept and use viewer_fetcher** 65 + 66 + Modify build_query_type signature (line 1460): 67 + 68 + ```gleam 69 + fn build_query_type( 70 + record_types: List(RecordType), 71 + object_types: dict.Dict(String, schema.Type), 72 + fetcher: RecordFetcher, 73 + aggregate_fetcher: option.Option(AggregateFetcher), 74 + field_type_registry: dict.Dict(String, dict.Dict(String, FieldType)), 75 + batch_fetcher: option.Option(dataloader.BatchFetcher), 76 + viewer_fetcher: option.Option(ViewerFetcher), 77 + ) -> schema.Type { 78 + ``` 79 + 80 + **Step 5: Build viewer field and add to all_query_fields (before line 1564)** 81 + 82 + Add after `let aggregate_query_fields = ...` block: 83 + 84 + ```gleam 85 + // Build viewer query field if viewer_fetcher provided 86 + let viewer_field = case viewer_fetcher { 87 + option.Some(vf) -> { 88 + // Build Viewer object type with did, handle, and profile join 89 + let viewer_base_fields = [ 90 + schema.field("did", schema.non_null(schema.string()), "User DID", fn(ctx) { 91 + case ctx.parent { 92 + option.Some(value.Object(fields)) -> 93 + case list.key_find(fields, "did") { 94 + Ok(v) -> Ok(v) 95 + Error(_) -> Ok(value.Null) 96 + } 97 + _ -> Ok(value.Null) 98 + } 99 + }), 100 + schema.field("handle", schema.string(), "User handle", fn(ctx) { 101 + case ctx.parent { 102 + option.Some(value.Object(fields)) -> 103 + case list.key_find(fields, "handle") { 104 + Ok(v) -> Ok(v) 105 + Error(_) -> Ok(value.Null) 106 + } 107 + _ -> Ok(value.Null) 108 + } 109 + }), 110 + ] 111 + 112 + // Add appBskyActorProfileByDid if that type exists 113 + let profile_field = case dict.get(object_types, "app.bsky.actor.profile") { 114 + Ok(profile_type) -> { 115 + [schema.field( 116 + "appBskyActorProfileByDid", 117 + profile_type, 118 + "User's profile record", 119 + fn(ctx) { 120 + case ctx.parent { 121 + option.Some(value.Object(fields)) -> 122 + case list.key_find(fields, "did") { 123 + Ok(value.String(did)) -> { 124 + case batch_fetcher { 125 + option.Some(bf) -> { 126 + case dataloader.batch_fetch_by_did([did], "app.bsky.actor.profile", bf) { 127 + Ok(results) -> 128 + case dict.get(results, did) { 129 + Ok([first, ..]) -> Ok(first) 130 + _ -> Ok(value.Null) 131 + } 132 + Error(_) -> Ok(value.Null) 133 + } 134 + } 135 + option.None -> Ok(value.Null) 136 + } 137 + } 138 + _ -> Ok(value.Null) 139 + } 140 + _ -> Ok(value.Null) 141 + } 142 + }, 143 + )] 144 + } 145 + Error(_) -> [] 146 + } 147 + 148 + let viewer_fields = list.append(viewer_base_fields, profile_field) 149 + let viewer_type = schema.object_type("Viewer", "Authenticated user", viewer_fields) 150 + 151 + // Create the viewer query field 152 + [schema.field( 153 + "viewer", 154 + viewer_type, 155 + "The currently authenticated user, or null if not authenticated", 156 + fn(ctx) { 157 + // Extract auth_token from context 158 + case ctx.data { 159 + option.Some(value.Object(fields)) -> 160 + case list.key_find(fields, "auth_token") { 161 + Ok(value.String(token)) -> { 162 + case vf(token) { 163 + Ok(#(did, handle_opt)) -> { 164 + let handle_value = case handle_opt { 165 + option.Some(h) -> value.String(h) 166 + option.None -> value.Null 167 + } 168 + Ok(value.Object([ 169 + #("did", value.String(did)), 170 + #("handle", handle_value), 171 + ])) 172 + } 173 + Error(_) -> Ok(value.Null) 174 + } 175 + } 176 + _ -> Ok(value.Null) 177 + } 178 + _ -> Ok(value.Null) 179 + } 180 + }, 181 + )] 182 + } 183 + option.None -> [] 184 + } 185 + 186 + // Combine all query fields 187 + let all_query_fields = list.flatten([query_fields, aggregate_query_fields, viewer_field]) 188 + ``` 189 + 190 + **Step 6: Commit** 191 + 192 + ```bash 193 + git add lexicon_graphql/src/lexicon_graphql/schema/database.gleam 194 + git commit -m "feat(graphql): add viewer query type to schema builder" 195 + ``` 196 + 197 + --- 198 + 199 + ## Task 2: Update Public API to Accept ViewerFetcher 200 + 201 + **Files:** 202 + - Modify: `lexicon_graphql/src/lexicon_graphql.gleam` 203 + 204 + **Step 1: Re-export ViewerFetcher type** 205 + 206 + Add to the exports (around line 10): 207 + 208 + ```gleam 209 + pub type ViewerFetcher = 210 + db_schema_builder.ViewerFetcher 211 + ``` 212 + 213 + **Step 2: Update build_schema_with_subscriptions to pass viewer_fetcher** 214 + 215 + Update the function (around line 46) to accept and forward viewer_fetcher: 216 + 217 + ```gleam 218 + pub fn build_schema_with_subscriptions( 219 + lexicons: List(types.Lexicon), 220 + fetcher: db_schema_builder.RecordFetcher, 221 + batch_fetcher: option.Option(dataloader.BatchFetcher), 222 + paginated_batch_fetcher: option.Option(dataloader.PaginatedBatchFetcher), 223 + create_factory: option.Option(mutation_builder.ResolverFactory), 224 + update_factory: option.Option(mutation_builder.ResolverFactory), 225 + delete_factory: option.Option(mutation_builder.ResolverFactory), 226 + upload_blob_factory: option.Option(mutation_builder.UploadBlobResolverFactory), 227 + aggregate_fetcher: option.Option(db_schema_builder.AggregateFetcher), 228 + viewer_fetcher: option.Option(ViewerFetcher), 229 + ) -> Result(schema.Schema, String) { 230 + db_schema_builder.build_schema_with_subscriptions( 231 + lexicons, 232 + fetcher, 233 + batch_fetcher, 234 + paginated_batch_fetcher, 235 + create_factory, 236 + update_factory, 237 + delete_factory, 238 + upload_blob_factory, 239 + aggregate_fetcher, 240 + viewer_fetcher, 241 + ) 242 + } 243 + ``` 244 + 245 + **Step 3: Commit** 246 + 247 + ```bash 248 + git add lexicon_graphql/src/lexicon_graphql.gleam 249 + git commit -m "feat(graphql): expose ViewerFetcher in public API" 250 + ``` 251 + 252 + --- 253 + 254 + ## Task 3: Implement ViewerFetcher in Server 255 + 256 + **Files:** 257 + - Modify: `server/src/graphql_gleam.gleam` 258 + 259 + **Step 1: Add import for actors repository** 260 + 261 + Add to imports (around line 10): 262 + 263 + ```gleam 264 + import database/repositories/actors 265 + ``` 266 + 267 + **Step 2: Create viewer_fetcher function** 268 + 269 + Add after existing helper functions: 270 + 271 + ```gleam 272 + /// Creates a viewer fetcher that validates token and returns user info 273 + fn create_viewer_fetcher( 274 + conn: sqlight.Connection, 275 + ) -> fn(String) -> Result(#(String, option.Option(String)), String) { 276 + fn(token: String) { 277 + // Verify token and get user DID 278 + case atproto_auth.verify_token(conn, token) { 279 + Error(_) -> Error("Invalid or expired token") 280 + Ok(user_info) -> { 281 + // Get handle from actors table 282 + let handle = case actors.get(conn, user_info.did) { 283 + Ok([actor, ..]) -> option.Some(actor.handle) 284 + _ -> option.None 285 + } 286 + Ok(#(user_info.did, handle)) 287 + } 288 + } 289 + } 290 + } 291 + ``` 292 + 293 + **Step 3: Pass viewer_fetcher when building schema** 294 + 295 + Find where `build_schema_with_subscriptions` is called and add the viewer_fetcher parameter: 296 + 297 + ```gleam 298 + let viewer_fetcher = option.Some(create_viewer_fetcher(conn)) 299 + 300 + lexicon_graphql.build_schema_with_subscriptions( 301 + lexicons, 302 + fetcher, 303 + batch_fetcher, 304 + paginated_batch_fetcher, 305 + create_factory, 306 + update_factory, 307 + delete_factory, 308 + upload_blob_factory, 309 + aggregate_fetcher, 310 + viewer_fetcher, 311 + ) 312 + ``` 313 + 314 + **Step 4: Commit** 315 + 316 + ```bash 317 + git add server/src/graphql_gleam.gleam 318 + git commit -m "feat(server): implement viewer fetcher with token validation" 319 + ``` 320 + 321 + --- 322 + 323 + ## Task 4: Update All Schema Builder Call Sites 324 + 325 + **Files:** 326 + - Modify: Any test files or other files that call `build_schema_with_subscriptions` 327 + 328 + **Step 1: Find all call sites** 329 + 330 + Run: `grep -r "build_schema_with_subscriptions" --include="*.gleam"` 331 + 332 + **Step 2: Update each call site to pass `option.None` for viewer_fetcher** 333 + 334 + For test files, add `option.None` as the last parameter: 335 + 336 + ```gleam 337 + db_schema_builder.build_schema_with_subscriptions( 338 + lexicons, 339 + fetcher, 340 + batch_fetcher, 341 + paginated_batch_fetcher, 342 + create_factory, 343 + update_factory, 344 + delete_factory, 345 + upload_blob_factory, 346 + aggregate_fetcher, 347 + option.None, // viewer_fetcher - not needed in tests 348 + ) 349 + ``` 350 + 351 + **Step 3: Verify build passes** 352 + 353 + Run: `gleam build` 354 + Expected: Build succeeds with no errors 355 + 356 + **Step 4: Commit** 357 + 358 + ```bash 359 + git add -A 360 + git commit -m "chore: update schema builder call sites for viewer_fetcher param" 361 + ``` 362 + 363 + --- 364 + 365 + ## Task 5: Update HTML Example to Use Viewer Query 366 + 367 + **Files:** 368 + - Modify: `examples/01-statusphere-html/index.html` 369 + 370 + **Step 1: Replace fetchUserProfile with fetchViewer** 371 + 372 + Find the `fetchUserProfile` function and replace with: 373 + 374 + ```javascript 375 + async function fetchViewer() { 376 + const query = ` 377 + query { 378 + viewer { 379 + did 380 + handle 381 + appBskyActorProfileByDid { 382 + displayName 383 + avatar { url } 384 + } 385 + } 386 + } 387 + `; 388 + 389 + const data = await graphqlQuery(query, {}, true); 390 + return data?.viewer; 391 + } 392 + ``` 393 + 394 + **Step 2: Update main() to use fetchViewer** 395 + 396 + Replace the profile fetching logic in main(): 397 + 398 + ```javascript 399 + // Render auth section 400 + if (isLoggedIn()) { 401 + try { 402 + const viewer = await fetchViewer(); 403 + if (viewer) { 404 + const profile = { 405 + did: viewer.did, 406 + actorHandle: viewer.handle, 407 + displayName: viewer.appBskyActorProfileByDid?.displayName, 408 + avatar: viewer.appBskyActorProfileByDid?.avatar, 409 + }; 410 + renderUserCard(profile); 411 + } else { 412 + renderUserCard(null); 413 + } 414 + } catch (error) { 415 + console.error('Failed to fetch viewer:', error); 416 + renderUserCard(null); 417 + } 418 + } else { 419 + renderLoginForm(); 420 + } 421 + ``` 422 + 423 + **Step 3: Remove unused getUserDid and storage of userDid** 424 + 425 + Remove references to `STORAGE_KEYS.userDid` and `getUserDid()` function if no longer needed. 426 + 427 + **Step 4: Commit** 428 + 429 + ```bash 430 + git add examples/01-statusphere-html/index.html 431 + git commit -m "feat(examples): use viewer query for authenticated user info" 432 + ``` 433 + 434 + --- 435 + 436 + ## Task 6: Verify End-to-End 437 + 438 + **Step 1: Build the project** 439 + 440 + Run: `gleam build` 441 + Expected: No errors 442 + 443 + **Step 2: Start the server** 444 + 445 + Run: `gleam run` or `make run` 446 + 447 + **Step 3: Test viewer query without auth** 448 + 449 + ```bash 450 + curl -X POST http://localhost:8080/graphql \ 451 + -H "Content-Type: application/json" \ 452 + -d '{"query": "{ viewer { did handle } }"}' 453 + ``` 454 + 455 + Expected: `{"data": {"viewer": null}}` 456 + 457 + **Step 4: Test viewer query with auth** 458 + 459 + ```bash 460 + curl -X POST http://localhost:8080/graphql \ 461 + -H "Content-Type: application/json" \ 462 + -H "Authorization: Bearer <valid-token>" \ 463 + -d '{"query": "{ viewer { did handle appBskyActorProfileByDid { displayName } } }"}' 464 + ``` 465 + 466 + Expected: `{"data": {"viewer": {"did": "did:plc:...", "handle": "user.bsky.social", "appBskyActorProfileByDid": {...}}}}` 467 + 468 + **Step 5: Test HTML example** 469 + 470 + 1. Start HTTP server: `npx serve examples/01-statusphere-html -p 3000` 471 + 2. Open http://127.0.0.1:3000 472 + 3. Login with OAuth 473 + 4. Verify user card shows correct name and handle 474 + 475 + **Step 6: Final commit if any fixes needed** 476 + 477 + ```bash 478 + git add -A 479 + git commit -m "fix: address viewer query issues found in testing" 480 + ``` 481 + 482 + --- 483 + 484 + ## Summary 485 + 486 + | Task | Description | Files | 487 + |------|-------------|-------| 488 + | 1 | Add Viewer type and query to schema builder | `lexicon_graphql/.../database.gleam` | 489 + | 2 | Update public API to accept ViewerFetcher | `lexicon_graphql/src/lexicon_graphql.gleam` | 490 + | 3 | Implement ViewerFetcher in server | `server/src/graphql_gleam.gleam` | 491 + | 4 | Update all schema builder call sites | Various test files | 492 + | 5 | Update HTML example to use viewer query | `examples/01-statusphere-html/index.html` | 493 + | 6 | End-to-end verification | N/A | 494 + 495 + **Total commits:** 5-6 focused commits following conventional commit format.
+94
examples/01-statusphere-html/README.md
··· 1 + # Statusphere HTML Example 2 + 3 + A single-file HTML example demonstrating quickslice's GraphQL API with OAuth authentication. 4 + 5 + ## Features 6 + 7 + - OAuth PKCE authentication flow 8 + - Post status updates (emoji) 9 + - View recent statuses from the network 10 + - Display user profiles 11 + 12 + ## Prerequisites 13 + 14 + 1. Quickslice server running at `http://localhost:8080` 15 + 2. A registered OAuth client 16 + 17 + ## Setup 18 + 19 + ### 1. Start Quickslice 20 + 21 + ```bash 22 + cd /path/to/quickslice 23 + make run 24 + ``` 25 + 26 + ### 2. Register an OAuth Client 27 + 28 + Via GraphQL mutation: 29 + 30 + ```graphql 31 + mutation { 32 + createOAuthClient(input: { 33 + name: "Statusphere HTML Example" 34 + redirectUris: ["http://localhost:3000/index.html"] 35 + tokenEndpointAuthMethod: "none" 36 + }) { 37 + clientId 38 + } 39 + } 40 + ``` 41 + 42 + Or use the quickslice admin UI. 43 + 44 + **Important:** Set the redirect URI to match where you'll serve this HTML file. 45 + 46 + ### 3. Serve the HTML File 47 + 48 + Option A - Python: 49 + ```bash 50 + cd examples/01-statusphere-html 51 + python -m http.server 3000 52 + # Open http://localhost:3000/index.html 53 + ``` 54 + 55 + Option B - Node.js: 56 + ```bash 57 + npx serve examples/01-statusphere-html -p 3000 58 + # Open http://localhost:3000/index.html 59 + ``` 60 + 61 + ### 4. Login 62 + 63 + 1. Enter your OAuth Client ID 64 + 2. Enter your Bluesky handle (e.g., `you.bsky.social`) 65 + 3. Click "Login with Bluesky" 66 + 4. Authorize the app on your AT Protocol PDS 67 + 5. You'll be redirected back and logged in 68 + 69 + ## Usage 70 + 71 + - Click any emoji to set your status 72 + - View recent statuses from the network 73 + - Click "Logout" to clear your session 74 + 75 + ## Security Notes 76 + 77 + - Tokens are stored in `sessionStorage` (cleared when tab closes) 78 + - No external dependencies - all code is inline 79 + - Uses PKCE for secure OAuth flow 80 + - CSP header restricts connections to localhost:8080 81 + 82 + ## Troubleshooting 83 + 84 + **"Failed to load statuses"** 85 + - Ensure quickslice server is running at localhost:8080 86 + - Check browser console for CORS errors 87 + 88 + **OAuth redirect fails** 89 + - Verify redirect URI matches exactly in OAuth client config 90 + - Check that the client ID is correct 91 + 92 + **Can't post status** 93 + - Ensure you're logged in (session may have expired) 94 + - Check browser console for error details
+941
examples/01-statusphere-html/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src http://localhost:8080; img-src 'self' https: data:;"> 7 + <title>Statusphere</title> 8 + <style> 9 + /* CSS Reset */ 10 + *, *::before, *::after { 11 + box-sizing: border-box; 12 + } 13 + * { 14 + margin: 0; 15 + } 16 + body { 17 + line-height: 1.5; 18 + -webkit-font-smoothing: antialiased; 19 + } 20 + input, button { 21 + font: inherit; 22 + } 23 + 24 + /* CSS Variables */ 25 + :root { 26 + --primary-500: #0078ff; 27 + --primary-400: #339dff; 28 + --primary-600: #0060cc; 29 + --gray-100: #f5f5f5; 30 + --gray-200: #e5e5e5; 31 + --gray-500: #737373; 32 + --gray-700: #404040; 33 + --gray-900: #171717; 34 + --border-color: #e5e5e5; 35 + --error-bg: #fef2f2; 36 + --error-border: #fecaca; 37 + --error-text: #dc2626; 38 + } 39 + 40 + /* Layout */ 41 + body { 42 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 43 + background: var(--gray-100); 44 + color: var(--gray-900); 45 + min-height: 100vh; 46 + padding: 2rem 1rem; 47 + } 48 + 49 + #app { 50 + max-width: 600px; 51 + margin: 0 auto; 52 + } 53 + 54 + /* Header */ 55 + header { 56 + text-align: center; 57 + margin-bottom: 2rem; 58 + } 59 + 60 + header h1 { 61 + font-size: 2.5rem; 62 + color: var(--primary-500); 63 + margin-bottom: 0.25rem; 64 + } 65 + 66 + .tagline { 67 + color: var(--gray-500); 68 + font-size: 1rem; 69 + } 70 + 71 + /* Cards */ 72 + .card { 73 + background: white; 74 + border-radius: 0.5rem; 75 + padding: 1.5rem; 76 + margin-bottom: 1rem; 77 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 78 + } 79 + 80 + /* Auth Section */ 81 + .login-form { 82 + display: flex; 83 + flex-direction: column; 84 + gap: 1rem; 85 + } 86 + 87 + .form-group { 88 + display: flex; 89 + flex-direction: column; 90 + gap: 0.25rem; 91 + } 92 + 93 + .form-group label { 94 + font-size: 0.875rem; 95 + font-weight: 500; 96 + color: var(--gray-700); 97 + } 98 + 99 + .form-group input { 100 + padding: 0.75rem; 101 + border: 1px solid var(--border-color); 102 + border-radius: 0.375rem; 103 + font-size: 1rem; 104 + } 105 + 106 + .form-group input:focus { 107 + outline: none; 108 + border-color: var(--primary-500); 109 + box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1); 110 + } 111 + 112 + .btn { 113 + padding: 0.75rem 1.5rem; 114 + border: none; 115 + border-radius: 0.375rem; 116 + font-size: 1rem; 117 + font-weight: 500; 118 + cursor: pointer; 119 + transition: background-color 0.15s; 120 + } 121 + 122 + .btn-primary { 123 + background: var(--primary-500); 124 + color: white; 125 + } 126 + 127 + .btn-primary:hover { 128 + background: var(--primary-600); 129 + } 130 + 131 + .btn-primary:disabled { 132 + background: var(--gray-200); 133 + color: var(--gray-500); 134 + cursor: not-allowed; 135 + } 136 + 137 + .btn-secondary { 138 + background: var(--gray-200); 139 + color: var(--gray-700); 140 + } 141 + 142 + .btn-secondary:hover { 143 + background: var(--border-color); 144 + } 145 + 146 + /* User Card */ 147 + .user-card { 148 + display: flex; 149 + align-items: center; 150 + justify-content: space-between; 151 + } 152 + 153 + .user-info { 154 + display: flex; 155 + align-items: center; 156 + gap: 0.75rem; 157 + } 158 + 159 + .user-avatar { 160 + width: 48px; 161 + height: 48px; 162 + border-radius: 50%; 163 + background: var(--gray-200); 164 + display: flex; 165 + align-items: center; 166 + justify-content: center; 167 + font-size: 1.5rem; 168 + } 169 + 170 + .user-avatar img { 171 + width: 100%; 172 + height: 100%; 173 + border-radius: 50%; 174 + object-fit: cover; 175 + } 176 + 177 + .user-name { 178 + font-weight: 600; 179 + } 180 + 181 + .user-handle { 182 + font-size: 0.875rem; 183 + color: var(--gray-500); 184 + } 185 + 186 + /* Emoji Picker */ 187 + .emoji-grid { 188 + display: grid; 189 + grid-template-columns: repeat(9, 1fr); 190 + gap: 0.5rem; 191 + } 192 + 193 + .emoji-btn { 194 + width: 100%; 195 + aspect-ratio: 1; 196 + font-size: 1.5rem; 197 + border: 2px solid var(--border-color); 198 + border-radius: 50%; 199 + background: white; 200 + cursor: pointer; 201 + transition: all 0.15s; 202 + display: flex; 203 + align-items: center; 204 + justify-content: center; 205 + } 206 + 207 + .emoji-btn:hover { 208 + background: rgba(0, 120, 255, 0.1); 209 + border-color: var(--primary-400); 210 + } 211 + 212 + .emoji-btn.selected { 213 + border-color: var(--primary-500); 214 + box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.2); 215 + } 216 + 217 + .emoji-btn:disabled { 218 + opacity: 0.5; 219 + cursor: not-allowed; 220 + } 221 + 222 + .emoji-btn:disabled:hover { 223 + background: white; 224 + border-color: var(--border-color); 225 + } 226 + 227 + /* Status Feed */ 228 + .feed-title { 229 + font-size: 1.125rem; 230 + font-weight: 600; 231 + margin-bottom: 1rem; 232 + color: var(--gray-700); 233 + } 234 + 235 + .status-list { 236 + list-style: none; 237 + padding: 0; 238 + } 239 + 240 + .status-item { 241 + position: relative; 242 + padding-left: 2rem; 243 + padding-bottom: 1.5rem; 244 + } 245 + 246 + .status-item::before { 247 + content: ""; 248 + position: absolute; 249 + left: 0.75rem; 250 + top: 1.5rem; 251 + bottom: 0; 252 + width: 2px; 253 + background: var(--border-color); 254 + } 255 + 256 + .status-item:last-child::before { 257 + display: none; 258 + } 259 + 260 + .status-item:last-child { 261 + padding-bottom: 0; 262 + } 263 + 264 + .status-emoji { 265 + position: absolute; 266 + left: 0; 267 + top: 0; 268 + font-size: 1.5rem; 269 + } 270 + 271 + .status-content { 272 + padding-top: 0.25rem; 273 + } 274 + 275 + .status-author { 276 + color: var(--primary-500); 277 + text-decoration: none; 278 + font-weight: 500; 279 + } 280 + 281 + .status-author:hover { 282 + text-decoration: underline; 283 + } 284 + 285 + .status-text { 286 + color: var(--gray-700); 287 + } 288 + 289 + .status-date { 290 + font-size: 0.875rem; 291 + color: var(--gray-500); 292 + } 293 + 294 + /* Error Banner */ 295 + #error-banner { 296 + position: fixed; 297 + top: 1rem; 298 + left: 50%; 299 + transform: translateX(-50%); 300 + background: var(--error-bg); 301 + border: 1px solid var(--error-border); 302 + color: var(--error-text); 303 + padding: 0.75rem 1rem; 304 + border-radius: 0.375rem; 305 + display: flex; 306 + align-items: center; 307 + gap: 0.75rem; 308 + max-width: 90%; 309 + z-index: 100; 310 + } 311 + 312 + #error-banner.hidden { 313 + display: none; 314 + } 315 + 316 + #error-banner button { 317 + background: none; 318 + border: none; 319 + color: var(--error-text); 320 + cursor: pointer; 321 + font-size: 1.25rem; 322 + line-height: 1; 323 + } 324 + 325 + /* Loading State */ 326 + .loading { 327 + text-align: center; 328 + color: var(--gray-500); 329 + padding: 2rem; 330 + } 331 + 332 + /* Responsive */ 333 + @media (max-width: 480px) { 334 + .emoji-grid { 335 + grid-template-columns: repeat(6, 1fr); 336 + } 337 + 338 + .emoji-btn { 339 + font-size: 1.25rem; 340 + } 341 + } 342 + 343 + /* Hidden utility */ 344 + .hidden { 345 + display: none !important; 346 + } 347 + </style> 348 + </head> 349 + <body> 350 + <div id="app"> 351 + <header> 352 + <h1>Statusphere</h1> 353 + <p class="tagline">Set your status on the Atmosphere</p> 354 + </header> 355 + <main> 356 + <div id="auth-section"></div> 357 + <div id="emoji-picker"></div> 358 + <div id="status-feed"></div> 359 + </main> 360 + <div id="error-banner" class="hidden"></div> 361 + </div> 362 + <script> 363 + // ============================================================================= 364 + // CONSTANTS 365 + // ============================================================================= 366 + 367 + const GRAPHQL_URL = 'http://localhost:8080/graphql'; 368 + const OAUTH_AUTHORIZE_URL = 'http://localhost:8080/oauth/authorize'; 369 + const OAUTH_TOKEN_URL = 'http://localhost:8080/oauth/token'; 370 + 371 + const EMOJIS = [ 372 + '👍', '👎', '💙', '😧', '😤', '🙃', '😉', '😎', '🤩', 373 + '🥳', '😭', '😱', '🥺', '😡', '💀', '🤖', '👻', '👽', 374 + '🎃', '🤡', '💩', '🔥', '⭐', '🌈', '🍕', '🎉', '💯' 375 + ]; 376 + 377 + const STORAGE_KEYS = { 378 + accessToken: 'qs_access_token', 379 + refreshToken: 'qs_refresh_token', 380 + userDid: 'qs_user_did', 381 + codeVerifier: 'qs_code_verifier', 382 + oauthState: 'qs_oauth_state', 383 + clientId: 'qs_client_id' 384 + }; 385 + 386 + // ============================================================================= 387 + // STORAGE UTILITIES 388 + // ============================================================================= 389 + 390 + const storage = { 391 + get(key) { 392 + return sessionStorage.getItem(key); 393 + }, 394 + set(key, value) { 395 + sessionStorage.setItem(key, value); 396 + }, 397 + remove(key) { 398 + sessionStorage.removeItem(key); 399 + }, 400 + clear() { 401 + Object.values(STORAGE_KEYS).forEach(key => sessionStorage.removeItem(key)); 402 + } 403 + }; 404 + 405 + // ============================================================================= 406 + // PKCE UTILITIES 407 + // ============================================================================= 408 + 409 + function base64UrlEncode(buffer) { 410 + const bytes = new Uint8Array(buffer); 411 + let binary = ''; 412 + for (let i = 0; i < bytes.length; i++) { 413 + binary += String.fromCharCode(bytes[i]); 414 + } 415 + return btoa(binary) 416 + .replace(/\+/g, '-') 417 + .replace(/\//g, '_') 418 + .replace(/=+$/, ''); 419 + } 420 + 421 + async function generateCodeVerifier() { 422 + const randomBytes = new Uint8Array(32); 423 + crypto.getRandomValues(randomBytes); 424 + return base64UrlEncode(randomBytes); 425 + } 426 + 427 + async function generateCodeChallenge(verifier) { 428 + const encoder = new TextEncoder(); 429 + const data = encoder.encode(verifier); 430 + const hash = await crypto.subtle.digest('SHA-256', data); 431 + return base64UrlEncode(hash); 432 + } 433 + 434 + function generateState() { 435 + const randomBytes = new Uint8Array(16); 436 + crypto.getRandomValues(randomBytes); 437 + return base64UrlEncode(randomBytes); 438 + } 439 + 440 + // ============================================================================= 441 + // OAUTH FUNCTIONS 442 + // ============================================================================= 443 + 444 + async function initiateLogin(clientId, handle) { 445 + const codeVerifier = await generateCodeVerifier(); 446 + const codeChallenge = await generateCodeChallenge(codeVerifier); 447 + const state = generateState(); 448 + 449 + // Store for callback 450 + storage.set(STORAGE_KEYS.codeVerifier, codeVerifier); 451 + storage.set(STORAGE_KEYS.oauthState, state); 452 + storage.set(STORAGE_KEYS.clientId, clientId); 453 + 454 + // Build redirect URI (current page without query params) 455 + const redirectUri = window.location.origin + window.location.pathname; 456 + 457 + // Build authorization URL 458 + const params = new URLSearchParams({ 459 + client_id: clientId, 460 + redirect_uri: redirectUri, 461 + response_type: 'code', 462 + code_challenge: codeChallenge, 463 + code_challenge_method: 'S256', 464 + state: state, 465 + login_hint: handle 466 + }); 467 + 468 + window.location.href = `${OAUTH_AUTHORIZE_URL}?${params.toString()}`; 469 + } 470 + 471 + async function handleOAuthCallback() { 472 + const params = new URLSearchParams(window.location.search); 473 + const code = params.get('code'); 474 + const state = params.get('state'); 475 + const error = params.get('error'); 476 + 477 + if (error) { 478 + throw new Error(`OAuth error: ${error} - ${params.get('error_description') || ''}`); 479 + } 480 + 481 + if (!code || !state) { 482 + return false; // Not a callback 483 + } 484 + 485 + // Verify state 486 + const storedState = storage.get(STORAGE_KEYS.oauthState); 487 + if (state !== storedState) { 488 + throw new Error('OAuth state mismatch - possible CSRF attack'); 489 + } 490 + 491 + // Get stored values 492 + const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier); 493 + const clientId = storage.get(STORAGE_KEYS.clientId); 494 + const redirectUri = window.location.origin + window.location.pathname; 495 + 496 + if (!codeVerifier || !clientId) { 497 + throw new Error('Missing OAuth session data'); 498 + } 499 + 500 + // Exchange code for tokens 501 + const tokenResponse = await fetch(OAUTH_TOKEN_URL, { 502 + method: 'POST', 503 + headers: { 504 + 'Content-Type': 'application/x-www-form-urlencoded' 505 + }, 506 + body: new URLSearchParams({ 507 + grant_type: 'authorization_code', 508 + code: code, 509 + redirect_uri: redirectUri, 510 + client_id: clientId, 511 + code_verifier: codeVerifier 512 + }) 513 + }); 514 + 515 + if (!tokenResponse.ok) { 516 + const errorData = await tokenResponse.json().catch(() => ({})); 517 + throw new Error(`Token exchange failed: ${errorData.error_description || tokenResponse.statusText}`); 518 + } 519 + 520 + const tokens = await tokenResponse.json(); 521 + 522 + // Store tokens 523 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 524 + if (tokens.refresh_token) { 525 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 526 + } 527 + 528 + // Extract DID from token response (sub claim) or we'll fetch it later 529 + if (tokens.sub) { 530 + storage.set(STORAGE_KEYS.userDid, tokens.sub); 531 + } 532 + 533 + // Clean up OAuth state 534 + storage.remove(STORAGE_KEYS.codeVerifier); 535 + storage.remove(STORAGE_KEYS.oauthState); 536 + 537 + // Clear URL params 538 + window.history.replaceState({}, document.title, window.location.pathname); 539 + 540 + return true; 541 + } 542 + 543 + function logout() { 544 + storage.clear(); 545 + window.location.reload(); 546 + } 547 + 548 + function isLoggedIn() { 549 + return !!storage.get(STORAGE_KEYS.accessToken); 550 + } 551 + 552 + function getAccessToken() { 553 + return storage.get(STORAGE_KEYS.accessToken); 554 + } 555 + 556 + function getUserDid() { 557 + return storage.get(STORAGE_KEYS.userDid); 558 + } 559 + 560 + // ============================================================================= 561 + // GRAPHQL UTILITIES 562 + // ============================================================================= 563 + 564 + async function graphqlQuery(query, variables = {}, requireAuth = false) { 565 + const headers = { 566 + 'Content-Type': 'application/json' 567 + }; 568 + 569 + if (requireAuth) { 570 + const token = getAccessToken(); 571 + if (!token) { 572 + throw new Error('Not authenticated'); 573 + } 574 + headers['Authorization'] = `Bearer ${token}`; 575 + } 576 + 577 + const response = await fetch(GRAPHQL_URL, { 578 + method: 'POST', 579 + headers, 580 + body: JSON.stringify({ query, variables }) 581 + }); 582 + 583 + if (!response.ok) { 584 + throw new Error(`GraphQL request failed: ${response.statusText}`); 585 + } 586 + 587 + const result = await response.json(); 588 + 589 + if (result.errors && result.errors.length > 0) { 590 + throw new Error(`GraphQL error: ${result.errors[0].message}`); 591 + } 592 + 593 + return result.data; 594 + } 595 + 596 + // ============================================================================= 597 + // DATA FETCHING 598 + // ============================================================================= 599 + 600 + async function fetchStatuses() { 601 + const query = ` 602 + query GetStatuses { 603 + xyzStatusphereStatus( 604 + first: 20 605 + sortBy: [{ field: "createdAt", direction: DESC }] 606 + ) { 607 + edges { 608 + node { 609 + uri 610 + did 611 + status 612 + createdAt 613 + appBskyActorProfileByDid { 614 + actorHandle 615 + displayName 616 + } 617 + } 618 + } 619 + } 620 + } 621 + `; 622 + 623 + const data = await graphqlQuery(query); 624 + return data.xyzStatusphereStatus?.edges?.map(e => e.node) || []; 625 + } 626 + 627 + async function fetchViewer() { 628 + const query = ` 629 + query { 630 + viewer { 631 + did 632 + handle 633 + appBskyActorProfileByDid { 634 + displayName 635 + avatar { url } 636 + } 637 + } 638 + } 639 + `; 640 + 641 + const data = await graphqlQuery(query, {}, true); 642 + return data?.viewer; 643 + } 644 + 645 + async function postStatus(emoji) { 646 + const mutation = ` 647 + mutation CreateStatus($status: String!, $createdAt: DateTime!) { 648 + createXyzStatusphereStatus( 649 + input: { status: $status, createdAt: $createdAt } 650 + ) { 651 + uri 652 + status 653 + createdAt 654 + } 655 + } 656 + `; 657 + 658 + const variables = { 659 + status: emoji, 660 + createdAt: new Date().toISOString() 661 + }; 662 + 663 + const data = await graphqlQuery(mutation, variables, true); 664 + return data.createXyzStatusphereStatus; 665 + } 666 + 667 + // ============================================================================= 668 + // UI RENDERING 669 + // ============================================================================= 670 + 671 + function showError(message) { 672 + const banner = document.getElementById('error-banner'); 673 + banner.innerHTML = ` 674 + <span>${escapeHtml(message)}</span> 675 + <button onclick="hideError()">&times;</button> 676 + `; 677 + banner.classList.remove('hidden'); 678 + } 679 + 680 + function hideError() { 681 + document.getElementById('error-banner').classList.add('hidden'); 682 + } 683 + 684 + function escapeHtml(text) { 685 + const div = document.createElement('div'); 686 + div.textContent = text; 687 + return div.innerHTML; 688 + } 689 + 690 + function formatDate(dateString) { 691 + const date = new Date(dateString); 692 + const now = new Date(); 693 + const isToday = date.toDateString() === now.toDateString(); 694 + 695 + if (isToday) { 696 + return 'today'; 697 + } 698 + 699 + return date.toLocaleDateString('en-US', { 700 + month: 'short', 701 + day: 'numeric', 702 + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined 703 + }); 704 + } 705 + 706 + function renderLoginForm() { 707 + const container = document.getElementById('auth-section'); 708 + const savedClientId = storage.get(STORAGE_KEYS.clientId) || ''; 709 + 710 + container.innerHTML = ` 711 + <div class="card"> 712 + <form class="login-form" onsubmit="handleLogin(event)"> 713 + <div class="form-group"> 714 + <label for="client-id">OAuth Client ID</label> 715 + <input 716 + type="text" 717 + id="client-id" 718 + placeholder="your-client-id" 719 + value="${escapeHtml(savedClientId)}" 720 + required 721 + > 722 + </div> 723 + <div class="form-group"> 724 + <label for="handle">Bluesky Handle</label> 725 + <input 726 + type="text" 727 + id="handle" 728 + placeholder="you.bsky.social" 729 + required 730 + > 731 + </div> 732 + <button type="submit" class="btn btn-primary">Login with Bluesky</button> 733 + </form> 734 + <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;"> 735 + Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a> 736 + </p> 737 + </div> 738 + `; 739 + } 740 + 741 + function renderUserCard(profile) { 742 + const container = document.getElementById('auth-section'); 743 + const displayName = profile?.displayName || 'User'; 744 + const handle = profile?.actorHandle || 'unknown'; 745 + const avatarUrl = profile?.avatar?.url; 746 + 747 + container.innerHTML = ` 748 + <div class="card user-card"> 749 + <div class="user-info"> 750 + <div class="user-avatar"> 751 + ${avatarUrl 752 + ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` 753 + : '👤'} 754 + </div> 755 + <div> 756 + <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> 757 + <div class="user-handle">@${escapeHtml(handle)}</div> 758 + </div> 759 + </div> 760 + <button class="btn btn-secondary" onclick="logout()">Logout</button> 761 + </div> 762 + `; 763 + } 764 + 765 + function renderEmojiPicker(currentStatus, enabled = true) { 766 + const container = document.getElementById('emoji-picker'); 767 + 768 + container.innerHTML = ` 769 + <div class="card"> 770 + <div class="emoji-grid"> 771 + ${EMOJIS.map(emoji => ` 772 + <button 773 + class="emoji-btn ${emoji === currentStatus ? 'selected' : ''}" 774 + onclick="selectStatus('${emoji}')" 775 + ${!enabled ? 'disabled' : ''} 776 + title="${enabled ? 'Set status' : 'Login to set status'}" 777 + > 778 + ${emoji} 779 + </button> 780 + `).join('')} 781 + </div> 782 + </div> 783 + `; 784 + } 785 + 786 + function renderStatusFeed(statuses) { 787 + const container = document.getElementById('status-feed'); 788 + 789 + if (statuses.length === 0) { 790 + container.innerHTML = ` 791 + <div class="card"> 792 + <p class="loading">No statuses yet. Be the first to post!</p> 793 + </div> 794 + `; 795 + return; 796 + } 797 + 798 + container.innerHTML = ` 799 + <div class="card"> 800 + <h2 class="feed-title">Recent Statuses</h2> 801 + <ul class="status-list"> 802 + ${statuses.map(status => { 803 + const handle = status.appBskyActorProfileByDid?.actorHandle || status.did; 804 + const displayHandle = handle.startsWith('did:') ? handle.substring(0, 20) + '...' : handle; 805 + const profileUrl = handle.startsWith('did:') 806 + ? `https://bsky.app/profile/${status.did}` 807 + : `https://bsky.app/profile/${handle}`; 808 + 809 + return ` 810 + <li class="status-item"> 811 + <span class="status-emoji">${status.status}</span> 812 + <div class="status-content"> 813 + <span class="status-text"> 814 + <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(displayHandle)}</a> 815 + is feeling ${status.status} 816 + </span> 817 + <div class="status-date">${formatDate(status.createdAt)}</div> 818 + </div> 819 + </li> 820 + `; 821 + }).join('')} 822 + </ul> 823 + </div> 824 + `; 825 + } 826 + 827 + function renderLoading(container) { 828 + document.getElementById(container).innerHTML = ` 829 + <div class="card"> 830 + <p class="loading">Loading...</p> 831 + </div> 832 + `; 833 + } 834 + 835 + // ============================================================================= 836 + // EVENT HANDLERS 837 + // ============================================================================= 838 + 839 + async function handleLogin(event) { 840 + event.preventDefault(); 841 + 842 + const clientId = document.getElementById('client-id').value.trim(); 843 + const handle = document.getElementById('handle').value.trim(); 844 + 845 + if (!clientId || !handle) { 846 + showError('Please enter both Client ID and Handle'); 847 + return; 848 + } 849 + 850 + try { 851 + await initiateLogin(clientId, handle); 852 + } catch (error) { 853 + showError(`Login failed: ${error.message}`); 854 + } 855 + } 856 + 857 + async function selectStatus(emoji) { 858 + if (!isLoggedIn()) { 859 + showError('Please login to set your status'); 860 + return; 861 + } 862 + 863 + try { 864 + // Disable buttons while posting 865 + document.querySelectorAll('.emoji-btn').forEach(btn => btn.disabled = true); 866 + 867 + await postStatus(emoji); 868 + 869 + // Refresh the page to show new status 870 + window.location.reload(); 871 + } catch (error) { 872 + showError(`Failed to post status: ${error.message}`); 873 + // Re-enable buttons 874 + document.querySelectorAll('.emoji-btn').forEach(btn => btn.disabled = false); 875 + } 876 + } 877 + 878 + // ============================================================================= 879 + // MAIN INITIALIZATION 880 + // ============================================================================= 881 + 882 + async function main() { 883 + try { 884 + // Check if this is an OAuth callback 885 + const isCallback = await handleOAuthCallback(); 886 + if (isCallback) { 887 + console.log('OAuth callback handled successfully'); 888 + } 889 + } catch (error) { 890 + showError(`Authentication failed: ${error.message}`); 891 + storage.clear(); 892 + } 893 + 894 + // Render auth section 895 + if (isLoggedIn()) { 896 + try { 897 + const viewer = await fetchViewer(); 898 + if (viewer) { 899 + const profile = { 900 + did: viewer.did, 901 + actorHandle: viewer.handle, 902 + displayName: viewer.appBskyActorProfileByDid?.displayName, 903 + avatar: viewer.appBskyActorProfileByDid?.avatar, 904 + }; 905 + renderUserCard(profile); 906 + } else { 907 + renderUserCard(null); 908 + } 909 + } catch (error) { 910 + console.error('Failed to fetch viewer:', error); 911 + renderUserCard(null); 912 + } 913 + } else { 914 + renderLoginForm(); 915 + } 916 + 917 + // Render emoji picker (enabled only if logged in) 918 + renderEmojiPicker(null, isLoggedIn()); 919 + 920 + // Fetch and render statuses 921 + renderLoading('status-feed'); 922 + try { 923 + const statuses = await fetchStatuses(); 924 + renderStatusFeed(statuses); 925 + } catch (error) { 926 + console.error('Failed to fetch statuses:', error); 927 + document.getElementById('status-feed').innerHTML = ` 928 + <div class="card"> 929 + <p class="loading" style="color: var(--error-text);"> 930 + Failed to load statuses. Is the quickslice server running at localhost:8080? 931 + </p> 932 + </div> 933 + `; 934 + } 935 + } 936 + 937 + // Run on page load 938 + main(); 939 + </script> 940 + </body> 941 + </html>
+6
lexicon_graphql/src/lexicon_graphql.gleam
··· 38 38 pub type Property = 39 39 types.Property 40 40 41 + // Re-export fetcher types 42 + pub type ViewerFetcher = 43 + db_schema_builder.ViewerFetcher 44 + 41 45 // Re-export main schema building functions 42 46 pub fn build_schema(lexicons: List(Lexicon)) { 43 47 schema_builder.build_schema(lexicons) ··· 53 57 delete_factory: Option(mutation_builder.ResolverFactory), 54 58 upload_blob_factory: Option(mutation_builder.UploadBlobResolverFactory), 55 59 aggregate_fetcher: Option(db_schema_builder.AggregateFetcher), 60 + viewer_fetcher: Option(ViewerFetcher), 56 61 ) { 57 62 db_schema_builder.build_schema_with_subscriptions( 58 63 lexicons, ··· 64 69 delete_factory, 65 70 upload_blob_factory, 66 71 aggregate_fetcher, 72 + viewer_fetcher, 67 73 ) 68 74 } 69 75
+260 -2
lexicon_graphql/src/lexicon_graphql/schema/database.gleam
··· 122 122 fn(String, AggregateParams) -> 123 123 Result(List(aggregate_types.AggregateResult), String) 124 124 125 + /// Fetches viewer info (did, handle) from auth token 126 + pub type ViewerFetcher = 127 + fn(String) -> Result(#(String, option.Option(String)), String) 128 + 125 129 /// Build a GraphQL schema from lexicons with database-backed resolvers 126 130 /// 127 131 /// The fetcher parameter should be a function that queries the database for records with pagination ··· 156 160 fetcher, 157 161 option.None, 158 162 dict.new(), 163 + batch_fetcher, 164 + paginated_batch_fetcher, 165 + option.None, 159 166 ) 160 167 161 168 // Build the mutation type with provided resolver factories ··· 190 197 delete_factory: option.Option(mutation_builder.ResolverFactory), 191 198 upload_blob_factory: option.Option(mutation_builder.UploadBlobResolverFactory), 192 199 aggregate_fetcher: option.Option(AggregateFetcher), 200 + viewer_fetcher: option.Option(ViewerFetcher), 193 201 ) -> Result(schema.Schema, String) { 194 202 case lexicons { 195 203 [] -> Error("Cannot build schema from empty lexicon list") ··· 210 218 fetcher, 211 219 aggregate_fetcher, 212 220 field_type_registry, 221 + batch_fetcher, 222 + paginated_batch_fetcher, 223 + viewer_fetcher, 213 224 ) 214 225 215 226 // Build the mutation type ··· 1463 1474 fetcher: RecordFetcher, 1464 1475 aggregate_fetcher: option.Option(AggregateFetcher), 1465 1476 field_type_registry: dict.Dict(String, dict.Dict(String, FieldType)), 1477 + batch_fetcher: option.Option(dataloader.BatchFetcher), 1478 + paginated_batch_fetcher: option.Option(dataloader.PaginatedBatchFetcher), 1479 + viewer_fetcher: option.Option(ViewerFetcher), 1466 1480 ) -> schema.Type { 1467 1481 // Build regular query fields 1468 1482 let query_fields = ··· 1558 1572 option.None -> [] 1559 1573 } 1560 1574 1561 - // Combine regular and aggregate query fields 1562 - let all_query_fields = list.append(query_fields, aggregate_query_fields) 1575 + // Build viewer query field if viewer_fetcher provided 1576 + let viewer_field = case viewer_fetcher { 1577 + option.Some(vf) -> { 1578 + // Build Viewer object type with did, handle, and DID-based joins to all record types 1579 + let viewer_base_fields = [ 1580 + schema.field( 1581 + "did", 1582 + schema.non_null(schema.string_type()), 1583 + "User DID", 1584 + fn(ctx) { 1585 + case ctx.data { 1586 + option.Some(value.Object(fields)) -> 1587 + case list.key_find(fields, "did") { 1588 + Ok(v) -> Ok(v) 1589 + Error(_) -> Ok(value.Null) 1590 + } 1591 + _ -> Ok(value.Null) 1592 + } 1593 + }, 1594 + ), 1595 + schema.field("handle", schema.string_type(), "User handle", fn(ctx) { 1596 + case ctx.data { 1597 + option.Some(value.Object(fields)) -> 1598 + case list.key_find(fields, "handle") { 1599 + Ok(v) -> Ok(v) 1600 + Error(_) -> Ok(value.Null) 1601 + } 1602 + _ -> Ok(value.Null) 1603 + } 1604 + }), 1605 + ] 1606 + 1607 + // Build DID-based join fields for all record types 1608 + // Check has_unique_did to determine if we return single object or connection 1609 + let did_join_fields = 1610 + list.filter_map(record_types, fn(record_type) { 1611 + case dict.get(object_types, record_type.nsid) { 1612 + Ok(target_object_type) -> { 1613 + let field_name = lowercase_first(record_type.type_name) <> "ByDid" 1614 + let target_nsid = record_type.nsid 1615 + 1616 + case record_type.meta.has_unique_did { 1617 + True -> { 1618 + // Unique DID join - returns single nullable object (e.g., profile) 1619 + Ok( 1620 + schema.field( 1621 + field_name, 1622 + target_object_type, 1623 + "DID join: " 1624 + <> record_type.nsid 1625 + <> " record for this user", 1626 + fn(ctx) { 1627 + case ctx.data { 1628 + option.Some(value.Object(fields)) -> 1629 + case list.key_find(fields, "did") { 1630 + Ok(value.String(did)) -> { 1631 + case batch_fetcher { 1632 + option.Some(bf) -> { 1633 + case 1634 + dataloader.batch_fetch_by_did( 1635 + [did], 1636 + target_nsid, 1637 + bf, 1638 + ) 1639 + { 1640 + Ok(results) -> 1641 + case dict.get(results, did) { 1642 + Ok([first, ..]) -> Ok(first) 1643 + _ -> Ok(value.Null) 1644 + } 1645 + Error(_) -> Ok(value.Null) 1646 + } 1647 + } 1648 + option.None -> Ok(value.Null) 1649 + } 1650 + } 1651 + _ -> Ok(value.Null) 1652 + } 1653 + _ -> Ok(value.Null) 1654 + } 1655 + }, 1656 + ), 1657 + ) 1658 + } 1659 + False -> { 1660 + // Non-unique DID join - returns connection (e.g., posts, statuses) 1661 + let edge_type = 1662 + connection.edge_type( 1663 + record_type.type_name, 1664 + target_object_type, 1665 + ) 1666 + let connection_type = 1667 + connection.connection_type(record_type.type_name, edge_type) 1668 + 1669 + // Build connection args with sortBy and where support 1670 + let sort_field_enum = build_sort_field_enum(record_type) 1671 + let where_input_type = build_where_input_type(record_type) 1672 + let connection_args = 1673 + lexicon_connection.lexicon_connection_args_with_field_enum_and_where( 1674 + record_type.type_name, 1675 + sort_field_enum, 1676 + where_input_type, 1677 + ) 1678 + 1679 + Ok( 1680 + schema.field_with_args( 1681 + field_name, 1682 + connection_type, 1683 + "DID join: " 1684 + <> record_type.nsid 1685 + <> " records for this user", 1686 + connection_args, 1687 + fn(ctx) { 1688 + case ctx.data { 1689 + option.Some(value.Object(fields)) -> 1690 + case list.key_find(fields, "did") { 1691 + Ok(value.String(did)) -> { 1692 + case paginated_batch_fetcher { 1693 + option.Some(fetcher) -> { 1694 + // Extract pagination params from context 1695 + let pagination_params = 1696 + extract_pagination_params(ctx) 1697 + 1698 + // Use paginated DataLoader to fetch records by DID 1699 + case 1700 + dataloader.batch_fetch_by_did_paginated( 1701 + did, 1702 + target_nsid, 1703 + pagination_params, 1704 + fetcher, 1705 + ) 1706 + { 1707 + Ok(batch_result) -> { 1708 + // Build edges from records with their cursors 1709 + let edges = 1710 + list.map( 1711 + batch_result.edges, 1712 + fn(edge_tuple) { 1713 + let #(record_value, record_cursor) = 1714 + edge_tuple 1715 + connection.Edge( 1716 + node: record_value, 1717 + cursor: record_cursor, 1718 + ) 1719 + }, 1720 + ) 1721 + 1722 + // Build PageInfo 1723 + let page_info = 1724 + connection.PageInfo( 1725 + has_next_page: batch_result.has_next_page, 1726 + has_previous_page: batch_result.has_previous_page, 1727 + start_cursor: case 1728 + list.first(edges) 1729 + { 1730 + Ok(edge) -> 1731 + option.Some(edge.cursor) 1732 + Error(_) -> option.None 1733 + }, 1734 + end_cursor: case list.last(edges) { 1735 + Ok(edge) -> 1736 + option.Some(edge.cursor) 1737 + Error(_) -> option.None 1738 + }, 1739 + ) 1740 + 1741 + // Build Connection 1742 + let conn = 1743 + connection.Connection( 1744 + edges: edges, 1745 + page_info: page_info, 1746 + total_count: batch_result.total_count, 1747 + ) 1748 + 1749 + Ok(connection.connection_to_value(conn)) 1750 + } 1751 + Error(_) -> { 1752 + Ok(empty_connection_value()) 1753 + } 1754 + } 1755 + } 1756 + option.None -> { 1757 + Ok(empty_connection_value()) 1758 + } 1759 + } 1760 + } 1761 + _ -> Ok(empty_connection_value()) 1762 + } 1763 + _ -> Ok(empty_connection_value()) 1764 + } 1765 + }, 1766 + ), 1767 + ) 1768 + } 1769 + } 1770 + } 1771 + Error(_) -> Error(Nil) 1772 + } 1773 + }) 1774 + 1775 + let viewer_fields = list.append(viewer_base_fields, did_join_fields) 1776 + let viewer_type = 1777 + schema.object_type("Viewer", "Authenticated user", viewer_fields) 1778 + 1779 + // Create the viewer query field 1780 + [ 1781 + schema.field( 1782 + "viewer", 1783 + viewer_type, 1784 + "The currently authenticated user, or null if not authenticated", 1785 + fn(ctx) { 1786 + // Extract auth_token from context 1787 + case ctx.data { 1788 + option.Some(value.Object(fields)) -> 1789 + case list.key_find(fields, "auth_token") { 1790 + Ok(value.String(token)) -> { 1791 + case vf(token) { 1792 + Ok(#(did, handle_opt)) -> { 1793 + let handle_value = case handle_opt { 1794 + option.Some(h) -> value.String(h) 1795 + option.None -> value.Null 1796 + } 1797 + Ok( 1798 + value.Object([ 1799 + #("did", value.String(did)), 1800 + #("handle", handle_value), 1801 + ]), 1802 + ) 1803 + } 1804 + Error(_) -> Ok(value.Null) 1805 + } 1806 + } 1807 + _ -> Ok(value.Null) 1808 + } 1809 + _ -> Ok(value.Null) 1810 + } 1811 + }, 1812 + ), 1813 + ] 1814 + } 1815 + option.None -> [] 1816 + } 1817 + 1818 + // Combine all query fields 1819 + let all_query_fields = 1820 + list.flatten([query_fields, aggregate_query_fields, viewer_field]) 1563 1821 1564 1822 schema.object_type("Query", "Root query type", all_query_fields) 1565 1823 }
+1
lexicon_graphql/test/sorting_test.gleam
··· 47 47 option.None, 48 48 option.None, 49 49 option.Some(aggregate_fetcher), 50 + option.None, 50 51 ) 51 52 { 52 53 Ok(s) -> s
+10
lexicon_graphql/test/subscription_schema_test.gleam
··· 57 57 None, 58 58 None, 59 59 // aggregate_fetcher 60 + None, 61 + // viewer_fetcher 60 62 ) 61 63 { 62 64 Ok(s) -> { ··· 107 109 None, 108 110 None, 109 111 // aggregate_fetcher 112 + None, 113 + // viewer_fetcher 110 114 ) 111 115 { 112 116 Ok(s) -> { ··· 179 183 None, 180 184 None, 181 185 // aggregate_fetcher 186 + None, 187 + // viewer_fetcher 182 188 ) 183 189 { 184 190 Ok(s) -> { ··· 244 250 None, 245 251 None, 246 252 // aggregate_fetcher 253 + None, 254 + // viewer_fetcher 247 255 ) 248 256 { 249 257 Ok(s) -> { ··· 330 338 None, 331 339 None, 332 340 // aggregate_fetcher 341 + None, 342 + // viewer_fetcher 333 343 ) 334 344 { 335 345 Ok(s) -> {
+1
lexicon_graphql/test/where_schema_test.gleam
··· 47 47 option.None, 48 48 option.None, 49 49 option.Some(aggregate_fetcher), 50 + option.None, 50 51 ) 51 52 { 52 53 Ok(s) -> s
+17
server/src/graphql_gleam.gleam
··· 1 1 /// Pure Gleam GraphQL Implementation 2 2 /// 3 3 /// This module provides GraphQL schema building and query execution 4 + import atproto_auth 4 5 import backfill 5 6 import config 6 7 import cursor ··· 446 447 |> result.map_error(fn(_) { "Failed to fetch aggregated records" }) 447 448 } 448 449 450 + // Step 5.6: Create a viewer fetcher function for authenticated user info 451 + let viewer_fetcher = fn(token: String) { 452 + case atproto_auth.verify_token(db, token) { 453 + Error(_) -> Error("Invalid or expired token") 454 + Ok(user_info) -> { 455 + // Get handle from actors table 456 + let handle = case actors.get(db, user_info.did) { 457 + Ok([actor, ..]) -> option.Some(actor.handle) 458 + _ -> option.None 459 + } 460 + Ok(#(user_info.did, handle)) 461 + } 462 + } 463 + } 464 + 449 465 // Step 6: Build schema with database-backed resolvers, mutations, and subscriptions 450 466 database.build_schema_with_subscriptions( 451 467 parsed_lexicons, ··· 457 473 delete_factory, 458 474 upload_blob_factory, 459 475 option.Some(aggregate_fetcher), 476 + option.Some(viewer_fetcher), 460 477 ) 461 478 } 462 479 }
+8 -2
server/src/server.gleam
··· 662 662 |> wisp.set_header("access-control-allow-origin", origin) 663 663 |> wisp.set_header("access-control-allow-credentials", "true") 664 664 |> wisp.set_header("access-control-allow-methods", "GET, POST, OPTIONS") 665 - |> wisp.set_header("access-control-allow-headers", "Content-Type") 665 + |> wisp.set_header( 666 + "access-control-allow-headers", 667 + "Content-Type, Authorization", 668 + ) 666 669 |> wisp.set_body(wisp.Text("")) 667 670 } 668 671 _ -> { ··· 671 674 |> wisp.set_header("access-control-allow-origin", origin) 672 675 |> wisp.set_header("access-control-allow-credentials", "true") 673 676 |> wisp.set_header("access-control-allow-methods", "GET, POST, OPTIONS") 674 - |> wisp.set_header("access-control-allow-headers", "Content-Type") 677 + |> wisp.set_header( 678 + "access-control-allow-headers", 679 + "Content-Type, Authorization", 680 + ) 675 681 } 676 682 } 677 683 }
+2
server/test/groupby_enum_validation_test.gleam
··· 55 55 option.None, 56 56 option.None, 57 57 option.Some(stub_aggregate_fetcher), 58 + option.None, 58 59 ) 59 60 60 61 // Introspection query to check if AppBskyFeedPostGroupByField enum exists ··· 203 204 option.None, 204 205 option.None, 205 206 option.Some(stub_aggregate_fetcher), 207 + option.None, 206 208 ) 207 209 208 210 // Introspection query to check AppBskyFeedPostGroupByFieldInput