{"version":3,"file":"policy-store.mjs","names":[],"sources":["../../../src/services/policy-store.ts"],"sourcesContent":["/**\n * Policy Store — File-based CRUD persistence for policies and usage tracking.\n *\n * Layout:\n *   ~/.openclawnch/policies/<userId>/\n *     policies.json   — array of Policy objects\n *     usage.json      — array of PolicyUsage (rolling window, pruned on load)\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync, readdirSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { createHash } from 'node:crypto';\nimport type { Policy, PolicyUsage, UsageEntry } from './policy-types.js';\n\nconst HOME = process.env.HOME ?? '/home/openclawnch';\nconst BASE_DIR = join(HOME, '.openclawnch', 'policies');\n\n/** Max usage entries per policy before pruning (keep 30 days). */\nconst MAX_USAGE_AGE_MS = 30 * 24 * 60 * 60 * 1000;\n\n/** Maximum policies per user (DoS prevention). */\nconst MAX_POLICIES_PER_USER = 50;\n\n/** File permissions for policy files (owner read/write only). */\nconst FILE_MODE = 0o600;\n\n// ─── Path helpers ───────────────────────────────────────────────────────\n\n/**\n * Hash userId to prevent collisions: \"user@1\" and \"user_1\" must NOT\n * map to the same directory. SHA-256 prefix is collision-resistant.\n */\nfunction sanitizeUserId(id: string): string {\n  return createHash('sha256').update(id).digest('hex').slice(0, 16);\n}\n\nfunction userDir(userId: string): string {\n  const dir = join(BASE_DIR, sanitizeUserId(userId));\n  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n  return dir;\n}\n\nfunction policiesPath(userId: string): string {\n  return join(userDir(userId), 'policies.json');\n}\n\nfunction usagePath(userId: string): string {\n  return join(userDir(userId), 'usage.json');\n}\n\n// ─── Policy CRUD ────────────────────────────────────────────────────────\n\nexport class PolicyStore {\n  private cache = new Map<string, Policy[]>();\n  private usageCache = new Map<string, PolicyUsage[]>();\n  /** Set of userIds whose policy files are corrupted. Fail-closed: block all. */\n  private _corrupted = new Set<string>();\n\n  /** Check if the store is corrupted for a user (fail-closed). */\n  isCorrupted(userId: string): boolean {\n    return this._corrupted.has(userId);\n  }\n\n  /** Find the first userId directory that has policies (for command fallback). */\n  findFirstUserWithPolicies(): string | null {\n    try {\n      const dirs = existsSync(BASE_DIR)\n        ? readdirSync(BASE_DIR).filter((d: string) => {\n            const p = join(BASE_DIR, d, 'policies.json');\n            return existsSync(p);\n          })\n        : [];\n      return dirs[0] ?? null;\n    } catch {\n      return null;\n    }\n  }\n\n  /** Load all policies for a user. */\n  listPolicies(userId: string): Policy[] {\n    const cached = this.cache.get(userId);\n    if (cached) return cached;\n\n    const path = policiesPath(userId);\n    if (!existsSync(path)) return [];\n\n    try {\n      const data = JSON.parse(readFileSync(path, 'utf8')) as Policy[];\n      this.cache.set(userId, data);\n      return data;\n    } catch (err) {\n      // Corruption detected — rename corrupt file for forensics and fail closed\n      this._corrupted.add(userId);\n      try {\n        renameSync(path, path + '.corrupt.' + Date.now());\n      } catch { /* best effort */ }\n      return [];\n    }\n  }\n\n  /** Get active policies only. */\n  getActivePolicies(userId: string): Policy[] {\n    return this.listPolicies(userId).filter(p => p.status === 'active');\n  }\n\n  /** Get a single policy by ID. */\n  getPolicy(userId: string, policyId: string): Policy | undefined {\n    return this.listPolicies(userId).find(p => p.id === policyId);\n  }\n\n  /** Get a single policy by name (case-insensitive). */\n  getPolicyByName(userId: string, name: string): Policy | undefined {\n    const lower = name.toLowerCase();\n    return this.listPolicies(userId).find(p => p.name.toLowerCase() === lower);\n  }\n\n  /** Create or update a policy. Throws if max count exceeded on insert. */\n  savePolicy(policy: Policy): void {\n    const policies = this.listPolicies(policy.userId);\n    const idx = policies.findIndex(p => p.id === policy.id);\n    if (idx >= 0) {\n      policies[idx] = policy;\n    } else {\n      if (policies.length >= MAX_POLICIES_PER_USER) {\n        throw new Error(`Maximum ${MAX_POLICIES_PER_USER} policies per user reached. Delete some before creating new ones.`);\n      }\n      policies.push(policy);\n    }\n    this.cache.set(policy.userId, policies);\n    this.persist(policy.userId);\n  }\n\n  /** Delete a policy by ID. */\n  deletePolicy(userId: string, policyId: string): boolean {\n    const policies = this.listPolicies(userId);\n    const idx = policies.findIndex(p => p.id === policyId);\n    if (idx < 0) return false;\n    policies.splice(idx, 1);\n    this.cache.set(userId, policies);\n    this.persist(userId);\n    // Also remove usage\n    const usages = this.loadUsage(userId);\n    const uIdx = usages.findIndex(u => u.policyId === policyId);\n    if (uIdx >= 0) {\n      usages.splice(uIdx, 1);\n      this.usageCache.set(userId, usages);\n      this.persistUsage(userId);\n    }\n    return true;\n  }\n\n  // ─── Usage Tracking ─────────────────────────────────────────────────\n\n  /** Load usage data for a user, pruning stale entries. */\n  loadUsage(userId: string): PolicyUsage[] {\n    const cached = this.usageCache.get(userId);\n    if (cached) return cached;\n\n    const path = usagePath(userId);\n    if (!existsSync(path)) return [];\n\n    try {\n      const data = JSON.parse(readFileSync(path, 'utf8')) as PolicyUsage[];\n      // Prune old entries\n      const cutoff = Date.now() - MAX_USAGE_AGE_MS;\n      let pruned = false;\n      for (const pu of data) {\n        const before = pu.entries.length;\n        pu.entries = pu.entries.filter(e => e.timestamp > cutoff);\n        if (pu.entries.length < before) pruned = true;\n      }\n      this.usageCache.set(userId, data);\n      // Persist pruned data to prevent unbounded file growth\n      if (pruned) this.persistUsage(userId);\n      return data;\n    } catch {\n      return [];\n    }\n  }\n\n  /** Get usage for a specific policy. */\n  getUsage(userId: string, policyId: string): PolicyUsage | undefined {\n    return this.loadUsage(userId).find(u => u.policyId === policyId);\n  }\n\n  /** Record a usage entry for a policy. */\n  recordUsage(userId: string, policyId: string, entry: UsageEntry): void {\n    const usages = this.loadUsage(userId);\n    let pu = usages.find(u => u.policyId === policyId);\n    if (!pu) {\n      pu = { policyId, entries: [] };\n      usages.push(pu);\n    }\n    pu.entries.push(entry);\n    this.usageCache.set(userId, usages);\n    this.persistUsage(userId);\n  }\n\n  /** Get total spend in a time window for a policy. */\n  getSpendInWindow(userId: string, policyId: string, windowMs: number): number {\n    const pu = this.getUsage(userId, policyId);\n    if (!pu) return 0;\n    const cutoff = Date.now() - windowMs;\n    return pu.entries\n      .filter(e => e.timestamp > cutoff && e.amountUsd != null)\n      .reduce((sum, e) => sum + (e.amountUsd ?? 0), 0);\n  }\n\n  /** Get call count in a time window for a policy. */\n  getCallsInWindow(userId: string, policyId: string, windowMs: number): number {\n    const pu = this.getUsage(userId, policyId);\n    if (!pu) return 0;\n    const cutoff = Date.now() - windowMs;\n    return pu.entries.filter(e => e.timestamp > cutoff).length;\n  }\n\n  // ─── Persistence ────────────────────────────────────────────────────\n\n  private persist(userId: string): void {\n    const policies = this.cache.get(userId) ?? [];\n    atomicWrite(policiesPath(userId), JSON.stringify(policies, null, 2));\n  }\n\n  private persistUsage(userId: string): void {\n    const usages = this.usageCache.get(userId) ?? [];\n    atomicWrite(usagePath(userId), JSON.stringify(usages, null, 2));\n  }\n\n  /** Clear all caches (for testing). */\n  reset(): void {\n    this.cache.clear();\n    this.usageCache.clear();\n    this._corrupted.clear();\n  }\n}\n\n// ─── Atomic Write Helper ────────────────────────────────────────────────\n\n/**\n * Write to a temp file then rename (atomic on POSIX).\n * Prevents data loss on crash mid-write. Sets restrictive permissions.\n */\nfunction atomicWrite(targetPath: string, data: string): void {\n  const tmpPath = targetPath + '.tmp.' + Date.now();\n  writeFileSync(tmpPath, data, { mode: FILE_MODE });\n  renameSync(tmpPath, targetPath);\n}\n\n// ─── Singleton ──────────────────────────────────────────────────────────\n\nlet _instance: PolicyStore | null = null;\n\nexport function getPolicyStore(): PolicyStore {\n  if (!_instance) _instance = new PolicyStore();\n  return _instance;\n}\n\nexport function resetPolicyStore(): void {\n  _instance?.reset();\n  _instance = null;\n}\n"],"mappings":"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"}