a love letter to tangled (android, iOS, and a search API)
19
fork

Configure Feed

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

feat: oauth through bluesky

+1228 -140
+6 -1
apps/twisted/.env.example
··· 1 - VITE_TWISTER_API_BASE_URL=http://localhost:8080 1 + VITE_TWISTER_API_BASE_URL=http://127.0.0.1:8080 2 + 3 + # OAuth configuration 4 + # VITE_OAUTH_CLIENT_ID must be a publicly accessible URL (use a tunnel for local dev) 5 + # VITE_OAUTH_CLIENT_ID=https://your-tunnel.example.com/oauth/client-metadata.json 6 + VITE_OAUTH_REDIRECT_URI=http://127.0.0.1:5173/oauth-callback
+7
apps/twisted/android/app/src/main/AndroidManifest.xml
··· 22 22 <category android:name="android.intent.category.LAUNCHER" /> 23 23 </intent-filter> 24 24 25 + <intent-filter android:autoVerify="true"> 26 + <action android:name="android.intent.action.VIEW" /> 27 + <category android:name="android.intent.category.DEFAULT" /> 28 + <category android:name="android.intent.category.BROWSABLE" /> 29 + <data android:scheme="io.ionic.starter" /> 30 + </intent-filter> 31 + 25 32 </activity> 26 33 27 34 <provider
+6 -4
apps/twisted/capacitor.config.ts
··· 1 - import type { CapacitorConfig } from '@capacitor/cli'; 1 + import type { CapacitorConfig } from "@capacitor/cli"; 2 2 3 3 const config: CapacitorConfig = { 4 - appId: 'io.ionic.starter', 5 - appName: 'Twisted', 6 - webDir: 'dist' 4 + appId: "io.ionic.starter", 5 + appName: "Twisted", 6 + webDir: "dist", 7 + server: { androidScheme: "io.ionic.starter" }, 8 + plugins: { CapacitorHttp: { enabled: true } }, 7 9 }; 8 10 9 11 export default config;
+9
apps/twisted/ios/App/App/Info.plist
··· 47 47 </array> 48 48 <key>UIViewControllerBasedStatusBarAppearance</key> 49 49 <true/> 50 + <key>CFBundleURLTypes</key> 51 + <array> 52 + <dict> 53 + <key>CFBundleURLSchemes</key> 54 + <array> 55 + <string>io.ionic.starter</string> 56 + </array> 57 + </dict> 58 + </array> 50 59 </dict> 51 60 </plist>
+2
apps/twisted/package.json
··· 15 15 "dependencies": { 16 16 "@atcute/bluesky": "^3.3.0", 17 17 "@atcute/client": "^4.2.1", 18 + "@atcute/identity-resolver": "^1.2.2", 19 + "@atcute/lexicons": "^1.2.9", 18 20 "@atcute/oauth-browser-client": "^3.0.0", 19 21 "@atcute/tangled": "^1.0.17", 20 22 "@capacitor/android": "^8.2.0",
+14
apps/twisted/public/client-metadata.json
··· 1 + { 2 + "client_id": "io.ionic.starter://client-metadata.json", 3 + "client_name": "Twisted", 4 + "client_uri": "https://tangled.org", 5 + "logo_uri": "https://tangled.org/logo.png", 6 + "tos_uri": "https://tangled.org/terms", 7 + "policy_uri": "https://tangled.org/privacy", 8 + "redirect_uris": ["io.ionic.starter://oauth-callback", "http://localhost:5173/oauth-callback"], 9 + "scope": "atproto", 10 + "grant_types": ["authorization_code", "refresh_token"], 11 + "response_types": ["code"], 12 + "application_type": "native", 13 + "dpop_bound_access_tokens": true 14 + }
+2
apps/twisted/src/app/router/index.ts
··· 5 5 6 6 const routes: RouteRecordRaw[] = [ 7 7 { path: "/", redirect: "/tabs/home" }, 8 + { path: "/login", component: () => import("@/features/auth/LoginPage.vue") }, 9 + { path: "/oauth-callback", component: () => import("@/features/auth/OAuthCallbackPage.vue") }, 8 10 { 9 11 path: "/tabs/", 10 12 component: TabsPage,
+38
apps/twisted/src/core/auth/client.ts
··· 1 + import { Client } from "@atcute/client"; 2 + import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 3 + import { useAuthStore } from "./store.ts"; 4 + 5 + let authClient: Client | null = null; 6 + let currentUserAgent: OAuthUserAgent | null = null; 7 + 8 + export function getAuthClient(): Client | null { 9 + const authStore = useAuthStore(); 10 + const userAgent = authStore.getUserAgent(); 11 + 12 + if (!userAgent) { 13 + authClient = null; 14 + currentUserAgent = null; 15 + return null; 16 + } 17 + 18 + if (authClient && currentUserAgent === userAgent) { 19 + return authClient; 20 + } 21 + 22 + authClient = new Client({ handler: userAgent }); 23 + currentUserAgent = userAgent; 24 + return authClient; 25 + } 26 + 27 + export function requireAuthClient(): Client { 28 + const client = getAuthClient(); 29 + if (!client) { 30 + throw new Error("Authentication required"); 31 + } 32 + return client; 33 + } 34 + 35 + export function clearAuthClient(): void { 36 + authClient = null; 37 + currentUserAgent = null; 38 + }
+3
apps/twisted/src/core/auth/index.ts
··· 1 + export { initializeOAuth, clientId, redirectUri } from "./oauth-config.ts"; 2 + export { useAuthStore, type AuthState } from "./store.ts"; 3 + export { getAuthClient, requireAuthClient, clearAuthClient } from "./client.ts";
+43
apps/twisted/src/core/auth/oauth-config.ts
··· 1 + import { configureOAuth } from "@atcute/oauth-browser-client"; 2 + import { 3 + LocalActorResolver, 4 + CompositeDidDocumentResolver, 5 + CompositeHandleResolver, 6 + PlcDidDocumentResolver, 7 + WebDidDocumentResolver, 8 + WellKnownHandleResolver, 9 + DohJsonHandleResolver, 10 + } from "@atcute/identity-resolver"; 11 + 12 + const isNative = typeof window !== "undefined" && !window.location.origin.startsWith("http"); 13 + const baseUrl = isNative ? "io.ionic.starter://" : window.location.origin; 14 + const clientId = 15 + (!isNative && import.meta.env.VITE_OAUTH_CLIENT_ID?.trim()) || 16 + `${baseUrl}/client-metadata.json`; 17 + const redirectUri = 18 + (!isNative && import.meta.env.VITE_OAUTH_REDIRECT_URI?.trim()) || 19 + `${baseUrl}/oauth-callback`; 20 + 21 + const didResolver = new CompositeDidDocumentResolver({ 22 + methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() }, 23 + }); 24 + 25 + const handleResolver = new CompositeHandleResolver({ 26 + strategy: "race", 27 + methods: { 28 + http: new WellKnownHandleResolver(), 29 + dns: new DohJsonHandleResolver({ dohUrl: "https://cloudflare-dns.com/dns-query" }), 30 + }, 31 + }); 32 + 33 + const actorResolver = new LocalActorResolver({ didDocumentResolver: didResolver, handleResolver }); 34 + 35 + export function initializeOAuth(): void { 36 + configureOAuth({ 37 + metadata: { client_id: clientId, redirect_uri: redirectUri }, 38 + identityResolver: actorResolver, 39 + storageName: "twisted-oauth", 40 + }); 41 + } 42 + 43 + export { clientId, redirectUri };
+136
apps/twisted/src/core/auth/store.ts
··· 1 + import { defineStore } from "pinia"; 2 + import { ref, computed, Ref } from "vue"; 3 + import type { Session } from "@atcute/oauth-browser-client"; 4 + import { getSession, deleteStoredSession, listStoredSessions, OAuthUserAgent } from "@atcute/oauth-browser-client"; 5 + import { initializeOAuth } from "./oauth-config.js"; 6 + 7 + export type AuthState = "idle" | "restoring" | "authenticating" | "authenticated" | "error"; 8 + 9 + export const useAuthStore = defineStore("auth", () => { 10 + const state = ref<AuthState>("idle"); 11 + const session = ref<Session | null>(null); 12 + const userAgent: Ref<OAuthUserAgent | null> = ref(null); 13 + const error = ref<string | null>(null); 14 + const accounts = ref<`did:${string}:${string}`[]>([]); 15 + 16 + const isAuthenticated = computed(() => state.value === "authenticated" && session.value !== null); 17 + const did = computed(() => session.value?.info.sub ?? null); 18 + const pds = computed(() => session.value?.info.aud ?? null); 19 + 20 + function initialize() { 21 + initializeOAuth(); 22 + accounts.value = listStoredSessions(); 23 + } 24 + 25 + async function restoreSession(preferredDid?: `did:${string}:${string}`): Promise<boolean> { 26 + state.value = "restoring"; 27 + error.value = null; 28 + 29 + try { 30 + const dids = listStoredSessions(); 31 + if (dids.length === 0) { 32 + state.value = "idle"; 33 + return false; 34 + } 35 + 36 + const targetDid = preferredDid && dids.includes(preferredDid) ? preferredDid : dids[0]; 37 + const restoredSession = await getSession(targetDid, { allowStale: true }); 38 + 39 + session.value = restoredSession; 40 + userAgent.value = new OAuthUserAgent(restoredSession); 41 + accounts.value = dids; 42 + state.value = "authenticated"; 43 + return true; 44 + } catch (e) { 45 + error.value = e instanceof Error ? e.message : "Failed to restore session"; 46 + state.value = "error"; 47 + return false; 48 + } 49 + } 50 + 51 + async function switchAccount(targetDid: `did:${string}:${string}`): Promise<boolean> { 52 + if (!accounts.value.includes(targetDid)) { 53 + error.value = "Account not found"; 54 + return false; 55 + } 56 + 57 + return restoreSession(targetDid); 58 + } 59 + 60 + function setSession(newSession: Session) { 61 + session.value = newSession; 62 + userAgent.value = new OAuthUserAgent(newSession); 63 + state.value = "authenticated"; 64 + error.value = null; 65 + accounts.value = listStoredSessions(); 66 + } 67 + 68 + async function refreshSession(): Promise<boolean> { 69 + if (!session.value) return false; 70 + 71 + try { 72 + const refreshed = await getSession(session.value.info.sub, { noCache: true }); 73 + session.value = refreshed; 74 + userAgent.value = new OAuthUserAgent(refreshed); 75 + return true; 76 + } catch (e) { 77 + error.value = e instanceof Error ? e.message : "Failed to refresh session"; 78 + return false; 79 + } 80 + } 81 + 82 + async function logout(): Promise<void> { 83 + if (userAgent.value) { 84 + try { 85 + await userAgent.value.signOut(); 86 + } catch { 87 + if (session.value) { 88 + deleteStoredSession(session.value.info.sub); 89 + } 90 + } 91 + } else if (session.value) { 92 + deleteStoredSession(session.value.info.sub); 93 + } 94 + 95 + session.value = null; 96 + userAgent.value = null; 97 + state.value = "idle"; 98 + error.value = null; 99 + accounts.value = listStoredSessions(); 100 + } 101 + 102 + async function logoutAll(): Promise<void> { 103 + const dids = listStoredSessions(); 104 + for (const d of dids) { 105 + deleteStoredSession(d); 106 + } 107 + session.value = null; 108 + userAgent.value = null; 109 + state.value = "idle"; 110 + error.value = null; 111 + accounts.value = []; 112 + } 113 + 114 + function getUserAgent(): OAuthUserAgent | null { 115 + return userAgent.value; 116 + } 117 + 118 + return { 119 + state, 120 + session, 121 + userAgent, 122 + error, 123 + accounts, 124 + isAuthenticated, 125 + did, 126 + pds, 127 + initialize, 128 + restoreSession, 129 + switchAccount, 130 + setSession, 131 + refreshSession, 132 + logout, 133 + logoutAll, 134 + getUserAgent, 135 + }; 136 + });
+185
apps/twisted/src/features/auth/LoginPage.vue
··· 1 + <template> 2 + <ion-page> 3 + <ion-header :translucent="true"> 4 + <ion-toolbar> 5 + <ion-buttons slot="start"> 6 + <ion-back-button default-href="/tabs/profile" /> 7 + </ion-buttons> 8 + <ion-title>Sign In</ion-title> 9 + </ion-toolbar> 10 + </ion-header> 11 + 12 + <ion-content :fullscreen="true"> 13 + <div class="login-container"> 14 + <div class="brand-icon"> 15 + <ion-icon :icon="codeSlashOutline" /> 16 + </div> 17 + 18 + <h2 class="login-title">Sign in to Tangled</h2> 19 + <p class="login-subtitle">Enter your AT Protocol handle to sign in with OAuth.</p> 20 + 21 + <form @submit.prevent="handleSignIn"> 22 + <ion-item class="handle-input" lines="none"> 23 + <ion-input 24 + v-model="handle" 25 + type="text" 26 + placeholder="username.bsky.social" 27 + :disabled="isLoading" 28 + autocomplete="username" 29 + enterkeyhint="go" /> 30 + </ion-item> 31 + 32 + <ion-button class="login-btn" expand="block" type="submit" :disabled="!handle.trim() || isLoading"> 33 + <ion-icon v-if="!isLoading" slot="start" :icon="logInOutline" /> 34 + <ion-spinner v-else slot="start" name="crescent" /> 35 + {{ isLoading ? "Signing in..." : "Continue with OAuth" }} 36 + </ion-button> 37 + </form> 38 + 39 + <p v-if="error" class="login-error">{{ error }}</p> 40 + 41 + <p class="login-hint"> 42 + Don't have a handle? 43 + <a href="https://bsky.app" target="_blank" rel="noopener">Get one at bsky.app</a> 44 + </p> 45 + </div> 46 + </ion-content> 47 + </ion-page> 48 + </template> 49 + 50 + <script setup lang="ts"> 51 + import { ref } from "vue"; 52 + import { 53 + IonPage, 54 + IonHeader, 55 + IonToolbar, 56 + IonTitle, 57 + IonContent, 58 + IonButton, 59 + IonIcon, 60 + IonButtons, 61 + IonBackButton, 62 + IonItem, 63 + IonInput, 64 + IonSpinner, 65 + toastController, 66 + } from "@ionic/vue"; 67 + import { codeSlashOutline, logInOutline } from "ionicons/icons"; 68 + import { createAuthorizationUrl } from "@atcute/oauth-browser-client"; 69 + import { useAuthStore } from "@/core/auth/store.js"; 70 + import type { Handle } from "@atcute/lexicons/syntax"; 71 + 72 + const authStore = useAuthStore(); 73 + 74 + const handle = ref(""); 75 + const isLoading = ref(false); 76 + const error = ref<string | null>(null); 77 + 78 + async function handleSignIn() { 79 + const trimmedHandle = handle.value.trim() as Handle; 80 + if (!trimmedHandle) return; 81 + 82 + isLoading.value = true; 83 + error.value = null; 84 + authStore.state = "authenticating"; 85 + 86 + try { 87 + const authUrl = await createAuthorizationUrl({ 88 + target: { type: "account", identifier: trimmedHandle }, 89 + scope: "atproto", 90 + }); 91 + 92 + window.location.href = authUrl.toString(); 93 + } catch (e) { 94 + error.value = e instanceof Error ? e.message : "Failed to start sign in"; 95 + authStore.state = "error"; 96 + isLoading.value = false; 97 + 98 + const toast = await toastController.create({ message: error.value, duration: 3000, color: "danger" }); 99 + await toast.present(); 100 + } 101 + } 102 + </script> 103 + 104 + <style scoped> 105 + .login-container { 106 + display: flex; 107 + flex-direction: column; 108 + align-items: center; 109 + justify-content: center; 110 + min-height: 70vh; 111 + padding: 32px 28px; 112 + text-align: center; 113 + gap: 14px; 114 + } 115 + 116 + .brand-icon { 117 + width: 72px; 118 + height: 72px; 119 + border-radius: var(--t-radius-lg); 120 + background: var(--t-accent-dim); 121 + border: 1px solid var(--t-border-strong); 122 + display: flex; 123 + align-items: center; 124 + justify-content: center; 125 + font-size: 30px; 126 + color: var(--t-accent); 127 + margin-bottom: 4px; 128 + } 129 + 130 + .login-title { 131 + font-size: 22px; 132 + font-weight: 700; 133 + color: var(--t-text-primary); 134 + margin: 0; 135 + line-height: 1.2; 136 + } 137 + 138 + .login-subtitle { 139 + font-size: 14px; 140 + color: var(--t-text-secondary); 141 + margin: 0; 142 + line-height: 1.55; 143 + max-width: 280px; 144 + } 145 + 146 + .handle-input { 147 + --background: var(--ion-color-light); 148 + --border-radius: var(--t-radius-md); 149 + --padding-start: 16px; 150 + --padding-end: 16px; 151 + width: 100%; 152 + max-width: 320px; 153 + margin-bottom: 8px; 154 + } 155 + 156 + .login-btn { 157 + --background: var(--t-accent); 158 + --background-activated: var(--t-accent); 159 + --color: #0d1117; 160 + --border-radius: var(--t-radius-md); 161 + width: 100%; 162 + max-width: 320px; 163 + font-weight: 600; 164 + font-size: 15px; 165 + margin-top: 6px; 166 + } 167 + 168 + .login-error { 169 + font-size: 13px; 170 + color: var(--ion-color-danger); 171 + margin: 8px 0 0; 172 + max-width: 280px; 173 + } 174 + 175 + .login-hint { 176 + font-size: 13px; 177 + color: var(--t-text-muted); 178 + margin: 4px 0 0; 179 + } 180 + 181 + .login-hint a { 182 + color: var(--t-accent); 183 + text-decoration: none; 184 + } 185 + </style>
+81
apps/twisted/src/features/auth/OAuthCallbackPage.vue
··· 1 + <template> 2 + <ion-page> 3 + <ion-content :fullscreen="true" class="callback-content"> 4 + <div class="callback-container"> 5 + <ion-spinner name="crescent" class="callback-spinner" /> 6 + <p class="callback-text">{{ statusMessage }}</p> 7 + </div> 8 + </ion-content> 9 + </ion-page> 10 + </template> 11 + 12 + <script setup lang="ts"> 13 + import { onMounted, ref } from "vue"; 14 + import { useRouter } from "vue-router"; 15 + import { IonPage, IonContent, IonSpinner, toastController } from "@ionic/vue"; 16 + import { finalizeAuthorization } from "@atcute/oauth-browser-client"; 17 + import { useAuthStore } from "@/core/auth/store.js"; 18 + 19 + const router = useRouter(); 20 + const authStore = useAuthStore(); 21 + 22 + const statusMessage = ref("Completing sign in..."); 23 + 24 + onMounted(async () => { 25 + try { 26 + const params = new URLSearchParams(window.location.hash.slice(1)); 27 + const result = await finalizeAuthorization(params); 28 + 29 + authStore.setSession(result.session); 30 + statusMessage.value = "Signed in successfully!"; 31 + 32 + const toast = await toastController.create({ 33 + message: "Signed in successfully!", 34 + duration: 2000, 35 + color: "success", 36 + }); 37 + await toast.present(); 38 + 39 + router.replace("/tabs/profile"); 40 + } catch (e) { 41 + const message = e instanceof Error ? e.message : "Sign in failed"; 42 + statusMessage.value = message; 43 + authStore.state = "error"; 44 + authStore.error = message; 45 + 46 + const toast = await toastController.create({ message, duration: 3000, color: "danger" }); 47 + await toast.present(); 48 + 49 + setTimeout(() => { 50 + router.replace("/tabs/profile"); 51 + }, 1500); 52 + } 53 + }); 54 + </script> 55 + 56 + <style scoped> 57 + .callback-content { 58 + --background: var(--ion-background-color); 59 + } 60 + 61 + .callback-container { 62 + display: flex; 63 + flex-direction: column; 64 + align-items: center; 65 + justify-content: center; 66 + min-height: 100vh; 67 + gap: 20px; 68 + } 69 + 70 + .callback-spinner { 71 + width: 48px; 72 + height: 48px; 73 + color: var(--t-accent); 74 + } 75 + 76 + .callback-text { 77 + font-size: 16px; 78 + color: var(--t-text-secondary); 79 + margin: 0; 80 + } 81 + </style>
+567 -85
apps/twisted/src/features/profile/ProfilePage.vue
··· 2 2 <ion-page> 3 3 <ion-header :translucent="true"> 4 4 <ion-toolbar> 5 - <ion-title>Profile</ion-title> 5 + <ion-title class="profile-title mono">{{ profile?.handle || "Profile" }}</ion-title> 6 + <ion-buttons v-if="authStore.isAuthenticated" slot="end"> 7 + <ion-button @click="goToSettings"> 8 + <ion-icon slot="icon-only" :icon="settingsOutline" /> 9 + </ion-button> 10 + <ion-button @click="handleLogout"> 11 + <ion-icon slot="icon-only" :icon="logOutOutline" color="danger" /> 12 + </ion-button> 13 + </ion-buttons> 6 14 </ion-toolbar> 7 15 </ion-header> 8 16 9 17 <ion-content :fullscreen="true"> 10 - <ion-header collapse="condense"> 11 - <ion-toolbar> 12 - <ion-title size="large">Profile</ion-title> 13 - </ion-toolbar> 14 - </ion-header> 18 + <template v-if="authStore.state === 'restoring'"> 19 + <SkeletonLoader variant="profile" /> 20 + <SkeletonLoader v-for="n in 3" :key="n" variant="card" /> 21 + </template> 22 + 23 + <template v-else-if="authStore.isAuthenticated"> 24 + <div class="profile-header"> 25 + <ion-avatar class="avatar"> 26 + <img v-if="profile?.avatar" :src="profile.avatar" :alt="`${profile.handle} avatar`" class="avatar-image" /> 27 + <div v-else class="avatar-fallback" :style="{ background: avatarColor(profile?.handle || '') }"> 28 + {{ initials(profile?.handle || '') }} 29 + </div> 30 + </ion-avatar> 15 31 16 - <div class="signin-container"> 17 - <div class="brand-icon"> 18 - <ion-icon :icon="codeSlashOutline" /> 32 + <div class="profile-info"> 33 + <div class="profile-handle mono">{{ profile?.handle || authStore.did }}</div> 34 + <div v-if="profile?.displayName" class="profile-name">{{ profile.displayName }}</div> 35 + </div> 19 36 </div> 20 37 21 - <h2 class="signin-title">Sign in to Tangled</h2> 22 - <p class="signin-subtitle"> 23 - Use your AT Protocol handle to sign in and access your starred repos, follow developers, and get a 24 - personalized activity feed. 25 - </p> 38 + <div v-if="profile?.bio" class="profile-bio">{{ profile.bio }}</div> 39 + 40 + <div v-if="(profile as any)?.location || (profile as any)?.pronouns" class="profile-meta"> 41 + <span v-if="(profile as any).location" class="meta-item"> 42 + <ion-icon :icon="locationOutline" class="meta-icon" /> 43 + {{ (profile as any).location }} 44 + </span> 45 + <span v-if="(profile as any).pronouns" class="meta-item"> 46 + <ion-icon :icon="personOutline" class="meta-icon" /> 47 + {{ (profile as any).pronouns }} 48 + </span> 49 + </div> 50 + 51 + <div v-if="(profile as any)?.links?.length" class="profile-links"> 52 + <a 53 + v-for="link in (profile as any).links" 54 + :key="link" 55 + :href="link" 56 + class="profile-link" 57 + target="_blank" 58 + rel="noopener noreferrer"> 59 + <ion-icon :icon="linkOutline" class="link-icon" /> 60 + {{ displayLink(link) }} 61 + </a> 62 + </div> 63 + 64 + <div class="stats-row"> 65 + <div v-for="stat in stats" :key="stat.label" class="stat-pill"> 66 + <span class="stat-value">{{ stat.value }}</span> 67 + <span class="stat-label">{{ stat.label }}</span> 68 + </div> 69 + </div> 70 + 71 + <ion-segment v-model="section" class="profile-segment" scrollable> 72 + <ion-segment-button value="repos">Repos</ion-segment-button> 73 + <ion-segment-button value="strings">Strings</ion-segment-button> 74 + <ion-segment-button value="issues">Issues</ion-segment-button> 75 + <ion-segment-button value="prs">PRs</ion-segment-button> 76 + <ion-segment-button value="following">Following</ion-segment-button> 77 + </ion-segment> 78 + 79 + <template v-if="section === 'repos'"> 80 + <template v-if="pinnedRepos.length"> 81 + <h3 class="section-label">Pinned</h3> 82 + <RepoCard 83 + v-for="repo in pinnedRepos" 84 + :key="repo.atUri" 85 + :repo="repo" 86 + @click="navigateToRepo(repo)" 87 + @owner-click="navigateToUser(repo.ownerHandle)" /> 88 + </template> 89 + 90 + <h3 class="section-label">Repositories</h3> 91 + <template v-if="reposQuery.isPending.value"> 92 + <SkeletonLoader v-for="n in 3" :key="n" variant="card" /> 93 + </template> 94 + <template v-else-if="otherRepos.length"> 95 + <RepoCard 96 + v-for="repo in otherRepos" 97 + :key="repo.atUri" 98 + :repo="repo" 99 + @click="navigateToRepo(repo)" 100 + @owner-click="navigateToUser(repo.ownerHandle)" /> 101 + </template> 102 + <EmptyState 103 + v-else-if="!pinnedRepos.length" 104 + :icon="codeSlashOutline" 105 + title="No repositories" 106 + message="You haven't created any repositories yet." /> 107 + </template> 108 + 109 + <UserStrings v-else-if="section === 'strings'" :strings="strings" :is-loading="stringsQuery.isPending.value" /> 110 + 111 + <RepoIssues 112 + v-else-if="section === 'issues'" 113 + :issues="issues" 114 + :is-loading="issuesQuery.isPending.value" 115 + @select="navigateToIssue" /> 116 + 117 + <RepoPRs 118 + v-else-if="section === 'prs'" 119 + :prs="pullRequests" 120 + :is-loading="pullRequestsQuery.isPending.value" 121 + @select="navigateToPullRequest" /> 26 122 27 - <ion-button class="signin-btn" expand="block" @click="handleSignIn"> 28 - <ion-icon slot="start" :icon="logInOutline" /> 29 - Sign in with AT Protocol 30 - </ion-button> 123 + <template v-else> 124 + <template v-if="followingQuery.isPending.value"> 125 + <SkeletonLoader v-for="n in 3" :key="n" variant="list-item" /> 126 + </template> 127 + <template v-else-if="following.length"> 128 + <UserCard 129 + v-for="user in following" 130 + :key="user.followAtUri" 131 + :user="user" 132 + @click="navigateToUser(user.handle)" /> 133 + </template> 134 + <EmptyState 135 + v-else 136 + :icon="peopleOutline" 137 + title="Not following anyone" 138 + message="You aren't following any profiles yet." /> 139 + </template> 31 140 32 - <p class="signin-hint"> 33 - Don't have a handle? 34 - <span class="signin-link">Get one at bsky.app</span> 35 - </p> 36 - </div> 141 + <ion-list v-if="authStore.accounts.length > 1" class="account-list" lines="none"> 142 + <ion-list-header> 143 + <ion-label>Switch Account</ion-label> 144 + </ion-list-header> 145 + <ion-item 146 + v-for="accountDid in authStore.accounts" 147 + :key="accountDid" 148 + button 149 + :disabled="accountDid === authStore.did" 150 + @click="switchToAccount(accountDid)"> 151 + <ion-label>{{ accountDid }}</ion-label> 152 + <ion-icon v-if="accountDid === authStore.did" :icon="checkmarkOutline" slot="end" color="primary" /> 153 + </ion-item> 154 + </ion-list> 155 + </template> 156 + 157 + <template v-else> 158 + <div class="signin-container"> 159 + <div class="brand-icon"> 160 + <ion-icon :icon="codeSlashOutline" /> 161 + </div> 162 + 163 + <h2 class="signin-title">Sign in to Tangled</h2> 164 + <p class="signin-subtitle"> 165 + Use your AT Protocol handle to sign in and access your starred repos, follow developers, and get a 166 + personalized activity feed. 167 + </p> 168 + 169 + <ion-button class="signin-btn" expand="block" @click="handleSignIn"> 170 + <ion-icon slot="start" :icon="logInOutline" /> 171 + Sign in with AT Protocol 172 + </ion-button> 173 + 174 + <p class="signin-hint"> 175 + Don't have a handle? 176 + <a href="https://bsky.app" target="_blank" rel="noopener">Get one at bsky.app</a> 177 + </p> 178 + </div> 179 + </template> 37 180 </ion-content> 38 181 </ion-page> 39 182 </template> 40 183 41 184 <script setup lang="ts"> 42 - import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButton, IonIcon } from "@ionic/vue"; 43 - import { codeSlashOutline, logInOutline } from "ionicons/icons"; 185 + import { ref, computed } from "vue"; 186 + import { useRouter } from "vue-router"; 187 + import { 188 + IonPage, 189 + IonHeader, 190 + IonToolbar, 191 + IonTitle, 192 + IonContent, 193 + IonButton, 194 + IonIcon, 195 + IonButtons, 196 + IonAvatar, 197 + IonList, 198 + IonItem, 199 + IonLabel, 200 + IonListHeader, 201 + IonSegment, 202 + IonSegmentButton, 203 + alertController, 204 + toastController, 205 + } from "@ionic/vue"; 206 + import { 207 + codeSlashOutline, 208 + logInOutline, 209 + logOutOutline, 210 + settingsOutline, 211 + personOutline, 212 + locationOutline, 213 + linkOutline, 214 + checkmarkOutline, 215 + peopleOutline, 216 + } from "ionicons/icons"; 217 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 218 + import EmptyState from "@/components/common/EmptyState.vue"; 219 + import RepoCard from "@/components/common/RepoCard.vue"; 220 + import UserCard from "@/components/common/UserCard.vue"; 221 + import RepoIssues from "@/features/repo/RepoIssues.vue"; 222 + import RepoPRs from "@/features/repo/RepoPRs.vue"; 223 + import UserStrings from "@/features/profile/UserStrings.vue"; 224 + import { useAuthStore } from "@/core/auth/store.js"; 225 + import { 226 + useActorProfile, 227 + useUserRepos, 228 + useUserStrings, 229 + useUserIssues, 230 + useUserPullRequests, 231 + useUserFollowing, 232 + } from "@/services/tangled/queries.js"; 233 + import { useIndexedProfileSummary } from "@/services/project-api/queries.js"; 234 + import type { IssueSummary } from "@/domain/models/issue.js"; 235 + import type { PullRequestSummary } from "@/domain/models/pull-request.js"; 236 + import type { RepoSummary } from "@/domain/models/repo.js"; 237 + 238 + const router = useRouter(); 239 + const authStore = useAuthStore(); 240 + 241 + const identifier = computed(() => authStore.did ?? ""); 242 + const isReady = computed(() => !!authStore.did); 243 + const section = ref<"repos" | "strings" | "issues" | "prs" | "following">("repos"); 244 + 245 + const profileQuery = useActorProfile(identifier, undefined, { enabled: isReady }); 246 + const reposQuery = useUserRepos(identifier, { enabled: isReady }); 247 + const stringsQuery = useUserStrings(identifier, { enabled: isReady }); 248 + const issuesQuery = useUserIssues(identifier, { enabled: isReady }); 249 + const pullRequestsQuery = useUserPullRequests(identifier, { enabled: isReady }); 250 + const followingQuery = useUserFollowing(identifier, { enabled: isReady }); 251 + const indexedProfileSummaryQuery = useIndexedProfileSummary(identifier, { enabled: isReady }); 252 + 253 + const profile = computed(() => profileQuery.data.value); 254 + const repos = computed(() => reposQuery.data.value ?? []); 255 + const strings = computed(() => stringsQuery.data.value ?? []); 256 + const issues = computed(() => issuesQuery.data.value ?? []); 257 + const pullRequests = computed(() => pullRequestsQuery.data.value ?? []); 258 + const following = computed(() => followingQuery.data.value ?? []); 259 + const indexedProfileSummary = computed(() => indexedProfileSummaryQuery.data.value); 260 + 261 + const pinnedUris = computed(() => (profile.value as { pinnedRepos?: string[] } | undefined)?.pinnedRepos ?? []); 262 + const pinnedRepos = computed(() => repos.value.filter((r) => pinnedUris.value.includes(r.atUri))); 263 + const otherRepos = computed(() => repos.value.filter((r) => !pinnedUris.value.includes(r.atUri))); 264 + 265 + const stats = computed(() => { 266 + const values = [ 267 + { label: "repos", value: repos.value.length }, 268 + { label: "strings", value: strings.value.length }, 269 + { label: "issues", value: issues.value.length }, 270 + { label: "prs", value: pullRequests.value.length }, 271 + ]; 272 + 273 + if (indexedProfileSummary.value?.followerCount != null) { 274 + values.splice(1, 0, { label: "followers", value: indexedProfileSummary.value.followerCount }); 275 + } 276 + 277 + values.splice(indexedProfileSummary.value?.followerCount != null ? 2 : 1, 0, { 278 + label: "following", 279 + value: indexedProfileSummary.value?.followingCount ?? following.value.length, 280 + }); 281 + 282 + return values; 283 + }); 44 284 45 - function handleSignIn() { 46 - // TODO: AT Protocol OAuth flow 47 - } 285 + function handleSignIn() { 286 + router.push("/login"); 287 + } 288 + 289 + async function handleLogout() { 290 + const alert = await alertController.create({ 291 + header: "Sign Out", 292 + message: "Are you sure you want to sign out?", 293 + buttons: [ 294 + { text: "Cancel", role: "cancel" }, 295 + { 296 + text: "Sign Out", 297 + role: "destructive", 298 + handler: async () => { 299 + await authStore.logout(); 300 + const toast = await toastController.create({ 301 + message: "Signed out successfully", 302 + duration: 2000, 303 + color: "success", 304 + }); 305 + await toast.present(); 306 + }, 307 + }, 308 + ], 309 + }); 310 + await alert.present(); 311 + } 312 + 313 + function goToSettings() { 314 + router.push("/tabs/settings"); 315 + } 316 + 317 + async function switchToAccount(did: `did:${string}:${string}`) { 318 + if (did === authStore.did) return; 319 + 320 + const success = await authStore.switchAccount(did); 321 + if (!success) { 322 + const toast = await toastController.create({ 323 + message: "Failed to switch account", 324 + duration: 2000, 325 + color: "danger", 326 + }); 327 + await toast.present(); 328 + } 329 + } 330 + 331 + function navigateToRepo(repo: RepoSummary) { 332 + router.push(`/tabs/home/repo/${repo.ownerHandle}/${repo.name}`); 333 + } 334 + 335 + function navigateToUser(handle: string) { 336 + router.push(`/tabs/home/user/${handle}`); 337 + } 338 + 339 + function navigateToIssue(issue: IssueSummary) { 340 + const repoName = repos.value.find((r) => r.atUri === issue.repoAtUri)?.name; 341 + if (!repoName) return; 342 + router.push(`/tabs/home/repo/${profile.value?.handle ?? identifier.value}/${repoName}/issues/${issue.rkey}`); 343 + } 344 + 345 + function navigateToPullRequest(pr: PullRequestSummary) { 346 + const repoName = repos.value.find((r) => r.atUri === pr.targetRepoAtUri)?.name; 347 + if (!repoName) return; 348 + router.push(`/tabs/home/repo/${profile.value?.handle ?? identifier.value}/${repoName}/pulls/${pr.rkey}`); 349 + } 350 + 351 + function displayLink(url: string): string { 352 + return url.trim().replace(/^[a-z]+:\/\//i, ""); 353 + } 354 + 355 + const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"]; 356 + 357 + function avatarColor(h: string): string { 358 + let hash = 0; 359 + for (const ch of h) hash = (hash * 31 + ch.charCodeAt(0)) & 0xffffffff; 360 + return PALETTE[Math.abs(hash) % PALETTE.length]; 361 + } 362 + 363 + function initials(h: string): string { 364 + return h.split(".")[0].slice(0, 2).toUpperCase(); 365 + } 48 366 </script> 49 367 50 368 <style scoped> 51 - .signin-container { 52 - display: flex; 53 - flex-direction: column; 54 - align-items: center; 55 - justify-content: center; 56 - min-height: 70vh; 57 - padding: 32px 28px; 58 - text-align: center; 59 - gap: 14px; 60 - } 369 + .profile-title { 370 + font-family: var(--t-mono); 371 + font-size: 14px; 372 + } 373 + 374 + .mono { 375 + font-family: var(--t-mono); 376 + } 377 + 378 + .profile-header { 379 + display: flex; 380 + align-items: center; 381 + gap: 16px; 382 + padding: 20px 16px 12px; 383 + } 61 384 62 - .brand-icon { 63 - width: 72px; 64 - height: 72px; 65 - border-radius: var(--t-radius-lg); 66 - background: var(--t-accent-dim); 67 - border: 1px solid var(--t-border-strong); 68 - display: flex; 69 - align-items: center; 70 - justify-content: center; 71 - font-size: 30px; 72 - color: var(--t-accent); 73 - margin-bottom: 4px; 74 - } 385 + .avatar { 386 + width: 64px; 387 + height: 64px; 388 + flex-shrink: 0; 389 + border-radius: var(--t-radius-md); 390 + overflow: hidden; 391 + } 392 + 393 + .avatar-image { 394 + width: 100%; 395 + height: 100%; 396 + object-fit: cover; 397 + display: block; 398 + } 75 399 76 - .signin-title { 77 - font-size: 22px; 78 - font-weight: 700; 79 - color: var(--t-text-primary); 80 - margin: 0; 81 - line-height: 1.2; 82 - } 400 + .avatar-fallback { 401 + width: 100%; 402 + height: 100%; 403 + display: flex; 404 + align-items: center; 405 + justify-content: center; 406 + font-family: var(--t-mono); 407 + font-size: 18px; 408 + font-weight: 700; 409 + color: #0d1117; 410 + } 83 411 84 - .signin-subtitle { 85 - font-size: 14px; 86 - color: var(--t-text-secondary); 87 - margin: 0; 88 - line-height: 1.55; 89 - max-width: 280px; 90 - } 412 + .profile-info { 413 + flex: 1; 414 + min-width: 0; 415 + } 91 416 92 - .signin-btn { 93 - --background: var(--t-accent); 94 - --background-activated: var(--t-accent); 95 - --color: #0d1117; 96 - --border-radius: var(--t-radius-md); 97 - width: 100%; 98 - max-width: 320px; 99 - font-weight: 600; 100 - font-size: 15px; 101 - margin-top: 6px; 102 - } 417 + .profile-handle { 418 + font-family: var(--t-mono); 419 + font-size: 15px; 420 + font-weight: 600; 421 + color: var(--t-accent); 422 + line-height: 1.3; 423 + } 103 424 104 - .signin-hint { 105 - font-size: 13px; 106 - color: var(--t-text-muted); 107 - margin: 4px 0 0; 108 - } 425 + .profile-name { 426 + font-size: 14px; 427 + font-weight: 500; 428 + color: var(--t-text-primary); 429 + margin-top: 2px; 430 + } 109 431 110 - .signin-link { 111 - color: var(--t-accent); 112 - cursor: pointer; 113 - } 432 + .profile-bio { 433 + font-size: 14px; 434 + color: var(--t-text-secondary); 435 + line-height: 1.55; 436 + padding: 0 16px 12px; 437 + } 438 + 439 + .profile-meta { 440 + display: flex; 441 + flex-wrap: wrap; 442 + gap: 12px; 443 + padding: 0 16px 12px; 444 + } 445 + 446 + .meta-item { 447 + display: flex; 448 + align-items: center; 449 + gap: 5px; 450 + font-size: 13px; 451 + color: var(--t-text-muted); 452 + } 453 + 454 + .meta-icon { 455 + font-size: 14px; 456 + } 457 + 458 + .profile-links { 459 + display: flex; 460 + flex-direction: column; 461 + gap: 6px; 462 + padding: 0 16px 14px; 463 + } 464 + 465 + .profile-link { 466 + display: flex; 467 + align-items: center; 468 + gap: 6px; 469 + font-size: 13px; 470 + color: var(--t-accent); 471 + text-decoration: none; 472 + } 473 + 474 + .link-icon { 475 + font-size: 14px; 476 + flex-shrink: 0; 477 + } 478 + 479 + .stats-row { 480 + display: flex; 481 + gap: 8px; 482 + padding: 0 16px 16px; 483 + overflow-x: auto; 484 + scrollbar-width: none; 485 + } 486 + 487 + .stats-row::-webkit-scrollbar { 488 + display: none; 489 + } 490 + 491 + .stat-pill { 492 + display: inline-flex; 493 + align-items: baseline; 494 + gap: 6px; 495 + padding: 10px 12px; 496 + border-radius: 999px; 497 + border: 1px solid var(--t-border); 498 + background: var(--t-surface-raised); 499 + flex-shrink: 0; 500 + } 501 + 502 + .stat-value { 503 + font-family: var(--t-mono); 504 + font-size: 12px; 505 + font-weight: 700; 506 + color: var(--t-text-primary); 507 + } 508 + 509 + .stat-label { 510 + font-size: 12px; 511 + color: var(--t-text-muted); 512 + text-transform: lowercase; 513 + } 514 + 515 + .profile-segment { 516 + padding: 0 12px 8px; 517 + } 518 + 519 + .section-label { 520 + font-size: 11px; 521 + font-weight: 600; 522 + text-transform: uppercase; 523 + letter-spacing: 0.07em; 524 + color: var(--t-text-muted); 525 + margin: 16px 16px 8px; 526 + } 527 + 528 + .account-list { 529 + padding: 0 16px; 530 + margin-top: 24px; 531 + } 532 + 533 + .signin-container { 534 + display: flex; 535 + flex-direction: column; 536 + align-items: center; 537 + justify-content: center; 538 + min-height: 70vh; 539 + padding: 32px 28px; 540 + text-align: center; 541 + gap: 14px; 542 + } 543 + 544 + .brand-icon { 545 + width: 72px; 546 + height: 72px; 547 + border-radius: var(--t-radius-lg); 548 + background: var(--t-accent-dim); 549 + border: 1px solid var(--t-border-strong); 550 + display: flex; 551 + align-items: center; 552 + justify-content: center; 553 + font-size: 30px; 554 + color: var(--t-accent); 555 + margin-bottom: 4px; 556 + } 557 + 558 + .signin-title { 559 + font-size: 22px; 560 + font-weight: 700; 561 + color: var(--t-text-primary); 562 + margin: 0; 563 + line-height: 1.2; 564 + } 565 + 566 + .signin-subtitle { 567 + font-size: 14px; 568 + color: var(--t-text-secondary); 569 + margin: 0; 570 + line-height: 1.55; 571 + max-width: 280px; 572 + } 573 + 574 + .signin-btn { 575 + --background: var(--t-accent); 576 + --background-activated: var(--t-accent); 577 + --color: #0d1117; 578 + --border-radius: var(--t-radius-md); 579 + width: 100%; 580 + max-width: 320px; 581 + font-weight: 600; 582 + font-size: 15px; 583 + margin-top: 6px; 584 + } 585 + 586 + .signin-hint { 587 + font-size: 13px; 588 + color: var(--t-text-muted); 589 + margin: 4px 0 0; 590 + } 591 + 592 + .signin-hint a { 593 + color: var(--t-accent); 594 + text-decoration: none; 595 + } 114 596 </style>
+8 -2
apps/twisted/src/main.ts
··· 9 9 import { persistQueryClient } from "@tanstack/query-persist-client-core"; 10 10 import { createIdbPersister } from "./core/query/persister.js"; 11 11 import { initializeThemePreference } from "./core/theme/preferences.js"; 12 + import { useAuthStore } from "./core/auth/store.js"; 12 13 13 14 import "@ionic/vue/css/core.css"; 14 15 import "@ionic/vue/css/normalize.css"; ··· 42 43 persistQueryClient({ queryClient: queryClient as any, persister: createIdbPersister(), maxAge: 30 * 60 * 1000 }); 43 44 } 44 45 45 - const app = createApp(App).use(IonicVue).use(router).use(createPinia()).use(VueQueryPlugin, { queryClient }); 46 + const pinia = createPinia(); 47 + const app = createApp(App).use(IonicVue).use(router).use(pinia).use(VueQueryPlugin, { queryClient }); 48 + 49 + const authStore = useAuthStore(pinia); 50 + authStore.initialize(); 46 51 47 - router.isReady().then(() => { 52 + router.isReady().then(async () => { 53 + await authStore.restoreSession(); 48 54 app.mount("#app"); 49 55 });
+1
apps/twisted/vite.config.ts
··· 9 9 export default defineConfig({ 10 10 plugins: [vue(), legacy()], 11 11 resolve: { alias: { "@": path.resolve(__dirname, "./src") } }, 12 + server: { host: "127.0.0.1", port: 5173 }, 12 13 test: { globals: true, environment: "jsdom", watch: false, ui: false }, 13 14 });
+5 -5
docs/roadmap.md
··· 67 67 68 68 **Depends on:** App: Search & Discovery (for Constellation service), API: Constellation Integration 69 69 70 - - [ ] OAuth setup with `@atcute/oauth-browser-client` 71 - - [ ] Login page, OAuth flow, callback handling 72 - - [ ] Capacitor deep link configuration 73 - - [ ] Session management (restore, refresh, logout, account switcher) 74 - - [ ] Auth-aware XRPC client using dpopFetch 70 + - [x] OAuth setup with `@atcute/oauth-browser-client` 71 + - [x] Login page, OAuth flow, callback handling 72 + - [x] Capacitor deep link configuration 73 + - [x] Session management (restore, refresh, logout, account switcher) 74 + - [x] Auth-aware XRPC client using dpopFetch 75 75 - [ ] Star repos (write to PDS, count from Constellation) 76 76 - [ ] Follow users (write to PDS, count from Constellation) 77 77 - [ ] React to content (write to PDS, count from Constellation)
+5
packages/api/.env.example
··· 25 25 LOG_FORMAT=json 26 26 ENABLE_ADMIN_ENDPOINTS=false 27 27 # ADMIN_AUTH_TOKEN= 28 + 29 + # OAuth client configuration for the Twisted mobile app 30 + # VITE_OAUTH_CLIENT_ID must be a publicly accessible URL (use a tunnel for local dev) 31 + # OAUTH_CLIENT_ID=https://your-tunnel.example.com/oauth/client-metadata.json 32 + # OAUTH_REDIRECT_URIS=http://127.0.0.1:5173/oauth-callback,io.ionic.starter://oauth-callback
+1
packages/api/internal/api/api.go
··· 48 48 49 49 mux.HandleFunc("GET /healthz", s.handleHealthz) 50 50 mux.HandleFunc("GET /readyz", s.handleReadyz) 51 + mux.HandleFunc("GET /oauth/client-metadata.json", s.handleOAuthClientMetadata) 51 52 mux.HandleFunc("GET /search", s.handleSearch) 52 53 mux.HandleFunc("GET /search/keyword", s.handleSearchKeyword) 53 54 mux.HandleFunc("GET /search/semantic", s.handleNotImplemented)
+48
packages/api/internal/api/oauth.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + ) 7 + 8 + type OAuthClientMetadata struct { 9 + ClientID string `json:"client_id"` 10 + ClientName string `json:"client_name"` 11 + ClientURI string `json:"client_uri,omitempty"` 12 + LogoURI string `json:"logo_uri,omitempty"` 13 + TosURI string `json:"tos_uri,omitempty"` 14 + PolicyURI string `json:"policy_uri,omitempty"` 15 + RedirectURIs []string `json:"redirect_uris"` 16 + Scope string `json:"scope"` 17 + GrantTypes []string `json:"grant_types"` 18 + ResponseTypes []string `json:"response_types"` 19 + ApplicationType string `json:"application_type"` 20 + DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 21 + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 22 + DpopSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` 23 + } 24 + 25 + func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) { 26 + if s.cfg.OAuthClientID == "" { 27 + writeJSON(w, http.StatusNotFound, errorBody("not_configured", "OAuth is not configured on this server")) 28 + return 29 + } 30 + 31 + metadata := OAuthClientMetadata{ 32 + ClientID: s.cfg.OAuthClientID, 33 + ClientName: "Twisted", 34 + ClientURI: s.cfg.OAuthClientID, 35 + RedirectURIs: s.cfg.OAuthRedirectURIs, 36 + Scope: "atproto", 37 + GrantTypes: []string{"authorization_code", "refresh_token"}, 38 + ResponseTypes: []string{"code"}, 39 + ApplicationType: "native", 40 + DpopBoundAccessTokens: true, 41 + TokenEndpointAuthMethod: "none", 42 + DpopSigningAlgValuesSupported: []string{"ES256"}, 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + w.Header().Set("Access-Control-Allow-Origin", "*") 47 + _ = json.NewEncoder(w).Encode(metadata) 48 + }
+55 -43
packages/api/internal/config/config.go
··· 12 12 ) 13 13 14 14 type Config struct { 15 - TursoURL string 16 - TursoToken string 17 - TapURL string 18 - TapAuthPassword string 19 - IndexedCollections string 20 - SearchDefaultLimit int 21 - SearchMaxLimit int 22 - SearchDefaultMode string 23 - EmbeddingProvider string 24 - EmbeddingModel string 25 - EmbeddingAPIKey string 26 - EmbeddingAPIURL string 27 - EmbeddingDim int 28 - EmbeddingBatchSize int 29 - HybridKeywordWeight float64 30 - HybridSemanticWeight float64 31 - HTTPBindAddr string 32 - IndexerHealthAddr string 33 - LogLevel string 34 - LogFormat string 35 - EnableAdminEndpoints bool 15 + TursoURL string 16 + TursoToken string 17 + TapURL string 18 + TapAuthPassword string 19 + IndexedCollections string 20 + SearchDefaultLimit int 21 + SearchMaxLimit int 22 + SearchDefaultMode string 23 + EmbeddingProvider string 24 + EmbeddingModel string 25 + EmbeddingAPIKey string 26 + EmbeddingAPIURL string 27 + EmbeddingDim int 28 + EmbeddingBatchSize int 29 + HybridKeywordWeight float64 30 + HybridSemanticWeight float64 31 + HTTPBindAddr string 32 + IndexerHealthAddr string 33 + LogLevel string 34 + LogFormat string 35 + EnableAdminEndpoints bool 36 36 AdminAuthToken string 37 37 EnableIngestEnrichment bool 38 38 PLCDirectoryURL string ··· 42 42 ConstellationUserAgent string 43 43 ConstellationTimeout time.Duration 44 44 ConstellationCacheTTL time.Duration 45 + OAuthClientID string 46 + OAuthRedirectURIs []string 45 47 } 46 48 47 49 type LoadOptions struct { ··· 53 55 loadDotEnv() 54 56 55 57 cfg := &Config{ 56 - TursoURL: os.Getenv("TURSO_DATABASE_URL"), 57 - TursoToken: os.Getenv("TURSO_AUTH_TOKEN"), 58 - TapURL: os.Getenv("TAP_URL"), 59 - TapAuthPassword: os.Getenv("TAP_AUTH_PASSWORD"), 60 - IndexedCollections: os.Getenv("INDEXED_COLLECTIONS"), 61 - SearchDefaultMode: envOrDefault("SEARCH_DEFAULT_MODE", "keyword"), 62 - EmbeddingProvider: os.Getenv("EMBEDDING_PROVIDER"), 63 - EmbeddingModel: os.Getenv("EMBEDDING_MODEL"), 64 - EmbeddingAPIKey: os.Getenv("EMBEDDING_API_KEY"), 65 - EmbeddingAPIURL: os.Getenv("EMBEDDING_API_URL"), 66 - HTTPBindAddr: envOrDefault("HTTP_BIND_ADDR", ":8080"), 67 - IndexerHealthAddr: envOrDefault("INDEXER_HEALTH_ADDR", ":9090"), 68 - LogLevel: envOrDefault("LOG_LEVEL", "info"), 69 - LogFormat: envOrDefault("LOG_FORMAT", "json"), 70 - AdminAuthToken: os.Getenv("ADMIN_AUTH_TOKEN"), 71 - SearchDefaultLimit: envInt("SEARCH_DEFAULT_LIMIT", 20), 72 - SearchMaxLimit: envInt("SEARCH_MAX_LIMIT", 100), 73 - EmbeddingDim: envInt("EMBEDDING_DIM", 768), 74 - EmbeddingBatchSize: envInt("EMBEDDING_BATCH_SIZE", 32), 75 - HybridKeywordWeight: envFloat("HYBRID_KEYWORD_WEIGHT", 0.65), 76 - HybridSemanticWeight: envFloat("HYBRID_SEMANTIC_WEIGHT", 0.35), 77 - EnableAdminEndpoints: envBool("ENABLE_ADMIN_ENDPOINTS", false), 58 + TursoURL: os.Getenv("TURSO_DATABASE_URL"), 59 + TursoToken: os.Getenv("TURSO_AUTH_TOKEN"), 60 + TapURL: os.Getenv("TAP_URL"), 61 + TapAuthPassword: os.Getenv("TAP_AUTH_PASSWORD"), 62 + IndexedCollections: os.Getenv("INDEXED_COLLECTIONS"), 63 + SearchDefaultMode: envOrDefault("SEARCH_DEFAULT_MODE", "keyword"), 64 + EmbeddingProvider: os.Getenv("EMBEDDING_PROVIDER"), 65 + EmbeddingModel: os.Getenv("EMBEDDING_MODEL"), 66 + EmbeddingAPIKey: os.Getenv("EMBEDDING_API_KEY"), 67 + EmbeddingAPIURL: os.Getenv("EMBEDDING_API_URL"), 68 + HTTPBindAddr: envOrDefault("HTTP_BIND_ADDR", ":8080"), 69 + IndexerHealthAddr: envOrDefault("INDEXER_HEALTH_ADDR", ":9090"), 70 + LogLevel: envOrDefault("LOG_LEVEL", "info"), 71 + LogFormat: envOrDefault("LOG_FORMAT", "json"), 72 + AdminAuthToken: os.Getenv("ADMIN_AUTH_TOKEN"), 73 + SearchDefaultLimit: envInt("SEARCH_DEFAULT_LIMIT", 20), 74 + SearchMaxLimit: envInt("SEARCH_MAX_LIMIT", 100), 75 + EmbeddingDim: envInt("EMBEDDING_DIM", 768), 76 + EmbeddingBatchSize: envInt("EMBEDDING_BATCH_SIZE", 32), 77 + HybridKeywordWeight: envFloat("HYBRID_KEYWORD_WEIGHT", 0.65), 78 + HybridSemanticWeight: envFloat("HYBRID_SEMANTIC_WEIGHT", 0.35), 79 + EnableAdminEndpoints: envBool("ENABLE_ADMIN_ENDPOINTS", false), 78 80 EnableIngestEnrichment: envBool("ENABLE_INGEST_ENRICHMENT", true), 79 81 PLCDirectoryURL: envOrDefault("PLC_DIRECTORY_URL", "https://plc.directory"), 80 82 IdentityServiceURL: envOrDefault("IDENTITY_SERVICE_URL", "https://public.api.bsky.app"), ··· 83 85 ConstellationUserAgent: envOrDefault("CONSTELLATION_USER_AGENT", "twister/1.0 (https://tangled.sh; Owais <desertthunder.dev@gmail.com>)"), 84 86 ConstellationTimeout: envDuration("CONSTELLATION_TIMEOUT", 10*time.Second), 85 87 ConstellationCacheTTL: envDuration("CONSTELLATION_CACHE_TTL", 5*time.Minute), 88 + OAuthClientID: os.Getenv("OAUTH_CLIENT_ID"), 89 + OAuthRedirectURIs: envSlice("OAUTH_REDIRECT_URIS", nil), 86 90 } 87 91 88 92 if opts.Local { ··· 200 204 } 201 205 return b 202 206 } 207 + 208 + func envSlice(key string, def []string) []string { 209 + v := os.Getenv(key) 210 + if v == "" { 211 + return def 212 + } 213 + return strings.Split(v, ",") 214 + }
+6
pnpm-lock.yaml
··· 16 16 '@atcute/client': 17 17 specifier: ^4.2.1 18 18 version: 4.2.1 19 + '@atcute/identity-resolver': 20 + specifier: ^1.2.2 21 + version: 1.2.2(@atcute/identity@1.1.4) 22 + '@atcute/lexicons': 23 + specifier: ^1.2.9 24 + version: 1.2.9 19 25 '@atcute/oauth-browser-client': 20 26 specifier: ^3.0.0 21 27 version: 3.0.0(@atcute/identity@1.1.4)