import { AsyncEntry } from '@napi-rs/keyring' import { BaseError } from '@/errors/base' import { ErrorCode } from '@/errors/codes' import { YamlStore } from './store' /** * Credential store keyed by an opaque (host, email) pair. */ export type TokenStore = { read: (host: string, email: string) => Promise write: (host: string, email: string, bearer: string) => Promise remove: (host: string, email: string) => Promise } const DOC_VERSION = 1 export type TokenDoc = { version?: number tokens?: Record> } export class FileTokenStore implements TokenStore { private readonly store: YamlStore constructor(filePath: string) { this.store = new YamlStore(filePath) } async read(host: string, email: string): Promise { const doc = await this.store.getTyped() if (doc === null) return '' // missing version = legacy pre-v1 format (same data shape); future unknown versions are rejected if (doc.version !== undefined && doc.version !== DOC_VERSION) return '' return doc.tokens?.[host]?.[email] ?? '' } async write(host: string, email: string, bearer: string): Promise { const doc = await this.load() const hostMap = doc.tokens[host] ?? {} hostMap[email] = bearer doc.tokens[host] = hostMap await this.store.setTyped(doc) } async remove(host: string, email: string): Promise { const doc = await this.store.getTyped() if (doc === null) return if (doc.version !== undefined && doc.version !== DOC_VERSION) return const tokens = doc.tokens ?? {} const hostMap = tokens[host] if (hostMap === undefined || !(email in hostMap)) return delete hostMap[email] if (Object.keys(hostMap).length === 0) delete tokens[host] await this.store.setTyped({ version: DOC_VERSION, tokens }) } private async load(): Promise<{ version: number, tokens: Record> }> { const doc = await this.store.getTyped() if (doc === null) return { version: DOC_VERSION, tokens: {} } if (doc.version !== undefined && doc.version !== DOC_VERSION) return { version: DOC_VERSION, tokens: {} } return { version: DOC_VERSION, tokens: (doc.tokens ?? {}) as Record> } } } /** * One OS-keyring entry per (host, email). */ export class KeychainTokenStore implements TokenStore { private readonly service: string constructor(service: string) { this.service = service } async read(host: string, email: string): Promise { let raw: string | null | undefined try { raw = await new AsyncEntry(this.service, entryName(host, email)).getPassword() } catch (err) { throw keyringUnavailableError(err) } if (raw === null || raw === undefined || raw === '') return '' try { const parsed: unknown = JSON.parse(raw) return typeof parsed === 'string' ? parsed : '' } catch { return '' } } async write(host: string, email: string, bearer: string): Promise { try { await new AsyncEntry(this.service, entryName(host, email)).setPassword(JSON.stringify(bearer)) } catch (err) { throw keyringUnavailableError(err) } } async remove(host: string, email: string): Promise { try { await new AsyncEntry(this.service, entryName(host, email)).deletePassword() } catch { /* missing entry is fine */ } } } function entryName(host: string, email: string): string { return `tokens.${host}.${email}` } function keyringUnavailableError(cause: unknown): BaseError { return new BaseError({ code: ErrorCode.KeyringUnavailable, message: 'OS keychain is unreachable', hint: 'credentials are stored in the system keychain but it could not be accessed; unlock the keychain (or the login session) and retry', cause, }) }