···11import { lineToString } from './pkt-line'
2233-const ZERO_SHA = '0000000000000000000000000000000000000000'
33+export const ZERO_SHA = '0000000000000000000000000000000000000000'
4455export interface Advertisement {
66 /** Ref name -> object SHA (unpeeled). For annotated tags this is the tag object. */
···65656666 return { refs, peeled, capabilities }
6767}
6868-6969-export { ZERO_SHA }
-2
server/utils/require-session.ts
···11-export { getSessionData, requireSession, writeSession } from './server-session'
22-export type { SynchubSessionData } from './server-session'
+24
server/utils/resolve-handle.ts
···11+import { Agent } from '@atproto/api'
22+import type { OAuthSession } from '@atproto/oauth-client-node'
33+44+/**
55+ * Resolve the human-readable handle for the authenticated DID.
66+ *
77+ * `com.atproto.repo.describeRepo` returns the handle the PDS has verified
88+ * against the DID document, so it's the authoritative answer and needs no
99+ * appview access. Best-effort: a handle is a display convenience, so any
1010+ * failure (network, unverified handle) returns null rather than blocking the
1111+ * sign-in flow. Strips a leading `@` if the PDS includes one.
1212+ */
1313+export async function resolveHandle(oauthSession: OAuthSession): Promise<string | null> {
1414+ try {
1515+ const agent = new Agent(oauthSession)
1616+ const { data } = await agent.com.atproto.repo.describeRepo({ repo: oauthSession.did })
1717+ const handle = data.handle?.trim().replace(/^@/, '')
1818+ if (!handle || handle === 'handle.invalid') return null
1919+ return handle
2020+ }
2121+ catch {
2222+ return null
2323+ }
2424+}
+88-13
server/utils/server-session.ts
···1010 * `process.env` directly so the helper is callable outside a request context
1111 * for tests, mirroring `encryption.ts`.
1212 *
1313- * v1 stores `installationId` alongside `did`: a user may have installed the
1414- * GitHub App on more than one account (personal + an org), but the session
1515- * pins us to the one they last authenticated against. See the dashboard
1616- * "Connect a different installation?" link for the workaround.
1313+ * A user may have connected more than one tangled identity on this device
1414+ * (e.g. a personal account and an org). The cookie therefore holds a list of
1515+ * `accounts`, one of which is `active`. `requireSession()` flattens to the
1616+ * active account so downstream handlers keep working with a single
1717+ * `{ did, installationId, handle }`. Switching is a device-local convenience:
1818+ * the OAuth callback appends, `/api/me/switch` flips `active`, logout drops the
1919+ * active account.
1720 */
1818-export interface SynchubSessionData {
2121+export interface SynchubAccount {
1922 did: string
2023 installationId: number
2124 handle?: string
2525+}
2626+2727+export interface SynchubSessionData {
2828+ active: string
2929+ accounts: SynchubAccount[]
2230}
23312432const COOKIE_NAME = 'synchub-session'
···4755 }
4856}
49575050-export async function getSessionData(event: H3Event): Promise<SynchubSessionData | null> {
5858+/**
5959+ * Normalise raw cookie data into a valid session, or null. Drops malformed
6060+ * account entries; if `active` doesn't name a surviving account, falls back to
6161+ * the first. Exported for unit testing the normalisation in isolation.
6262+ */
6363+export function readAccounts(data: Partial<SynchubSessionData>): SynchubSessionData | null {
6464+ if (typeof data.active !== 'string' || !Array.isArray(data.accounts)) return null
6565+ const accounts = data.accounts.filter(
6666+ (a): a is SynchubAccount => typeof a?.did === 'string' && typeof a?.installationId === 'number',
6767+ )
6868+ if (accounts.length === 0) return null
6969+ const active = accounts.some(a => a.did === data.active) ? data.active : accounts[0]!.did
7070+ return { active, accounts }
7171+}
7272+7373+/**
7474+ * Return all accounts on this device plus the active DID, or null if the
7575+ * cookie is empty / malformed.
7676+ */
7777+export async function getDeviceSession(event: H3Event): Promise<SynchubSessionData | null> {
5178 const session = await useSession<SynchubSessionData>(event, sessionConfig())
5252- const { did, installationId } = session.data
5353- if (typeof did !== 'string' || typeof installationId !== 'number') return null
5454- return { did, installationId, handle: session.data.handle }
7979+ return readAccounts(session.data)
8080+}
8181+8282+/**
8383+ * Resolve the active account, flattened to the single-account shape that
8484+ * handlers expect.
8585+ */
8686+export async function getSessionData(event: H3Event): Promise<SynchubAccount | null> {
8787+ const data = await getDeviceSession(event)
8888+ if (!data) return null
8989+ return data.accounts.find(a => a.did === data.active) ?? null
5590}
56915792/**
5858- * Return the current session or throw a 401. Use from any handler that needs
9393+ * Return the active account or throw a 401. Use from any handler that needs
5994 * an authenticated user. The thrown error is consumed by Nitro and rendered
6095 * as `{ statusCode: 401, statusMessage: 'unauthenticated' }`.
6196 */
6262-export async function requireSession(event: H3Event): Promise<SynchubSessionData> {
9797+export async function requireSession(event: H3Event): Promise<SynchubAccount> {
6398 const data = await getSessionData(event)
6499 if (!data) {
65100 throw createError({ statusCode: 401, statusMessage: 'unauthenticated' })
···67102 return data
68103}
691047070-export async function writeSession(event: H3Event, data: SynchubSessionData): Promise<void> {
105105+/**
106106+ * Add (or refresh) an account on this device and make it active. Dedupes by
107107+ * DID: re-connecting an existing handle updates its installation binding and
108108+ * activates it rather than duplicating the entry.
109109+ */
110110+export async function addAccount(event: H3Event, account: SynchubAccount): Promise<void> {
111111+ const session = await useSession<SynchubSessionData>(event, sessionConfig())
112112+ const existing = readAccounts(session.data)?.accounts ?? []
113113+ const accounts = [...existing.filter(a => a.did !== account.did), account]
114114+ await session.update({ active: account.did, accounts })
115115+}
116116+117117+/**
118118+ * Switch the active account to `did`. Returns false if the DID isn't present
119119+ * on this device.
120120+ */
121121+export async function switchAccount(event: H3Event, did: string): Promise<boolean> {
71122 const session = await useSession<SynchubSessionData>(event, sessionConfig())
7272- await session.update(data)
123123+ const data = readAccounts(session.data)
124124+ if (!data || !data.accounts.some(a => a.did === did)) return false
125125+ await session.update({ active: did, accounts: data.accounts })
126126+ return true
127127+}
128128+129129+/**
130130+ * Drop the active account. If others remain, the first becomes active and the
131131+ * cookie is rewritten; otherwise the cookie is cleared. Returns the number of
132132+ * accounts left signed in.
133133+ */
134134+export async function dropActiveAccount(event: H3Event): Promise<number> {
135135+ const session = await useSession<SynchubSessionData>(event, sessionConfig())
136136+ const data = readAccounts(session.data)
137137+ if (!data) {
138138+ await session.clear()
139139+ return 0
140140+ }
141141+ const remaining = data.accounts.filter(a => a.did !== data.active)
142142+ if (remaining.length === 0) {
143143+ await session.clear()
144144+ return 0
145145+ }
146146+ await session.update({ active: remaining[0]!.did, accounts: remaining })
147147+ return remaining.length
73148}