Coves frontend - a photon fork
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}