import { chmodSync, closeSync, mkdirSync, openSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { cwd } from 'node:process'
import { getHandleForDid, resolveDid, unsafeRawIdentityGet } from '@wispplace/atproto-utils'
import { isBun } from '@wispplace/bun-firehose'
export const KEYCHAIN_SERVICE = 'wispctl'
export const DEFAULT_DB_PATH = join(homedir(), '.config', 'wispctl', 'state.sqlite')
/**
* OAuth sessions are stored under the bare DID (the `sub` the atproto session
* store hands us), so existing keychain entries keep working untouched. App
* passwords are long-lived credentials and live under their own prefix.
*/
const APP_PASSWORD_ACCOUNT_PREFIX = 'app-password:'
/** Read-only sentinel used to check the credential store is reachable. */
const PROBE_ACCOUNT = '__wispctl_probe__'
const ACCOUNT_PREFIX = 'account:'
const HANDLE_PREFIX = 'handle:'
const DIR_PREFIX = 'dir:'
const DEFAULT_ACCOUNT_KEY = 'default_account'
const STORE_VERSION_KEY = 'store_version'
const STORE_VERSION = '1'
/** How long a cached handle -> DID alias is trusted before re-resolving. */
const HANDLE_ALIAS_TTL_MS = 24 * 60 * 60 * 1000
export type AuthMethod = 'oauth' | 'app-password'
export interface StoredAccount {
did: string
handle?: string
method: AuthMethod
pdsUrl?: string
addedAt: number
lastUsedAt: number
/** When `handle` was last confirmed against the network. */
handleCheckedAt?: number
/**
* Which OAuth scope strategy minted this session.
*
* A loopback client declares its scopes inside the `client_id`, so the two
* strategies are two different client identities. Restoring a session has to
* rebuild the same one or the token refresh is rejected.
*/
oauthScope?: OAuthScopeStrategy
}
/** `sets` requests `include:place.wisp.*`; `granular` requests their expansion. */
export type OAuthScopeStrategy = 'sets' | 'granular'
// ---------------------------------------------------------------------------
// Keychain access
// ---------------------------------------------------------------------------
interface KeyringEntryLike {
setPassword(password: string): void
getPassword(): string | null
deletePassword(): void
}
export type KeyringEntryConstructor = new (service: string, account: string) => KeyringEntryLike
export interface KeychainProbeResult {
available: boolean
detail?: string
moduleAvailable: boolean
}
let keyringEntryConstructor: KeyringEntryConstructor | null | undefined
export async function getKeyringEntryConstructor(): Promise<KeyringEntryConstructor | null> {
if (keyringEntryConstructor !== undefined) {
return keyringEntryConstructor
}
try {
const module = await import('@napi-rs/keyring')
keyringEntryConstructor = module.Entry as KeyringEntryConstructor
} catch {
keyringEntryConstructor = null
}
return keyringEntryConstructor
}
function formatProbeError(error: unknown): string | undefined {
if (error instanceof Error) {
return error.message
}
if (typeof error === 'string') {
return error
}
return undefined
}
export function describeUnavailableKeychain(result: KeychainProbeResult): string {
if (process.platform === 'darwin') {
if (!result.moduleAvailable) {
return 'macOS Keychain support is unavailable in this build.'
}
if (result.detail?.toLowerCase().includes('authorization')) {
return 'macOS Keychain access could not be authorized.'
}
return result.detail ? `macOS Keychain access failed: ${result.detail}` : 'macOS Keychain access is unavailable.'
}
if (process.platform === 'linux') {
if (!result.moduleAvailable) {
return 'System keychain support is unavailable in this build.'
}
if (result.detail?.toLowerCase().includes('secret service')) {
return 'System keychain is unavailable (no Secret Service daemon or equivalent).'
}
return result.detail ? `System keychain access failed: ${result.detail}` : 'System keychain is unavailable.'
}
if (process.platform === 'win32') {
if (!result.moduleAvailable) {
return 'Windows Credential Manager support is unavailable in this build.'
}
return result.detail
? `Windows Credential Manager access failed: ${result.detail}`
: 'Windows Credential Manager is unavailable.'
}
if (!result.moduleAvailable) {
return 'Secure OS credential storage is unavailable in this build.'
}
return result.detail
? `Secure OS credential storage failed: ${result.detail}`
: 'Secure OS credential storage is unavailable.'
}
/**
* Every keychain touch is a separate OS credential-store access, and on macOS an
* unauthorized binary is prompted for each one. A single command used to cost
* five: a write plus a delete to probe, then reads for the session and the app
* password. So keep one `Entry` per account for the life of the process, cache
* what we read, and never write just to see whether the store works.
*/
const entryCache = new Map<string, KeyringEntryLike>()
const secretCache = new Map<string, string | null>()
async function getEntry(account: string): Promise<KeyringEntryLike | null> {
const KeyringEntry = await getKeyringEntryConstructor()
if (!KeyringEntry) return null
const cached = entryCache.get(account)
if (cached) return cached
const entry = new KeyringEntry(KEYCHAIN_SERVICE, account)
entryCache.set(account, entry)
return entry
}
async function readSecret(account: string): Promise<string | null> {
const cached = secretCache.get(account)
if (cached !== undefined) return cached
const entry = await getEntry(account)
if (!entry) return null
let value: string | null = null
try {
value = entry.getPassword()
} catch {
value = null
}
secretCache.set(account, value)
return value
}
async function writeSecret(account: string, value: string): Promise<boolean> {
const entry = await getEntry(account)
if (!entry) return false
try {
entry.setPassword(value)
secretCache.set(account, value)
return true
} catch {
return false
}
}
async function removeSecret(account: string): Promise<void> {
const entry = await getEntry(account)
if (!entry) return
try {
entry.deletePassword()
} catch {}
secretCache.set(account, null)
}
let keychainProbe: KeychainProbeResult | undefined
export async function probeKeychain(): Promise<KeychainProbeResult> {
if (keychainProbe) return keychainProbe
const KeyringEntry = await getKeyringEntryConstructor()
if (!KeyringEntry) {
keychainProbe = { available: false, moduleAvailable: false }
return keychainProbe
}
try {
// Reading a key that was never stored returns null rather than throwing,
// which is enough to prove the store is reachable. A missing backend (no
// Secret Service, say) throws instead. Unlike the write-then-delete probe
// this replaces, it does not ask the user to authorize anything.
await getEntry(PROBE_ACCOUNT).then((entry) => entry?.getPassword())
keychainProbe = { available: true, moduleAvailable: true }
} catch (error) {
keychainProbe = {
available: false,
detail: formatProbeError(error),
moduleAvailable: true,
}
}
return keychainProbe
}
export async function getStoredAppPassword(did: string): Promise<string | null> {
return await readSecret(`${APP_PASSWORD_ACCOUNT_PREFIX}${did}`)
}
/**
* Persist an app password. Returns false when there is no OS credential store —
* app passwords are long-lived and full-scope, so unlike refreshable OAuth
* tokens they are never written to the plaintext SQLite fallback.
*/
export async function setStoredAppPassword(did: string, password: string): Promise<boolean> {
return await writeSecret(`${APP_PASSWORD_ACCOUNT_PREFIX}${did}`, password)
}
export async function deleteStoredAppPassword(did: string): Promise<void> {
await removeSecret(`${APP_PASSWORD_ACCOUNT_PREFIX}${did}`)
}
export async function getStoredOAuthSession(did: string): Promise<string | null> {
return await readSecret(did)
}
export async function setStoredOAuthSession(did: string, session: string): Promise<boolean> {
return await writeSecret(did, session)
}
export async function deleteStoredOAuthSession(did: string): Promise<void> {
await removeSecret(did)
}
// ---------------------------------------------------------------------------
// Runtime-agnostic KV adapter — all SQL is baked in so return types are known
// ---------------------------------------------------------------------------
interface KvRow {
value: string
expires_at: number | null
}
interface KvEntryRow extends KvRow {
key: string
}
export interface KvAdapter {
get(key: string): KvRow | undefined
set(key: string, value: string, expiresAt: number | null): void
del(key: string): void
clear(): void
entriesByPrefix(prefix: string): { key: string; value: string }[]
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
expires_at INTEGER
)
`
const PREFIX_QUERY = "SELECT key, value, expires_at FROM kv WHERE key LIKE ? ESCAPE '\\'"
/** Escape LIKE wildcards so a prefix is matched literally. */
function likePrefix(prefix: string): string {
return `${prefix.replace(/[\\%_]/g, '\\$&')}%`
}
function liveEntries(rows: KvEntryRow[]): { key: string; value: string }[] {
const now = Date.now()
return rows
.filter((row) => row.expires_at === null || row.expires_at > now)
.map((row) => ({ key: row.key, value: row.value }))
}
function prepareDatabaseFile(dbPath: string): void {
if (dbPath === ':memory:') return
mkdirSync(dirname(dbPath), { recursive: true, mode: 0o700 })
const fd = openSync(dbPath, 'a', 0o600)
closeSync(fd)
chmodSync(dbPath, 0o600)
}
export async function openKv(dbPath: string): Promise<KvAdapter> {
prepareDatabaseFile(dbPath)
if (isBun) {
const { Database } = await import('bun:sqlite')
const db = new Database(dbPath)
db.run('PRAGMA journal_mode = WAL')
db.run(SCHEMA)
const getStmt = db.query<KvRow, [string]>('SELECT value, expires_at FROM kv WHERE key = ?')
const setStmt = db.query('INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)')
const delStmt = db.query('DELETE FROM kv WHERE key = ?')
const prefixStmt = db.query<KvEntryRow, [string]>(PREFIX_QUERY)
return {
get: (key) => getStmt.get(key) ?? undefined,
set: (key, value, expiresAt) => {
setStmt.run(key, value, expiresAt)
},
del: (key) => {
delStmt.run(key)
},
clear: () => db.run('DELETE FROM kv'),
entriesByPrefix: (prefix) => liveEntries(prefixStmt.all(likePrefix(prefix))),
}
} else {
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(dbPath)
db.exec('PRAGMA journal_mode = WAL')
db.exec(SCHEMA)
const getStmt = db.prepare('SELECT value, expires_at FROM kv WHERE key = ?')
const setStmt = db.prepare('INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)')
const delStmt = db.prepare('DELETE FROM kv WHERE key = ?')
const prefixStmt = db.prepare(PREFIX_QUERY)
return {
get: (key) => getStmt.get(key) as KvRow | undefined,
set: (key, value, expiresAt) => {
setStmt.run(key, value, expiresAt)
},
del: (key) => {
delStmt.run(key)
},
clear: () => db.exec('DELETE FROM kv'),
entriesByPrefix: (prefix) => liveEntries(prefixStmt.all(likePrefix(prefix)) as unknown as KvEntryRow[]),
}
}
}
export function kvGet(kv: KvAdapter, key: string): string | undefined {
const row = kv.get(key)
if (!row) return undefined
if (row.expires_at !== null && row.expires_at <= Date.now()) {
kv.del(key)
return undefined
}
return row.value
}
export function kvSet(kv: KvAdapter, key: string, value: string, ttlSeconds?: number): void {
const expiresAt = ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : null
kv.set(key, value, expiresAt)
}
// ---------------------------------------------------------------------------
// Account records
// ---------------------------------------------------------------------------
export function normalizeHandle(handle: string): string {
return handle.trim().toLowerCase().replace(/^@/, '')
}
function parseStoredAccount(raw: string): StoredAccount | undefined {
let value: unknown
try {
value = JSON.parse(raw)
} catch {
return undefined
}
if (!value || typeof value !== 'object') return undefined
const account = value as Partial<StoredAccount>
if (
typeof account.did !== 'string' ||
!account.did.startsWith('did:') ||
(account.handle !== undefined && typeof account.handle !== 'string') ||
(account.pdsUrl !== undefined && typeof account.pdsUrl !== 'string') ||
(account.method !== 'oauth' && account.method !== 'app-password') ||
typeof account.addedAt !== 'number' ||
!Number.isFinite(account.addedAt) ||
typeof account.lastUsedAt !== 'number' ||
!Number.isFinite(account.lastUsedAt) ||
(account.handleCheckedAt !== undefined &&
(typeof account.handleCheckedAt !== 'number' || !Number.isFinite(account.handleCheckedAt)))
) {
return undefined
}
return account as StoredAccount
}
export function readAccount(kv: KvAdapter, did: string): StoredAccount | undefined {
const raw = kvGet(kv, `${ACCOUNT_PREFIX}${did}`)
return raw ? parseStoredAccount(raw) : undefined
}
export function writeAccount(kv: KvAdapter, account: StoredAccount): void {
kvSet(kv, `${ACCOUNT_PREFIX}${account.did}`, JSON.stringify(account))
if (account.handle) {
kvSet(kv, `${HANDLE_PREFIX}${account.handle}`, account.did)
}
}
export function listAccounts(kv: KvAdapter): StoredAccount[] {
return kv
.entriesByPrefix(ACCOUNT_PREFIX)
.map(({ value }) => parseStoredAccount(value))
.filter((account): account is StoredAccount => account !== undefined)
.sort((a, b) => (a.handle ?? a.did).localeCompare(b.handle ?? b.did))
}
function detachHandle(kv: KvAdapter, did: string, handle: string): void {
const aliasKey = `${HANDLE_PREFIX}${handle}`
if (kvGet(kv, aliasKey) === did) {
kv.del(aliasKey)
}
const account = readAccount(kv, did)
if (account?.handle !== handle) return
const updated = { ...account }
delete updated.handle
delete updated.handleCheckedAt
kvSet(kv, `${ACCOUNT_PREFIX}${did}`, JSON.stringify(updated))
}
/**
* Create or refresh the account record for `did`. Non-secret bookkeeping only —
* credentials themselves live in the keychain.
*/
export function upsertAccount(
kv: KvAdapter,
did: string,
updates: {
handle?: string
method?: AuthMethod
pdsUrl?: string
handleChecked?: boolean
oauthScope?: OAuthScopeStrategy
} = {},
): StoredAccount {
const now = Date.now()
const existing = readAccount(kv, did)
const handle = updates.handle ? normalizeHandle(updates.handle) : existing?.handle
if (existing?.handle && existing.handle !== handle) {
detachHandle(kv, did, existing.handle)
}
if (handle) {
const ownerDid = kvGet(kv, `${HANDLE_PREFIX}${handle}`)
if (ownerDid && ownerDid !== did) {
detachHandle(kv, ownerDid, handle)
}
}
const account: StoredAccount = {
did,
handle,
method: updates.method ?? existing?.method ?? 'oauth',
pdsUrl: updates.pdsUrl ?? existing?.pdsUrl,
addedAt: existing?.addedAt ?? now,
lastUsedAt: now,
handleCheckedAt: updates.handleChecked ? now : handle !== existing?.handle ? undefined : existing?.handleCheckedAt,
oauthScope: updates.oauthScope ?? existing?.oauthScope,
}
writeAccount(kv, account)
return account
}
export function deleteAccount(kv: KvAdapter, did: string): void {
const account = readAccount(kv, did)
if (account?.handle) {
kv.del(`${HANDLE_PREFIX}${account.handle}`)
}
// Any other alias rows still pointing at this DID.
for (const { key, value } of kv.entriesByPrefix(HANDLE_PREFIX)) {
if (value === did) kv.del(key)
}
for (const { key, value } of kv.entriesByPrefix(DIR_PREFIX)) {
if (value === did) kv.del(key)
}
if (kvGet(kv, DEFAULT_ACCOUNT_KEY) === did) {
kv.del(DEFAULT_ACCOUNT_KEY)
}
kv.del(`${ACCOUNT_PREFIX}${did}`)
}
// ---------------------------------------------------------------------------
// Directory + default account mappings
// ---------------------------------------------------------------------------
export function dirKey(dir: string = cwd()): string {
return `${DIR_PREFIX}${dir}`
}
export function getDirDid(kv: KvAdapter): string | undefined {
return kvGet(kv, dirKey())
}
export function setDirDid(kv: KvAdapter, did: string): void {
kvSet(kv, dirKey(), did)
}
export function clearDirDid(kv: KvAdapter): void {
kv.del(dirKey())
}
export function getDefaultDid(kv: KvAdapter): string | undefined {
return kvGet(kv, DEFAULT_ACCOUNT_KEY)
}
export function setDefaultDid(kv: KvAdapter, did: string): void {
kvSet(kv, DEFAULT_ACCOUNT_KEY, did)
}
export function listDirsForDid(kv: KvAdapter, did: string): string[] {
return kv
.entriesByPrefix(DIR_PREFIX)
.filter(({ value }) => value === did)
.map(({ key }) => key.slice(DIR_PREFIX.length))
}
/**
* Which account a bare command in this directory should use.
*
* Directory mapping wins, then an explicitly chosen default (`wispctl accounts
* use`), then a lone stored account. With several accounts and no explicit
* choice we return nothing so the caller prompts rather than guessing an
* identity to deploy as.
*/
export function resolveAccountForDir(kv: KvAdapter): StoredAccount | undefined {
const dirDid = getDirDid(kv)
if (dirDid) {
return readAccount(kv, dirDid) ?? upsertAccount(kv, dirDid)
}
const defaultDid = getDefaultDid(kv)
if (defaultDid) {
const account = readAccount(kv, defaultDid)
if (account) return account
}
const accounts = listAccounts(kv)
return accounts.length === 1 ? accounts[0] : undefined
}
// ---------------------------------------------------------------------------
// Identifier resolution
// ---------------------------------------------------------------------------
function isAliasFresh(account: StoredAccount | undefined, handle: string): boolean {
if (!account || account.handle !== handle) return false
return account.handleCheckedAt !== undefined && Date.now() - account.handleCheckedAt < HANDLE_ALIAS_TTL_MS
}
/**
* Resolve a handle or DID to a DID, preferring the local alias cache so the
* common case costs no network round trip. A stale or missing alias falls
* through to a real resolve; a failed resolve falls back to the cache so the
* CLI still works offline against an already-known account.
*/
export async function resolveIdentifierToDid(kv: KvAdapter, identifier: string): Promise<string | null> {
if (identifier.startsWith('did:')) {
return identifier
}
const handle = normalizeHandle(identifier)
const cachedDid = kvGet(kv, `${HANDLE_PREFIX}${handle}`)
if (cachedDid && isAliasFresh(readAccount(kv, cachedDid), handle)) {
return cachedDid
}
const resolved = await resolveDid(handle, unsafeRawIdentityGet).catch(() => null)
if (!resolved) {
return cachedDid ?? null
}
if (cachedDid && cachedDid !== resolved) {
detachHandle(kv, cachedDid, handle)
}
if (readAccount(kv, resolved)) {
upsertAccount(kv, resolved, { handle, handleChecked: true })
} else {
kvSet(kv, `${HANDLE_PREFIX}${handle}`, resolved)
}
return resolved
}
/** Fill in a missing handle for an account we only know by DID. */
export async function backfillHandle(kv: KvAdapter, did: string): Promise<string | undefined> {
const account = readAccount(kv, did)
if (account?.handle) return account.handle
const handle = await getHandleForDid(did, unsafeRawIdentityGet).catch(() => null)
if (!handle) return undefined
return upsertAccount(kv, did, { handle: handle, handleChecked: true }).handle
}
// ---------------------------------------------------------------------------
// Store bootstrap
// ---------------------------------------------------------------------------
/**
* Promote pre-existing per-directory mappings into account records. Handles are
* filled in lazily on first use, so this stays offline and cheap.
*/
function migrateDirMappings(kv: KvAdapter): void {
if (kvGet(kv, STORE_VERSION_KEY) === STORE_VERSION) return
for (const { value: did } of kv.entriesByPrefix(DIR_PREFIX)) {
if (!did.startsWith('did:')) continue
if (!readAccount(kv, did)) {
upsertAccount(kv, did, { method: 'oauth' })
}
}
kvSet(kv, STORE_VERSION_KEY, STORE_VERSION)
}
export async function openAccountStore(dbPath?: string): Promise<KvAdapter> {
const kv = await openKv(dbPath || DEFAULT_DB_PATH)
migrateDirMappings(kv)
return kv
}