mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

at main 5.5 kB View raw
1import type { H3Event, SessionConfig } from 'h3' 2 3/** 4 * Cookie-backed session for the dashboard. The OAuth callback writes the 5 * session after a successful AT Proto login; `requireSession()` reads it on 6 * every dashboard / `/api/me/*` / `/api/repos/*` request. 7 * 8 * Iron-session-style sealed cookie via h3's built-in `useSession`. The 9 * `password` comes from `NUXT_SESSION_PASSWORD` (32+ chars). We read 10 * `process.env` directly so the helper is callable outside a request context 11 * for tests, mirroring `encryption.ts`. 12 * 13 * A user may have connected more than one tangled identity on this device 14 * (e.g. a personal account and an org). The cookie therefore holds a list of 15 * `accounts`, one of which is `active`. `requireSession()` flattens to the 16 * active account so downstream handlers keep working with a single 17 * `{ did, installationId, handle }`. Switching is a device-local convenience: 18 * the OAuth callback appends, `/api/me/switch` flips `active`, logout drops the 19 * active account. 20 */ 21export interface SynchubAccount { 22 did: string 23 installationId: number 24 handle?: string 25} 26 27export interface SynchubSessionData { 28 active: string 29 accounts: SynchubAccount[] 30} 31 32const COOKIE_NAME = 'synchub-session' 33const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 // 30 days 34 35function sessionPassword(): string { 36 const raw = process.env.NUXT_SESSION_PASSWORD 37 if (!raw || raw.length < 32) { 38 throw new Error('NUXT_SESSION_PASSWORD is not set (need 32+ characters of entropy)') 39 } 40 return raw 41} 42 43export function sessionConfig(): SessionConfig { 44 return { 45 name: COOKIE_NAME, 46 password: sessionPassword(), 47 maxAge: COOKIE_MAX_AGE_SECONDS, 48 cookie: { 49 httpOnly: true, 50 sameSite: 'lax', 51 secure: !(process.env.NUXT_PUBLIC_URL?.startsWith('http://127.0.0.1') 52 || process.env.NUXT_PUBLIC_URL?.startsWith('http://localhost')), 53 path: '/', 54 }, 55 } 56} 57 58/** 59 * Normalise raw cookie data into a valid session, or null. Drops malformed 60 * account entries; if `active` doesn't name a surviving account, falls back to 61 * the first. Exported for unit testing the normalisation in isolation. 62 */ 63export function readAccounts(data: Partial<SynchubSessionData>): SynchubSessionData | null { 64 if (typeof data.active !== 'string' || !Array.isArray(data.accounts)) return null 65 const accounts = data.accounts.filter( 66 (a): a is SynchubAccount => typeof a?.did === 'string' && typeof a?.installationId === 'number', 67 ) 68 if (accounts.length === 0) return null 69 const active = accounts.some(a => a.did === data.active) ? data.active : accounts[0]!.did 70 return { active, accounts } 71} 72 73/** 74 * Return all accounts on this device plus the active DID, or null if the 75 * cookie is empty / malformed. 76 */ 77export async function getDeviceSession(event: H3Event): Promise<SynchubSessionData | null> { 78 const session = await useSession<SynchubSessionData>(event, sessionConfig()) 79 return readAccounts(session.data) 80} 81 82/** 83 * Resolve the active account, flattened to the single-account shape that 84 * handlers expect. 85 */ 86export async function getSessionData(event: H3Event): Promise<SynchubAccount | null> { 87 const data = await getDeviceSession(event) 88 if (!data) return null 89 return data.accounts.find(a => a.did === data.active) ?? null 90} 91 92/** 93 * Return the active account or throw a 401. Use from any handler that needs 94 * an authenticated user. The thrown error is consumed by Nitro and rendered 95 * as `{ statusCode: 401, statusMessage: 'unauthenticated' }`. 96 */ 97export async function requireSession(event: H3Event): Promise<SynchubAccount> { 98 const data = await getSessionData(event) 99 if (!data) { 100 throw createError({ statusCode: 401, statusMessage: 'unauthenticated' }) 101 } 102 return data 103} 104 105/** 106 * Add (or refresh) an account on this device and make it active. Dedupes by 107 * DID: re-connecting an existing handle updates its installation binding and 108 * activates it rather than duplicating the entry. 109 */ 110export async function addAccount(event: H3Event, account: SynchubAccount): Promise<void> { 111 const session = await useSession<SynchubSessionData>(event, sessionConfig()) 112 const existing = readAccounts(session.data)?.accounts ?? [] 113 const accounts = [...existing.filter(a => a.did !== account.did), account] 114 await session.update({ active: account.did, accounts }) 115} 116 117/** 118 * Switch the active account to `did`. Returns false if the DID isn't present 119 * on this device. 120 */ 121export async function switchAccount(event: H3Event, did: string): Promise<boolean> { 122 const session = await useSession<SynchubSessionData>(event, sessionConfig()) 123 const data = readAccounts(session.data) 124 if (!data || !data.accounts.some(a => a.did === did)) return false 125 await session.update({ active: did, accounts: data.accounts }) 126 return true 127} 128 129/** 130 * Drop the active account. If others remain, the first becomes active and the 131 * cookie is rewritten; otherwise the cookie is cleared. Returns the number of 132 * accounts left signed in. 133 */ 134export async function dropActiveAccount(event: H3Event): Promise<number> { 135 const session = await useSession<SynchubSessionData>(event, sessionConfig()) 136 const data = readAccounts(session.data) 137 if (!data) { 138 await session.clear() 139 return 0 140 } 141 const remaining = data.accounts.filter(a => a.did !== data.active) 142 if (remaining.length === 0) { 143 await session.clear() 144 return 0 145 } 146 await session.update({ active: remaining[0]!.did, accounts: remaining }) 147 return remaining.length 148}