Statusphere running on a slice ๐Ÿ•

๐Ÿ‘

+4
.gitignore
··· 1 + node_modules 2 + .env 3 + .env.prod 4 + *.db*
+19
deno.json
··· 1 + { 2 + "tasks": { 3 + "start": "deno run -A --unstable-kv --env-file=.env src/main.ts", 4 + "dev": "deno run -A --unstable-kv --env-file=.env --watch src/main.ts" 5 + }, 6 + "compilerOptions": { 7 + "jsx": "precompile", 8 + "jsxImportSource": "preact" 9 + }, 10 + "imports": { 11 + "@slices/oauth": "jsr:@slices/oauth@^0.3.2", 12 + "@slices/session": "jsr:@slices/session@^0.2.0", 13 + "preact": "npm:preact@^10.27.1", 14 + "preact-render-to-string": "npm:preact-render-to-string@^6.5.13", 15 + "@std/http": "jsr:@std/http@^1.0.20", 16 + "typed-htmx": "npm:typed-htmx@^0.3.1" 17 + }, 18 + "nodeModulesDir": "auto" 19 + }
+189
deno.lock
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@slices/oauth@~0.3.2": "0.3.2", 5 + "jsr:@slices/session@0.2": "0.2.0", 6 + "jsr:@std/cli@^1.0.21": "1.0.21", 7 + "jsr:@std/encoding@^1.0.10": "1.0.10", 8 + "jsr:@std/fmt@^1.0.8": "1.0.8", 9 + "jsr:@std/fs@^1.0.19": "1.0.19", 10 + "jsr:@std/html@^1.0.4": "1.0.4", 11 + "jsr:@std/http@^1.0.20": "1.0.20", 12 + "jsr:@std/internal@^1.0.10": "1.0.10", 13 + "jsr:@std/media-types@^1.1.0": "1.1.0", 14 + "jsr:@std/net@^1.0.4": "1.0.5", 15 + "jsr:@std/path@^1.1.1": "1.1.2", 16 + "jsr:@std/streams@^1.0.10": "1.0.11", 17 + "npm:@types/node@*": "22.15.15", 18 + "npm:pg@^8.16.3": "8.16.3", 19 + "npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1", 20 + "npm:preact@^10.27.1": "10.27.1", 21 + "npm:typed-htmx@~0.3.1": "0.3.1" 22 + }, 23 + "jsr": { 24 + "@slices/oauth@0.3.2": { 25 + "integrity": "51feaa6be538a61a3278ee7f1d264ed937187d09da2be1f0a2a837128df82526" 26 + }, 27 + "@slices/session@0.2.0": { 28 + "integrity": "5245be75a1de2e397ba96b2ce80179cedaa95a33e2edf1e16c348d353271ff57", 29 + "dependencies": [ 30 + "jsr:@slices/oauth", 31 + "npm:pg" 32 + ] 33 + }, 34 + "@std/cli@1.0.21": { 35 + "integrity": "cd25b050bdf6282e321854e3822bee624f07aca7636a3a76d95f77a3a919ca2a" 36 + }, 37 + "@std/encoding@1.0.10": { 38 + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 39 + }, 40 + "@std/fmt@1.0.8": { 41 + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" 42 + }, 43 + "@std/fs@1.0.19": { 44 + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06" 45 + }, 46 + "@std/html@1.0.4": { 47 + "integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e" 48 + }, 49 + "@std/http@1.0.20": { 50 + "integrity": "b5cc33fc001bccce65ed4c51815668c9891c69ccd908295997e983d8f56070a1", 51 + "dependencies": [ 52 + "jsr:@std/cli", 53 + "jsr:@std/encoding", 54 + "jsr:@std/fmt", 55 + "jsr:@std/fs", 56 + "jsr:@std/html", 57 + "jsr:@std/media-types", 58 + "jsr:@std/net", 59 + "jsr:@std/path", 60 + "jsr:@std/streams" 61 + ] 62 + }, 63 + "@std/internal@1.0.10": { 64 + "integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7" 65 + }, 66 + "@std/media-types@1.1.0": { 67 + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" 68 + }, 69 + "@std/net@1.0.5": { 70 + "integrity": "b759d8c5e17d997e164af6379d57764668c6714f30109685eec0fd5e194d501a" 71 + }, 72 + "@std/path@1.1.2": { 73 + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", 74 + "dependencies": [ 75 + "jsr:@std/internal" 76 + ] 77 + }, 78 + "@std/streams@1.0.11": { 79 + "integrity": "db583d27e28d133f389f1eec318cffdf4998305e5134c1d4b1c56b361cee6018" 80 + } 81 + }, 82 + "npm": { 83 + "@types/node@22.15.15": { 84 + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", 85 + "dependencies": [ 86 + "undici-types" 87 + ] 88 + }, 89 + "pg-cloudflare@1.2.7": { 90 + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==" 91 + }, 92 + "pg-connection-string@2.9.1": { 93 + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==" 94 + }, 95 + "pg-int8@1.0.1": { 96 + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" 97 + }, 98 + "pg-pool@3.10.1_pg@8.16.3": { 99 + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", 100 + "dependencies": [ 101 + "pg" 102 + ] 103 + }, 104 + "pg-protocol@1.10.3": { 105 + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" 106 + }, 107 + "pg-types@2.2.0": { 108 + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", 109 + "dependencies": [ 110 + "pg-int8", 111 + "postgres-array", 112 + "postgres-bytea", 113 + "postgres-date", 114 + "postgres-interval" 115 + ] 116 + }, 117 + "pg@8.16.3": { 118 + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", 119 + "dependencies": [ 120 + "pg-connection-string", 121 + "pg-pool", 122 + "pg-protocol", 123 + "pg-types", 124 + "pgpass" 125 + ], 126 + "optionalDependencies": [ 127 + "pg-cloudflare" 128 + ] 129 + }, 130 + "pgpass@1.0.5": { 131 + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", 132 + "dependencies": [ 133 + "split2" 134 + ] 135 + }, 136 + "postgres-array@2.0.0": { 137 + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" 138 + }, 139 + "postgres-bytea@1.0.0": { 140 + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==" 141 + }, 142 + "postgres-date@1.0.7": { 143 + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" 144 + }, 145 + "postgres-interval@1.2.0": { 146 + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", 147 + "dependencies": [ 148 + "xtend" 149 + ] 150 + }, 151 + "preact-render-to-string@6.5.13_preact@10.27.1": { 152 + "integrity": "sha512-iGPd+hKPMFKsfpR2vL4kJ6ZPcFIoWZEcBf0Dpm3zOpdVvj77aY8RlLiQji5OMrngEyaxGogeakTb54uS2FvA6w==", 153 + "dependencies": [ 154 + "preact" 155 + ] 156 + }, 157 + "preact@10.27.1": { 158 + "integrity": "sha512-V79raXEWch/rbqoNc7nT9E4ep7lu+mI3+sBmfRD4i1M73R3WLYcCtdI0ibxGVf4eQL8ZIz2nFacqEC+rmnOORQ==" 159 + }, 160 + "split2@4.2.0": { 161 + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" 162 + }, 163 + "typed-html@3.0.1": { 164 + "integrity": "sha512-JKCM9zTfPDuPqQqdGZBWSEiItShliKkBFg5c6yOR8zth43v763XkAzTWaOlVqc0Y6p9ee8AaAbipGfUnCsYZUA==" 165 + }, 166 + "typed-htmx@0.3.1": { 167 + "integrity": "sha512-6WSPsukTIOEMsVbx5wzgVSvldLmgBUVcFIm2vJlBpRPtcbDOGC5y1IYrCWNX1yUlNsrv1Ngcw4gGM8jsPyNV7w==", 168 + "dependencies": [ 169 + "typed-html" 170 + ] 171 + }, 172 + "undici-types@6.21.0": { 173 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" 174 + }, 175 + "xtend@4.0.2": { 176 + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" 177 + } 178 + }, 179 + "workspace": { 180 + "dependencies": [ 181 + "jsr:@slices/oauth@~0.3.2", 182 + "jsr:@slices/session@0.2", 183 + "jsr:@std/http@^1.0.20", 184 + "npm:preact-render-to-string@^6.5.13", 185 + "npm:preact@^10.27.1", 186 + "npm:typed-htmx@~0.3.1" 187 + ] 188 + } 189 + }
+123
scripts/register-oauth-client.sh
··· 1 + #!/bin/bash 2 + 3 + # OAuth Dynamic Client Registration Script for AT Protocol 4 + # Registers a new OAuth client with the AIP server per RFC 7591 5 + # Usage: bash scripts/register-oauth-client.sh 6 + 7 + set -e # Exit on any error 8 + 9 + # Configuration 10 + AIP_BASE="${AIP_BASE_URL:-http://localhost:8081}" 11 + CLIENT_BASE_URL="${CLIENT_BASE_URL:-http://localhost:8080}" 12 + CLIENT_NAME="${CLIENT_NAME:-Slice AT Proto Client}" 13 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 14 + ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" 15 + CONFIG_FILE="$ROOT_DIR/.env" 16 + 17 + echo "๐Ÿš€ OAuth Dynamic Client Registration for Slice" 18 + echo "AIP Server: $AIP_BASE" 19 + echo "Client Base URL: $CLIENT_BASE_URL" 20 + echo "Client Name: $CLIENT_NAME" 21 + echo 22 + 23 + # Check if client is already registered 24 + if [ -f "$CONFIG_FILE" ]; then 25 + echo "โš ๏ธ Existing OAuth client configuration found at $CONFIG_FILE" 26 + echo -n "Do you want to register a new client? This will overwrite the existing config. (y/N): " 27 + read -r OVERWRITE 28 + if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then 29 + echo "โŒ Registration cancelled" 30 + exit 1 31 + fi 32 + fi 33 + 34 + echo "๐Ÿ” Using OAuth registration endpoint..." 35 + REGISTRATION_ENDPOINT="$AIP_BASE/oauth/clients/register" 36 + 37 + echo "โœ… Registration endpoint: $REGISTRATION_ENDPOINT" 38 + echo 39 + 40 + # Create client registration request 41 + echo "๐Ÿ“ Creating client registration request..." 42 + REDIRECT_URI="$CLIENT_BASE_URL/oauth/callback" 43 + 44 + REGISTRATION_REQUEST=$(cat <<EOF 45 + { 46 + "client_name": "$CLIENT_NAME", 47 + "redirect_uris": ["$REDIRECT_URI"], 48 + "scope": "atproto:atproto atproto:transition:generic openid profile email", 49 + "grant_types": ["authorization_code", "refresh_token"], 50 + "response_types": ["code"], 51 + "token_endpoint_auth_method": "client_secret_basic" 52 + } 53 + EOF 54 + ) 55 + 56 + echo "Registration request:" 57 + echo "$REGISTRATION_REQUEST" | jq '.' 2>/dev/null || echo "$REGISTRATION_REQUEST" 58 + echo 59 + 60 + # Register the client 61 + echo "๐Ÿ”„ Registering client with AIP server..." 62 + REGISTRATION_RESPONSE=$(curl -s -X POST "$REGISTRATION_ENDPOINT" \ 63 + -H "Content-Type: application/json" \ 64 + -d "$REGISTRATION_REQUEST" || { 65 + echo "โŒ Failed to register client with AIP server" 66 + echo "Make sure the AIP server is running at $AIP_BASE" 67 + exit 1 68 + }) 69 + 70 + echo "Registration response:" 71 + echo "$REGISTRATION_RESPONSE" | jq '.' 2>/dev/null || echo "$REGISTRATION_RESPONSE" 72 + echo 73 + 74 + # Extract client credentials 75 + CLIENT_ID=$(echo "$REGISTRATION_RESPONSE" | grep -o '"client_id":"[^"]*' | cut -d'"' -f4) 76 + CLIENT_SECRET=$(echo "$REGISTRATION_RESPONSE" | grep -o '"client_secret":"[^"]*' | cut -d'"' -f4) 77 + 78 + if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then 79 + echo "โŒ Failed to extract client credentials from registration response" 80 + echo "Expected client_id and client_secret in response" 81 + echo "Response was: $REGISTRATION_RESPONSE" 82 + exit 1 83 + fi 84 + 85 + echo "โœ… Client registered successfully!" 86 + echo "Client ID: $CLIENT_ID" 87 + echo "Client Secret: [REDACTED]" 88 + echo 89 + 90 + # Save credentials to .env.oauth file 91 + echo "๐Ÿ’พ Saving client credentials to $CONFIG_FILE..." 92 + cat > "$CONFIG_FILE" <<EOF 93 + # OAuth Client Credentials for Slice AT Proto Client 94 + # Generated on $(date) 95 + # AIP Server: $AIP_BASE 96 + 97 + OAUTH_CLIENT_ID="$CLIENT_ID" 98 + OAUTH_CLIENT_SECRET="$CLIENT_SECRET" 99 + OAUTH_REDIRECT_URI="$REDIRECT_URI" 100 + OAUTH_AIP_BASE_URL="$AIP_BASE" 101 + EOF 102 + 103 + echo "โœ… Client registration complete!" 104 + echo 105 + echo "๐Ÿ“‹ Summary:" 106 + echo " - Client ID: $CLIENT_ID" 107 + echo " - Client Name: $CLIENT_NAME" 108 + echo " - Redirect URI: $REDIRECT_URI" 109 + echo " - Scopes: atproto:atproto atproto:transition:generic openid profile email" 110 + echo " - Config saved to: $CONFIG_FILE" 111 + echo 112 + echo "๐Ÿ”ง Environment variables saved to $CONFIG_FILE:" 113 + echo " OAUTH_CLIENT_ID" 114 + echo " OAUTH_CLIENT_SECRET" 115 + echo " OAUTH_REDIRECT_URI" 116 + echo " OAUTH_AIP_BASE_URL" 117 + echo 118 + echo "๐Ÿ’ก To use these credentials in your application:" 119 + echo " source $CONFIG_FILE" 120 + echo " # Or load them in your .env file" 121 + echo 122 + echo "๐Ÿงช To test the OAuth flow, you can now use the registered credentials" 123 + echo " with your AtProtoClient in TypeScript/Deno."
+34
src/api.ts
··· 1 + import { atprotoClient } from "./config.ts"; 2 + import { HydratedStatus } from "./types.ts"; 3 + 4 + export async function fetchStatusesWithAuthors(): Promise<HydratedStatus[]> { 5 + try { 6 + const records = await atprotoClient.xyz.statusphere.status.listRecords({ 7 + sort: "createdAt:desc", 8 + }); 9 + const statuses = records.records || []; 10 + 11 + // Get unique DIDs from statuses 12 + const dids = [...new Set(statuses.map((s) => s.did))]; 13 + 14 + // Fetch actors for all authors of the statuses 15 + const actorsResponse = await atprotoClient.getActors({ 16 + dids, 17 + }); 18 + 19 + // Create a map of DID to actor info 20 + const actorMap = new Map(); 21 + actorsResponse.actors?.forEach((actor) => { 22 + actorMap.set(actor.did, actor); 23 + }); 24 + 25 + // Hydrate statuses with author info 26 + return statuses.map((status) => ({ 27 + ...status, 28 + author: actorMap.get(status.did), 29 + })); 30 + } catch (error) { 31 + console.error("Error fetching statuses:", error); 32 + return []; 33 + } 34 + }
+31
src/components/App.tsx
··· 1 + import { HomePage } from "./HomePage.tsx"; 2 + import { LoginPage } from "./LoginPage.tsx"; 3 + import { Layout } from "./Layout.tsx"; 4 + import { HydratedStatus } from "../types.ts"; 5 + 6 + interface AppProps { 7 + currentUser: { 8 + isAuthenticated: boolean; 9 + handle?: string; 10 + sub?: string; 11 + }; 12 + statuses?: HydratedStatus[]; 13 + page?: string; 14 + error?: string; 15 + } 16 + 17 + export function App({ currentUser, statuses = [], page, error }: AppProps) { 18 + if (page === "login") { 19 + return ( 20 + <Layout title="Login - Statusphere" currentUser={currentUser}> 21 + <LoginPage error={error} /> 22 + </Layout> 23 + ); 24 + } 25 + 26 + return ( 27 + <Layout title="Statusphere" currentUser={currentUser}> 28 + <HomePage currentUser={currentUser} statuses={statuses} /> 29 + </Layout> 30 + ); 31 + }
+118
src/components/HomePage.tsx
··· 1 + import { HydratedStatus } from "../types.ts"; 2 + 3 + interface HomePageProps { 4 + currentUser: { 5 + isAuthenticated: boolean; 6 + handle?: string; 7 + }; 8 + statuses: HydratedStatus[]; 9 + } 10 + 11 + const statusOptions = [ 12 + "๐Ÿ‘", 13 + "๐Ÿ‘Ž", 14 + "๐Ÿ’™", 15 + "โค๏ธ", 16 + "๐Ÿ˜", 17 + "๐Ÿคฉ", 18 + "๐Ÿ˜Ž", 19 + "๐Ÿค”", 20 + "๐Ÿ˜ด", 21 + "๐ŸŽ‰", 22 + "๐Ÿ”ฅ", 23 + "๐Ÿ’ฏ", 24 + "โœจ", 25 + "โญ", 26 + "๐ŸŒŸ", 27 + "๐Ÿš€", 28 + "๐Ÿ’ช", 29 + "๐Ÿ™Œ", 30 + "๐Ÿ‘", 31 + "๐Ÿค", 32 + "๐Ÿค—", 33 + "๐Ÿ˜Š", 34 + "๐Ÿ˜„", 35 + "๐Ÿ˜†", 36 + "๐Ÿ˜‚", 37 + "๐Ÿฅณ", 38 + "๐Ÿคฏ", 39 + "๐Ÿ˜ฑ", 40 + "๐Ÿ˜ญ", 41 + "๐Ÿ’”", 42 + "๐Ÿ™", 43 + "๐Ÿคž", 44 + "๐Ÿ‘€", 45 + "๐Ÿง ", 46 + "๐Ÿ’ก", 47 + ]; 48 + 49 + export function HomePage({ currentUser, statuses }: HomePageProps) { 50 + return ( 51 + <div> 52 + <div class="card"> 53 + <h2>How are you feeling?</h2> 54 + <div class="status-options"> 55 + {statusOptions.map((emoji) => ( 56 + <button 57 + type="button" 58 + key={emoji} 59 + class="status-option" 60 + title={`Set status to ${emoji}`} 61 + hx-post="/status" 62 + hx-vals={`{"status": "${emoji}"}`} 63 + hx-target="#status-timeline" 64 + hx-swap="outerHTML" 65 + hx-indicator="#loading" 66 + > 67 + {emoji} 68 + </button> 69 + ))} 70 + </div> 71 + <div id="loading" class="htmx-indicator"> 72 + Setting status... 73 + </div> 74 + </div> 75 + 76 + <div class="card"> 77 + <h3>Recent Statuses</h3> 78 + <StatusTimeline statuses={statuses} /> 79 + </div> 80 + </div> 81 + ); 82 + } 83 + 84 + interface StatusTimelineProps { 85 + statuses: HydratedStatus[]; 86 + } 87 + 88 + export function StatusTimeline({ statuses }: StatusTimelineProps) { 89 + return ( 90 + <div id="status-timeline"> 91 + {statuses.length === 0 ? ( 92 + <div class="status-timeline"> 93 + <p class="status-meta">No statuses yet. Be the first to share!</p> 94 + </div> 95 + ) : ( 96 + <div class="status-timeline"> 97 + {statuses.map((status) => { 98 + const authorHandle = status.author?.handle || 99 + status.did?.split(":").pop() || 100 + "unknown"; 101 + const createdAt = new Date(status.value.createdAt).toLocaleString(); 102 + 103 + return ( 104 + <div key={status.uri} class="status-item"> 105 + <div class="status-emoji">{status.value.status}</div> 106 + <div> 107 + <div class="status-meta"> 108 + @{authorHandle} โ€ข {createdAt} 109 + </div> 110 + </div> 111 + </div> 112 + ); 113 + })} 114 + </div> 115 + )} 116 + </div> 117 + ); 118 + }
+320
src/components/Layout.tsx
··· 1 + import type { ComponentChildren } from "preact"; 2 + 3 + interface LayoutProps { 4 + title: string; 5 + currentUser: { 6 + isAuthenticated: boolean; 7 + handle?: string; 8 + }; 9 + children: ComponentChildren; 10 + } 11 + 12 + export function Layout({ title, currentUser, children }: LayoutProps) { 13 + return ( 14 + <html lang="en"> 15 + <head> 16 + <meta charset="UTF-8" /> 17 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 18 + <title>{title}</title> 19 + <script src="https://unpkg.com/htmx.org@2.0.2"></script> 20 + <style dangerouslySetInnerHTML={{ __html: styles }} /> 21 + </head> 22 + <body> 23 + <nav class="nav"> 24 + <div class="nav-container"> 25 + <a href="/" class="nav-brand"> 26 + Statusphere 27 + </a> 28 + <div class="nav-user"> 29 + {currentUser.isAuthenticated ? ( 30 + <div class="user-info"> 31 + <span class="handle">@{currentUser.handle}</span> 32 + <form method="post" action="/logout"> 33 + <button type="submit" class="btn btn-secondary"> 34 + Logout 35 + </button> 36 + </form> 37 + </div> 38 + ) : ( 39 + <a href="/login" class="btn btn-primary"> 40 + Login 41 + </a> 42 + )} 43 + </div> 44 + </div> 45 + </nav> 46 + <main class="container"> 47 + {children} 48 + </main> 49 + </body> 50 + </html> 51 + ); 52 + } 53 + 54 + const styles = ` 55 + /* Josh's CSS Reset */ 56 + *, *::before, *::after { box-sizing: border-box; } 57 + * { margin: 0; } 58 + body { line-height: 1.5; -webkit-font-smoothing: antialiased; } 59 + img, picture, video, canvas, svg { display: block; max-width: 100%; } 60 + input, button, textarea, select { font: inherit; } 61 + p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; } 62 + 63 + /* Custom Properties */ 64 + :root { 65 + --primary-50: #eff6ff; 66 + --primary-100: #dbeafe; 67 + --primary-200: #bfdbfe; 68 + --primary-300: #93c5fd; 69 + --primary-400: #60a5fa; 70 + --primary-500: #3b82f6; 71 + --primary-600: #2563eb; 72 + --primary-700: #1d4ed8; 73 + --primary-800: #1e40af; 74 + --primary-900: #1e3a8a; 75 + 76 + --gray-50: #f9fafb; 77 + --gray-100: #f3f4f6; 78 + --gray-200: #e5e7eb; 79 + --gray-300: #d1d5db; 80 + --gray-400: #9ca3af; 81 + --gray-500: #6b7280; 82 + --gray-600: #4b5563; 83 + --gray-700: #374151; 84 + --gray-800: #1f2937; 85 + --gray-900: #111827; 86 + 87 + --error-500: #ef4444; 88 + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); 89 + } 90 + 91 + body { 92 + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 93 + background: var(--gray-50); 94 + color: var(--gray-900); 95 + } 96 + 97 + .nav { 98 + background: white; 99 + border-bottom: 1px solid var(--gray-200); 100 + padding: 1rem 0; 101 + } 102 + 103 + .nav-container { 104 + max-width: 600px; 105 + margin: 0 auto; 106 + padding: 0 1rem; 107 + display: flex; 108 + justify-content: space-between; 109 + align-items: center; 110 + } 111 + 112 + .nav-brand { 113 + font-size: 1.25rem; 114 + font-weight: 600; 115 + color: var(--primary-600); 116 + text-decoration: none; 117 + } 118 + 119 + .nav-user { 120 + display: flex; 121 + align-items: center; 122 + gap: 1rem; 123 + } 124 + 125 + .user-info { 126 + display: flex; 127 + align-items: center; 128 + gap: 1rem; 129 + } 130 + 131 + .handle { 132 + color: var(--gray-600); 133 + } 134 + 135 + .container { 136 + max-width: 600px; 137 + margin: 0 auto; 138 + padding: 2rem 1rem; 139 + } 140 + 141 + .btn { 142 + padding: 0.5rem 1rem; 143 + border: none; 144 + border-radius: 0.375rem; 145 + font-size: 0.875rem; 146 + font-weight: 500; 147 + cursor: pointer; 148 + text-decoration: none; 149 + display: inline-flex; 150 + align-items: center; 151 + justify-content: center; 152 + transition: all 0.15s ease-in-out; 153 + } 154 + 155 + .btn-primary { 156 + background: var(--primary-600); 157 + color: white; 158 + } 159 + 160 + .btn-primary:hover { 161 + background: var(--primary-700); 162 + box-shadow: var(--shadow); 163 + } 164 + 165 + .btn-secondary { 166 + background: var(--gray-100); 167 + color: var(--gray-700); 168 + border: 1px solid var(--gray-300); 169 + } 170 + 171 + .btn-secondary:hover { 172 + background: var(--gray-200); 173 + } 174 + 175 + .card { 176 + background: white; 177 + border: 1px solid var(--gray-200); 178 + border-radius: 0.5rem; 179 + padding: 1.5rem; 180 + box-shadow: var(--shadow); 181 + margin-bottom: 1rem; 182 + } 183 + 184 + .form-group { 185 + margin-bottom: 1rem; 186 + } 187 + 188 + .form-label { 189 + display: block; 190 + font-weight: 500; 191 + margin-bottom: 0.25rem; 192 + color: var(--gray-700); 193 + } 194 + 195 + .form-input { 196 + width: 100%; 197 + padding: 0.5rem 0.75rem; 198 + border: 1px solid var(--gray-300); 199 + border-radius: 0.375rem; 200 + font-size: 0.875rem; 201 + } 202 + 203 + .form-input:focus { 204 + outline: none; 205 + border-color: var(--primary-500); 206 + box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1); 207 + } 208 + 209 + .status-options { 210 + display: flex; 211 + flex-wrap: wrap; 212 + gap: 0.5rem; 213 + margin: 1rem 0; 214 + } 215 + 216 + .status-option { 217 + width: 3rem; 218 + height: 3rem; 219 + border: 2px solid var(--gray-200); 220 + border-radius: 50%; 221 + background: white; 222 + display: flex; 223 + align-items: center; 224 + justify-content: center; 225 + font-size: 1.5rem; 226 + cursor: pointer; 227 + transition: all 0.15s ease-in-out; 228 + } 229 + 230 + .status-option:hover { 231 + border-color: var(--primary-300); 232 + box-shadow: var(--shadow); 233 + } 234 + 235 + .status-timeline { 236 + margin-top: 2rem; 237 + } 238 + 239 + .status-item { 240 + display: flex; 241 + align-items: center; 242 + gap: 1rem; 243 + padding: 1rem 0; 244 + border-bottom: 1px solid var(--gray-100); 245 + } 246 + 247 + .status-item:last-child { 248 + border-bottom: none; 249 + } 250 + 251 + .status-emoji { 252 + font-size: 1.5rem; 253 + width: 2.5rem; 254 + text-align: center; 255 + } 256 + 257 + .status-meta { 258 + color: var(--gray-600); 259 + font-size: 0.875rem; 260 + } 261 + 262 + .error { 263 + color: var(--error-500); 264 + font-size: 0.875rem; 265 + margin-top: 0.25rem; 266 + padding: 0.75rem; 267 + background: #fef2f2; 268 + border: 1px solid #fecaca; 269 + border-radius: 0.375rem; 270 + margin-bottom: 1rem; 271 + } 272 + 273 + .form-container { 274 + margin-top: 1.5rem; 275 + } 276 + 277 + .form-help { 278 + font-size: 0.75rem; 279 + color: var(--gray-500); 280 + margin-top: 0.25rem; 281 + } 282 + 283 + .btn-block { 284 + width: 100%; 285 + } 286 + 287 + .btn-text { 288 + display: inline; 289 + } 290 + 291 + .signup-prompt { 292 + margin-top: 1.5rem; 293 + text-align: center; 294 + padding-top: 1.5rem; 295 + border-top: 1px solid var(--gray-200); 296 + } 297 + 298 + .link { 299 + color: var(--primary-600); 300 + text-decoration: none; 301 + } 302 + 303 + .link:hover { 304 + text-decoration: underline; 305 + } 306 + 307 + .htmx-indicator { 308 + display: none; 309 + color: var(--gray-500); 310 + font-size: 0.875rem; 311 + } 312 + 313 + .htmx-request .htmx-indicator { 314 + display: inline; 315 + } 316 + 317 + .htmx-request .btn-text { 318 + display: none; 319 + } 320 + `;
+56
src/components/LoginPage.tsx
··· 1 + interface LoginPageProps { 2 + error?: string; 3 + } 4 + 5 + export function LoginPage({ error }: LoginPageProps) { 6 + return ( 7 + <div class="card"> 8 + <h1>Sign In to Statusphere</h1> 9 + <p class="status-meta"> 10 + Share your current status with a single emoji using your AT Protocol identity. 11 + </p> 12 + 13 + {error && ( 14 + <div class="error" role="alert"> 15 + {error} 16 + </div> 17 + )} 18 + 19 + <form 20 + method="post" 21 + action="/oauth/authorize" 22 + class="form-container" 23 + > 24 + <div class="form-group"> 25 + <label class="form-label" for="loginHint"> 26 + AT Protocol Handle 27 + </label> 28 + <input 29 + type="text" 30 + id="loginHint" 31 + name="loginHint" 32 + class="form-input" 33 + placeholder="alice.bsky.social" 34 + required 35 + /> 36 + <div class="form-help"> 37 + Enter your Bluesky or AT Protocol handle 38 + </div> 39 + </div> 40 + 41 + <button type="submit" class="btn btn-primary btn-block"> 42 + Sign In with OAuth 43 + </button> 44 + </form> 45 + 46 + <div class="signup-prompt"> 47 + <p class="status-meta"> 48 + Don't have an account? 49 + <a href="https://bsky.app" target="_blank" rel="noopener" class="link"> 50 + Sign up on Bluesky 51 + </a> 52 + </p> 53 + </div> 54 + </div> 55 + ); 56 + }
+62
src/config.ts
··· 1 + import { OAuthClient, DenoKVOAuthStorage } from "@slices/oauth"; 2 + import { SessionStore, DenoKVAdapter, withOAuthSession } from "@slices/session"; 3 + import { AtProtoClient } from "./generated_client.ts"; 4 + 5 + // Environment configuration 6 + export const PORT = parseInt(Deno.env.get("PORT") || "8080"); 7 + export const DATABASE_URL = Deno.env.get("DATABASE_URL") || "./statusphere.db"; 8 + export const OAUTH_CLIENT_ID = Deno.env.get("OAUTH_CLIENT_ID") || ""; 9 + export const OAUTH_CLIENT_SECRET = Deno.env.get("OAUTH_CLIENT_SECRET") || ""; 10 + export const OAUTH_REDIRECT_URI = 11 + Deno.env.get("OAUTH_REDIRECT_URI") || 12 + `http://localhost:${PORT}/oauth/callback`; 13 + export const OAUTH_AIP_BASE_URL = 14 + Deno.env.get("OAUTH_AIP_BASE_URL") || "https://bsky.social"; 15 + export const SLICE_URI = 16 + Deno.env.get("SLICE_URI") || 17 + "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5y476bws2q"; 18 + export const SLICES_API_URL = Deno.env.get("SLICES_API_URL") || ""; 19 + 20 + // OAuth and Session setup 21 + // In Deno Deploy, don't specify a path to use the default KV store 22 + const kv = await Deno.openKv( 23 + Deno.env.get("ENV") === "production" ? undefined : DATABASE_URL 24 + ); 25 + const oauthStorage = new DenoKVOAuthStorage(kv); 26 + export const oauthClient = new OAuthClient( 27 + { 28 + clientId: OAUTH_CLIENT_ID, 29 + clientSecret: OAUTH_CLIENT_SECRET, 30 + authBaseUrl: OAUTH_AIP_BASE_URL, 31 + redirectUri: OAUTH_REDIRECT_URI, 32 + scopes: [ 33 + "openid", 34 + "profile", 35 + "email", 36 + "atproto:atproto", 37 + "atproto:transition:generic", 38 + ], 39 + }, 40 + oauthStorage 41 + ); 42 + 43 + export const sessionStore = new SessionStore({ 44 + adapter: new DenoKVAdapter(kv), 45 + cookieOptions: { 46 + httpOnly: true, 47 + secure: Deno.env.get("ENV") === "production", 48 + sameSite: "lax", 49 + path: "/", 50 + }, 51 + }); 52 + 53 + export const oauthSessions = withOAuthSession(sessionStore, oauthClient, { 54 + autoRefresh: true, 55 + }); 56 + 57 + // Slices AT Protocol client 58 + export const atprotoClient = new AtProtoClient( 59 + SLICES_API_URL, 60 + SLICE_URI, 61 + oauthClient 62 + );
+647
src/generated_client.ts
··· 1 + // Generated TypeScript client for AT Protocol records 2 + // Generated at: 2025-08-28 00:22:31 UTC 3 + // Lexicons: 2 4 + 5 + /** 6 + * @example Usage 7 + * ```ts 8 + * import { AtProtoClient } from "./generated_client.ts"; 9 + * 10 + * const client = new AtProtoClient( 11 + * 'https://slices-api.fly.dev', 12 + * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5y476bws2q' 13 + * ); 14 + * 15 + * // List records from the app.bsky.actor.profile collection 16 + * const records = await client.app.bsky.actor.profile.listRecords(); 17 + * 18 + * // Get a specific record 19 + * const record = await client.app.bsky.actor.profile.getRecord({ 20 + * uri: 'at://did:plc:example/app.bsky.actor.profile/3abc123' 21 + * }); 22 + * 23 + * // Search records in the collection 24 + * const searchResults = await client.app.bsky.actor.profile.searchRecords({ 25 + * query: "example search term" 26 + * }); 27 + * 28 + * // Search specific field 29 + * const fieldSearch = await client.app.bsky.actor.profile.searchRecords({ 30 + * query: "blog", 31 + * field: "title" 32 + * }); 33 + * 34 + * // Serve the records as JSON 35 + * Deno.serve(async () => new Response(JSON.stringify(records.records.map(r => r.value)))); 36 + * ``` 37 + */ 38 + 39 + import { OAuthClient } from "@slices/oauth"; 40 + 41 + export interface RecordResponse<T> { 42 + uri: string; 43 + cid: string; 44 + did: string; 45 + collection: string; 46 + value: T; 47 + indexedAt: string; 48 + } 49 + 50 + export interface ListRecordsResponse<T> { 51 + records: RecordResponse<T>[]; 52 + cursor?: string; 53 + } 54 + 55 + export interface GetActorsResponse { 56 + actors: Actor[]; 57 + cursor?: string; 58 + } 59 + 60 + export interface ListRecordsParams<TSortField extends string = string> { 61 + author?: string; 62 + authors?: string[]; 63 + limit?: number; 64 + cursor?: string; 65 + sort?: 66 + | `${TSortField}:${"asc" | "desc"}` 67 + | `${TSortField}:${"asc" | "desc"},${TSortField}:${"asc" | "desc"}`; 68 + } 69 + 70 + export interface GetRecordParams { 71 + uri: string; 72 + } 73 + 74 + export interface GetActorsParams { 75 + search?: string; 76 + dids?: string[]; 77 + limit?: number; 78 + cursor?: string; 79 + } 80 + 81 + export interface SearchRecordsParams<TSortField extends string = string> { 82 + query: string; 83 + field?: string; 84 + limit?: number; 85 + cursor?: string; 86 + sort?: 87 + | `${TSortField}:${"asc" | "desc"}` 88 + | `${TSortField}:${"asc" | "desc"},${TSortField}:${"asc" | "desc"}`; 89 + } 90 + 91 + export interface IndexedRecord { 92 + uri: string; 93 + cid: string; 94 + did: string; 95 + collection: string; 96 + value: Record<string, unknown>; 97 + indexedAt: string; 98 + } 99 + 100 + export interface Actor { 101 + did: string; 102 + handle?: string; 103 + sliceUri: string; 104 + indexedAt: string; 105 + } 106 + 107 + export interface CodegenXrpcRequest { 108 + target: string; 109 + slice: string; 110 + } 111 + 112 + export interface CodegenXrpcResponse { 113 + success: boolean; 114 + generatedCode?: string; 115 + error?: string; 116 + } 117 + 118 + export interface BulkSyncParams { 119 + collections?: string[]; 120 + externalCollections?: string[]; 121 + repos?: string[]; 122 + limitPerRepo?: number; 123 + } 124 + 125 + export interface BulkSyncOutput { 126 + success: boolean; 127 + totalRecords: number; 128 + collectionsSynced: string[]; 129 + reposProcessed: number; 130 + message: string; 131 + } 132 + 133 + export interface SyncJobResponse { 134 + success: boolean; 135 + jobId?: string; 136 + message: string; 137 + } 138 + 139 + export interface SyncJobResult { 140 + success: boolean; 141 + totalRecords: number; 142 + collectionsSynced: string[]; 143 + reposProcessed: number; 144 + message: string; 145 + } 146 + 147 + export interface JobStatus { 148 + jobId: string; 149 + status: string; 150 + createdAt: string; 151 + startedAt?: string; 152 + completedAt?: string; 153 + result?: SyncJobResult; 154 + error?: string; 155 + retryCount: number; 156 + } 157 + 158 + export interface GetJobStatusParams { 159 + jobId: string; 160 + } 161 + 162 + export interface GetJobHistoryParams { 163 + userDid: string; 164 + sliceUri: string; 165 + limit?: number; 166 + } 167 + 168 + export type GetJobHistoryResponse = JobStatus[]; 169 + 170 + export interface CollectionStats { 171 + collection: string; 172 + recordCount: number; 173 + uniqueActors: number; 174 + } 175 + 176 + export interface SliceStatsParams { 177 + slice: string; 178 + } 179 + 180 + export interface SliceStatsOutput { 181 + success: boolean; 182 + collections: string[]; 183 + collectionStats: CollectionStats[]; 184 + totalLexicons: number; 185 + totalRecords: number; 186 + totalActors: number; 187 + message?: string; 188 + } 189 + 190 + export interface SliceRecordsParams { 191 + slice: string; 192 + collection: string; 193 + repo?: string; 194 + limit?: number; 195 + cursor?: string; 196 + } 197 + 198 + export interface SliceRecordsOutput { 199 + success: boolean; 200 + records: IndexedRecord[]; 201 + cursor?: string; 202 + message?: string; 203 + } 204 + 205 + export interface UploadBlobRequest { 206 + data: ArrayBuffer | Uint8Array; 207 + mimeType: string; 208 + } 209 + 210 + export interface BlobRef { 211 + $type: string; 212 + ref: string; 213 + mimeType: string; 214 + size: number; 215 + } 216 + 217 + export interface UploadBlobResponse { 218 + blob: BlobRef; 219 + } 220 + 221 + export interface CollectionOperations<T> { 222 + listRecords(params?: ListRecordsParams): Promise<ListRecordsResponse<T>>; 223 + getRecord(params: GetRecordParams): Promise<RecordResponse<T>>; 224 + searchRecords(params: SearchRecordsParams): Promise<ListRecordsResponse<T>>; 225 + } 226 + 227 + export interface AppBskyActorProfileRecord { 228 + /** Small image to be displayed next to posts from account. AKA, 'profile picture' */ 229 + avatar?: BlobRef; 230 + /** Larger horizontal image to display behind profile view. */ 231 + banner?: BlobRef; 232 + createdAt?: string; 233 + /** Free-form profile description text. */ 234 + description?: string; 235 + displayName?: string; 236 + } 237 + 238 + export type AppBskyActorProfileRecordSortFields = 239 + | "createdAt" 240 + | "description" 241 + | "displayName"; 242 + 243 + export interface XyzStatusphereStatusRecord { 244 + createdAt: string; 245 + status: string; 246 + } 247 + 248 + export type XyzStatusphereStatusRecordSortFields = "createdAt" | "status"; 249 + 250 + class BaseClient { 251 + protected readonly baseUrl: string; 252 + protected oauthClient?: OAuthClient; 253 + 254 + constructor(baseUrl: string, oauthClient?: OAuthClient) { 255 + this.baseUrl = baseUrl; 256 + this.oauthClient = oauthClient; 257 + } 258 + 259 + protected async ensureValidToken(): Promise<void> { 260 + if (!this.oauthClient) { 261 + throw new Error("OAuth client not configured"); 262 + } 263 + 264 + await this.oauthClient.ensureValidToken(); 265 + } 266 + 267 + protected async makeRequest<T = unknown>( 268 + endpoint: string, 269 + method?: "GET" | "POST" | "PUT" | "DELETE", 270 + params?: Record<string, unknown> | unknown 271 + ): Promise<T> { 272 + return this.makeRequestWithRetry(endpoint, method, params, false); 273 + } 274 + 275 + private async makeRequestWithRetry<T = unknown>( 276 + endpoint: string, 277 + method?: "GET" | "POST" | "PUT" | "DELETE", 278 + params?: Record<string, unknown> | unknown, 279 + isRetry?: boolean 280 + ): Promise<T> { 281 + isRetry = isRetry ?? false; 282 + const httpMethod = method || "GET"; 283 + let url = `${this.baseUrl}/xrpc/${endpoint}`; 284 + 285 + const requestInit: RequestInit = { 286 + method: httpMethod, 287 + headers: {}, 288 + }; 289 + 290 + // Add authorization header if OAuth client is available 291 + if (this.oauthClient) { 292 + try { 293 + const tokens = await this.oauthClient.ensureValidToken(); 294 + if (tokens.accessToken) { 295 + (requestInit.headers as Record<string, string>)[ 296 + "Authorization" 297 + ] = `${tokens.tokenType} ${tokens.accessToken}`; 298 + } 299 + } catch (tokenError) { 300 + // For write operations, OAuth tokens are required 301 + if (httpMethod !== "GET") { 302 + throw new Error( 303 + `Authentication required: OAuth tokens are invalid or expired. Please log in again.` 304 + ); 305 + } 306 + 307 + // For read operations, continue without auth (allow read-only operations) 308 + } 309 + } 310 + 311 + if (httpMethod === "GET" && params) { 312 + const searchParams = new URLSearchParams(); 313 + Object.entries(params).forEach(([key, value]) => { 314 + if (value !== undefined && value !== null) { 315 + searchParams.append(key, String(value)); 316 + } 317 + }); 318 + const queryString = searchParams.toString(); 319 + if (queryString) { 320 + url += "?" + queryString; 321 + } 322 + } else if (httpMethod !== "GET" && params) { 323 + // Regular API endpoints expect JSON 324 + (requestInit.headers as Record<string, string>)["Content-Type"] = 325 + "application/json"; 326 + requestInit.body = JSON.stringify(params); 327 + } 328 + 329 + const response = await fetch(url, requestInit); 330 + if (!response.ok) { 331 + // Handle 404 gracefully for GET requests 332 + if (response.status === 404 && httpMethod === "GET") { 333 + return null as T; 334 + } 335 + 336 + // Handle 401 Unauthorized - attempt token refresh and retry once 337 + if ( 338 + response.status === 401 && 339 + !isRetry && 340 + this.oauthClient && 341 + httpMethod !== "GET" 342 + ) { 343 + try { 344 + // Force token refresh by calling ensureValidToken again 345 + await this.oauthClient.ensureValidToken(); 346 + // Retry the request once with refreshed tokens 347 + return this.makeRequestWithRetry(endpoint, method, params, true); 348 + } catch (_refreshError) { 349 + throw new Error( 350 + `Authentication required: OAuth tokens are invalid or expired. Please log in again.` 351 + ); 352 + } 353 + } 354 + 355 + throw new Error( 356 + `Request failed: ${response.status} ${response.statusText}` 357 + ); 358 + } 359 + 360 + return (await response.json()) as T; 361 + } 362 + } 363 + 364 + class ProfileActorBskyAppClient extends BaseClient { 365 + private readonly sliceUri: string; 366 + 367 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 368 + super(baseUrl, oauthClient); 369 + this.sliceUri = sliceUri; 370 + } 371 + 372 + async listRecords( 373 + params?: ListRecordsParams<AppBskyActorProfileRecordSortFields> 374 + ): Promise<ListRecordsResponse<AppBskyActorProfileRecord>> { 375 + const requestParams = { ...params, slice: this.sliceUri }; 376 + return await this.makeRequest< 377 + ListRecordsResponse<AppBskyActorProfileRecord> 378 + >("app.bsky.actor.profile.list", "GET", requestParams); 379 + } 380 + 381 + async getRecord( 382 + params: GetRecordParams 383 + ): Promise<RecordResponse<AppBskyActorProfileRecord>> { 384 + const requestParams = { ...params, slice: this.sliceUri }; 385 + return await this.makeRequest<RecordResponse<AppBskyActorProfileRecord>>( 386 + "app.bsky.actor.profile.get", 387 + "GET", 388 + requestParams 389 + ); 390 + } 391 + 392 + async searchRecords( 393 + params: SearchRecordsParams<AppBskyActorProfileRecordSortFields> 394 + ): Promise<ListRecordsResponse<AppBskyActorProfileRecord>> { 395 + const requestParams = { ...params, slice: this.sliceUri }; 396 + return await this.makeRequest< 397 + ListRecordsResponse<AppBskyActorProfileRecord> 398 + >("app.bsky.actor.profile.searchRecords", "GET", requestParams); 399 + } 400 + 401 + async createRecord( 402 + record: AppBskyActorProfileRecord, 403 + useSelfRkey?: boolean 404 + ): Promise<{ uri: string; cid: string }> { 405 + const recordWithType = { $type: "app.bsky.actor.profile", ...record }; 406 + const payload = useSelfRkey 407 + ? { ...recordWithType, rkey: "self" } 408 + : recordWithType; 409 + return await this.makeRequest<{ uri: string; cid: string }>( 410 + "app.bsky.actor.profile.create", 411 + "POST", 412 + payload 413 + ); 414 + } 415 + 416 + async updateRecord( 417 + rkey: string, 418 + record: AppBskyActorProfileRecord 419 + ): Promise<{ uri: string; cid: string }> { 420 + const recordWithType = { $type: "app.bsky.actor.profile", ...record }; 421 + return await this.makeRequest<{ uri: string; cid: string }>( 422 + "app.bsky.actor.profile.update", 423 + "POST", 424 + { rkey, record: recordWithType } 425 + ); 426 + } 427 + 428 + async deleteRecord(rkey: string): Promise<void> { 429 + return await this.makeRequest<void>( 430 + "app.bsky.actor.profile.delete", 431 + "POST", 432 + { rkey } 433 + ); 434 + } 435 + } 436 + 437 + class ActorBskyAppClient extends BaseClient { 438 + readonly profile: ProfileActorBskyAppClient; 439 + private readonly sliceUri: string; 440 + 441 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 442 + super(baseUrl, oauthClient); 443 + this.sliceUri = sliceUri; 444 + this.profile = new ProfileActorBskyAppClient( 445 + baseUrl, 446 + sliceUri, 447 + oauthClient 448 + ); 449 + } 450 + } 451 + 452 + class BskyAppClient extends BaseClient { 453 + readonly actor: ActorBskyAppClient; 454 + private readonly sliceUri: string; 455 + 456 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 457 + super(baseUrl, oauthClient); 458 + this.sliceUri = sliceUri; 459 + this.actor = new ActorBskyAppClient(baseUrl, sliceUri, oauthClient); 460 + } 461 + } 462 + 463 + class AppClient extends BaseClient { 464 + readonly bsky: BskyAppClient; 465 + private readonly sliceUri: string; 466 + 467 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 468 + super(baseUrl, oauthClient); 469 + this.sliceUri = sliceUri; 470 + this.bsky = new BskyAppClient(baseUrl, sliceUri, oauthClient); 471 + } 472 + } 473 + 474 + class StatusStatusphereXyzClient extends BaseClient { 475 + private readonly sliceUri: string; 476 + 477 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 478 + super(baseUrl, oauthClient); 479 + this.sliceUri = sliceUri; 480 + } 481 + 482 + async listRecords( 483 + params?: ListRecordsParams<XyzStatusphereStatusRecordSortFields> 484 + ): Promise<ListRecordsResponse<XyzStatusphereStatusRecord>> { 485 + const requestParams = { ...params, slice: this.sliceUri }; 486 + return await this.makeRequest< 487 + ListRecordsResponse<XyzStatusphereStatusRecord> 488 + >("xyz.statusphere.status.list", "GET", requestParams); 489 + } 490 + 491 + async getRecord( 492 + params: GetRecordParams 493 + ): Promise<RecordResponse<XyzStatusphereStatusRecord>> { 494 + const requestParams = { ...params, slice: this.sliceUri }; 495 + return await this.makeRequest<RecordResponse<XyzStatusphereStatusRecord>>( 496 + "xyz.statusphere.status.get", 497 + "GET", 498 + requestParams 499 + ); 500 + } 501 + 502 + async searchRecords( 503 + params: SearchRecordsParams<XyzStatusphereStatusRecordSortFields> 504 + ): Promise<ListRecordsResponse<XyzStatusphereStatusRecord>> { 505 + const requestParams = { ...params, slice: this.sliceUri }; 506 + return await this.makeRequest< 507 + ListRecordsResponse<XyzStatusphereStatusRecord> 508 + >("xyz.statusphere.status.searchRecords", "GET", requestParams); 509 + } 510 + 511 + async createRecord( 512 + record: XyzStatusphereStatusRecord, 513 + useSelfRkey?: boolean 514 + ): Promise<{ uri: string; cid: string }> { 515 + const recordWithType = { $type: "xyz.statusphere.status", ...record }; 516 + const payload = useSelfRkey 517 + ? { ...recordWithType, rkey: "self" } 518 + : recordWithType; 519 + return await this.makeRequest<{ uri: string; cid: string }>( 520 + "xyz.statusphere.status.create", 521 + "POST", 522 + payload 523 + ); 524 + } 525 + 526 + async updateRecord( 527 + rkey: string, 528 + record: XyzStatusphereStatusRecord 529 + ): Promise<{ uri: string; cid: string }> { 530 + const recordWithType = { $type: "xyz.statusphere.status", ...record }; 531 + return await this.makeRequest<{ uri: string; cid: string }>( 532 + "xyz.statusphere.status.update", 533 + "POST", 534 + { rkey, record: recordWithType } 535 + ); 536 + } 537 + 538 + async deleteRecord(rkey: string): Promise<void> { 539 + return await this.makeRequest<void>( 540 + "xyz.statusphere.status.delete", 541 + "POST", 542 + { rkey } 543 + ); 544 + } 545 + } 546 + 547 + class StatusphereXyzClient extends BaseClient { 548 + readonly status: StatusStatusphereXyzClient; 549 + private readonly sliceUri: string; 550 + 551 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 552 + super(baseUrl, oauthClient); 553 + this.sliceUri = sliceUri; 554 + this.status = new StatusStatusphereXyzClient( 555 + baseUrl, 556 + sliceUri, 557 + oauthClient 558 + ); 559 + } 560 + } 561 + 562 + class XyzClient extends BaseClient { 563 + readonly statusphere: StatusphereXyzClient; 564 + private readonly sliceUri: string; 565 + 566 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 567 + super(baseUrl, oauthClient); 568 + this.sliceUri = sliceUri; 569 + this.statusphere = new StatusphereXyzClient(baseUrl, sliceUri, oauthClient); 570 + } 571 + } 572 + 573 + export class AtProtoClient extends BaseClient { 574 + readonly app: AppClient; 575 + readonly xyz: XyzClient; 576 + readonly oauth?: OAuthClient; 577 + private readonly sliceUri: string; 578 + 579 + constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { 580 + super(baseUrl, oauthClient); 581 + this.sliceUri = sliceUri; 582 + this.app = new AppClient(baseUrl, sliceUri, oauthClient); 583 + this.xyz = new XyzClient(baseUrl, sliceUri, oauthClient); 584 + this.oauth = this.oauthClient; 585 + } 586 + 587 + async getActors(params: GetActorsParams): Promise<GetActorsResponse> { 588 + const requestParams = { ...params, slice: this.sliceUri }; 589 + return await this.makeRequest<GetActorsResponse>( 590 + "social.slices.slice.getActors", 591 + "GET", 592 + requestParams 593 + ); 594 + } 595 + 596 + uploadBlob(request: UploadBlobRequest): Promise<UploadBlobResponse> { 597 + return this.uploadBlobWithRetry(request, false); 598 + } 599 + 600 + private async uploadBlobWithRetry( 601 + request: UploadBlobRequest, 602 + isRetry?: boolean 603 + ): Promise<UploadBlobResponse> { 604 + isRetry = isRetry ?? false; 605 + // Special handling for blob upload with binary data 606 + const httpMethod = "POST"; 607 + const url = `${this.baseUrl}/xrpc/com.atproto.repo.uploadBlob`; 608 + 609 + if (!this.oauthClient) { 610 + throw new Error("OAuth client not configured"); 611 + } 612 + 613 + const tokens = await this.oauthClient.ensureValidToken(); 614 + 615 + const requestInit: RequestInit = { 616 + method: httpMethod, 617 + headers: { 618 + "Content-Type": request.mimeType, 619 + Authorization: `${tokens.tokenType} ${tokens.accessToken}`, 620 + }, 621 + body: request.data, 622 + }; 623 + 624 + const response = await fetch(url, requestInit); 625 + if (!response.ok) { 626 + // Handle 401 Unauthorized - attempt token refresh and retry once 627 + if (response.status === 401 && !isRetry && this.oauthClient) { 628 + try { 629 + // Force token refresh by calling ensureValidToken again 630 + await this.oauthClient.ensureValidToken(); 631 + // Retry the request once with refreshed tokens 632 + return this.uploadBlobWithRetry(request, true); 633 + } catch (_refreshError) { 634 + throw new Error( 635 + `Authentication required: OAuth tokens are invalid or expired. Please log in again.` 636 + ); 637 + } 638 + } 639 + 640 + throw new Error( 641 + `Blob upload failed: ${response.status} ${response.statusText}` 642 + ); 643 + } 644 + 645 + return await response.json(); 646 + } 647 + }
+176
src/main.ts
··· 1 + import { render } from "preact-render-to-string"; 2 + import { App } from "./components/App.tsx"; 3 + import { StatusTimeline } from "./components/HomePage.tsx"; 4 + import { PORT, sessionStore, oauthSessions, atprotoClient } from "./config.ts"; 5 + import { AuthenticatedUser } from "./types.ts"; 6 + import { fetchStatusesWithAuthors } from "./api.ts"; 7 + 8 + async function handler(req: Request): Promise<Response> { 9 + const url = new URL(req.url); 10 + const pathname = url.pathname; 11 + 12 + // Get current user session 13 + const currentUser = await sessionStore.getCurrentUser(req); 14 + 15 + // Routing 16 + if (pathname === "/") { 17 + return await handleHome(req, currentUser); 18 + } 19 + 20 + if (pathname === "/login") { 21 + return handleLogin(req, currentUser); 22 + } 23 + 24 + if (pathname === "/oauth/authorize" && req.method === "POST") { 25 + return await handleOAuthAuthorize(req); 26 + } 27 + 28 + if (pathname === "/oauth/callback") { 29 + return await handleOAuthCallback(req); 30 + } 31 + 32 + if (pathname === "/logout" && req.method === "POST") { 33 + return await handleLogout(req); 34 + } 35 + 36 + if (pathname === "/status" && req.method === "POST") { 37 + return await handleSetStatus(req, currentUser); 38 + } 39 + 40 + return new Response("Not Found", { status: 404 }); 41 + } 42 + 43 + async function handleHome( 44 + _req: Request, 45 + currentUser: AuthenticatedUser 46 + ): Promise<Response> { 47 + const statuses = await fetchStatusesWithAuthors(); 48 + 49 + const html = render(App({ currentUser, statuses })); 50 + 51 + return new Response(`<!DOCTYPE html>${html}`, { 52 + headers: { "Content-Type": "text/html" }, 53 + }); 54 + } 55 + 56 + function handleLogin(req: Request, currentUser: AuthenticatedUser): Response { 57 + if (currentUser.isAuthenticated) { 58 + return Response.redirect(new URL("/", req.url), 302); 59 + } 60 + 61 + const html = render(App({ currentUser, page: "login" })); 62 + 63 + return new Response(`<!DOCTYPE html>${html}`, { 64 + headers: { "Content-Type": "text/html" }, 65 + }); 66 + } 67 + 68 + async function handleOAuthAuthorize(req: Request): Promise<Response> { 69 + await atprotoClient.oauth?.logout(); 70 + 71 + const formData = await req.formData(); 72 + const loginHint = formData.get("loginHint") as string; 73 + 74 + const authResult = await atprotoClient.oauth!.authorize({ loginHint }); 75 + 76 + return Response.redirect(authResult.authorizationUrl, 302); 77 + } 78 + 79 + async function handleOAuthCallback(req: Request): Promise<Response> { 80 + const url = new URL(req.url); 81 + const code = url.searchParams.get("code"); 82 + const state = url.searchParams.get("state"); 83 + 84 + if (!code || !state) { 85 + return new Response("Missing OAuth parameters", { status: 400 }); 86 + } 87 + 88 + await atprotoClient.oauth!.handleCallback({ code, state }); 89 + 90 + const sessionId = await oauthSessions.createOAuthSession(); 91 + 92 + if (!sessionId) { 93 + return new Response("Failed to create session", { status: 500 }); 94 + } 95 + 96 + const sessionCookie = sessionStore.createSessionCookie(sessionId); 97 + 98 + return new Response(null, { 99 + status: 302, 100 + headers: { 101 + Location: new URL("/", req.url).toString(), 102 + "Set-Cookie": sessionCookie, 103 + }, 104 + }); 105 + } 106 + 107 + async function handleLogout(req: Request): Promise<Response> { 108 + const session = await sessionStore.getSessionFromRequest(req); 109 + 110 + if (session) { 111 + await oauthSessions.logout(session.sessionId); 112 + } 113 + 114 + const clearCookie = sessionStore.createLogoutCookie(); 115 + 116 + return new Response(null, { 117 + status: 302, 118 + headers: { 119 + Location: new URL("/login", req.url).toString(), 120 + "Set-Cookie": clearCookie, 121 + }, 122 + }); 123 + } 124 + 125 + async function handleSetStatus( 126 + req: Request, 127 + currentUser: AuthenticatedUser 128 + ): Promise<Response> { 129 + if (!currentUser.isAuthenticated) { 130 + // For HTMX requests, send redirect header 131 + const isHtmxRequest = req.headers.get("HX-Request") === "true"; 132 + if (isHtmxRequest) { 133 + return new Response(null, { 134 + status: 200, 135 + headers: { 136 + "HX-Redirect": "/login", 137 + }, 138 + }); 139 + } 140 + return Response.redirect(new URL("/login", req.url), 302); 141 + } 142 + 143 + const isHtmxRequest = req.headers.get("HX-Request") === "true"; 144 + 145 + const formData = await req.formData(); 146 + const status = formData.get("status") as string; 147 + 148 + try { 149 + await atprotoClient.xyz.statusphere.status.createRecord({ 150 + status, 151 + createdAt: new Date().toISOString(), 152 + }); 153 + } catch (error) { 154 + console.error("Error setting status:", error); 155 + return new Response("Error setting status", { status: 500 }); 156 + } 157 + 158 + if (isHtmxRequest) { 159 + // Return updated timeline for HTMX 160 + try { 161 + const statuses = await fetchStatusesWithAuthors(); 162 + const html = render(StatusTimeline({ statuses })); 163 + 164 + return new Response(html, { 165 + headers: { "Content-Type": "text/html" }, 166 + }); 167 + } catch (error) { 168 + console.error("Error fetching updated statuses:", error); 169 + return new Response("Error loading statuses", { status: 500 }); 170 + } 171 + } 172 + 173 + return Response.redirect(new URL("/", req.url), 302); 174 + } 175 + 176 + Deno.serve({ port: PORT, hostname: "0.0.0.0" }, handler);
+11
src/types.ts
··· 1 + import { XyzStatusphereStatusRecord, RecordResponse, Actor } from "./generated_client.ts"; 2 + 3 + export interface AuthenticatedUser { 4 + isAuthenticated: boolean; 5 + handle?: string; 6 + sub?: string; 7 + } 8 + 9 + export interface HydratedStatus extends RecordResponse<XyzStatusphereStatusRecord> { 10 + author?: Actor; 11 + }