dify/cli/src/store/token-store.ts
gigglewang 56a026505e
fix(cli/e2e): remove LLM nodes from fixture DSLs and fix test assertions (#37463)
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-06-16 08:58:53 +00:00

131 lines
3.9 KiB
TypeScript

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<string>
write: (host: string, email: string, bearer: string) => Promise<void>
remove: (host: string, email: string) => Promise<void>
}
const DOC_VERSION = 1
export type TokenDoc = {
version?: number
tokens?: Record<string, Record<string, string>>
}
export class FileTokenStore implements TokenStore {
private readonly store: YamlStore
constructor(filePath: string) {
this.store = new YamlStore(filePath)
}
async read(host: string, email: string): Promise<string> {
const doc = await this.store.getTyped<TokenDoc>()
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<void> {
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<void> {
const doc = await this.store.getTyped<TokenDoc>()
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<string, Record<string, string>> }> {
const doc = await this.store.getTyped<TokenDoc>()
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<string, Record<string, string>> }
}
}
/**
* 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<string> {
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<void> {
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<void> {
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,
})
}