/**
 * SubscriptionPool — multi-account subscription registry (P1.1).
 *
 * Part of the Subscription & Auth Standard
 * (docs/specs/_drafts/subscription-auth-standard-master-spec.md).
 *
 * The source of truth for "which subscriptions does this operator have, per
 * provider." Each account is a first-class registry entry keyed to its login
 * LOCATION — its per-account config home (e.g. CLAUDE_CONFIG_DIR) — NOT its
 * tokens. This is the load-bearing invariant behind decision 1A
 * ("re-enroll per machine"): because the registry only ever stores the
 * config-home path, a leaked registry file never leaks a credential.
 *
 * Cross-machine account follow-me (WS5.2, docs/specs/ws52-account-follow-me-security.md):
 * the DEFAULT is RE-MINT PER MACHINE (Mechanism B) — each machine drives its OWN
 * operator-approved login and holds its own grant; an OAuth config-home NEVER crosses
 * machines. Only a NON-credential, redacted metadata projection replicates (the
 * `subscription-account-meta` JournalKind — id/nickname/email/provider/framework/status/
 * quota; configHome STRIPPED). Shipping each account's credential blob over E2E secret-sync
 * is Mechanism A — a SEPARATE, per-provider-allowlist, default-OFF path that is REFUSED for
 * Anthropic (its ToS prohibits relocating Claude OAuth tokens). NOT the default; do not
 * conflate the two.
 *
 * Why never tokens: Anthropic prohibits Claude OAuth tokens in non-Claude-Code
 * tools and enforces it. The pool drives each account through its real
 * framework client pointed at that account's config home; instar never extracts
 * a token. Storing only the location keeps that invariant structural.
 *
 * File-backed JSON at `<stateDir>/subscription-pool.json`, atomic tmp+rename
 * writes, optimistic CAS via a per-record `version` field. Mirrors the
 * CommitmentTracker durable-registry pattern (a simpler single-writer form).
 *
 * Ships DARK: nothing instantiates a pool with accounts unless the operator
 * enrolls one. A pool of zero accounts is a no-op — single-account agents are
 * entirely unaffected.
 */
import fs from 'node:fs';
import path from 'node:path';
/**
 * WS5.2 §6.2 — "locally executable" predicate. An account is executable on THIS
 * machine iff this machine holds it with a real local `configHome` AND a valid
 * login (status active/warming, never needs-reauth/disabled/rate-limited). A
 * meta-only account replicated in from a peer (no local credential, empty
 * `configHome`) is NOT locally executable and must be invisible to every
 * account-selection / swap-target / placement path — closing the force-mode
 * "use an account I have metadata for but no credential" hole at SELECTION time.
 *
 * This is a pure tightening: every real pool account today carries a non-empty
 * `configHome` (required by `add()`), so this only ever excludes a credential-less
 * meta projection — it never changes selection among genuinely-held accounts.
 */
export function isLocallyExecutable(a) {
    return (typeof a.configHome === 'string' &&
        a.configHome.trim().length > 0 &&
        (a.status === 'active' || a.status === 'warming'));
}
const ID_RE = /^[a-z0-9-]+$/;
const PROVIDERS = [
    'anthropic',
    'openai',
    'github-copilot',
    'google',
];
const FRAMEWORKS = [
    'claude-code',
    'codex-cli',
    'gemini-cli',
    'pi-cli',
];
const STATUSES = [
    'active',
    'warming',
    'rate-limited',
    'needs-reauth',
    'disabled',
];
/**
 * Field names that would smuggle a credential into the registry. Rejected at
 * add()/update() — the registry stores LOCATION, never secrets. This makes the
 * "never store tokens" invariant a structural guard, not a convention.
 */
const FORBIDDEN_CREDENTIAL_FIELDS = [
    'accesstoken',
    'refreshtoken',
    'token',
    'apikey',
    'api_key',
    'credential',
    'credentials',
    'secret',
    'password',
    'oauth',
];
export class ValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = 'ValidationError';
    }
}
export class SubscriptionPool {
    storePath;
    store;
    /**
     * WS5.2 §6.1a — optional emit seam for cross-machine registry follow-me (metadata only).
     * Wired in server.ts ONLY when `multiMachine.accountFollowMe` resolves enabled; null = no
     * replication (single-machine / dark default). The pool emits a REDACTED projection
     * (projectAccountToMeta strips configHome + every credential field by allowlist) — never the
     * login location, never a token.
     */
    metaReplication = null;
    constructor(config) {
        this.storePath = path.join(config.stateDir, 'subscription-pool.json');
        this.store = this.load();
    }
    /** Inject the follow-me meta emitter (server.ts, gated behind accountFollowMe). */
    setMetaReplicationEmitter(emitter) {
        this.metaReplication = emitter;
    }
    // ── Reads ────────────────────────────────────────────────────────
    /** All accounts (a shallow copy — callers can't mutate the store). */
    list() {
        return this.store.accounts.map((a) => ({ ...a }));
    }
    /** One account by id, or null. */
    get(id) {
        const found = this.store.accounts.find((a) => a.id === id);
        return found ? { ...found } : null;
    }
    /**
     * WS5.2 §6.2 — accounts THIS machine can actually execute against (real local
     * `configHome` + a valid login). The canonical selectable set for the router and
     * every swap/placement path; a credential-less meta projection is excluded.
     */
    locallyExecutable() {
        return this.store.accounts.filter(isLocallyExecutable).map((a) => ({ ...a }));
    }
    /** Count of accounts. A pool of 0 is the dark/no-op default. */
    size() {
        return this.store.accounts.length;
    }
    // ── Writes ───────────────────────────────────────────────────────
    /**
     * Add a new account. Throws ValidationError on bad input or duplicate id.
     * `rawExtra` (if provided) is scanned for credential-bearing field names and
     * rejected — the registry never stores tokens.
     */
    add(input, rawExtra) {
        this.assertNoCredentialFields(input);
        if (rawExtra)
            this.assertNoCredentialFields(rawExtra);
        const id = (input.id ?? '').trim();
        if (!id)
            throw new ValidationError('id is required');
        if (!ID_RE.test(id)) {
            throw new ValidationError('id must match ^[a-z0-9-]+$');
        }
        if (this.store.accounts.some((a) => a.id === id)) {
            throw new ValidationError(`account ${id} already exists`);
        }
        const nickname = (input.nickname ?? '').trim();
        if (!nickname)
            throw new ValidationError('nickname is required');
        if (!PROVIDERS.includes(input.provider)) {
            throw new ValidationError(`provider must be one of: ${PROVIDERS.join(', ')}`);
        }
        if (!FRAMEWORKS.includes(input.framework)) {
            throw new ValidationError(`framework must be one of: ${FRAMEWORKS.join(', ')}`);
        }
        const configHome = (input.configHome ?? '').trim();
        if (!configHome)
            throw new ValidationError('configHome is required');
        const status = input.status ?? 'active';
        if (!STATUSES.includes(status)) {
            throw new ValidationError(`status must be one of: ${STATUSES.join(', ')}`);
        }
        const account = {
            id,
            nickname,
            ...(input.email?.trim() ? { email: input.email.trim() } : {}),
            provider: input.provider,
            framework: input.framework,
            configHome,
            status,
            lastQuota: null,
            enrolledAt: new Date().toISOString(),
            version: 1,
        };
        this.store.accounts.push(account);
        this.save();
        this.metaReplication?.emitPut(account);
        return { ...account };
    }
    /**
     * Patch a mutable account. Returns the updated account, or null if not found.
     * id/provider/enrolledAt are immutable here; version auto-increments (CAS).
     * Throws ValidationError on bad field values or credential-bearing input.
     */
    update(id, patch, rawExtra) {
        this.assertNoCredentialFields(patch);
        if (rawExtra)
            this.assertNoCredentialFields(rawExtra);
        const acct = this.store.accounts.find((a) => a.id === id);
        if (!acct)
            return null;
        if (patch.nickname !== undefined) {
            const nn = patch.nickname.trim();
            if (!nn)
                throw new ValidationError('nickname cannot be empty');
            acct.nickname = nn;
        }
        if (patch.framework !== undefined) {
            if (!FRAMEWORKS.includes(patch.framework)) {
                throw new ValidationError(`framework must be one of: ${FRAMEWORKS.join(', ')}`);
            }
            acct.framework = patch.framework;
        }
        if (patch.configHome !== undefined) {
            const ch = patch.configHome.trim();
            if (!ch)
                throw new ValidationError('configHome cannot be empty');
            acct.configHome = ch;
        }
        if (patch.status !== undefined) {
            if (!STATUSES.includes(patch.status)) {
                throw new ValidationError(`status must be one of: ${STATUSES.join(', ')}`);
            }
            acct.status = patch.status;
        }
        if (patch.lastQuota !== undefined) {
            acct.lastQuota = patch.lastQuota;
        }
        if (patch.lastUsedAt !== undefined) {
            acct.lastUsedAt = patch.lastUsedAt;
        }
        if (patch.lastRefreshAt !== undefined) {
            acct.lastRefreshAt = patch.lastRefreshAt;
        }
        if (patch.email !== undefined) {
            const em = patch.email.trim();
            acct.email = em || undefined;
        }
        acct.version += 1;
        this.save();
        // Re-emit on any mutation — a peer must SEE a status/quota change (§6.1a holder stream).
        this.metaReplication?.emitPut(acct);
        return { ...acct };
    }
    /** Remove an account. Returns true if one was removed. */
    remove(id) {
        const before = this.store.accounts.length;
        this.store.accounts = this.store.accounts.filter((a) => a.id !== id);
        const removed = this.store.accounts.length < before;
        if (removed) {
            this.save();
            this.metaReplication?.emitDelete(id, new Date().toISOString());
        }
        return removed;
    }
    // ── Health ───────────────────────────────────────────────────────
    getHealth() {
        const total = this.store.accounts.length;
        const usable = this.store.accounts.filter((a) => a.status === 'active' || a.status === 'warming').length;
        return {
            status: 'healthy',
            message: `${total} account(s), ${usable} usable`,
            lastCheck: new Date().toISOString(),
        };
    }
    // ── Persistence ──────────────────────────────────────────────────
    assertNoCredentialFields(obj) {
        if (!obj || typeof obj !== 'object')
            return;
        for (const key of Object.keys(obj)) {
            if (FORBIDDEN_CREDENTIAL_FIELDS.includes(key.toLowerCase())) {
                throw new ValidationError(`the registry stores login LOCATION, never credentials — field "${key}" is not allowed`);
            }
        }
    }
    load() {
        try {
            if (fs.existsSync(this.storePath)) {
                const data = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'));
                if (data && data.version === 1 && Array.isArray(data.accounts)) {
                    // Backfill version field on any pre-CAS record (defensive).
                    for (const a of data.accounts) {
                        if (typeof a.version !== 'number')
                            a.version = 1;
                    }
                    return data;
                }
            }
        }
        catch {
            // @silent-fallback-ok — corrupt/unreadable store starts fresh; the
            // registry is metadata only, never credentials, so a fresh start loses
            // nothing irrecoverable (the operator re-enrolls / accounts re-detect).
        }
        return { version: 1, accounts: [], lastModified: new Date().toISOString() };
    }
    save() {
        this.store.lastModified = new Date().toISOString();
        try {
            const dir = path.dirname(this.storePath);
            fs.mkdirSync(dir, { recursive: true });
            const tmpPath = `${this.storePath}.${process.pid}.tmp`;
            fs.writeFileSync(tmpPath, JSON.stringify(this.store, null, 2) + '\n');
            fs.renameSync(tmpPath, this.storePath);
        }
        catch {
            // @silent-fallback-ok — state persistence failure; the in-memory store
            // remains authoritative for this process and the next write retries.
        }
    }
}
//# sourceMappingURL=SubscriptionPool.js.map