mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
* fix(orcad): keep the integration credential key only in the owner-only key file Managed servers no longer create, read or wait on the macOS login Keychain or a Secret Service keyring for their credential master key. A record an hourly build pointed at the keyring is migrated once: the key is copied into the key file when it reads back without UI within 5s, otherwise a new key file is started and old credentials show the usual reconnect error. Removes the locked-Keychain status fields, retry, the integrationCredentials.moveKeyToFile RPC and the Settings notice. * fix(orcad): serialize the keyring-record migration so concurrent starts agree on one key * docs(orcad): note the migration lock * fix(orcad): publish the migration lock with its PID and bound every wait * refactor(orcad): reuse the file transaction lock for the keyring-record migration
77 lines
2.2 KiB
TypeScript
77 lines
2.2 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import type { IntegrationCredentialService } from '../shared/integration-credential-errors'
|
|
import {
|
|
CredentialDecryptionError,
|
|
credentialFileHasContent,
|
|
readStoredCredentialToken
|
|
} from './integration-credential-file'
|
|
import {
|
|
createIntegrationStateFileCache,
|
|
type IntegrationStateFileCache
|
|
} from './integration-state-file-cache'
|
|
|
|
type TokenFileState = { present: boolean; token?: string; error?: string }
|
|
const MAX_CREDENTIAL_ENTRIES = 128
|
|
|
|
export function createIntegrationTokenFileCache(args: {
|
|
service: IntegrationCredentialService
|
|
filePath: (id: string) => string
|
|
}) {
|
|
const entries = new Map<string, IntegrationStateFileCache<TokenFileState>>()
|
|
const stateFor = (id: string): TokenFileState => {
|
|
let entry = entries.get(id)
|
|
if (!entry) {
|
|
entry = createIntegrationStateFileCache<TokenFileState>({
|
|
filePath: () => args.filePath(id),
|
|
readFromDisk: () => ({ present: credentialFileHasContent(args.filePath(id)) })
|
|
})
|
|
}
|
|
entries.delete(id)
|
|
entries.set(id, entry)
|
|
while (entries.size > MAX_CREDENTIAL_ENTRIES) {
|
|
const oldest = entries.keys().next().value
|
|
if (oldest === undefined) {
|
|
break
|
|
}
|
|
entries.delete(oldest)
|
|
}
|
|
return entry.get()
|
|
}
|
|
return {
|
|
has(id: string): boolean {
|
|
return stateFor(id).present
|
|
},
|
|
read(id: string, force = true): string | null {
|
|
// Stamp before decrypting so a concurrent rotation cannot pin older bytes as current.
|
|
const state = stateFor(id)
|
|
if (state.token !== undefined) {
|
|
return state.token
|
|
}
|
|
if (!force || !state.present) {
|
|
return null
|
|
}
|
|
try {
|
|
const token = readStoredCredentialToken(args.service, readFileSync(args.filePath(id)))
|
|
state.token = token ?? undefined
|
|
state.error = undefined
|
|
return token
|
|
} catch (error) {
|
|
if (error instanceof CredentialDecryptionError) {
|
|
state.error = error.message
|
|
throw error
|
|
}
|
|
return null
|
|
}
|
|
},
|
|
error(id: string): string | undefined {
|
|
return stateFor(id).error
|
|
},
|
|
forget(id: string): void {
|
|
entries.delete(id)
|
|
},
|
|
clear(): void {
|
|
entries.clear()
|
|
}
|
|
}
|
|
}
|