mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
Move cc-haha managed settings writes behind one shared transaction helper so H5 access mutations and provider settings sync serialize against the same file. Also treat persisted H5 state without a valid token hash as disabled so origin allowlists cannot remain active after partial corruption. Constraint: H5 and provider config both share ~/.claude/cc-haha/settings.json Rejected: Keep per-service locks | concurrent read-modify-write still drops unrelated fields Rejected: Only harden isOriginAllowed | corrupted enabled state would still surface as active in settings reads Confidence: high Scope-risk: narrow Directive: Any future writer of cc-haha/settings.json must go through the shared managed settings transaction path Tested: bun test src/server/__tests__/h5-access-service.test.ts src/server/__tests__/h5-access-api.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/provider-presets.test.ts Not-tested: Full repo typecheck remains blocked by the existing bun-types/tsconfig environment issue in this worktree
84 lines
2.7 KiB
TypeScript
84 lines
2.7 KiB
TypeScript
import * as fs from 'node:fs/promises'
|
|
import * as os from 'node:os'
|
|
import * as path from 'node:path'
|
|
import { randomBytes } from 'node:crypto'
|
|
import { ApiError } from '../middleware/errorHandler.js'
|
|
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
|
|
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
|
|
|
|
export class ManagedSettingsService {
|
|
private static writeLocks = new Map<string, Promise<void>>()
|
|
|
|
private getConfigDir(): string {
|
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
}
|
|
|
|
private getSettingsPath(): string {
|
|
return path.join(this.getConfigDir(), 'cc-haha', 'settings.json')
|
|
}
|
|
|
|
private async withWriteLock<T>(
|
|
filePath: string,
|
|
task: () => Promise<T>,
|
|
): Promise<T> {
|
|
const previousWrite = ManagedSettingsService.writeLocks.get(filePath) ?? Promise.resolve()
|
|
const nextWrite = previousWrite.catch(() => {}).then(task)
|
|
const trackedWrite = nextWrite.then(() => {}, () => {})
|
|
|
|
ManagedSettingsService.writeLocks.set(filePath, trackedWrite)
|
|
|
|
try {
|
|
return await nextWrite
|
|
} finally {
|
|
if (ManagedSettingsService.writeLocks.get(filePath) === trackedWrite) {
|
|
ManagedSettingsService.writeLocks.delete(filePath)
|
|
}
|
|
}
|
|
}
|
|
|
|
private async writeSettings(settings: Record<string, unknown>): Promise<void> {
|
|
const filePath = this.getSettingsPath()
|
|
const dir = path.dirname(filePath)
|
|
const contents = JSON.stringify(settings, null, 2) + '\n'
|
|
const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString('hex')}`
|
|
|
|
await fs.mkdir(dir, { recursive: true })
|
|
|
|
try {
|
|
await fs.writeFile(tmpFile, contents, 'utf-8')
|
|
await fs.rename(tmpFile, filePath)
|
|
} catch (error) {
|
|
await fs.unlink(tmpFile).catch(() => {})
|
|
throw ApiError.internal(`Failed to write settings.json: ${error}`)
|
|
}
|
|
}
|
|
|
|
async readSettings(): Promise<Record<string, unknown>> {
|
|
await ensurePersistentStorageUpgraded()
|
|
return readRecoverableJsonFile({
|
|
filePath: this.getSettingsPath(),
|
|
label: 'cc-haha managed settings',
|
|
defaultValue: {},
|
|
normalize: normalizeJsonObject,
|
|
})
|
|
}
|
|
|
|
async updateSettings<T>(
|
|
updater: (current: Record<string, unknown>) => Promise<{
|
|
settings: Record<string, unknown>
|
|
result: T
|
|
}> | {
|
|
settings: Record<string, unknown>
|
|
result: T
|
|
},
|
|
): Promise<T> {
|
|
const filePath = this.getSettingsPath()
|
|
return this.withWriteLock(filePath, async () => {
|
|
const current = await this.readSettings()
|
|
const { settings, result } = await updater(current)
|
|
await this.writeSettings(settings)
|
|
return result
|
|
})
|
|
}
|
|
}
|