Coves frontend - a photon fork
1import { redirect, type Handle, type HandleServerError } from '@sveltejs/kit'
2import { dev } from '$app/environment'
3import { env } from '$env/dynamic/public'
4import {
5 parseApiMeResponse,
6 asInstanceURL,
7 asSealedToken,
8} from '$lib/server/session'
9
10function getInstanceUrl(): string {
11 return env.PUBLIC_INTERNAL_INSTANCE || env.PUBLIC_INSTANCE_URL || ''
12}
13
14/**
15 * Returns the canonical hostname (with port) from PUBLIC_INSTANCE_URL, if configured.
16 *
17 * In development, the ATProto OAuth spec (RFC 8252) requires the callback redirect_uri
18 * to use 127.0.0.1 rather than "localhost". The Go backend sets APPVIEW_PUBLIC_URL to
19 * http://127.0.0.1:8080, so the coves_session cookie is set on the 127.0.0.1 domain.
20 * If a user navigates to localhost:8080 instead, the cookie is invisible and the user
21 * appears unauthenticated. This function extracts the canonical host so we can redirect
22 * mismatched hostnames to the correct origin.
23 */
24function getCanonicalHost(): string | null {
25 const publicUrl = env.PUBLIC_INSTANCE_URL
26 if (!publicUrl) return null
27 try {
28 return new URL(publicUrl).host
29 } catch {
30 return null
31 }
32}
33
34/**
35 * Checks whether an error is a network-level failure (DNS, TLS, connection refused, etc.).
36 * Inspects the error message for known network-related keywords rather than matching on
37 * error type alone, to avoid misclassifying programming bugs as transient network errors.
38 */
39function isNetworkError(error: unknown): boolean {
40 if (error instanceof Error) {
41 const msg = error.message.toLowerCase()
42 return (
43 msg.includes('fetch failed') ||
44 msg.includes('network') ||
45 msg.includes('econnrefused') ||
46 msg.includes('enotfound') ||
47 msg.includes('etimedout') ||
48 msg.includes('tls') ||
49 msg.includes('ssl') ||
50 msg.includes('dns')
51 )
52 }
53 return false
54}
55
56export const handle: Handle = async ({ event, resolve }) => {
57 // DEV MODE: Normalize hostname to match the OAuth callback domain.
58 // The ATProto PDS requires 127.0.0.1 in redirect_uri (per RFC 8252), so the
59 // coves_session cookie is set on 127.0.0.1. If the user accesses the app via
60 // "localhost" instead, the cookie is invisible and auth silently fails.
61 // Redirect to the canonical host from PUBLIC_INSTANCE_URL to ensure consistency.
62 if (dev) {
63 const canonicalHost = getCanonicalHost()
64 if (canonicalHost && event.url.host !== canonicalHost) {
65 const canonicalUrl = new URL(event.url)
66 const canonical = new URL(env.PUBLIC_INSTANCE_URL!)
67 canonicalUrl.hostname = canonical.hostname
68 canonicalUrl.port = canonical.port
69 canonicalUrl.protocol = canonical.protocol
70 redirect(302, canonicalUrl.toString())
71 }
72 }
73
74 event.locals.auth = { authenticated: false }
75
76 const covesSession = event.cookies.get('coves_session')
77 if (!covesSession) {
78 return resolve(event)
79 }
80
81 const instanceUrl = getInstanceUrl()
82 if (!instanceUrl) {
83 throw new Error(
84 '[hooks] No instance URL configured. Set PUBLIC_INTERNAL_INSTANCE or PUBLIC_INSTANCE_URL.',
85 )
86 }
87
88 // Validate configuration eagerly — these throw on invalid input and must
89 // NOT be caught so that misconfiguration surfaces immediately on the first request.
90 const instance = asInstanceURL(instanceUrl)
91 const sealedToken = asSealedToken(covesSession)
92
93 // TODO: Consider caching /api/me responses or skipping validation for proxy
94 // requests to reduce latency. Currently /api/me is called on every request.
95 try {
96 const response = await fetch(`${instance}/api/me`, {
97 headers: {
98 Cookie: `coves_session=${covesSession}`,
99 },
100 })
101
102 if (!response.ok) {
103 if (response.status === 401) {
104 // Session expired or revoked — clear the stale cookie so we don't
105 // make a wasted /api/me round-trip on every subsequent request.
106 event.cookies.delete('coves_session', { path: '/' })
107 // Flag so the layout can show "Your session has expired" to the user
108 event.locals.sessionExpired = true
109 } else {
110 console.warn(
111 `[hooks] /api/me returned ${response.status} - treating as unauthenticated`,
112 )
113 }
114 return resolve(event)
115 }
116
117 const data: unknown = await response.json()
118 const account = parseApiMeResponse(data, instance, sealedToken)
119
120 if (!account) {
121 console.warn(
122 '[hooks] /api/me response failed validation - treating as unauthenticated',
123 )
124 event.locals.authError = 'validation_error'
125 return resolve(event)
126 }
127
128 event.locals.auth = {
129 authenticated: true,
130 account,
131 authToken: sealedToken,
132 }
133 } catch (error) {
134 // Distinguish network/infrastructure errors from validation errors.
135 // Network errors (DNS, TLS, timeouts, connection refused) are likely
136 // temporary — preserve the cookie so the user can retry.
137 if (isNetworkError(error)) {
138 console.warn(
139 '[hooks] Network error calling /api/me - backend may be unreachable:',
140 error,
141 )
142 event.locals.authError = 'network_error'
143 } else if (error instanceof SyntaxError) {
144 // JSON parse error from response.json() — the server returned
145 // non-JSON content (e.g. HTML error page, empty body)
146 console.warn(
147 '[hooks] /api/me returned invalid JSON - treating as unauthenticated:',
148 error,
149 )
150 event.locals.authError = 'validation_error'
151 } else {
152 console.warn(
153 '[hooks] Unexpected error calling /api/me - treating as unauthenticated:',
154 error,
155 )
156 event.locals.authError = 'network_error'
157 }
158 }
159
160 return resolve(event)
161}
162
163export const handleError: HandleServerError = async ({
164 error,
165 event,
166 status,
167 message,
168}) => {
169 if (status == 404) {
170 return { message: 'Not found' }
171 }
172
173 console.error(`An error was captured:`)
174 console.error(error)
175 console.error(`Event:`, event)
176 console.error(`Status:`, status)
177 console.error(`Message:`, message)
178
179 return { message: 'An unexpected error occurred' }
180}