Files
orca/src/main/integration-token-file-cache.ts
T
Neil f8089c787b fix(orcad): keep the integration credential key only in the owner-only key file (#29859)
* 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
2026-10-16 15:53:30 -07:00

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()
}
}
}