/**
 * Per-account encrypted token store for Outlook OAuth credentials.
 *
 * Adapted from nsakki55/outlook-mcp@e49d8e68ac8d69db653c25803127dc3b98626cda
 * (MIT). Adaptations:
 *   - Per-account paths under {ACCOUNTS_DIR}/{accountId}/secrets/outlook/
 *   - keytar fallback removed (Pi runs headless, no Secret Service)
 *   - node-persist removed; single JSON blob written atomically (temp + rename)
 *
 *   tokens.enc      AES-256-CBC ciphertext "<iv-hex>:<cipher-hex>" of the JSON blob
 *   .key            32 random bytes, base64, mode 0600 (the AES key)
 *
 * The JSON blob shape is {accessToken, refreshToken, accessTokenExpiry,
 * refreshTokenExpiry, lastRefresh, graphUserId, scopes}.
 *
 * Rationale: per CLAUDE.md doctrine, account scope is absolute. Each account
 * holds its own AES key — compromising account A's keyfile cannot decrypt
 * account B's tokens.
 */
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
import { join } from "node:path";
const REFRESH_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
const REFRESH_TOKEN_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
export class TokenStore {
    accountId;
    cachedBlob = null;
    encryptionKey = null;
    secretsDir;
    tokensPath;
    keyPath;
    constructor(accountId, accountsDir) {
        this.accountId = accountId;
        this.secretsDir = join(accountsDir, accountId, "secrets", "outlook");
        this.tokensPath = join(this.secretsDir, "tokens.enc");
        this.keyPath = join(this.secretsDir, ".key");
    }
    /** Load (or create) the AES key. Idempotent and TOCTOU-safe via wx flag. */
    loadKey() {
        if (this.encryptionKey)
            return this.encryptionKey;
        mkdirSync(this.secretsDir, { recursive: true, mode: 0o700 });
        chmodSync(this.secretsDir, 0o700);
        if (existsSync(this.keyPath)) {
            const stat = statSync(this.keyPath);
            if ((stat.mode & 0o777) !== 0o600)
                chmodSync(this.keyPath, 0o600);
            const raw = readFileSync(this.keyPath, "utf-8").trim();
            const key = Buffer.from(raw, "base64");
            if (key.length !== 32) {
                throw new Error(`Outlook key for account=${this.accountId} is corrupt (length=${key.length}); delete ${this.keyPath} and re-run outlook-account-register.`);
            }
            this.encryptionKey = key;
            return key;
        }
        const newKey = randomBytes(32);
        try {
            writeFileSync(this.keyPath, newKey.toString("base64"), {
                flag: "wx",
                mode: 0o600,
            });
        }
        catch (err) {
            // Concurrent flow created the key first — read theirs.
            if (err.code === "EEXIST") {
                const raw = readFileSync(this.keyPath, "utf-8").trim();
                this.encryptionKey = Buffer.from(raw, "base64");
                return this.encryptionKey;
            }
            throw err;
        }
        this.encryptionKey = newKey;
        return newKey;
    }
    encrypt(plaintext) {
        const key = this.loadKey();
        const iv = randomBytes(16);
        const cipher = createCipheriv("aes-256-cbc", key, iv);
        let encrypted = cipher.update(plaintext, "utf8", "hex");
        encrypted += cipher.final("hex");
        return `${iv.toString("hex")}:${encrypted}`;
    }
    decrypt(payload) {
        const key = this.loadKey();
        const [ivHex, ...rest] = payload.split(":");
        if (!ivHex || rest.length === 0) {
            throw new Error("token-decrypt-failed: malformed cipher payload");
        }
        const iv = Buffer.from(ivHex, "hex");
        const decipher = createDecipheriv("aes-256-cbc", key, iv);
        let decrypted = decipher.update(rest.join(":"), "hex", "utf8");
        decrypted += decipher.final("utf8");
        return decrypted;
    }
    /** Atomic write — temp + rename. Either old or new tokens, never partial. */
    writeBlob(blob) {
        const ciphertext = this.encrypt(JSON.stringify(blob));
        const temp = `${this.tokensPath}.tmp`;
        writeFileSync(temp, ciphertext, { mode: 0o600 });
        chmodSync(temp, 0o600);
        renameSync(temp, this.tokensPath);
        this.cachedBlob = blob;
    }
    /** Read and decrypt the blob from disk; cached after first read. */
    read() {
        if (this.cachedBlob)
            return this.cachedBlob;
        if (!existsSync(this.tokensPath))
            return null;
        const raw = readFileSync(this.tokensPath, "utf-8").trim();
        if (!raw)
            return null;
        const json = this.decrypt(raw);
        const blob = JSON.parse(json);
        this.cachedBlob = blob;
        return blob;
    }
    store(accessToken, refreshToken, expiresInSeconds, extras = {}) {
        const now = Date.now();
        const previous = this.cachedBlob ?? this.tryReadSilent();
        const blob = {
            accessToken,
            refreshToken: refreshToken ?? previous?.refreshToken ?? null,
            accessTokenExpiry: now + expiresInSeconds * 1000,
            refreshTokenExpiry: refreshToken
                ? now + REFRESH_TOKEN_LIFETIME_MS
                : (previous?.refreshTokenExpiry ?? now + REFRESH_TOKEN_LIFETIME_MS),
            lastRefresh: now,
            graphUserId: extras.graphUserId ?? previous?.graphUserId ?? null,
            scopes: extras.scopes ?? previous?.scopes ?? [],
        };
        this.writeBlob(blob);
        return blob;
    }
    tryReadSilent() {
        try {
            return this.read();
        }
        catch {
            return null;
        }
    }
    /**
     * Whether the access token is within the refresh threshold of expiry. Caller
     * should refresh before next Graph call when this is true.
     */
    needsRefresh() {
        const blob = this.read();
        if (!blob)
            return false;
        return Date.now() > blob.accessTokenExpiry - REFRESH_THRESHOLD_MS;
    }
    hasTokens() {
        return this.read() !== null;
    }
    refreshTokenExpired() {
        const blob = this.read();
        if (!blob)
            return false;
        return Date.now() > blob.refreshTokenExpiry;
    }
    clear() {
        this.cachedBlob = null;
        if (existsSync(this.tokensPath))
            unlinkSync(this.tokensPath);
    }
}
/** Exported for tests + the mailbox-info tool. */
export const refreshThresholdMs = REFRESH_THRESHOLD_MS;
//# sourceMappingURL=token-store.js.map