mirror your GitHub repos to tangled.org automatically
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}