// The Node-only half of @radial/atproto, imported as `@radial/atproto/node`. // // The package's main entry is deliberately isomorphic: transport, resolvers, and CredentialClient // reach for nothing but `fetch` and `URL`, so a browser bundle can run the identical read and write // paths the daemon and CLI run. Everything that needs a filesystem, a DNS resolver, or SQLite lives // here instead — a bundler that follows the main entry never sees `node:fs` or `node:sqlite`. import { chmod, lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import type { ActorSession, IdentityResolution, TxtResolver } from './client.js' import type { StrongRef } from '@radial/core' export interface StoredActor extends ActorSession { profile: string agentProfile?: StrongRef } interface SessionFile { version: 1 defaultProfile?: string profiles: Record } const object = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) const sessionQueues = new Map>() export class FileSessionStore { readonly path: string constructor(directory = defaultDataDirectory()) { this.path = join(directory, 'sessions.json') } async #read(): Promise { try { const info = await lstat(this.path) if (info.isSymbolicLink() || !info.isFile()) { throw new Error(`Session path is not a regular non-symlink file: ${this.path}`) } if ((info.mode & 0o077) !== 0) await chmod(this.path, 0o600) const value = JSON.parse(await readFile(this.path, 'utf8')) as unknown if (!object(value) || value.version !== 1 || !object(value.profiles)) { throw new TypeError('Invalid Radial session file') } return value as unknown as SessionFile } catch (error) { if (object(error) && error.code === 'ENOENT') return { version: 1, profiles: {} } throw error } } async get(profile?: string): Promise { const state = await this.#read() const selected = profile ?? state.defaultProfile return selected ? state.profiles[selected] : undefined } /** Every stored profile, and which one `get()` returns by default — for UIs that offer a picker. */ async list(): Promise<{ profiles: StoredActor[]; defaultProfile?: string }> { const state = await this.#read() return { profiles: Object.values(state.profiles), ...(state.defaultProfile ? { defaultProfile: state.defaultProfile } : {}), } } async save(actor: StoredActor, makeDefault = true): Promise { const directory = dirname(this.path) await mkdir(directory, { recursive: true, mode: 0o700 }) await chmod(directory, 0o700) await this.#withLock(async () => { const state = await this.#read() state.profiles[actor.profile] = actor if (makeDefault || !state.defaultProfile) state.defaultProfile = actor.profile await this.#write(state) }) } async coordinateRefresh( profile: string, current: ActorSession, refresh: (session: ActorSession) => Promise, ): Promise { return this.#withLock(async () => { const state = await this.#read() const stored = state.profiles[profile] if (!stored) throw new Error(`Radial profile not found during refresh: ${profile}`) if (stored.refreshJwt !== current.refreshJwt) return stored const rotated = await refresh(stored) state.profiles[profile] = { ...stored, ...rotated } await this.#write(state) return rotated }) } async #write(state: SessionFile): Promise { const temporary = `${this.path}.tmp-${Math.random().toString(36).slice(2)}` await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }) await chmod(temporary, 0o600) await rename(temporary, this.path) await chmod(this.path, 0o600) } async #withLock(operation: () => Promise): Promise { // A module-local queue prevents a synchronous SQLite wait from blocking an // async holder in this process. SQLite supplies the cross-process advisory // lock and releases it automatically if its owner exits or crashes. const previous = sessionQueues.get(this.path) ?? Promise.resolve() let release = (): void => {} const held = new Promise((resolve) => { release = resolve }) const queued = previous.then(() => held) sessionQueues.set(this.path, queued) await previous const database = new DatabaseSync(`${this.path}.lock.db`) try { database.exec(` PRAGMA busy_timeout = 30000; CREATE TABLE IF NOT EXISTS session_lock (id INTEGER PRIMARY KEY) STRICT; BEGIN IMMEDIATE; `) return await operation() } finally { try { database.exec('COMMIT') } finally { database.close() release() if (sessionQueues.get(this.path) === queued) sessionQueues.delete(this.path) } } } } /** The operator's Radial state directory. `env` is a parameter rather than a read of `process.env` * so a caller resolving a data directory for one *instance* (daemon `instance.ts`) can decide the * precedence itself and still land on the same default. */ export function defaultDataDirectory(env: Record = process.env): string { if (env.RADIAL_DATA_DIR) return env.RADIAL_DATA_DIR if (env.XDG_STATE_HOME) return join(env.XDG_STATE_HOME, 'radial') if (!env.HOME) throw new Error('Set RADIAL_DATA_DIR or HOME') return join(env.HOME, '.local', 'state', 'radial') } /** * The `_atproto` DNS TXT lookup, which only a Node process can do. Pass it to `resolveHandleDid` / * `resolveIdentityPds` to get the full two-method handle resolution; without it those fall back to * the HTTPS well-known method (and any XRPC `directory` supplied), which is what a browser uses. */ export const nodeResolveTxt: TxtResolver = async (hostname: string) => (await import('node:dns/promises')).resolveTxt(hostname) /** `nodeResolveTxt` as an `IdentityResolution`, for call sites that pass options straight through. */ export const nodeIdentityResolution: IdentityResolution = { resolveTxt: nodeResolveTxt }