/**
 * Credential bundle coordinator.
 *
 * Owns every concern around server-issued `credentialBundle` deliveries
 * over the heartbeat:
 *
 *   - exposing the locally-applied signature so the heartbeat layer can
 *     send it back to the server (which decides whether to re-ship)
 *   - materialising delivered bundles to disk (via `runtime-credentials.ts`)
 *   - bouncing cloudflared when a tunnel rotation lands, with restart
 *     coalescing so concurrent rotations don't race
 *
 * Replaces the old `TunnelCredentialHandler` + `bootstrapCredentials`
 * flag heuristic. The signature path is server-authoritative and
 * forward-compatible: when a future credential kind is added to
 * `CredentialBundleBody`, the same signature mechanism naturally
 * detects + delivers it without any new wire fields.
 *
 * Persistence model — deliberately none. The applied signature lives
 * only in memory, which means **every fresh process gets a full bundle
 * delivery on its first heartbeat**. That's the recovery path for any
 * disk drift we can't enumerate in advance: a corrupted creds JSON, a
 * stale config.yml, a wiped profile dir, an operator-edited manifest —
 * doesn't matter. Restart the agent, the server hands us the current
 * bundle, we re-apply, we're back in sync. The cost is one bundle
 * download per process lifetime (a few KB), which is negligible.
 */
import { TunnelRotationManager } from "./tunnel-rotation-manager.js";
import { applyRuntimeCredentials, } from "./runtime-credentials.js";
import { logger } from "../logger.js";
export class CredentialBundleHandler {
    getAgentName;
    getConfig;
    restartTunnel;
    onBedrockChanged;
    tunnelRotation;
    /**
     * The signature the agent will send to the server on the next
     * heartbeat. `undefined` means "I have no opinion, please send me
     * the bundle" — which is the state every fresh process starts in,
     * by design. Updated only after a successful apply.
     */
    _appliedSignature = undefined;
    _restartInFlight = null;
    /**
     * Set whenever a `tunnelChanged` apply lands while a previous restart
     * is still in flight. The in-flight restart already snapshotted disk
     * state at its start (`resolveTunnelOpts()` in `obs/server.ts`), so
     * the second rotation needs its own bounce — we re-kick from the
     * restart's `finally` block when this flag is set.
     *
     * Stored as the latest `ApplyRuntimeCredentialsResult` so the
     * rotation label in the follow-up log line still reflects the
     * *latest* tunnel delivery, not the first one already restarted.
     */
    _pendingRestart = null;
    constructor(options) {
        this.getAgentName = options.getAgentName;
        this.getConfig = options.getConfig;
        this.restartTunnel = options.restartTunnel;
        this.onBedrockChanged = options.onBedrockChanged;
        this.tunnelRotation = new TunnelRotationManager({ obsPort: options.obsPort, restartTunnel: options.restartTunnel });
    }
    /**
     * The signature to attach to the next heartbeat as
     * `appliedCredentialSignature`. `undefined` until the first
     * successful apply in this process — which forces the server to
     * ship the bundle on the startup heartbeat (the recovery path).
     */
    get appliedSignature() {
        return this._appliedSignature;
    }
    /**
     * Handle the optional `credentialBundle` from a heartbeat response.
     *
     * Defensive by contract: never throws — a failed materialisation must
     * not block heartbeats. A missing/undefined bundle means "server
     * agrees with our applied signature": no work to do.
     */
    handle(bundle) {
        if (!bundle)
            return;
        if (typeof bundle.signature !== "string" || bundle.signature.trim() === "") {
            logger.warn(`Credential bundle delivery missing signature; ignoring`);
            return;
        }
        let result = null;
        try {
            result = applyRuntimeCredentials(bundle.body, {
                agentName: this.getAgentName(),
                config: this.getConfig?.(),
            });
            this.tunnelRotation.handle(bundle.body.tunnelRotation);
        }
        catch (err) {
            logger.warn(`Credential bundle apply failed: ${err instanceof Error ? err.message : String(err)}`);
            // Deliberately do NOT update `_appliedSignature` on failure: the
            // server should keep retrying the bundle until we report success.
            return;
        }
        // Remember the new signature even when the bundle body was empty
        // (e.g. server has no credentials provisioned yet). This is an
        // intentional policy choice: empty bundle means "no new server-
        // issued credential to apply", NOT "tear down any local tunnel
        // currently on disk". The agent therefore keeps its local tunnel
        // materialization as-is and simply records agreement on the empty
        // server state for the rest of this process lifetime.
        this._appliedSignature = bundle.signature.trim();
        // If the apply rotated cloudflared's UUID, bounce it. Without this
        // obs/server.ts spawned cloudflared against whatever was on disk at
        // startup, and a fresh UUID would sit unused until the next
        // process restart.
        if (result.tunnelApplied && result.tunnelChanged && result.tunnel) {
            this.triggerRestart(result);
        }
        if (result.bedrockChanged) {
            this.onBedrockChanged?.();
        }
    }
    /**
     * Kick `restartTunnel` in the background, coalescing concurrent
     * invocations. Logged at `system` level because a restart is a
     * visible event for operators tailing pm2 logs.
     *
     * If a restart is already in flight when we're called, we don't drop
     * the trigger — the in-flight restart sampled disk before this newer
     * credential was materialised and would launch cloudflared against
     * the stale UUID. Instead we record the latest result in
     * `_pendingRestart` and re-kick from the in-flight restart's
     * `finally` block, which guarantees one extra restart that picks up
     * whatever's now on disk.
     */
    triggerRestart(result) {
        if (!this.restartTunnel || !result.tunnel)
            return;
        if (this._restartInFlight) {
            // Always overwrite — only the most recent credential matters; any
            // intermediate bundles will be on disk by the time the follow-up
            // restart re-probes via `obs/server.ts:resolveTunnelOpts()`.
            this._pendingRestart = result;
            return;
        }
        const newTunnelId = result.tunnel.tunnelId;
        const previousTunnelId = result.previousTunnelId;
        const rotationLabel = previousTunnelId === null
            ? "initial"
            : previousTunnelId === newTunnelId
                ? "refreshed"
                : `rotated (${previousTunnelId} → ${newTunnelId})`;
        logger.system(`Runtime credential: tunnel ${rotationLabel}; restarting cloudflared`);
        const restart = this.restartTunnel;
        this._restartInFlight = (async () => {
            try {
                await restart();
            }
            catch (err) {
                logger.warn(`Failed to restart tunnel after credential update: ${err instanceof Error ? err.message : String(err)}`);
            }
            finally {
                this._restartInFlight = null;
                // Drain any rotation that landed mid-flight. We re-enter
                // triggerRestart synchronously (not via setImmediate) so the
                // follow-up restart starts as soon as the previous Promise
                // resolves, but `_restartInFlight` is already null above so the
                // recursive call goes down the spawn path, not the coalesce path.
                const pending = this._pendingRestart;
                if (pending) {
                    this._pendingRestart = null;
                    this.triggerRestart(pending);
                }
            }
        })();
    }
}
//# sourceMappingURL=credential-bundle-handler.js.map