Something went wrong. Try again.
mirror your GitHub repos to tangled.org automatically synchub.to
mirror sync tangled github git
Something went wrong. Try again.
5.5 kB · 148 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149import type { H3Event, SessionConfig } from 'h3'
/** * Cookie-backed session for the dashboard. The OAuth callback writes the * session after a successful AT Proto login; `requireSession()` reads it on * every dashboard / `/api/me/*` / `/api/repos/*` request. * * Iron-session-style sealed cookie via h3's built-in `useSession`. The * `password` comes from `NUXT_SESSION_PASSWORD` (32+ chars). We read * `process.env` directly so the helper is callable outside a request context * for tests, mirroring `encryption.ts`. * * A user may have connected more than one tangled identity on this device * (e.g. a personal account and an org). The cookie therefore holds a list of * `accounts`, one of which is `active`. `requireSession()` flattens to the * active account so downstream handlers keep working with a single * `{ did, installationId, handle }`. Switching is a device-local convenience: * the OAuth callback appends, `/api/me/switch` flips `active`, logout drops the * active account. */export interface SynchubAccount { did: string installationId: number handle?: string}
export interface SynchubSessionData { active: string accounts: SynchubAccount[]}
const COOKIE_NAME = 'synchub-session'const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 // 30 days
function sessionPassword(): string { const raw = process.env.NUXT_SESSION_PASSWORD if (!raw || raw.length < 32) { throw new Error('NUXT_SESSION_PASSWORD is not set (need 32+ characters of entropy)') } return raw}
export function sessionConfig(): SessionConfig { return { name: COOKIE_NAME, password: sessionPassword(), maxAge: COOKIE_MAX_AGE_SECONDS, cookie: { httpOnly: true, sameSite: 'lax', secure: !(process.env.NUXT_PUBLIC_URL?.startsWith('http://127.0.0.1') || process.env.NUXT_PUBLIC_URL?.startsWith('http://localhost')), path: '/', }, }}
/** * Normalise raw cookie data into a valid session, or null. Drops malformed * account entries; if `active` doesn't name a surviving account, falls back to * the first. Exported for unit testing the normalisation in isolation. */export function readAccounts(data: Partial<SynchubSessionData>): SynchubSessionData | null { if (typeof data.active !== 'string' || !Array.isArray(data.accounts)) return null const accounts = data.accounts.filter( (a): a is SynchubAccount => typeof a?.did === 'string' && typeof a?.installationId === 'number', ) if (accounts.length === 0) return null const active = accounts.some(a => a.did === data.active) ? data.active : accounts[0]!.did return { active, accounts }}
/** * Return all accounts on this device plus the active DID, or null if the * cookie is empty / malformed. */export async function getDeviceSession(event: H3Event): Promise<SynchubSessionData | null> { const session = await useSession<SynchubSessionData>(event, sessionConfig()) return readAccounts(session.data)}
/** * Resolve the active account, flattened to the single-account shape that * handlers expect. */export async function getSessionData(event: H3Event): Promise<SynchubAccount | null> { const data = await getDeviceSession(event) if (!data) return null return data.accounts.find(a => a.did === data.active) ?? null}
/** * Return the active account or throw a 401. Use from any handler that needs * an authenticated user. The thrown error is consumed by Nitro and rendered * as `{ statusCode: 401, statusMessage: 'unauthenticated' }`. */export async function requireSession(event: H3Event): Promise<SynchubAccount> { const data = await getSessionData(event) if (!data) { throw createError({ statusCode: 401, statusMessage: 'unauthenticated' }) } return data}
/** * Add (or refresh) an account on this device and make it active. Dedupes by * DID: re-connecting an existing handle updates its installation binding and * activates it rather than duplicating the entry. */export async function addAccount(event: H3Event, account: SynchubAccount): Promise<void> { const session = await useSession<SynchubSessionData>(event, sessionConfig()) const existing = readAccounts(session.data)?.accounts ?? [] const accounts = [...existing.filter(a => a.did !== account.did), account] await session.update({ active: account.did, accounts })}
/** * Switch the active account to `did`. Returns false if the DID isn't present * on this device. */export async function switchAccount(event: H3Event, did: string): Promise<boolean> { const session = await useSession<SynchubSessionData>(event, sessionConfig()) const data = readAccounts(session.data) if (!data || !data.accounts.some(a => a.did === did)) return false await session.update({ active: did, accounts: data.accounts }) return true}
/** * Drop the active account. If others remain, the first becomes active and the * cookie is rewritten; otherwise the cookie is cleared. Returns the number of * accounts left signed in. */export async function dropActiveAccount(event: H3Event): Promise<number> { const session = await useSession<SynchubSessionData>(event, sessionConfig()) const data = readAccounts(session.data) if (!data) { await session.clear() return 0 } const remaining = data.accounts.filter(a => a.did !== data.active) if (remaining.length === 0) { await session.clear() return 0 } await session.update({ active: remaining[0]!.did, accounts: remaining }) return remaining.length}