/**
 * Persistence for {@link AgentGrant} records.
 *
 * A single JSON file (`~/.mailpouch-agents.json`) holds every grant the
 * server has ever seen, across all three statuses. Writes are atomic via
 * tmp→rename, consistent with the rest of the project's credential-hygiene
 * story. The file is mode 0600.
 *
 * We deliberately keep this in memory as a Map<clientId, AgentGrant> and
 * flush the whole file on every mutation. n is small (<100 grants even in
 * aggressive use), JSON serialization is tens of μs, and atomic writes
 * eliminate the need for a lockfile.
 */
import { readFileSync, writeFileSync, existsSync, renameSync } from "fs";
import { randomBytes } from "crypto";
import { logger } from "../utils/logger.js";
import { notifications } from "./notifications.js";
import { withFileLock } from "../utils/file-lock.js";
/**
 * XPORT-021: ceiling on simultaneously-pending (not-yet-reviewed) grants. The
 * DCR endpoint creates a pending grant per registration; a per-IP rate limiter
 * caps the burst but a slow flood could still grow the settings-UI grant list
 * without bound. When the cap is reached we evict the oldest pending grant
 * before admitting a new one, so the review queue stays usefully small.
 */
const MAX_PENDING_GRANTS = 50;
export class AgentGrantStore {
    grants = new Map();
    path;
    constructor(path) {
        this.path = path;
        this.load();
    }
    load() {
        if (!existsSync(this.path))
            return;
        try {
            const raw = readFileSync(this.path, "utf-8");
            const parsed = JSON.parse(raw);
            const list = Array.isArray(parsed.grants) ? parsed.grants : [];
            for (const g of list) {
                if (g && typeof g.clientId === "string") {
                    this.grants.set(g.clientId, g);
                }
            }
        }
        catch (err) {
            logger.warn(`AgentGrantStore: failed to parse ${this.path}, starting empty`, "AgentGrantStore", err);
            this.grants.clear();
        }
    }
    /**
     * PERM-006: run a structural mutation under a cross-process advisory lock.
     * Both the MCP server and the settings server hold their own AgentGrantStore
     * instance over the same file; without locking + a reload-merge, one
     * process's whole-file rewrite silently drops grants the other process
     * created or modified. We reload the on-disk state under the lock, MERGE the
     * caller's pending in-memory record set on top (so a fresh local `set` made
     * by `fn` before persist isn't lost), apply `fn`, then persist atomically.
     */
    mutate(fn) {
        return withFileLock(this.path, () => {
            this.reloadMerge();
            return fn();
        });
    }
    /**
     * Reload the file from disk under the lock so on-disk state is authoritative
     * before we mutate + persist. This both recovers grants another process
     * created (the PERM-006 lost-grant case) AND refreshes the *status* of grants
     * we already hold — without this, a grant that process A approved/revoked
     * while we held a stale `pending` copy would be silently reverted by our
     * whole-file `persist()`. The one thing the disk is NOT authoritative for is
     * the call counters (`totalCalls`/`lastCallAt`): `recordCall` bumps those in
     * memory and defers the fsync, so if our in-memory count is ahead we carry it
     * forward onto the disk record rather than losing the unflushed increments.
     */
    reloadMerge() {
        if (!existsSync(this.path))
            return;
        try {
            const parsed = JSON.parse(readFileSync(this.path, "utf-8"));
            const list = Array.isArray(parsed.grants) ? parsed.grants : [];
            const seen = new Set();
            for (const g of list) {
                if (!g || typeof g.clientId !== "string")
                    continue;
                seen.add(g.clientId);
                const mine = this.grants.get(g.clientId);
                if (mine && mine.totalCalls > (g.totalCalls ?? 0)) {
                    // Preserve our not-yet-flushed call counters; disk wins for everything else.
                    this.grants.set(g.clientId, { ...g, totalCalls: mine.totalCalls, lastCallAt: mine.lastCallAt });
                }
                else {
                    this.grants.set(g.clientId, g);
                }
            }
            // Drop in-memory records that no longer exist on disk (pruned by a peer),
            // but keep any we created this session that haven't been persisted yet
            // (none, given mutate() persists under the lock — defensive only).
            for (const clientId of [...this.grants.keys()]) {
                if (!seen.has(clientId))
                    this.grants.delete(clientId);
            }
        }
        catch (err) {
            logger.warn(`AgentGrantStore: reloadMerge failed for ${this.path}`, "AgentGrantStore", err);
        }
    }
    persist() {
        const payload = { version: 1, grants: [...this.grants.values()] };
        // tmp MUST be on the same filesystem as the destination for rename(2) to
        // be atomic. On Linux installs where /tmp is tmpfs and $HOME is on
        // separate storage, using os.tmpdir() fails with EXDEV. Put the tmp
        // next to the destination instead.
        const tmp = `${this.path}.${randomBytes(8).toString("hex")}.tmp`;
        writeFileSync(tmp, JSON.stringify(payload, null, 2), { encoding: "utf-8", mode: 0o600 });
        renameSync(tmp, this.path);
    }
    /**
     * Record a brand-new DCR client as a pending grant. Called from the OAuth
     * DCR handler; idempotent when the same client_id is registered twice (e.g.
     * an MCP host retrying on a disconnected tunnel).
     */
    createPending(args) {
        return this.mutate(() => {
            const existing = this.grants.get(args.clientId);
            if (existing)
                return existing;
            // XPORT-021: bound the pending-review queue. Evict the oldest pending
            // grant(s) (by createdAt) once we'd exceed the cap so a registration
            // flood can't grow the settings-UI list unboundedly.
            const pending = [...this.grants.values()]
                .filter(g => g.status === "pending")
                .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
            for (let i = 0; i <= pending.length - MAX_PENDING_GRANTS; i++) {
                this.grants.delete(pending[i].clientId);
            }
            const grant = {
                clientId: args.clientId,
                clientName: args.clientName || "(unnamed client)",
                status: "pending",
                preset: "read_only", // placeholder — replaced on approve
                createdAt: new Date().toISOString(),
                totalCalls: 0,
            };
            this.grants.set(args.clientId, grant);
            this.persist();
            notifications.emitGrantChanged("grant-created", grant);
            return grant;
        });
    }
    approve(args) {
        return this.mutate(() => {
            const g = this.grants.get(args.clientId);
            if (!g)
                return null;
            g.status = "active";
            g.preset = args.preset;
            g.toolOverrides = args.toolOverrides;
            g.conditions = args.conditions;
            g.note = args.note;
            g.approvedAt = new Date().toISOString();
            g.revokedAt = undefined;
            this.persist();
            notifications.emitGrantChanged("grant-approved", g);
            return g;
        });
    }
    deny(clientId, note) {
        return this.mutate(() => {
            const g = this.grants.get(clientId);
            if (!g)
                return null;
            const wasPending = g.status === "pending";
            g.status = "revoked";
            g.revokedAt = new Date().toISOString();
            g.note = note ?? g.note;
            this.persist();
            // Distinguish "deny" (never-approved pending grant rejected) from
            // "revoke" (previously-approved grant taken back) for UI filtering.
            notifications.emitGrantChanged(wasPending ? "grant-denied" : "grant-revoked", g);
            return g;
        });
    }
    revoke(clientId) {
        return this.deny(clientId);
    }
    /**
     * Mark a grant as expired in-place. Called by the gate when it observes
     * expiresAt has passed; atomic so concurrent tool calls agree on the state.
     */
    markExpired(clientId) {
        return this.mutate(() => {
            const g = this.grants.get(clientId);
            if (!g)
                return null;
            if (g.status === "expired")
                return g;
            g.status = "expired";
            this.persist();
            notifications.emitGrantChanged("grant-expired", g);
            return g;
        });
    }
    /** Record a successful tool call against the grant's counters. */
    recordCall(clientId) {
        const g = this.grants.get(clientId);
        if (!g)
            return;
        g.lastCallAt = new Date().toISOString();
        g.totalCalls += 1;
        // Only persist periodically-ish to avoid fsync on every single tool
        // call. We flush on every mutation anyway when the grant *changes*;
        // this method deliberately does NOT persist so hot paths stay cheap.
        // PERM-010: index.ts now calls flushCounters() on a 5-minute interval and
        // again in gracefulShutdown, so these in-memory increments survive restart.
    }
    /** Force-flush any in-memory call-count updates to disk. */
    flushCounters() {
        // Go through mutate() so we reload + reconcile on-disk status first; a bare
        // persist() here would blind-write our whole map and could revert a status
        // change a peer process made (the same hazard reloadMerge guards against).
        this.mutate(() => this.persist());
    }
    get(clientId) {
        return this.grants.get(clientId);
    }
    list(filter) {
        const rows = [...this.grants.values()];
        if (filter?.status)
            return rows.filter(g => g.status === filter.status);
        return rows;
    }
    /** Drop revoked/expired grants older than `retainDays` days. */
    prune(retainDays = 90, now = Date.now()) {
        return this.mutate(() => {
            const cutoff = now - retainDays * 24 * 60 * 60_000;
            let removed = 0;
            for (const [k, g] of this.grants) {
                if (g.status !== "revoked" && g.status !== "expired")
                    continue;
                const endAt = g.revokedAt ?? g.approvedAt ?? g.createdAt;
                if (Date.parse(endAt) < cutoff) {
                    this.grants.delete(k);
                    removed++;
                }
            }
            if (removed > 0)
                this.persist();
            return removed;
        });
    }
}
//# sourceMappingURL=grant-store.js.map