Coves frontend - a photon fork
at main 212 lines 6.3 kB view raw
1// ============================================================================ 2// Re-export shared ATProto branded types (defined in $lib/types/atproto.ts) 3// ============================================================================ 4 5export type { DID, Handle, InstanceURL } from '$lib/types/atproto' 6export { 7 isValidDID, 8 isValidHandle, 9 isValidInstanceURL, 10 asDID, 11 asHandle, 12 asInstanceURL, 13 tryAsDID, 14 tryAsHandle, 15 tryAsInstanceURL, 16} from '$lib/types/atproto' 17 18// Import for local use within this module 19import { isValidDID, isValidHandle } from '$lib/types/atproto' 20import type { DID, Handle, InstanceURL } from '$lib/types/atproto' 21 22// ============================================================================ 23// Server-Only Branded Types 24// ============================================================================ 25 26/** 27 * Branded type for sealed (encrypted) authentication tokens. 28 * These tokens are encrypted by the Coves backend and should be treated as opaque. 29 * They are used for API authentication via the Authorization header. 30 */ 31export type SealedToken = string & { readonly __brand: 'SealedToken' } 32 33/** 34 * Creates a branded SealedToken from a string. 35 * Sealed tokens are opaque encrypted strings from the Coves backend, 36 * so validation is minimal (just non-empty check). 37 */ 38export function asSealedToken(value: string): SealedToken { 39 if (!value || value.trim().length === 0) { 40 throw new Error('Invalid SealedToken: cannot be empty') 41 } 42 return value as SealedToken 43} 44 45// ============================================================================ 46// Session Types 47// ============================================================================ 48 49/** 50 * Represents a single authenticated account in the session. 51 */ 52export interface AccountSession { 53 /** The DID (Decentralized Identifier) of the account */ 54 readonly did: DID 55 /** The handle/username of the account */ 56 readonly handle: Handle 57 /** The instance/server the account belongs to */ 58 readonly instance: InstanceURL 59 /** Sealed access token for API calls (sealed = encrypted by Coves backend) */ 60 readonly sealedToken: SealedToken 61 /** Optional avatar URL */ 62 readonly avatar?: string 63} 64 65/** 66 * Client-safe account data (excludes sensitive tokens). 67 * This is what gets passed to the client via page data. 68 * Derived from AccountSession to ensure types stay in sync. 69 */ 70export type ClientAccount = Omit<AccountSession, 'sealedToken'> & { id: string } 71 72/** 73 * Unauthenticated client session -- no valid account. 74 */ 75interface UnauthenticatedClientSession { 76 readonly authenticated: false 77 readonly activeAccountId: null 78 readonly account: null 79} 80 81/** 82 * Authenticated client session -- valid account present. 83 */ 84interface AuthenticatedClientSession { 85 readonly authenticated: true 86 readonly activeAccountId: string 87 readonly account: ClientAccount 88} 89 90/** 91 * Client-safe session data (excludes sensitive tokens). 92 * This is what gets passed to the client via page data. 93 * 94 * Uses a discriminated union so that `authenticated: true` guarantees 95 * both `activeAccountId` and `account` are non-null, and vice-versa. 96 */ 97export type ClientSession = 98 | UnauthenticatedClientSession 99 | AuthenticatedClientSession 100 101/** 102 * Response from Go backend's /api/me endpoint. 103 * Returns profile data from the database after validating the session. 104 */ 105export interface ApiMeResponse { 106 did: string 107 handle: string 108 avatar?: string 109} 110 111/** 112 * Converts an AccountSession to a ClientAccount by removing sensitive data. 113 */ 114export function toClientAccount(account: AccountSession): ClientAccount { 115 return { 116 // Use DID as the client-facing ID because the UI components (ProfileSelection, 117 // accounts page, etc.) identify accounts by an `id` field rather than `did`. 118 id: account.did, 119 did: account.did, 120 handle: account.handle, 121 instance: account.instance, 122 avatar: account.avatar, 123 } 124} 125 126/** 127 * Converts an AccountSession (or null) to a ClientSession. 128 */ 129export function toClientSession(account: AccountSession | null): ClientSession { 130 if (!account) { 131 return { authenticated: false, activeAccountId: null, account: null } 132 } 133 const clientAccount = toClientAccount(account) 134 return { 135 authenticated: true, 136 activeAccountId: clientAccount.id, 137 account: clientAccount, 138 } 139} 140 141/** 142 * Validates that a URL string uses a safe protocol (http: or https:). 143 * Rejects javascript:, data:, and other potentially dangerous URI schemes. 144 */ 145function isSafeAvatarUrl(url: string): boolean { 146 try { 147 const parsed = new URL(url) 148 return parsed.protocol === 'https:' || parsed.protocol === 'http:' 149 } catch { 150 return false 151 } 152} 153 154/** 155 * Parses and validates a /api/me response into an AccountSession. 156 * Combines the API response with the instance URL and sealed token (from cookie). 157 * Returns null if validation fails. Logs warnings for each specific validation failure 158 * to aid debugging. 159 */ 160export function parseApiMeResponse( 161 data: unknown, 162 instance: InstanceURL, 163 sealedToken: SealedToken, 164): AccountSession | null { 165 if (typeof data !== 'object' || data === null) { 166 console.error( 167 '[parseApiMeResponse] Invalid input: expected object, got', 168 typeof data, 169 ) 170 return null 171 } 172 const obj = data as Record<string, unknown> 173 174 if (typeof obj.did !== 'string') { 175 console.error('[parseApiMeResponse] Missing or non-string "did" field') 176 return null 177 } 178 if (!isValidDID(obj.did)) { 179 console.error('[parseApiMeResponse] Invalid DID format:', obj.did) 180 return null 181 } 182 183 if (typeof obj.handle !== 'string') { 184 console.error('[parseApiMeResponse] Missing or non-string "handle" field') 185 return null 186 } 187 if (!isValidHandle(obj.handle)) { 188 console.error('[parseApiMeResponse] Invalid handle format:', obj.handle) 189 return null 190 } 191 192 let avatar: string | undefined 193 if (typeof obj.avatar === 'string') { 194 if (isSafeAvatarUrl(obj.avatar)) { 195 avatar = obj.avatar 196 } else { 197 console.warn( 198 '[parseApiMeResponse] Avatar URL rejected (unsafe protocol or invalid URL):', 199 obj.avatar, 200 ) 201 avatar = undefined 202 } 203 } 204 205 return { 206 did: obj.did as DID, 207 handle: obj.handle as Handle, 208 instance, 209 sealedToken, 210 avatar, 211 } 212}