"use strict";
/**
 * Entitlement verifier — single source of truth for effective tier and
 * purchasedPlugins on a Maxy/Real-Agent install. Pre-launch security gate
 * for paid tiers.
 *
 * Doctrine: account.json is agent-writable; entitlement is not. Effective
 * tier and purchasedPlugins derive from a Rubytech-signed Ed25519 payload,
 * not from raw account.json values. Editing account.json directly has no
 * runtime effect; the verifier compares disk against signed and uses signed.
 *
 *   ┌────────────────────────────┐
 *   │ session start              │
 *   └─────────────┬──────────────┘
 *                 ▼
 *   ┌────────────────────────────┐
 *   │ read brand.json            │
 *   │ commercialMode? (default   │
 *   │ false on personal-mode)    │
 *   └────────┬───────────────┬───┘
 *            │false          │true
 *            ▼               ▼
 *   source=implicit-trust    read entitlement.json
 *   (raw account.json)              │
 *                                   ├─missing──▶ anonymous-fallback (lock)
 *                                   ├─malformed─▶ anonymous-fallback
 *                                   ├─pubkey-tampered─▶ anonymous-fallback
 *                                   ├─bad sig──▶ anonymous-fallback
 *                                   ├─binding mismatch─▶ anonymous-fallback
 *                                   ├─clock skew──▶ anonymous-fallback
 *                                   ├─expired+grace─▶ signed-grace
 *                                   ├─expired post-grace─▶ anonymous-fallback (lock)
 *                                   └─valid─▶ signed
 *
 * Threat model: this code closes the agent-self-elevation bypass and the
 * casual operator vim-edit bypass. A determined operator with shell access
 * who replaces the vendored pubkey AND regenerates the SHA-256 hash baked
 * into PUBKEY_SHA256 below can still defeat the verifier — that is the
 * accepted out-of-scope per task 831 ("raise bypass cost to break the
 * signature, not to edit JSON"). Hardware binding is the only counter and
 * is explicitly out of scope.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.PUBKEY_SHA256 = void 0;
exports.resolveEntitlement = resolveEntitlement;
exports._clearMemo = _clearMemo;
const node_crypto_1 = require("node:crypto");
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const canonicalize_js_1 = require("./canonicalize.js");
// __dirname is undefined in pure ESM bundles (platform/ui's tsup output is
// format: ['esm'] with no shims:true). The MCP plugin path imports the CJS
// build (dist/) where __dirname works, but the UI server imports the TS
// source directly and tsup-bundles it as ESM. To work in both worlds, the
// caller passes the platform root explicitly via BrandConfigSubset.platformRoot.
// ---------------------------------------------------------------------------
// Vendored pubkey — shipped at platform/lib/entitlement/rubytech-pubkey.pem.
// PUBKEY_SHA256 is the expected hex SHA-256 of the file's bytes; if the
// on-disk file's hash diverges the verifier emits pubkey-tamper and falls
// back to anonymous. Update both when Rubytech rotates the keypair.
// ---------------------------------------------------------------------------
exports.PUBKEY_SHA256 = "8eee6bcb33545fd13b16d3199a5735ca5db5062834c7b49dfe4f23801d99db79";
const GRACE_DAYS = 7;
const GRACE_MS = GRACE_DAYS * 24 * 60 * 60 * 1000;
function pubkeyPath(brand) {
    return (0, node_path_1.resolve)(brand.platformRoot, "lib", "entitlement", "rubytech-pubkey.pem");
}
// Memoize per-process, scoped to (mtime, accountId, customerEmail). Scoping
// by accountId is mandatory: the binding check at verifyAndResolve runs once
// per call, and a memo keyed on mtime alone would return account A's verified
// result to account B's request — bypassing the per-call binding check
// (account isolation doctrine, feedback_account_isolation_absolute).
let memo = null;
function memoKey(mtimeMs, account) {
    // Coarse 60-second time bucket ensures the memo invalidates around the
    // expires/grace boundary even when entitlement.json mtime never changes
    // (long-running UI server, no file refresh ships).
    const bucket = Math.floor(Date.now() / 60000);
    return `${mtimeMs}:${bucket}:${account.accountId}:${account.customerEmail ?? ""}`;
}
const VALID_TIERS = new Set(["solo", "family", "pro"]);
/**
 * Resolve effective entitlement for the current install. Sync — Ed25519
 * verify is a CPU-bound primitive on the order of 0.3ms; no async surface.
 *
 * @param brand   Brand config (read from platform/config/brand.json by caller)
 * @param account Account config (read from data/accounts/<id>/account.json)
 * @returns Effective entitlement plus the source tag for observability.
 */
function resolveEntitlement(brand, account) {
    // Personal mode (commercialMode !== true): always implicit-trust. A stray
    // entitlement.json (backup-restore, dev artefact) must NOT lock out a
    // personal-mode user — verification only runs when the brand is commercial.
    if (brand.commercialMode !== true) {
        return logResolved(implicitTrust(account), null);
    }
    const entitlementPath = (0, node_path_1.resolve)(brand.configDir, "entitlement.json");
    if (!(0, node_fs_1.existsSync)(entitlementPath)) {
        return logResolved(anonymousFallback("missing"), { reason: "missing" });
    }
    // (mtime, time-bucket, accountId, customerEmail)-keyed memoization
    const stat = (0, node_fs_1.statSync)(entitlementPath);
    const key = memoKey(stat.mtimeMs, account);
    if (memo && memo.key === key) {
        return memo.result;
    }
    const result = verifyAndResolve(brand, entitlementPath, account);
    memo = { key, result };
    return result;
}
/** Test-only: clear the memoization cache. Not used in production. */
function _clearMemo() {
    memo = null;
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
function verifyAndResolve(brand, entitlementPath, account) {
    // Step 1: pubkey integrity. If the vendored pubkey has been swapped on
    // disk, refuse to use it — emit verify-failed and degrade.
    let pubkeyPem;
    try {
        pubkeyPem = (0, node_fs_1.readFileSync)(pubkeyPath(brand), "utf-8");
    }
    catch (err) {
        return logResolved(anonymousFallback("pubkey-missing"), {
            reason: "pubkey-missing",
        });
    }
    const pubkeyHash = (0, node_crypto_1.createHash)("sha256").update(pubkeyPem).digest("hex");
    if (pubkeyHash !== exports.PUBKEY_SHA256) {
        return logResolved(anonymousFallback("pubkey-tamper"), {
            reason: "pubkey-tamper",
        });
    }
    // Step 2: parse the entitlement envelope.
    let envelope;
    try {
        envelope = JSON.parse((0, node_fs_1.readFileSync)(entitlementPath, "utf-8"));
    }
    catch {
        return logResolved(anonymousFallback("malformed"), { reason: "malformed" });
    }
    if (typeof envelope !== "object" ||
        envelope === null ||
        typeof envelope.signature !== "string" ||
        typeof envelope.payload !== "object" ||
        envelope.payload === null) {
        return logResolved(anonymousFallback("malformed"), { reason: "malformed" });
    }
    const payload = envelope.payload;
    const signatureB64 = envelope.signature;
    // Step 3: signature verification over canonical bytes.
    let pubkey;
    try {
        pubkey = (0, node_crypto_1.createPublicKey)(pubkeyPem);
    }
    catch {
        return logResolved(anonymousFallback("pubkey-malformed"), {
            reason: "pubkey-malformed",
        });
    }
    let canonical;
    try {
        canonical = (0, canonicalize_js_1.canonicalize)(payload);
    }
    catch {
        return logResolved(anonymousFallback("payload-shape"), {
            reason: "payload-shape",
        });
    }
    let sigBuf;
    try {
        sigBuf = Buffer.from(signatureB64, "base64");
    }
    catch {
        return logResolved(anonymousFallback("signature-encoding"), {
            reason: "signature-encoding",
        });
    }
    const ok = (0, node_crypto_1.verify)(null, Buffer.from(canonical, "utf-8"), pubkey, sigBuf);
    if (!ok) {
        return logResolved(anonymousFallback("signature"), { reason: "signature" });
    }
    // Step 4: bindings — accountId and customerEmail must match local truth.
    if (typeof payload.accountId !== "string" || payload.accountId !== account.accountId) {
        return logResolved(anonymousFallback("binding-account"), {
            reason: "binding-account",
        });
    }
    // customerEmail binding: enforce when EITHER side has a value. Skipping when
    // only the local side is empty would let an attacker forge an account.json
    // without customerEmail to silently bypass the email binding while keeping
    // the matching accountId. Symmetric: signed has it OR local has it → must match.
    const signedEmail = typeof payload.customerEmail === "string" ? payload.customerEmail : undefined;
    if (signedEmail !== undefined || account.customerEmail !== undefined) {
        if (signedEmail !== account.customerEmail) {
            return logResolved(anonymousFallback("binding-email"), {
                reason: "binding-email",
            });
        }
    }
    // Step 5: temporal checks.
    const issuedRaw = payload.issued;
    const expiresRaw = payload.expires;
    if (typeof issuedRaw !== "string" || typeof expiresRaw !== "string") {
        return logResolved(anonymousFallback("temporal-shape"), {
            reason: "temporal-shape",
        });
    }
    const issued = Date.parse(issuedRaw);
    const expires = Date.parse(expiresRaw);
    const now = Date.now();
    if (Number.isNaN(issued) || Number.isNaN(expires)) {
        return logResolved(anonymousFallback("temporal-shape"), {
            reason: "temporal-shape",
        });
    }
    if (issued > now) {
        return logResolved(anonymousFallback("clock-skew"), {
            reason: "clock-skew",
        });
    }
    // Reject empty/unknown tier. An empty string passing through here would
    // hit `MAX_ADMINS_BY_TIER[""] ?? MAX_ADMINS_DEFAULT` at admin-add and
    // silently grant pro-tier admin slots — a signing-side bug becoming a
    // privilege grant. Strict allowlist closes the gap.
    const tier = typeof payload.tier === "string" ? payload.tier : "";
    if (!VALID_TIERS.has(tier)) {
        return logResolved(anonymousFallback("tier-shape"), { reason: "tier-shape" });
    }
    const purchased = Array.isArray(payload.purchasedPlugins)
        ? payload.purchasedPlugins.filter((s) => typeof s === "string")
        : [];
    // Step 6: expiry gate.
    if (now <= expires) {
        return logResolved({
            tier,
            purchasedPlugins: purchased,
            source: "signed",
            issued: issuedRaw,
            expires: expiresRaw,
        }, maybeTamperLine(account, tier));
    }
    if (now <= expires + GRACE_MS) {
        return logResolved({
            tier,
            purchasedPlugins: purchased,
            source: "signed-grace",
            issued: issuedRaw,
            expires: expiresRaw,
        }, maybeTamperLine(account, tier));
    }
    return logResolved(anonymousFallback("expired"), { reason: "expired" });
}
function implicitTrust(account) {
    const tier = typeof account.tier === "string" ? account.tier : "";
    const purchased = Array.isArray(account.purchasedPlugins) ? account.purchasedPlugins : [];
    return {
        tier,
        purchasedPlugins: purchased,
        source: "implicit-trust",
        issued: null,
        expires: null,
    };
}
function anonymousFallback(_reason) {
    return {
        tier: "solo",
        purchasedPlugins: [],
        source: "anonymous-fallback",
        issued: null,
        expires: null,
    };
}
function maybeTamperLine(account, signedTier) {
    if (typeof account.tier === "string" && account.tier !== signedTier) {
        return { reason: "tampered", diskTier: account.tier, signedTier };
    }
    return null;
}
function logResolved(result, detail) {
    console.error(`[entitlement] resolved: tier=${result.tier} plugins=${result.purchasedPlugins.length} source=${result.source} issued=${result.issued ?? "-"} expires=${result.expires ?? "-"}`);
    if (detail && "diskTier" in detail) {
        console.error(`[entitlement] tampered: tier-on-disk=${detail.diskTier} signed=${detail.signedTier} action=using-signed`);
    }
    else if (detail && result.source === "anonymous-fallback") {
        const action = detail.reason === "expired" || detail.reason === "missing" ? "lock" : "degrade";
        console.error(`[entitlement] verify-failed: reason=${detail.reason} action=${action}`);
    }
    return result;
}
//# sourceMappingURL=index.js.map