import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { homedir } from "node:os"; import { getSession } from "./neo4j.js"; import { encrypt, decrypt } from "./crypto.js"; import { encryptWith, decryptWith } from "./file-crypto.js"; // Storage abstraction so the same MCP body runs against Neo4j (platform mode) // or an on-disk encrypted file (standalone mode, e.g. spawned by Claude Code // from ~/.claude/). Selected by the PROPERTY_DATA_STANDALONE env flag at // boot. See .docs/realagent-mcps-standalone-mode.md for the contract. export interface StoredKey { apiKey: string; createdAt: string; updatedAt: string; } export interface KeyStore { readonly backendName: string; readonly backendPath: string; load(accountId: string): Promise; save(accountId: string, apiKey: string): Promise; delete(accountId: string): Promise; } class Neo4jKeyStore implements KeyStore { readonly backendName = "neo4j"; readonly backendPath = process.env.NEO4J_URI ?? ""; async load(accountId: string): Promise { const session = getSession(); try { const result = await session.run( `MATCH (k:PropertyDataKey {accountId: $accountId}) RETURN k.encryptedKey AS encryptedKey, k.createdAt AS createdAt, k.updatedAt AS updatedAt`, { accountId }, ); if (result.records.length === 0) return null; const r = result.records[0]; return { apiKey: decrypt(r.get("encryptedKey")), createdAt: r.get("createdAt") ?? "", updatedAt: r.get("updatedAt") ?? "", }; } finally { await session.close(); } } async save(accountId: string, apiKey: string): Promise { const encryptedKey = encrypt(apiKey); const now = new Date().toISOString(); const session = getSession(); try { await session.run( `CREATE INDEX property_data_key_account IF NOT EXISTS FOR (k:PropertyDataKey) ON (k.accountId)`, {}, ); await session.run( `MERGE (k:PropertyDataKey {accountId: $accountId}) ON CREATE SET k.encryptedKey = $encryptedKey, k.createdAt = $now, k.updatedAt = $now ON MATCH SET k.encryptedKey = $encryptedKey, k.updatedAt = $now`, { accountId, encryptedKey, now }, ); } finally { await session.close(); } } async delete(accountId: string): Promise { const session = getSession(); try { const result = await session.run( `MATCH (k:PropertyDataKey {accountId: $accountId}) DELETE k RETURN count(k) AS deleted`, { accountId }, ); const deleted = result.records[0]?.get("deleted")?.toNumber?.() ?? 0; return deleted > 0; } finally { await session.close(); } } } // FileKeyStore — single key per accountId stored as an AES-256-GCM-encrypted // JSON blob. The file holds a map { [accountId]: { encryptedKey, createdAt, // updatedAt } } so it parallels the Neo4j shape, but in standalone mode the // accountId is "local" (only one). interface FileEnvelope { [accountId: string]: { encryptedKey: string; createdAt: string; updatedAt: string }; } const LABEL = "property-data"; class FileKeyStore implements KeyStore { readonly backendName = "file"; readonly backendPath: string; private readonly storePath: string; private readonly keyFilePath: string; constructor() { const home = homedir(); this.storePath = resolve(home, ".claude/.realagent-property-data-key.enc"); this.keyFilePath = resolve(home, ".claude/.realagent-property-data-encryption-key"); this.backendPath = this.storePath; } private ensureDir(): void { mkdirSync(dirname(this.storePath), { recursive: true }); } private readAll(): FileEnvelope { if (!existsSync(this.storePath)) return {}; const blob = readFileSync(this.storePath, "utf-8").trim(); if (!blob) return {}; const json = decryptWith(blob, this.keyFilePath, LABEL); return JSON.parse(json) as FileEnvelope; } private writeAll(env: FileEnvelope): void { this.ensureDir(); const blob = encryptWith(JSON.stringify(env), this.keyFilePath, LABEL); writeFileSync(this.storePath, blob, { mode: 0o600 }); } async load(accountId: string): Promise { const env = this.readAll(); const entry = env[accountId]; if (!entry) return null; // The encryptedKey field is double-encrypted: the file blob is decrypted // above, the inner field is the original AES-GCM-wrapped key. Keep the // inner layer so an unauthenticated read of decrypted JSON still doesn't // expose the API key directly in process memory until decryptWith runs. return { apiKey: decryptWith(entry.encryptedKey, this.keyFilePath, LABEL), createdAt: entry.createdAt, updatedAt: entry.updatedAt, }; } async save(accountId: string, apiKey: string): Promise { const env = this.readAll(); const now = new Date().toISOString(); const encryptedKey = encryptWith(apiKey, this.keyFilePath, LABEL); const prev = env[accountId]; env[accountId] = { encryptedKey, createdAt: prev?.createdAt ?? now, updatedAt: now, }; this.writeAll(env); } async delete(accountId: string): Promise { const env = this.readAll(); if (!(accountId in env)) return false; delete env[accountId]; this.writeAll(env); return true; } } let cached: KeyStore | null = null; export function selectKeyStore(): KeyStore { if (cached) return cached; cached = process.env.PROPERTY_DATA_STANDALONE === "1" ? new FileKeyStore() : new Neo4jKeyStore(); return cached; } // Test seam — used by unit tests to reset the cached store between cases. export function _resetKeyStoreForTests(): void { cached = null; }