fix csrf, rename cookie as __HOST
12344567891091112131415167 unmodified linesExpand allservices: postgres: profiles: [dev] ports: # `!reset` replaces the base file's `5432:5432` publish instead of adding to it: # binding both `0.0.0.0:5432` and `127.0.0.1:5432` on one container EADDRINUSEs. ports: !reset - "127.0.0.1:${DEV_POSTGRES_PORT:-5432}:5432"
redis: profiles: [dev] ports: ports: !reset - "127.0.0.1:${DEV_REDIS_PORT:-6379}:6379"
minio: profiles: [dev]167 unmodified linesExpand all18 unmodified lines1920212223242526272829303154 unmodified lines86878889909192939495969728 unmodified lines126127128129124130131132133134368 unmodified linesExpand all18 unmodified lines getOAuthClient, rotateKeysIfNeeded,} from './lib/oauth-client'import { startPrivateSiteReaper } from './lib/private-site-reaper'import { pruneHandoffs, pruneSessions } from './lib/private-sites-db'import { closeRedisClient, getRedisClient } from './lib/redis'import { ensureServiceIdentityKeypair } from './lib/service-identity'import type { Config } from './lib/types'import { SESSION_COOKIE_NAME } from './lib/wisp-auth'import { adminRoutes } from './routes/admin'import { authRoutes } from './routes/auth'import { domainRoutes } from './routes/domain'import { privateRedeemRoutes } from './routes/private-redeem'54 unmodified linesconst runMaintenance = async () => { console.log('[Maintenance] Running periodic maintenance...') await cleanupExpiredSessions() await rotateKeysIfNeeded() // Handoff and private-session rows are single-use or short-lived by design; without // a sweep they accumulate forever. await pruneHandoffs() await pruneSessions()}
// Run maintenance on startuprunMaintenance()28 unmodified lines id: Bun.env.NODE_ENV !== 'production' ? undefined : null, }, cookie: { secrets: cookieSecret, sign: ['did'], sign: [SESSION_COOKIE_NAME], },}) // Observability middleware .onBeforeHandle(observabilityMiddleware('main-app').beforeHandle)368 unmodified linesExpand all21 unmodified lines2223242526272829303132333434353637383940414278 unmodified lines12112212312412512412612712812913026 unmodified lines15715815916015916116216316416539 unmodified lines20520620720820720820921021121221321466 unmodified lines28128228328428328428528628728828929029129229329429529629729829916 unmodified lines31631731831932032132232332432532632732832933030931033133233333433533685 unmodified lines21 unmodified linesimport { type AccessPrincipal, buildPrivateStorageKey, buildSessionCookie, countCookieOccurrences, evaluateAccess, generateRecordId, generateSessionSecret, hashSecret, hashShareTokenSync, isValidSiteId, PRIVATE_ACCESS_PAGE_STYLES, PRIVATE_GRANT_QUERY_PARAM, PRIVATE_SESSION_COOKIE, PRIVATE_SESSION_TTL_MINUTES, parseCookieHeader, privateResponseHeaders, sessionCookieName,} from '@wispplace/private-sites'import { consumeHandoff, createSession,78 unmodified lines */const scopedSignInPage = (siteId: string, token: string, audienceDid: string): Response => { const body = `${privatePageHead('sign in required')}<body><main class="private-page private-shell"><script>history.replaceState(null, '', location.pathname)</script><div class="private-brand"><strong>wisp.place</strong><span>private access</span></div><div class="private-brand"><strong>wisp.place</strong></div><p class="private-kicker">account access</p><h1>this link is for a specific account</h1><p>it was shared with <code>${escapeHtml(audienceDid)}</code>. sign in with that account to open it.</p><form class="private-form" method="POST" action="${escapeHtml(mainAppOrigin())}/private/redeem">26 unmodified lines */const anonymousSignInPage = (siteId: string): Response => { const body = `${privatePageHead('sign in')}<body><main class="private-page private-shell"><div class="private-brand"><strong>wisp.place</strong><span>private access</span></div><div class="private-brand"><strong>wisp.place</strong></div><p class="private-kicker">access required</p><h1>private site</h1><p>this address needs a share link, or an account with access to it.</p><form class="private-form" method="GET" action="${escapeHtml(mainAppOrigin())}/private/open">39 unmodified lines if (handoffToken) { // Single-use handoff minted by main-app: either an owner crossing origins, or a // DID-scoped share redeemed after the identity bounce. main-app has already made // the access decision in both cases; consuming the row is what proves it. const consumed = await consumeHandoff(hashSecret(handoffToken)) if (!consumed || consumed.siteId !== siteId) { const consumed = await consumeHandoff(hashSecret(handoffToken), siteId) if (!consumed) { logger.info('[PrivateSite] Handoff rejected', { siteId }) return privateNotFound() } if (consumed.ownerDid) {66 unmodified lines * The session must belong to this exact site, so a cookie leaked from one private origin * cannot authorize another. */const principalFromSession = async (request: Request, siteId: string): Promise<AccessPrincipal | null> => { const cookies = parseCookieHeader(request.headers.get('cookie')) const raw = cookies[PRIVATE_SESSION_COOKIE] const cookieHeader = request.headers.get('cookie') // The name depends on the transport: https deployments use the `__Host-` prefixed // name, which the browser refuses to set from any other host or with a Domain // attribute. Anything under the other name is ignored here. const name = sessionCookieName(isSecureRequest(request)) // A duplicate name means the jar was tampered with (a Domain-scoped cookie tossed // from a sibling private site). Refuse to pick one. if (countCookieOccurrences(cookieHeader, name) !== 1) return null
const cookies = parseCookieHeader(cookieHeader) const raw = cookies[name] if (!raw) return null
const session = await findLiveSession(hashSecret(raw)) if (!session || session.siteId !== siteId) return null16 unmodified lines if (!isValidSiteId(siteId)) return privateNotFound()
const site = await getPrivateSite(siteId)
// A credential presented at a site that does not exist must answer exactly like a // bad credential at one that does. Without this, `?k=garbage` would render the // sign-in page only for nonexistent sites, turning any credential parameter into a // private-site existence oracle. if (!site) { const probe = new URL(request.url) if (probe.searchParams.has(PRIVATE_SHARE_QUERY_PARAM) || probe.searchParams.has(PRIVATE_GRANT_QUERY_PARAM)) { return privateNotFound() } }
// A credential in the URL is exchanged for a cookie and redirected away. Runs before // the existence check so a bad credential and a missing site are indistinguishable. // A credential in the URL is exchanged for a cookie and redirected away. Combined // with the probe above, a bad credential and a missing site are indistinguishable. const exchanged = site ? await tryExchangeCredential(request, siteId) : null if (exchanged) return exchanged
const principal = site ? await principalFromSession(request, siteId) : null85 unmodified lines162 unmodified linesExpand all1631641651661671671681691701711721731741751761771781791801781811821831841851 unmodified line162 unmodified linesExpand all `}
/** * Consume a one-time owner handoff token. * Consume a one-time owner handoff token for a specific site. * * The update is conditional and returns the row, so a concurrent second use cannot also * succeed: whichever statement wins marks it consumed and the other matches nothing. * The site id is part of the condition, so presenting a valid handoff at the wrong * private host neither succeeds nor burns the token for its real destination. */export async function consumeHandoff( secretHash: string, siteId: string,): Promise<{ siteId: string; ownerDid: string | null; shareId: string | null } | null> { const rows = await sql<Array<{ site_id: string; owner_did: string | null; share_id: string | null }>>` UPDATE private_site_handoffs SET consumed_at = NOW() WHERE secret_hash = ${secretHash} AND consumed_at IS NULL AND expires_at > NOW() WHERE secret_hash = ${secretHash} AND site_id = ${siteId} AND consumed_at IS NULL AND expires_at > NOW() RETURNING site_id, owner_did, share_id ` const row = rows[0] return row ? { siteId: row.site_id, ownerDid: row.owner_did, shareId: row.share_id } : null1 unmodified line55 unmodified lines5657585960616263646566676869707172737475767778798067818283848527 unmodified lines55 unmodified linesexport const readPrivateSiteUpload = async ( request: Request, options: PrivateSiteUploadOptions = {},): Promise<PrivateSiteUpload> => { // Reject oversized bodies before they are buffered whole into memory. The per-file // accounting below only counts `files` parts, so without this a giant non-file // field would sail past the size limit. 1 MiB of slack covers multipart boundaries // and small fields; `Content-Length` is advisory, so this is a first line, not the // only one (the per-file checks below still apply). const declaredLength = Number(request.headers.get('content-length') ?? '0') if (declaredLength > MAX_PRIVATE_SITE_SIZE + 1024 * 1024) { throw new PrivateSiteUploadError(`private sites are limited to ${MAX_PRIVATE_SITE_SIZE} bytes`, 413) }
let form: FormData try { form = await request.formData() } catch { throw new PrivateSiteUploadError('expected multipart/form-data body', 400) }
const rawName = form.get('name') if (typeof rawName === 'string' && rawName.length > 4096) { throw new PrivateSiteUploadError('name must be at most 128 characters', 400) } const name = String(form.get('name') ?? '').trim() const name = String(rawName ?? '').trim() const expiryMinutes = parseExpiryMinutes(form.get('expiryMinutes')) const files: UploadedPrivateFile[] = [] let totalBytes = 0
27 unmodified lines293 unmodified linesExpand all294295296297298299300301302293 unmodified linesExpand all/** Remove consumed and expired handoff tokens. */export const pruneHandoffs = async (): Promise<void> => { await db`DELETE FROM private_site_handoffs WHERE expires_at < NOW() OR consumed_at IS NOT NULL`}
/** Remove expired site sessions. Live revocation is handled by the share join at read time. */export const pruneSessions = async (): Promise<void> => { await db`DELETE FROM private_site_sessions WHERE expires_at < NOW()`}121 unmodified linesExpand all12212312412512612712812913013113213313413513627 unmodified lines164165166167161162163168169170171172173174175176177178179180181215 unmodified linesExpand all121 unmodified linesExpand all const path = sanitizePath(file.path) if (path.length === 0) { throw new PrivateSiteError(`invalid file path: ${file.path}`, 'invalidRequest') } // The sanitized path becomes a storage key verbatim. Backslashes are rejected // because some object stores treat them as separators (turning `..\..` back into // parent references and failing the write mid-ingest), and control characters // have no business in a key. if (/[\\\p{Cc}]/u.test(path)) { throw new PrivateSiteError(`invalid file path: ${file.path}`, 'invalidRequest') } if (seen.has(path)) { throw new PrivateSiteError(`duplicate file path: ${path}`, 'invalidRequest') } seen.add(path)27 unmodified lines })), })
// Files are written after the row exists so a failed write leaves a deletable record // rather than orphaned bytes with no owner. for (const file of prepared) { await writePrivateFile(site.siteId, file.path, file.bytes, file.mimeType) // rather than orphaned bytes with no owner. A failure partway through removes both // the bytes and the row, so a rejected upload never leaves a phantom site behind. try { for (const file of prepared) { await writePrivateFile(site.siteId, file.path, file.bytes, file.mimeType) } } catch (err) { await deletePrivateSiteFiles(site.siteId).catch(() => 0) await deletePrivateSite(site.siteId).catch(() => false) throw err }
logger.info('[PrivateSite] Created', { siteId: site.siteId,215 unmodified linesExpand all1 unmodified line234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556571658596061625 unmodified lines6869707172733174757677781 unmodified line1 unmodified lineimport type { NodeOAuthClient, OAuthSession } from '@atproto/oauth-client-node'import type { Cookie } from 'elysia'import { logger } from './logger'
/** * Name of the signed account session cookie. * * Production uses the `__Host-` prefix so the browser itself refuses the cookie when it * is set with a `Domain` attribute. That is what stops cookie tossing from user-content * subdomains (`*.priv.wisp.place`, `sites.wisp.place`, claimed `*.wisp.place` domains): * a page on any of those origins can otherwise plant a `Domain=wisp.place` cookie named * `did`, and RFC 6265 ordering (oldest first) combined with first-wins parsing lets the * tossed cookie shadow the real one — a login CSRF where the victim browses as the * attacker. * * `__Host-` requires `Secure`, which local http development cannot offer, so the * unprefixed name is kept outside production. The duplicate-cookie guard below covers * the development topology instead. */export const SESSION_COOKIE_NAME = process.env.NODE_ENV === 'production' ? '__Host-did' : 'did'
export interface AuthenticatedContext { did: Did session: OAuthSession}
/** * Count how many times a cookie name appears in a raw `Cookie` header. * * Browsers are the only realistic source of duplicate names, and a duplicate account * cookie is exactly what a cookie-tossing attack produces. Failing closed on any * duplicate removes the parser-ordering question (first-wins vs last-wins) entirely. */export const countCookieOccurrences = (header: string | null | undefined, name: string): number => { if (!header) return 0 let count = 0 for (const part of header.split(';')) { const eq = part.indexOf('=') if (eq === -1) continue if (part.slice(0, eq).trim() === name) count += 1 } return count}
export const authenticateRequest = async ( client: NodeOAuthClient, cookies: Record<string, Cookie<unknown>>, rawCookieHeader?: string | null,): Promise<AuthenticatedContext | null> => { try { // More than one cookie carrying the session name means the jar was tampered with // (e.g. a Domain-scoped cookie tossed from a sibling subdomain). Refuse to pick one. if (rawCookieHeader !== undefined && countCookieOccurrences(rawCookieHeader, SESSION_COOKIE_NAME) > 1) { return null }
const did = cookies.did?.value as Did const did = cookies[SESSION_COOKIE_NAME]?.value as Did if (!did) return null
const session = await client.restore(did, 'auto') return session ? { did, session } : null5 unmodified lines
export const requireAuth = async ( client: NodeOAuthClient, cookies: Record<string, Cookie<unknown>>, rawCookieHeader?: string | null,): Promise<AuthenticatedContext> => { const auth = await authenticateRequest(client, cookies) const auth = await authenticateRequest(client, cookies, rawCookieHeader) if (!auth) { throw new Error('Authentication required') } return auth1 unmodified line123345567891011121314141516171865 unmodified lines8485868788888990919293939495969724 unmodified lines122123124125126126127128129130131132133134135136137138139140135136137138139142143140141142145146147148149150151152153143144145146147148149150151155156157158159160152162163164165166167168153154155156157158159160161162163164165174175176177178166167168169180181182183184185186187188189190191170171172173194195196197198199200174175176177178179180181182183184import type { NodeOAuthClient } from '@atproto/oauth-client-node'import { createLogger } from '@wispplace/observability'import { Elysia, t } from 'elysia'import { Elysia } from 'elysia'import { getDomainByDid, getSitesByDid } from '../lib/db'import { authenticateRequest } from '../lib/wisp-auth'import { authenticateRequest, SESSION_COOKIE_NAME } from '../lib/wisp-auth'import { resolvePrivateShareState } from './private-redeem'
const logger = createLogger('main-app')
export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({ cookie: { secrets: cookieSecret, sign: ['did'], sign: [SESSION_COOKIE_NAME], }, }) /** * GET /api/auth/login65 unmodified lines const { session, state } = await client.callback(params)
if (!session) { logger.error('[Auth] OAuth callback failed: no session returned') c.cookie.did.remove() c.cookie[SESSION_COOKIE_NAME].remove() return c.redirect('/?error=auth_failed') }
const cookieSession = c.cookie cookieSession.did.set({ cookieSession[SESSION_COOKIE_NAME].set({ value: session.did, httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax',24 unmodified lines return c.redirect('/editor') } catch (err) { // This catches state validation failures and other OAuth errors logger.error('[Auth] OAuth callback error', err) c.cookie.did.remove() c.cookie[SESSION_COOKIE_NAME].remove() return c.redirect('/?error=auth_failed') } }) /** * POST /api/auth/logout * Success: { success: true } * Failure: { error: 'Logout failed' } */ .post( '/api/auth/logout', async (c) => { try { const cookieSession = c.cookie const did = cookieSession.did?.value .post('/api/auth/logout', async (c) => { try { const cookieSession = c.cookie const did = cookieSession[SESSION_COOKIE_NAME]?.value
// Clear the session cookie cookieSession.did.remove() // Clear the session cookie cookieSession[SESSION_COOKIE_NAME].remove()
// If we have a DID, try to revoke the OAuth session if (did && typeof did === 'string') { try { await client.revoke(did) logger.debug('[Auth] Revoked OAuth session for', did as any) } catch (err) { logger.error('[Auth] Failed to revoke session', err) // Continue with logout even if revoke fails } // If we have a DID, try to revoke the OAuth session if (did && typeof did === 'string') { try { await client.revoke(did) logger.debug('[Auth] Revoked OAuth session for', did as any) } catch (err) { logger.error('[Auth] Failed to revoke session', err) // Continue with logout even if revoke fails }
return { success: true } } catch (err) { logger.error('[Auth] Logout error', err) c.set.status = 500 return { error: 'Logout failed' } } }, { cookie: t.Cookie({ did: t.Optional(t.String()), }), }, )
return { success: true } } catch (err) { logger.error('[Auth] Logout error', err) c.set.status = 500 return { error: 'Logout failed' } } }) /** * GET /api/auth/status * Authenticated: { authenticated: true, did } * Not authenticated: { authenticated: false } */ .get( '/api/auth/status', async (c) => { try { const auth = await authenticateRequest(client, c.cookie) .get('/api/auth/status', async (c) => { try { const auth = await authenticateRequest(client, c.cookie, c.request.headers.get('cookie'))
if (!auth) { c.cookie.did.remove() return { authenticated: false } }
return { authenticated: true, did: auth.did, } } catch (err) { logger.error('[Auth] Status check error', err) c.cookie.did.remove() if (!auth) { c.cookie[SESSION_COOKIE_NAME].remove() return { authenticated: false } } }, { cookie: t.Cookie({ did: t.Optional(t.String()), }), }, )
return { authenticated: true, did: auth.did, } } catch (err) { logger.error('[Auth] Status check error', err) c.cookie[SESSION_COOKIE_NAME].remove() return { authenticated: false } } })19 unmodified lines202122232424252627282930313233333435363755 unmodified lines939495969798979899100101102332 unmodified linesExpand all19 unmodified lines updateWispDomainSite,} from '../lib/db'import { verifyCustomDomain } from '../lib/dns-verify'import { extractWispHandle, isValidHandle, normalizeDomain, toDomain, validateCustomDomain } from '../lib/domain-utils'import { requireAuth } from '../lib/wisp-auth'import { requireAuth, SESSION_COOKIE_NAME } from '../lib/wisp-auth'
const logger = createLogger('main-app')
export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({ prefix: '/api/domain', cookie: { secrets: cookieSecret, sign: ['did'], sign: [SESSION_COOKIE_NAME], }, }) // Public endpoints (no auth required) /**55 unmodified lines return { error: 'Failed to check domain' } } }) // Authenticated endpoints (require auth) .derive(async ({ cookie }) => { const auth = await requireAuth(client, cookie) .derive(async ({ cookie, request }) => { const auth = await requireAuth(client, cookie, request.headers.get('cookie')) return { auth } }) /** * POST /api/domain/claim332 unmodified linesExpand all17 unmodified lines1819202122222324252614 unmodified lines4142434445454647484934 unmodified lines848586878889888990919233 unmodified lines12612712812913113013113213313448 unmodified lines18318418518618818718818919019119219319419619519619719819920020120320220320420520617 unmodified lines22422522622722922822923023123223323423623523623723823945 unmodified lines28528628728829028929029129229329429529629829729829930030171 unmodified lines17 unmodified linesimport { createLogger } from '@wispplace/observability'import { PRIVATE_ACCESS_PAGE_STYLES } from '@wispplace/private-sites'import { Elysia } from 'elysia'import { openPrivateSiteForAccount, redeemScopedShare, resolveShareLink } from '../lib/private-sites-service'import { authenticateRequest } from '../lib/wisp-auth'import { authenticateRequest, SESSION_COOKIE_NAME } from '../lib/wisp-auth'
const logger = createLogger('main-app')
/** Embed a value in an inline script without letting it break out of the string. */14 unmodified lines * with the token still in a POST body rather than in a URL. */const signInPage = (siteId: string, token: string): string => `${privatePageHead('sign in')}<body><main class="private-page private-shell"><div class="private-brand"><strong>wisp.place</strong><span>private access</span></div><div class="private-brand"><strong>wisp.place</strong></div><p class="private-kicker">shared link</p><h1>sign in to open this link</h1><p>this private site was shared with a specific account.</p><form id="f" class="private-form">34 unmodified lines
/** Sign-in prompt for an owner opening their own private site by address. */const ownerSignInPage = (siteId: string): string => `${privatePageHead('sign in')}<body><main class="private-page private-shell"><div class="private-brand"><strong>wisp.place</strong><span>private access</span></div><p class="private-kicker">owner access</p><div class="private-brand"><strong>wisp.place</strong></div><h1>sign in to open this private site</h1><p>only accounts with access can open it without a share link.</p><form id="f" class="private-form"> <label for="handle">your handle</label>33 unmodified lines
/** Shown for every failure, so no outcome reveals whether a private site exists. */const deniedPage = (): string => `${privatePageHead('not available')}<body><main class="private-page private-shell"><div class="private-brand"><strong>wisp.place</strong><span>private access</span></div><div class="private-brand"><strong>wisp.place</strong></div><p class="private-kicker">link unavailable</p><h1>this link is not available</h1><p>it may have been revoked, expired, or issued to a different account.</p><p class="private-note">no change was made to your account.</p>48 unmodified lines return { url: await redeemScopedShare(parsed.siteId, parsed.token, viewerDid) }}
export const privateRedeemRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({ cookie: { secrets: cookieSecret, sign: ['did'] } }) new Elysia({ cookie: { secrets: cookieSecret, sign: [SESSION_COOKIE_NAME] } }) /** * POST /private/redeem * * Entry point from a private origin's sign-in interstitial. If the visitor already * has an account session, the share is evaluated immediately and they are redirected * back with a single-use handoff. Otherwise they are offered sign-in. */ .post('/private/redeem', async ({ body, cookie, set }) => { .post('/private/redeem', async ({ body, cookie, request, set }) => { const form = readForm(body) if (!form) { set.status = 400 return htmlResponse(set, deniedPage()) }
const auth = await authenticateRequest(client, cookie) const auth = await authenticateRequest(client, cookie, request.headers.get('cookie')) if (!auth) { return htmlResponse(set, signInPage(form.siteId, form.token)) }
17 unmodified lines * * SameSite=Lax account cookies are sent on this top-level navigation, unlike the * cross-site POST fallback, so an already signed-in viewer continues immediately. */ .get('/private/open', async ({ query, cookie, set }) => { .get('/private/open', async ({ query, cookie, request, set }) => { const siteId = typeof query.siteId === 'string' ? query.siteId : '' if (!siteId) { set.status = 400 return htmlResponse(set, deniedPage()) }
const auth = await authenticateRequest(client, cookie) const auth = await authenticateRequest(client, cookie, request.headers.get('cookie')) if (!auth) { return htmlResponse(set, ownerSignInPage(siteId)) }
45 unmodified lines * * A non-owner and a non-existent site produce the identical denial page, so this * cannot be used to enumerate private sites. */ .post('/private/open', async ({ body, cookie, set }) => { .post('/private/open', async ({ body, cookie, request, set }) => { const siteId = typeof (body as { siteId?: unknown } | null)?.siteId === 'string' ? (body as { siteId: string }).siteId : '' if (!siteId) { set.status = 400 return htmlResponse(set, deniedPage()) }
const auth = await authenticateRequest(client, cookie) const auth = await authenticateRequest(client, cookie, request.headers.get('cookie')) if (!auth) { return htmlResponse(set, ownerSignInPage(siteId)) }
71 unmodified lines25 unmodified lines2627282930303132333424 unmodified lines596061626363646566656667686970186 unmodified linesExpand all25 unmodified lines requireOwnedSite, revokeSiteShare,} from '../lib/private-sites-service'import { SlingshotHandleResolver } from '../lib/slingshot-handle-resolver'import { requireAuth } from '../lib/wisp-auth'import { requireAuth, SESSION_COOKIE_NAME } from '../lib/wisp-auth'import { privateOwnerUrl, privateShareUrl } from './xrpc-private-site'
const logger = createLogger('main-app')
24 unmodified lines
export const privateSiteApiRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({ prefix: '/api/user/private-sites', cookie: { secrets: cookieSecret, sign: ['did'] }, cookie: { secrets: cookieSecret, sign: [SESSION_COOKIE_NAME] }, }) .derive(async ({ cookie }) => { const auth = await requireAuth(client, cookie) .derive(async ({ cookie, request }) => { const auth = await requireAuth(client, cookie, request.headers.get('cookie')) return { auth } }) /** * GET /api/user/private-sites186 unmodified linesExpand all