/**
 * PendingLoginStore — durable record of in-flight subscription logins (P2.1 of
 * the Subscription & Auth Standard, the mobile-first enrollment wizard).
 *
 * When the operator enrolls an account from their phone, the login is a
 * short-lived artifact: a device-code (Codex: `auth.openai.com/codex/device` +
 * a 9-char code, ~15min TTL) or a URL+paste-back-code flow (Claude). The pi
 * live-test exposed the gap this closes: the first code expired before the
 * operator got to it, the provider's error was opaque, and re-issuing required a
 * manual round-trip. This store makes pending logins durable + TTL-aware so the
 * wizard can:
 *   - surface a code/URL IMMEDIATELY with its TTL visible,
 *   - detect expiry and re-issue a fresh one WITHOUT the operator asking,
 *   - present a "Pending Logins" surface where a fresh code is one tap away.
 *
 * It stores NO credential — only the public device-code / auth URL the provider
 * shows, which the operator types into the provider's own page. The actual login
 * happens against the provider, never through instar (Secret-Drop discipline).
 *
 * File-backed JSON (atomic tmp+rename), mirrors the SubscriptionPool durable-
 * registry pattern. The re-issue mechanism (re-driving the framework login) is
 * the wizard's concern + injected, so this store stays pure + hermetically
 * testable (no spawning, no network).
 */
import fs from 'node:fs';
import path from 'node:path';
const ID_RE = /^[a-z0-9-]+$/;
const DEFAULT_TTL_MS = 15 * 60_000;
const FORBIDDEN_CREDENTIAL_FIELDS = [
    'accesstoken', 'refreshtoken', 'token', 'apikey', 'api_key',
    'credential', 'credentials', 'secret', 'password',
];
export class ValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = 'ValidationError';
    }
}
export class PendingLoginStore {
    storePath;
    store;
    now;
    constructor(config) {
        this.storePath = path.join(config.stateDir, 'pending-logins.json');
        this.now = config.now ?? (() => Date.now());
        this.store = this.load();
    }
    /** All pending logins, with live status (expiry computed against the clock). */
    list() {
        return this.store.logins.map((l) => ({ ...this.withLiveStatus(l) }));
    }
    get(id) {
        const found = this.store.logins.find((l) => l.id === id);
        return found ? { ...this.withLiveStatus(found) } : null;
    }
    /** Logins that are expired (TTL elapsed) but not completed/abandoned — the
     *  auto-reissue work-list. */
    expired() {
        return this.list().filter((l) => l.status === 'expired');
    }
    /** Active (still-valid, awaiting-approval) logins — the "Pending Logins" surface. */
    active() {
        return this.list().filter((l) => l.status === 'pending');
    }
    size() {
        return this.store.logins.length;
    }
    /** Issue a new pending login. Stores the public URL/code only — never a token. */
    issue(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.logins.some((l) => l.id === id)) {
            throw new ValidationError(`pending login ${id} already exists`);
        }
        if (!input.label?.trim())
            throw new ValidationError('label is required');
        if (!input.verificationUrl?.trim())
            throw new ValidationError('verificationUrl is required');
        if (input.kind === 'device-code' && !input.userCode?.trim()) {
            throw new ValidationError('device-code flow requires a userCode');
        }
        const nowIso = new Date(this.now()).toISOString();
        const login = {
            id,
            label: input.label.trim(),
            provider: input.provider,
            framework: input.framework,
            kind: input.kind,
            ...(input.configHome ? { configHome: input.configHome } : {}),
            verificationUrl: input.verificationUrl.trim(),
            ...(input.userCode ? { userCode: input.userCode.trim() } : {}),
            ...(input.notice?.trim() ? { notice: input.notice.trim() } : {}),
            ...(input.expectedEmail?.trim() ? { expectedEmail: input.expectedEmail.trim() } : {}),
            ttlExpiresAt: new Date(this.now() + (input.ttlMs ?? DEFAULT_TTL_MS)).toISOString(),
            status: 'pending',
            reissueCount: 0,
            createdAt: nowIso,
            updatedAt: nowIso,
            version: 1,
        };
        this.store.logins.push(login);
        this.save();
        return { ...login };
    }
    /**
     * Re-issue an expired (or pending) login with a fresh URL/code + TTL — the
     * auto-reissue path. Bumps reissueCount, resets status to pending. Returns null
     * if not found.
     */
    reissue(id, fresh) {
        this.assertNoCredentialFields(fresh);
        const login = this.store.logins.find((l) => l.id === id);
        if (!login)
            return null;
        if (login.status === 'completed' || login.status === 'abandoned') {
            throw new ValidationError(`cannot reissue a ${login.status} login`);
        }
        if (!fresh.verificationUrl?.trim())
            throw new ValidationError('verificationUrl is required');
        login.verificationUrl = fresh.verificationUrl.trim();
        if (fresh.userCode !== undefined)
            login.userCode = fresh.userCode.trim();
        login.ttlExpiresAt = new Date(this.now() + (fresh.ttlMs ?? DEFAULT_TTL_MS)).toISOString();
        login.status = 'pending';
        login.reissueCount += 1;
        login.updatedAt = new Date(this.now()).toISOString();
        login.version += 1;
        this.save();
        return { ...this.withLiveStatus(login) };
    }
    /** Mark a login completed (the operator approved + the account enrolled). */
    complete(id) {
        return this.transition(id, 'completed');
    }
    /** Cancel a login (operator/admin). */
    abandon(id) {
        return this.transition(id, 'abandoned');
    }
    transition(id, to) {
        const login = this.store.logins.find((l) => l.id === id);
        if (!login)
            return null;
        login.status = to;
        login.updatedAt = new Date(this.now()).toISOString();
        login.version += 1;
        this.save();
        return { ...login };
    }
    /** Computes live `expired` status from the TTL without mutating the store
     *  (pending → expired when past TTL; completed/abandoned are terminal). */
    withLiveStatus(login) {
        if (login.status === 'pending' && Date.parse(login.ttlExpiresAt) <= this.now()) {
            return { ...login, status: 'expired' };
        }
        return login;
    }
    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(`pending logins store public codes/URLs only, 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.logins)) {
                    for (const l of data.logins)
                        if (typeof l.version !== 'number')
                            l.version = 1;
                    return data;
                }
            }
        }
        catch {
            // @silent-fallback-ok: corrupt/unreadable store starts fresh; it holds no
            // credentials and the wizard re-issues, so nothing irrecoverable is lost.
        }
        return { version: 1, logins: [], lastModified: new Date(this.now()).toISOString() };
    }
    save() {
        this.store.lastModified = new Date(this.now()).toISOString();
        try {
            fs.mkdirSync(path.dirname(this.storePath), { recursive: true });
            const tmp = `${this.storePath}.${process.pid}.tmp`;
            fs.writeFileSync(tmp, JSON.stringify(this.store, null, 2) + '\n');
            fs.renameSync(tmp, this.storePath);
        }
        catch {
            // @silent-fallback-ok: persistence best-effort; in-memory store authoritative this process.
        }
    }
}
//# sourceMappingURL=PendingLoginStore.js.map