UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

1,621 lines • 77.7 kB
// GENERATED from packages/tools/src/cloudflare-checkup/snapshot/contract.ts — do not edit
/**
 * Local Cloudflare Checkup snapshot contract (`vigilbase.cloudflare-checkup.snapshot/1`).
 *
 * Single source of truth for the snapshot the Vigilbase CLI collects with
 * Cloudflare's `cf` CLI and vigilbase.com evaluates with the existing scanner:
 * schemas, limits, request keys, projectors, the IP pseudonymiser, the rule-run
 * encoder and expander, the credential tripwire, the snapshot serialiser and
 * the local-session HTTP contract.
 *
 * Portability rules (the file is vendored byte-for-byte into packages/cli):
 * - imports only `zod`, never a relative module;
 * - ES2022 built-ins only, so it compiles under the tools config
 *   (ES2017 target, bundler resolution) and the CLI config (ES2022, NodeNext);
 * - no raw line terminators inside regex literals (TS1161); use `\p{…}` escapes.
 *
 * Projection principle (spec §D3). A value the scanner prints is kept verbatim,
 * or the whole entry becomes `collection_failed/invalid_output`. A value the
 * scanner only compares against fixed literals may be mapped to a sentinel that
 * cannot equal any of those literals. A value the scanner tests for truthiness
 * keeps its truthiness. Projection never truncates; caps fail the entry.
 * Line references below are to `packages/tools/src/cloudflare-checkup/scanner.ts`
 * before the read-client refactor; the function names are the stable anchor.
 *
 * Any change to a field read or a projection rule is a new schema version
 * (`/2`), and the server then accepts both for at least one CLI minor release.
 */
import { z } from 'zod';
// ============================================================================
// Versions, HTTP contract constants
// ============================================================================
export const SNAPSHOT_SCHEMA_VERSION = 'vigilbase.cloudflare-checkup.snapshot/1';
export const SNAPSHOT_PROJECTION_VERSION = 1;
export const IP_PSEUDONYMIZATION = 'per_prefix_sequential/1';
export const SNAPSHOT_COLLECTOR_NAME = '@vigilbase/cli';
export const SESSION_CODE_REGEX = /^vbcs1_[A-Za-z0-9_-]{66}$/;
export const PRODUCTION_ORIGIN = 'https://vigilbase.com';
export const LOCAL_SESSION_PATH = '/api/v1/tools/cloudflare-checkup/local-session';
export const LOCAL_SNAPSHOT_PATH = '/api/v1/tools/cloudflare-checkup/local-session/snapshot';
/** Before asking for an email, the CLI checks that local checkups are on and what they accept (no auth, no side effects). */
export const LOCAL_AVAILABILITY_PATH = '/api/v1/tools/cloudflare-checkup/local-session/availability';
/** The CLI asks vigilbase.com to email a verification code (no browser, no Turnstile). */
export const LOCAL_REQUEST_PATH = '/api/v1/tools/cloudflare-checkup/local-session/request';
/** The CLI trades the verification token and the emailed code for a session code. */
export const LOCAL_VERIFY_PATH = '/api/v1/tools/cloudflare-checkup/local-session/verify';
/** `vbcv1_` + base64url_nopad(iv[12] ‖ AES-256-GCM(runId[16] ‖ iat[4]) ‖ tag[16]): names a run awaiting its emailed code. */
export const VERIFICATION_TOKEN_REGEX = /^vbcv1_[A-Za-z0-9_-]{64}$/;
/** The six-digit code One emails for a tool run. */
export const EMAIL_CODE_REGEX = /^\d{6}$/;
/** Verification email languages; the CLI picks one from LC_ALL, LC_MESSAGES or LANG. */
export const LOCAL_EMAIL_LOCALES = ['en', 'pt-PT', 'ar'];
export const SNAPSHOT_SHA256_HEADER = 'X-Vigilbase-Snapshot-Sha256';
export const CLI_VERSION_HEADER = 'X-Vigilbase-CLI-Version';
export const CF_SUPPORTED = Object.freeze({
    min: '1.0.0-beta.13',
    maxExclusive: '2.0.0',
    tested: Object.freeze(['1.0.0-beta.13']),
});
export const SNAPSHOT_LIMITS = Object.freeze({
    maxBytes: 8_388_608,
    defaultServerMaxBytes: 4_194_304,
    maxZones: 100,
    maxEntries: 12_000,
    maxListItems: 5_000,
    maxRuleBodies: 2_000,
    maxRunsPerBody: 5_000,
    maxRulesPerBody: 20_000,
    maxRulesExpandedTotal: 400_000,
    // Output caps: every expanded issue rule becomes its own `wafAnalysis.issues`
    // entry with its id and name, so the result grows with the expansion. Counted
    // per issue (a disabled managed block-all rule is two) and in UTF-8 bytes of
    // `ruleId` + `ruleName`, summed over every `zone_ruleset` reference.
    maxIssueRulesExpandedTotal: 20_000,
    maxIssueTextExpandedBytes: 2_097_152,
    maxCollectionHours: 6,
    maxAgeHours: 24,
    maxFutureSkewSeconds: 300,
    // cf prints cursor-only lists without the cursor; a list this long may be
    // truncated. Lowered to the server's per_page maximum observed in launch gate 1.
    rulesetListTruncationAt: 100,
});
// The 19 zone settings the scanner reads (scanZone 664-732; phase functions).
export const ZONE_SETTING_IDS = [
    'ssl',
    'always_use_https',
    'min_tls_version',
    'tls_1_3',
    'security_level',
    'email_obfuscation',
    'hotlink_protection',
    'browser_check',
    'origin_pulls',
    'challenge_ttl',
    'browser_cache_ttl',
    'minify',
    'early_hints',
    'rocket_loader',
    'waf',
    'automatic_https_rewrites',
    'security_header',
    'opportunistic_encryption',
    'always_online',
];
// Public Cloudflare managed ruleset IDs, segmented like scanner.ts:56-62 to
// avoid credential heuristics.
const seg = (...parts) => parts.join('');
export const MANAGED_RULESET_IDS = Object.freeze([
    seg('efb7b8c949ac4650', 'a09736fc376e9aee'),
    seg('4814384a9e5d4991', 'b9815dcfc25d2f1f'),
    seg('c2e184081120413c', '86c3ab7e14069605'),
    seg('e22d83c647c64a3e', 'ae91b71b499d988e'),
]);
// Lower-cased managed ruleset names `hasExecutedRuleset` searches for (2412-2429).
export const MANAGED_NAME_NEEDLES = [
    'cloudflare managed ruleset',
    'owasp core ruleset',
    'exposed credentials check',
    'sensitive data detection',
];
// `LEGACY_PAGE_RULE_ACTIONS` (scanner.ts:64-70).
export const LEGACY_PAGE_RULE_ACTIONS = [
    'disable_security',
    'disable_performance',
    'waf',
    'security_level',
    'browser_check',
];
export const ACCOUNT_KINDS = [
    'account',
    'zones',
    'account_members',
    'account_rulesets',
    'account_access_rules',
    'access_apps',
    'access_identity_providers',
    'access_service_tokens',
    'gateway_rules',
    'gateway_locations',
    'cfd_tunnels',
];
export const ZONE_SINGLE_KINDS = [
    'zone_dnssec',
    'zone_dns_records',
    'zone_universal_ssl',
    'zone_total_tls',
    'zone_rulesets',
    'zone_access_rules',
    'zone_page_rules',
    'zone_bot_management',
    'zone_page_shield',
    'zone_schema_validation_settings',
    'zone_schema_validation_schemas',
];
// cf 1.0.0-beta.13 has no command for these reads; they are always `not_collected`.
export const NOT_COLLECTABLE_ZONE_KINDS = [
    'zone_legacy_firewall_rules',
    'zone_legacy_filters',
    'zone_legacy_waf_packages',
    'zone_legacy_rate_limits',
    'zone_waf_analytics',
];
export const SNAPSHOT_KINDS = [
    ...ACCOUNT_KINDS,
    'zone_setting',
    ...ZONE_SINGLE_KINDS,
    'zone_ruleset',
    ...NOT_COLLECTABLE_ZONE_KINDS,
];
export const COLLECTION_FAILURE_DETAILS = [
    'cf_error',
    'invalid_output',
    'timeout',
    'output_too_large',
    'possibly_truncated',
    'page_limit',
    'command_unavailable',
];
// Scope names verified in cf beta.13; their sufficiency is launch gate 1.
export const RECOMMENDED_CF_SCOPES = [
    'account:read',
    'account-settings.read',
    'memberships.read',
    'user:read',
    'user-details.read',
    'zone:read',
    'zone.read',
    'zone-settings.read',
    'dns.read',
    'dns_records:read',
    'ssl-and-certificates.read',
    'zone-waf.read',
    'account-waf.read',
    'account-rulesets.read',
    'http-ddos-managed-ruleset.read',
    'firewall-services.read',
    'account-firewall-access-rules.read',
    'page-rules.read',
    'bot-management.read',
    'page-shield.read',
    'domain-page-shield.read',
    'api-gateway.read',
    'access.read',
    'access:read',
    'access-app.read',
    'access-idp.read',
    'access-service-token.read',
    'teams.read',
    'teams:read',
    'argotunnel.read',
    'teams-connector-cloudflared.read',
];
// ============================================================================
// Versions
// ============================================================================
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.]+))?$/;
/**
 * SemVer 2 precedence. Returns a negative number, zero or a positive number,
 * or `NaN` when either side is not `MAJOR.MINOR.PATCH[-PRERELEASE]`.
 */
export function compareSemver(a, b) {
    const left = SEMVER_RE.exec(a);
    const right = SEMVER_RE.exec(b);
    if (!left || !right)
        return Number.NaN;
    for (let i = 1; i <= 3; i++) {
        const diff = Number(left[i]) - Number(right[i]);
        if (diff !== 0)
            return diff;
    }
    const leftPre = left[4];
    const rightPre = right[4];
    if (leftPre === undefined || rightPre === undefined) {
        if (leftPre === rightPre)
            return 0;
        return leftPre === undefined ? 1 : -1;
    }
    const leftIds = leftPre.split('.');
    const rightIds = rightPre.split('.');
    for (let i = 0; i < Math.min(leftIds.length, rightIds.length); i++) {
        const l = leftIds[i];
        const r = rightIds[i];
        if (l === r)
            continue;
        const lNum = /^\d+$/.test(l);
        const rNum = /^\d+$/.test(r);
        if (lNum && rNum)
            return Number(l) - Number(r);
        if (lNum !== rNum)
            return lNum ? -1 : 1;
        return l < r ? -1 : 1;
    }
    return leftIds.length - rightIds.length;
}
/**
 * True when `version` lies in `[CF_SUPPORTED.min, CF_SUPPORTED.maxExclusive)`.
 * The upper bound compares release cores only, so a `2.0.0-beta.1` preview of
 * the next major is unsupported too.
 */
export function isSupportedCfVersion(version) {
    const core = SEMVER_RE.exec(version);
    if (!core)
        return false;
    const atLeastMin = compareSemver(version, CF_SUPPORTED.min) >= 0;
    const belowMax = compareSemver(`${core[1]}.${core[2]}.${core[3]}`, CF_SUPPORTED.maxExclusive) < 0;
    return atLeastMin && belowMax;
}
// ============================================================================
// Primitive schemas
// ============================================================================
// Control (Cc), format/bidi (Cf), line separator (Zl = LS), paragraph separator (Zp = PS)
// and lone UTF-16 surrogates (Cs; a `u` regex sees a valid pair as one astral code point).
// Postgres jsonb rejects a lone surrogate escape, so one would strand the run in One.
// Property escapes only: a raw LS/PS inside a regex literal is a SyntaxError (TS1161).
// Not `String.prototype.isWellFormed()`: it is ES2024 and the CLI copy compiles against ES2022.
const CTRL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/u;
const TOKEN_RE = /^[A-Za-z0-9_.:-]+$/;
export const HEX32_REGEX = /^[0-9a-f]{32}$/;
const IP_CONTENT_RE = /^[0-9A-Fa-f:.]+$/;
const ACCESS_VALUE_RE = /^[A-Za-z0-9.:/_*-]+$/;
const RULE_ACTION_RE = /^[A-Za-z_]{1,32}$/;
const GEOIP_BLOCK_RE = /ip\.geoip\.country\s+(eq|in)/;
const text = (max) => z.string().max(max).refine((v) => !CTRL.test(v), 'control_or_format_character');
const token = (max = 64) => z.string().min(1).max(max).regex(TOKEN_RE);
const hex32 = z.string().regex(HEX32_REGEX);
const hostname = z.string().min(1).max(253).regex(/^[A-Za-z0-9*_.-]+$/);
const semver = z.string().max(40).regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/);
const iso = z.iso.datetime({ offset: true });
const scalar = z.union([token(64), z.literal(''), z.number().finite(), z.boolean(), z.null()]);
const email = z.string().max(254).regex(/^[^\s@\p{Cc}\p{Cf}\p{Cs}]+@[^\s@\p{Cc}\p{Cf}\p{Cs}]+$/u); // \s covers LS/PS
const accessValue = z.string().min(1).max(128).regex(ACCESS_VALUE_RE);
const bodyRef = z.string().regex(/^r[0-9]{1,5}$/);
const httpError = z.object({ ok: z.literal(false), reason: z.literal('http_error'), status: z.number().int().min(400).max(599) }).strict();
const notCollected = z.object({ ok: z.literal(false), reason: z.literal('not_collected') }).strict();
const collectionFailed = z.object({ ok: z.literal(false), reason: z.literal('collection_failed'), detail: z.enum(COLLECTION_FAILURE_DETAILS) }).strict();
const ok = (data) => z.object({ ok: z.literal(true), data }).strict();
const result = (data) => z.union([ok(data), httpError, collectionFailed]);
const list = (item, max = SNAPSHOT_LIMITS.maxListItems) => z.array(item).max(max);
const counted = z.object({ count: z.number().int().min(0).max(SNAPSHOT_LIMITS.maxListItems) }).strict();
const enabledOnly = z.object({ enabled: z.boolean().optional() }).strict();
// ============================================================================
// Entry data schemas
// ============================================================================
export const AccountData = z.object({ id: hex32, name: text(256), type: token(32).optional(),
    settings: z.object({ enforce_twofactor: z.boolean().optional() }).strict().optional() }).strict();
export const ZoneData = z.object({ id: hex32, name: hostname, status: token(32).optional(), paused: z.boolean().optional(),
    type: token(32).optional(), development_mode: z.number().int().optional(),
    plan: z.object({ name: text(64) }).strict().optional(), account: z.object({ id: hex32, name: text(256) }).strict() }).strict();
export const MemberData = z.object({ status: z.literal('pending').optional(), email: email.optional(),
    user: z.object({ two_factor_authentication_enabled: z.literal(false) }).strict().optional() }).strict();
export const AccessRuleData = z.object({ mode: token(32).optional(),
    configuration: z.object({ target: token(32).optional(), value: accessValue.optional() }).strict().optional() }).strict();
export const IdentityProviderData = z.object({ type: token(64).optional() }).strict();
export const ServiceTokenData = z.object({ expires_at: iso.optional() }).strict();
export const GatewayRuleData = z.object({ enabled: z.literal(false).optional(), filters: z.array(token(32)).max(16).optional() }).strict();
export const TunnelData = z.object({ status: token(32).optional(), is_deleted: z.literal(true).optional() }).strict();
export const SettingData = z.object({ value: z.union([scalar,
        z.record(token(64), z.union([scalar, z.record(token(64), scalar)]))]).optional() }).strict();
export const DnssecData = z.object({ status: token(32).optional(), algorithm: z.union([token(16), z.number().int()]).optional() }).strict();
export const DnsRecordData = z.object({ type: z.enum(['A', 'AAAA']), name: hostname.optional(),
    content: z.string().min(1).max(64).regex(IP_CONTENT_RE), proxied: z.literal(true).optional() }).strict();
export const RulesetHeadData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional() }).strict();
export const RulesetDetailData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional(),
    rulesBody: bodyRef.optional() }).strict();
export const PageRuleData = z.object({ actions: z.array(z.object({ id: z.enum(LEGACY_PAGE_RULE_ACTIONS) }).strict()).max(32).optional() }).strict();
// `stale_zone_configuration` is its key list in API order: the scanner prints
// `Object.keys(...)`, and the serialiser sorts object keys, so a `{key: true}`
// object would reach the server in alphabetical order.
const staleKeyList = z.array(token(64)).max(64).refine((keys) => new Set(keys).size === keys.length, 'duplicate_stale_key');
export const BotManagementData = z.record(token(64), z.union([scalar, staleKeyList]))
    .refine((o) => Object.keys(o).length <= 64 && Object.entries(o).every(([k, v]) => k === 'stale_zone_configuration' || v === null || typeof v !== 'object'), 'only_stale_zone_configuration_may_be_a_list')
    .refine((o) => ['fight_mode', 'enable_js'].every((k) => !(k in o) || typeof o[k] === 'boolean'), 'truthiness_keys_are_booleans');
export const SchemaValidationSettingsData = z.record(token(64), scalar).refine((o) => Object.keys(o).length <= 64, 'too_many_keys');
export const CountedData = counted;
export const EnabledOnlyData = enabledOnly;
const needleJoin = z.string().max(200).refine((v) => {
    const parts = v.split(' | ');
    return parts.every((x) => MANAGED_NAME_NEEDLES.includes(x)) && new Set(parts).size === parts.length;
});
const ruleShape = {
    action: z.string().regex(RULE_ACTION_RE).optional(), // non-conforming raw actions become 'other'
    enabled: z.literal(false).optional(),
    expression: z.enum(['true', 'ip.geoip.country in']).optional(),
    action_parameters: z.object({ id: z.string().refine((v) => MANAGED_RULESET_IDS.includes(v)).optional(),
        name: needleJoin.optional() }).strict().optional(),
    id: token(64).optional(),
    ref: text(128).optional(),
    description: text(500).optional(),
};
export const ProjectedRuleData = z.object(ruleShape).strict();
export const RuleRunData = z.object({
    n: z.number().int().min(2).max(SNAPSHOT_LIMITS.maxRulesPerBody).optional(),
    ...ruleShape,
}).strict();
// Collector-side ruleset detail: rules are projected but not yet encoded into
// `ruleBodies` (`assembleSnapshot` does that once every zone is collected).
export const ProjectedRulesetDetailData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional(),
    rules: z.array(ProjectedRuleData).max(SNAPSHOT_LIMITS.maxRulesPerBody) }).strict();
// ============================================================================
// Entries and the snapshot
// ============================================================================
const A = (kind, r) => z.object({ kind: z.literal(kind), result: r }).strict();
const Z = (kind, r) => z.object({ kind: z.literal(kind), zoneId: hex32, result: r }).strict();
export const SnapshotEntrySchema = z.discriminatedUnion('kind', [
    A('account', z.union([ok(AccountData), httpError])), // the scanner tolerates 400/401/403/404 (runInitPhase 4369-4378); other failures abort collection
    A('zones', ok(list(ZoneData, SNAPSHOT_LIMITS.maxZones))),
    A('account_members', result(list(MemberData))),
    A('account_rulesets', result(counted)),
    A('account_access_rules', result(list(AccessRuleData))),
    A('access_apps', result(counted)),
    A('access_identity_providers', result(list(IdentityProviderData))),
    A('access_service_tokens', result(list(ServiceTokenData))),
    A('gateway_rules', result(list(GatewayRuleData))),
    A('gateway_locations', result(counted)),
    A('cfd_tunnels', result(list(TunnelData))),
    z.object({ kind: z.literal('zone_setting'), zoneId: hex32, settingId: z.enum(ZONE_SETTING_IDS), result: result(SettingData) }).strict(),
    Z('zone_dnssec', result(DnssecData)),
    Z('zone_dns_records', result(list(DnsRecordData))),
    Z('zone_universal_ssl', result(enabledOnly)),
    Z('zone_total_tls', result(enabledOnly)),
    Z('zone_rulesets', result(z.array(RulesetHeadData).max(200))),
    // A failed detail is never written: the collector fails the zone's zone_rulesets instead (§D1, §F5).
    z.object({ kind: z.literal('zone_ruleset'), zoneId: hex32, rulesetId: hex32,
        result: z.union([ok(RulesetDetailData), httpError]) }).strict(),
    Z('zone_access_rules', result(list(AccessRuleData))),
    Z('zone_page_rules', result(list(PageRuleData))),
    Z('zone_bot_management', result(BotManagementData)),
    Z('zone_page_shield', result(enabledOnly)),
    Z('zone_schema_validation_settings', result(SchemaValidationSettingsData)),
    Z('zone_schema_validation_schemas', result(counted)),
    Z('zone_legacy_firewall_rules', notCollected),
    Z('zone_legacy_filters', notCollected),
    Z('zone_legacy_waf_packages', notCollected),
    Z('zone_legacy_rate_limits', notCollected),
    Z('zone_waf_analytics', notCollected),
]);
export const SnapshotCollectorSchema = z.object({
    name: z.literal(SNAPSHOT_COLLECTOR_NAME), version: semver, cf: z.object({ version: semver }).strict(),
    authSource: z.enum(['cf_profile', 'cf_env_token', 'cf_default_profile']), // auth KIND only, never a credential
    platform: z.enum(['darwin', 'linux', 'win32']),
    startedAt: iso, finishedAt: iso, cfInvocations: z.number().int().min(0).max(50_000),
}).strict();
const SnapshotObjectSchema = z.object({
    schemaVersion: z.literal(SNAPSHOT_SCHEMA_VERSION),
    projection: z.object({ version: z.literal(SNAPSHOT_PROJECTION_VERSION), ipPseudonymization: z.literal(IP_PSEUDONYMIZATION) }).strict(),
    accountId: hex32,
    collector: SnapshotCollectorSchema,
    entries: z.array(SnapshotEntrySchema).min(2).max(SNAPSHOT_LIMITS.maxEntries),
    ruleBodies: z.record(bodyRef, z.array(RuleRunData).min(1).max(SNAPSHOT_LIMITS.maxRunsPerBody)),
}).strict();
export const CloudflareCheckupSnapshotV1Schema = SnapshotObjectSchema.superRefine(validateSnapshotSemantics);
const MAX_REPEATED_ISSUES = 20;
const ACCOUNT_TOLERATED_STATUSES = [400, 401, 403, 404];
/**
 * Cross-entry rules the per-entry schema cannot express. Runs as the
 * snapshot schema's `superRefine`; every issue carries `params.reason`, which
 * `snapshotRejectReason` maps to the HTTP reason. The time window relative to
 * the server clock is checked in com, not here.
 */
export function validateSnapshotSemantics(snapshot, ctx) {
    const counts = new Map();
    const issue = (message, path, reason = 'snapshot_invalid') => {
        const seen = counts.get(message) ?? 0;
        counts.set(message, seen + 1);
        if (seen < MAX_REPEATED_ISSUES)
            ctx.addIssue({ code: 'custom', message, path, params: { reason } });
    };
    const { accountId, entries, ruleBodies } = snapshot;
    // 1. Exactly one account and one zones entry; the account failure is a tolerated status.
    const accountEntries = entries.filter((e) => e.kind === 'account');
    const zonesEntries = entries.filter((e) => e.kind === 'zones');
    if (accountEntries.length !== 1)
        issue('account_entry_count', ['entries'], 'snapshot_incomplete');
    if (zonesEntries.length !== 1)
        issue('zones_entry_count', ['entries'], 'snapshot_incomplete');
    entries.forEach((entry, i) => {
        if (entry.kind !== 'account')
            return;
        if (entry.result.ok) {
            if (entry.result.data.id !== accountId)
                issue('account_id_mismatch', ['entries', i, 'result', 'data', 'id']);
        }
        else if (!ACCOUNT_TOLERATED_STATUSES.includes(entry.result.status)) {
            issue('account_failure_status', ['entries', i, 'result', 'status']);
        }
    });
    // 2. Zones: unique, owned by this account.
    const zonesEntry = zonesEntries[0];
    const zones = zonesEntry?.kind === 'zones' ? zonesEntry.result.data : [];
    const zoneIds = zones.map((zone) => zone.id);
    const zoneIdSet = new Set(zoneIds);
    if (zoneIdSet.size !== zoneIds.length)
        issue('zone_duplicate', ['entries']);
    if (zoneIds.length > SNAPSHOT_LIMITS.maxZones)
        issue('too_many_zones', ['entries']);
    zones.forEach((zone, z) => {
        if (zone.account.id !== accountId) {
            const index = entries.indexOf(zonesEntry);
            issue('zone_account_mismatch', ['entries', index, 'result', 'data', z, 'account', 'id']);
        }
    });
    // 3. Exact key set.
    const listedRulesets = {};
    const failedRulesetLists = new Set();
    const phaseByRulesetRef = new Map();
    for (const entry of entries) {
        if (entry.kind !== 'zone_rulesets' || !zoneIdSet.has(entry.zoneId))
            continue;
        if (entry.result.ok)
            listedRulesets[entry.zoneId] = entry.result.data.map((head) => head.id);
        else
            failedRulesetLists.add(entry.zoneId);
    }
    const expected = new Set(expectedEntryKeys(accountId, zoneIds, listedRulesets));
    const seen = new Set();
    entries.forEach((entry, i) => {
        if ('zoneId' in entry && !zoneIdSet.has(entry.zoneId)) {
            issue('foreign_zone', ['entries', i, 'zoneId'], 'snapshot_incomplete');
            return;
        }
        if (entry.kind === 'zone_ruleset') {
            if (failedRulesetLists.has(entry.zoneId)) {
                issue('ruleset_under_failed_list', ['entries', i], 'snapshot_incomplete');
                return;
            }
            if (!(listedRulesets[entry.zoneId] ?? []).includes(entry.rulesetId)) {
                issue('unlisted_ruleset', ['entries', i, 'rulesetId'], 'snapshot_incomplete');
                return;
            }
            if (entry.result.ok) {
                if (entry.result.data.id !== entry.rulesetId)
                    issue('ruleset_id_mismatch', ['entries', i, 'result', 'data', 'id']);
                const ref = entry.result.data.rulesBody;
                if (ref !== undefined) {
                    const phases = phaseByRulesetRef.get(ref) ?? [];
                    phases.push(entry.result.data.phase);
                    phaseByRulesetRef.set(ref, phases);
                }
            }
        }
        const key = snapshotEntryKey(entry, accountId);
        if (seen.has(key))
            issue('duplicate_entry', ['entries', i], 'snapshot_incomplete');
        else if (!expected.has(key))
            issue('unexpected_entry', ['entries', i], 'snapshot_incomplete');
        seen.add(key);
    });
    for (const key of expected) {
        if (!seen.has(key))
            issue('missing_entry', ['entries'], 'snapshot_incomplete');
    }
    // 4. Rule bodies: bounded, all resolved, none unreferenced, global expansion cap.
    const bodyRefs = Object.keys(ruleBodies);
    if (bodyRefs.length > SNAPSHOT_LIMITS.maxRuleBodies)
        issue('too_many_rule_bodies', ['ruleBodies']);
    const expandedByRef = new Map();
    for (const ref of bodyRefs) {
        const runs = ruleBodies[ref];
        const expanded = runs.reduce((sum, run) => sum + (run.n ?? 1), 0);
        expandedByRef.set(ref, expanded);
        if (expanded > SNAPSHOT_LIMITS.maxRulesPerBody)
            issue('rule_body_too_large', ['ruleBodies', ref]);
        runs.forEach((run, j) => {
            const path = ['ruleBodies', ref, j];
            const action = run.action?.toLowerCase();
            if (run.expression !== undefined && action !== 'block')
                issue('expression_on_non_block_rule', [...path, 'expression']);
            if (run.action_parameters !== undefined && run.action !== 'execute') {
                issue('action_parameters_on_non_execute_rule', [...path, 'action_parameters']);
            }
            if (run.ref !== undefined && run.description)
                issue('ref_with_description', [...path, 'ref']);
            const carriesDetail = run.id !== undefined || run.description !== undefined || run.ref !== undefined;
            if (carriesDetail && (phaseByRulesetRef.get(ref) ?? []).some((phase) => !isIssueRule(run, phase))) {
                issue('detail_on_non_issue_rule', path);
            }
        });
    }
    // The evaluation output grows with the expansion too: every expanded issue
    // rule becomes its own `wafAnalysis.issues` entry carrying its id and name.
    let expandedTotal = 0;
    let issuesTotal = 0;
    let issueTextBytes = 0;
    const issueLoadByRefPhase = new Map();
    entries.forEach((entry, i) => {
        if (entry.kind !== 'zone_ruleset' || !entry.result.ok)
            return;
        const ref = entry.result.data.rulesBody;
        if (ref === undefined)
            return;
        if (!Object.prototype.hasOwnProperty.call(ruleBodies, ref)) {
            issue('rule_body_unresolved', ['entries', i, 'result', 'data', 'rulesBody']);
            return;
        }
        expandedTotal += expandedByRef.get(ref) ?? 0;
        // A phase matters only through `includes('managed')`, so each body is scanned at most twice.
        const managedPhase = isManagedPhase(entry.result.data.phase);
        const loadKey = `${ref}|${managedPhase ? 'managed' : 'other'}`;
        let load = issueLoadByRefPhase.get(loadKey);
        if (load === undefined) {
            load = wafIssueLoad(ruleBodies[ref], managedPhase);
            issueLoadByRefPhase.set(loadKey, load);
        }
        issuesTotal += load.issues;
        issueTextBytes += load.textBytes;
    });
    for (const ref of bodyRefs) {
        if (!phaseByRulesetRef.has(ref))
            issue('rule_body_unreferenced', ['ruleBodies', ref]);
    }
    if (expandedTotal > SNAPSHOT_LIMITS.maxRulesExpandedTotal)
        issue('expanded_rules_cap', ['ruleBodies']);
    if (issuesTotal > SNAPSHOT_LIMITS.maxIssueRulesExpandedTotal)
        issue('expanded_issue_rules_cap', ['ruleBodies']);
    if (issueTextBytes > SNAPSHOT_LIMITS.maxIssueTextExpandedBytes)
        issue('expanded_issue_text_cap', ['ruleBodies']);
    // 5. Timestamps: startedAt <= finishedAt <= startedAt + 6 h.
    const started = Date.parse(snapshot.collector.startedAt);
    const finished = Date.parse(snapshot.collector.finishedAt);
    if (!(started <= finished) || finished - started > SNAPSHOT_LIMITS.maxCollectionHours * 3_600_000) {
        issue('collection_window', ['collector', 'finishedAt']);
    }
    // 6. Credential tripwire over every string (and record key) in the snapshot.
    walkStrings(snapshot, [], (value, path) => {
        if (containsSecretLikeValue(value))
            issue('secret_like_value', path, 'secret_like_value');
    });
}
/**
 * The `wafAnalysis.issues` one reference to a rule body produces under a
 * ruleset phase, and the UTF-8 bytes of the rule text those issues print
 * (`ruleId` and `ruleName = description || ref`).
 */
function wafIssueLoad(runs, managedPhase) {
    let issues = 0;
    let textBytes = 0;
    for (const run of runs) {
        const perRule = wafIssueCount(run, managedPhase);
        if (perRule === 0)
            continue;
        const count = (run.n ?? 1) * perRule;
        issues += count;
        textBytes += count * (utf8Length(run.id ?? '') + utf8Length(run.description || run.ref || ''));
    }
    return { issues, textBytes };
}
/** UTF-8 length of a string (a lone surrogate counts as the 3-byte replacement character). */
function utf8Length(value) {
    let bytes = 0;
    for (let i = 0; i < value.length; i++) {
        const unit = value.charCodeAt(i);
        if (unit < 0x80)
            bytes += 1;
        else if (unit < 0x800)
            bytes += 2;
        else if (unit >= 0xd800 && unit <= 0xdbff && i + 1 < value.length && (value.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
            bytes += 4;
            i++;
        }
        else
            bytes += 3;
    }
    return bytes;
}
const SESSION_CODE_LIKE_RE = /vbc[sv]1_[A-Za-z0-9_-]{20,}/;
const JWT_LIKE_RE = /eyJ[\w-]{10,}\.[\w-]{10,}\./;
const PEM_LIKE_RE = /-----BEGIN/;
const BEARER_LIKE_RE = /Bearer\s+\S{20,}/;
const TOKEN_CHARSET_RUN_RE = /[A-Za-z0-9_-]{40,}/g;
/**
 * Credential tripwire (§D1.6): a Vigilbase session code or verification token, a 40-character
 * `[A-Za-z0-9_-]` window with upper case, lower case and a digit (the
 * Cloudflare API token shape), a JWT, a PEM header or a bearer header.
 */
export function containsSecretLikeValue(value) {
    if (SESSION_CODE_LIKE_RE.test(value) || JWT_LIKE_RE.test(value) || PEM_LIKE_RE.test(value) || BEARER_LIKE_RE.test(value)) {
        return true;
    }
    for (const match of value.match(TOKEN_CHARSET_RUN_RE) ?? []) {
        if (hasMixedClassWindow(match, 40))
            return true;
    }
    return false;
}
function hasMixedClassWindow(run, width) {
    let upper = 0;
    let lower = 0;
    let digit = 0;
    const classOf = (c) => (c >= 'A' && c <= 'Z' ? 1 : c >= 'a' && c <= 'z' ? 2 : c >= '0' && c <= '9' ? 3 : 0);
    const bump = (c, delta) => {
        const k = classOf(c);
        if (k === 1)
            upper += delta;
        else if (k === 2)
            lower += delta;
        else if (k === 3)
            digit += delta;
    };
    for (let i = 0; i < run.length; i++) {
        bump(run[i], 1);
        if (i >= width)
            bump(run[i - width], -1);
        if (i >= width - 1 && upper > 0 && lower > 0 && digit > 0)
            return true;
    }
    return false;
}
function walkStrings(value, path, visit) {
    if (typeof value === 'string') {
        visit(value, path);
    }
    else if (Array.isArray(value)) {
        value.forEach((item, i) => walkStrings(item, [...path, i], visit));
    }
    else if (value !== null && typeof value === 'object') {
        for (const [key, item] of Object.entries(value)) {
            visit(key, [...path, key]);
            walkStrings(item, [...path, key], visit);
        }
    }
}
/**
 * Map the issues of a failed `CloudflareCheckupSnapshotV1Schema.safeParse` to
 * the HTTP reason: the tripwire wins, then key-set errors, then everything else.
 */
export function snapshotRejectReason(issues) {
    const reasons = new Set(issues.map((i) => i.params?.reason));
    if (reasons.has('secret_like_value'))
        return 'secret_like_value';
    if (reasons.has('snapshot_incomplete'))
        return 'snapshot_incomplete';
    return 'snapshot_invalid';
}
/**
 * The `issues` array of a local-session error response: at most 20
 * `{path, code}` pairs and never a value from the snapshot.
 */
export function snapshotIssueList(issues) {
    return issues.slice(0, 20).map((i) => ({
        path: (i.path ?? []).map((p) => String(p)).join('.').slice(0, 200),
        code: String((i.code === 'custom' ? i.message : i.code) ?? 'invalid').slice(0, 64),
    }));
}
/** The scanner REST call a snapshot entry answers. The replay client derives the same key per call. */
export function snapshotRequestKey(kind, ids) {
    const a = ids.accountId;
    const z = ids.zoneId ?? '';
    switch (kind) {
        case 'account': return `GET /accounts/${a}`;
        case 'zones': return `GET /zones?account.id=${a}`;
        case 'account_members': return `GET /accounts/${a}/members`;
        case 'account_rulesets': return `GET /accounts/${a}/rulesets`;
        case 'account_access_rules': return `GET /accounts/${a}/firewall/access_rules/rules`;
        case 'access_apps': return `GET /accounts/${a}/access/apps`;
        case 'access_identity_providers': return `GET /accounts/${a}/access/identity_providers`;
        case 'access_service_tokens': return `GET /accounts/${a}/access/service_tokens`;
        case 'gateway_rules': return `GET /accounts/${a}/gateway/rules`;
        case 'gateway_locations': return `GET /accounts/${a}/gateway/locations`;
        case 'cfd_tunnels': return `GET /accounts/${a}/cfd_tunnel?is_deleted=false`;
        case 'zone_setting': return `GET /zones/${z}/settings/${ids.settingId ?? ''}`;
        case 'zone_dnssec': return `GET /zones/${z}/dnssec`;
        case 'zone_dns_records': return `GET /zones/${z}/dns_records`;
        case 'zone_universal_ssl': return `GET /zones/${z}/ssl/universal/settings`;
        case 'zone_total_tls': return `GET /zones/${z}/acm/total_tls`;
        case 'zone_rulesets': return `GET /zones/${z}/rulesets`;
        case 'zone_ruleset': return `GET /zones/${z}/rulesets/${ids.rulesetId ?? ''}`;
        case 'zone_access_rules': return `GET /zones/${z}/firewall/access_rules/rules`;
        case 'zone_page_rules': return `GET /zones/${z}/pagerules`;
        case 'zone_bot_management': return `GET /zones/${z}/bot_management`;
        case 'zone_page_shield': return `GET /zones/${z}/page_shield`;
        case 'zone_schema_validation_settings': return `GET /zones/${z}/schema_validation/settings`;
        case 'zone_schema_validation_schemas': return `GET /zones/${z}/schema_validation/schemas`;
        case 'zone_legacy_firewall_rules': return `GET /zones/${z}/firewall/rules`;
        case 'zone_legacy_filters': return `GET /zones/${z}/filters`;
        case 'zone_legacy_waf_packages': return `GET /zones/${z}/firewall/waf/packages`;
        case 'zone_legacy_rate_limits': return `GET /zones/${z}/rate_limits`;
        case 'zone_waf_analytics': return `POST /graphql#firewallEventsAdaptiveGroups/${z}`;
    }
}
export function snapshotEntryKey(entry, accountId) {
    return snapshotRequestKey(entry.kind, {
        accountId,
        zoneId: entry.zoneId,
        settingId: entry.settingId,
        rulesetId: entry.rulesetId,
    });
}
/**
 * Every key a complete snapshot holds, in canonical entry order: account kinds,
 * then per zone (in list order) the 19 settings, the single kinds with each
 * listed ruleset detail right after `zone_rulesets`, then the not-collectable
 * kinds. `rulesetIdsByZone` holds only zones whose `zone_rulesets` is ok.
 */
export function expectedEntryKeys(accountId, zones, rulesetIdsByZone) {
    const keys = ACCOUNT_KINDS.map((kind) => snapshotRequestKey(kind, { accountId }));
    for (const zone of zones) {
        const zoneId = typeof zone === 'string' ? zone : zone.id;
        for (const settingId of ZONE_SETTING_IDS)
            keys.push(snapshotRequestKey('zone_setting', { accountId, zoneId, settingId }));
        for (const kind of ZONE_SINGLE_KINDS) {
            keys.push(snapshotRequestKey(kind, { accountId, zoneId }));
            if (kind !== 'zone_rulesets')
                continue;
            const seen = new Set();
            for (const rulesetId of rulesetIdsByZone[zoneId] ?? []) {
                if (seen.has(rulesetId))
                    continue;
                seen.add(rulesetId);
                keys.push(snapshotRequestKey('zone_ruleset', { accountId, zoneId, rulesetId }));
            }
        }
        for (const kind of NOT_COLLECTABLE_ZONE_KINDS)
            keys.push(snapshotRequestKey(kind, { accountId, zoneId }));
    }
    return keys;
}
class ProjectionFailure {
    detail;
    constructor(detail) {
        this.detail = detail;
    }
}
const invalid = () => {
    throw new ProjectionFailure('invalid_output');
};
const tooLarge = () => {
    throw new ProjectionFailure('output_too_large');
};
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
function rawObject(raw) {
    return isObject(raw) ? raw : invalid();
}
function rawItems(raw, max = SNAPSHOT_LIMITS.maxListItems) {
    if (!Array.isArray(raw))
        return invalid();
    if (raw.length > max)
        return tooLarge();
    return raw.map((item) => rawObject(item));
}
function runProjector(schema, build) {
    try {
        const data = build();
        // Every printed value is copied verbatim; anything the schema cannot
        // represent fails the entry instead of being silently altered.
        return schema.safeParse(data).success ? { ok: true, data } : { ok: false, detail: 'invalid_output' };
    }
    catch (error) {
        if (error instanceof ProjectionFailure)
            return { ok: false, detail: error.detail };
        throw error;
    }
}
const isToken = (value, max) => typeof value === 'string' && value.length <= max && TOKEN_RE.test(value);
/** Compared-only strings: tokens verbatim, `''` stays `''` (falsy), anything else becomes `'other'` (truthy). */
const comparedString = (value) => (isToken(value, 64) ? value : value === '' ? '' : 'other');
/** A field the scanner prints with an `x || fallback`: falsy values are omitted, the rest copied verbatim. */
const printedIfTruthy = (value) => (value ? value : undefined);
function assignDefined(target, values) {
    for (const [key, value] of Object.entries(values)) {
        if (value !== undefined)
            target[key] = value;
    }
    return target;
}
/**
 * `account`: `id, name, type, settings.enforce_twofactor`. createAccountMfaCheck
 * (1110) compares `enforce_twofactor === true`; runInitPhase (4369-4381) and
 * assembleCheckupResult (5127-5136) print `name`, `type` and `enforce_twofactor`.
 */
export function projectAccount(raw) {
    return runProjector(AccountData, () => {
        const account = rawObject(raw);
        const settings = isObject(account.settings) ? account.settings : undefined;
        const out = assignDefined({}, { id: account.id, name: account.name, type: account.type });
        if (settings && settings.enforce_twofactor !== undefined) {
            out.settings = { enforce_twofactor: settings.enforce_twofactor };
        }
        return out;
    });
}
/**
 * `zones` (all pages merged, API order): `id, name, status, paused, type,
 * development_mode, plan.name, account.{id,name}`. zoneCtxFor (4338) prints the
 * account; createZoneStatusCheck (1960-1990), createDevModeCheck (2165-2197),
 * createDNSModeCheck (3218-3234) and assembleCheckupResult (5099-5102) read the rest.
 * `paused` is only tested for truthiness.
 */
export function projectZones(raw) {
    return runProjector(z.array(ZoneData).max(SNAPSHOT_LIMITS.maxZones), () => rawItems(raw, SNAPSHOT_LIMITS.maxZones).map((zone) => {
        const plan = isObject(zone.plan) ? zone.plan : undefined;
        const account = isObject(zone.account) ? zone.account : invalid();
        const out = assignDefined({}, {
            id: zone.id,
            name: zone.name,
            status: zone.status,
            paused: zone.paused === undefined || typeof zone.paused === 'boolean' ? zone.paused : Boolean(zone.paused),
            // A null `type` reads as full setup and a null `development_mode` as
            // disabled; neither is printed then, so null is the same as absent.
            type: zone.type ?? undefined,
            development_mode: zone.development_mode ?? undefined,
        });
        if (plan && printedIfTruthy(plan.name) !== undefined)
            out.plan = { name: plan.name };
        out.account = { id: account.id, name: account.name };
        return out;
    }));
}
const MEMBER_EMAIL_LIMIT = 8;
/**
 * `account_members`: one item per member, in order. createMemberMfaCheck and
 * createPendingMembersCheck (1162-1198, 1224-1252) read only `status ===
 * 'pending'`, `two_factor_authentication_enabled === false`, `.length`, and the
 * first 8 truthy `m.email || m.user.email` of each selection.
 */
export function projectAccountMembers(raw) {
    return runProjector(list(MemberData), () => {
        let pendingEmails = 0;
        let without2faEmails = 0;
        return rawItems(raw).map((member) => {
            const out = {};
            const user = isObject(member.user) ? member.user : undefined;
            const pending = member.status === 'pending';
            const without2fa = user?.two_factor_authentication_enabled === false;
            if (pending)
                out.status = 'pending';
            const address = member.email || user?.email;
            if (address && pending && pendingEmails < MEMBER_EMAIL_LIMIT) {
                out.email = address;
                pendingEmails++;
            }
            else if (address && !pending && without2fa && without2faEmails < MEMBER_EMAIL_LIMIT) {
                out.email = address;
                without2faEmails++;
            }
            if (without2fa)
                out.user = { two_factor_authentication_enabled: false };
            return out;
        });
    });
}
/**
 * `account_rulesets`, `access_apps`, `gateway_locations`,
 * `zone_schema_validation_schemas`: only `.length` is read (1259-1297, 1421,
 * 1744, 3872), so only the count leaves the machine.
 */
export function projectCounted(raw) {
    return runProjector(counted, () => ({ count: rawItems(raw).length }));
}
const ACCESS_RULE_DETAIL_LIMIT = 5;
function accessRuleValue(value) {
    if (!value)
        return undefined;
    // `${config.value}` is printed; a numeric value (an ASN) prints identically as a string.
    return typeof value === 'number' && Number.isFinite(value) ? String(value) : value;
}
function projectAccessRules(raw, isBroad, broadOnly) {
    return runProjector(list(AccessRuleData), () => {
        let broadSeen = 0;
        return rawItems(raw).map((rule) => {
            const config = isObject(rule.configuration) ? rule.configuration : undefined;
            if (!isBroad(rule, config))
                return {};
            broadSeen++;
            if (broadSeen > ACCESS_RULE_DETAIL_LIMIT)
                return broadOnly(rule, config);
            // `${rule.mode || 'unknown'} ${config.target || 'target'} ${config.value || ''}` is printed.
            const out = assignDefined({}, { mode: printedIfTruthy(rule.mode) });
            const configuration = assignDefined({}, {
                target: printedIfTruthy(config?.target),
                value: accessRuleValue(config?.value),
            });
            if (Object.keys(configuration).length > 0)
                out.configuration = configuration;
            return out;
        });
    });
}
/**
 * `account_access_rules`. createAccountAccessRulesCheck (1318-1354): broad =
 * `mode === 'whitelist' || target === 'country' || target === 'asn'`; prints
 * `broad.length`, `accessRules.length` and the first 5 broad rules. Later broad
 * rules keep only the fields that make them broad; other rules are `{}`.
 */
export function projectAccountAccessRules(raw) {
    return projectAccessRules(raw, (rule, config) => String(rule.mode || '') === 'whitelist' || config?.target === 'country' || config?.target === 'asn', (rule, config) => {
        const out = {};
        const target = config?.target;
        if (String(rule.mode || '') === 'whitelist')
            out.mode = 'whitelist';
        if (target === 'country' || target === 'asn')
            out.configuration = { target };
        return out;
    });
}
const ZONE_BROAD_TARGETS = ['ip', 'ip_range', 'country', 'asn'];
const ZONE_BROAD_VALUES = ['0.0.0.0/0', '::/0'];
/**
 * `zone_access_rules`. createZoneIpAccessRulesCheck (2613-2673): broad =
 * `target.toLowerCase() ∈ {ip, ip_range, country, asn} || value ∈ {0.0.0.0/0, ::/0}`.
 * Same first-5 rule as the account list.
 */
export function projectZoneAccessRules(raw) {
    const broadTarget = (config) => ZONE_BROAD_TARGETS.includes(String(config?.target || '').toLowerCase());
    const broadValue = (config) => ZONE_BROAD_VALUES.includes(String(config?.value || ''));
    return projectAccessRules(raw, (_rule, config) => broadTarget(config) || broadValue(config), (_rule, config) => {
        const configuration = {};
        if (broadTarget(config))
            configuration.target = config?.target;
        if (broadValue(config))
            configuration.value = config?.value;
        return { configuration };
    });
}
/**
 * `access_identity_providers`: `{type}`. createAccessIdentityProvidersCheck
 * (1515-1540) reads `.length` and compares `type.toLowerCase()` with fixed
 * literals; `type` is never printed, so non-token types become `'other'`.
 */
export function projectIdentityProviders(raw) {
    return runProjector(list(IdentityProviderData), () => rawItems(raw).map((idp) => {
        if (typeof idp.type !== 'string' || idp.type === '')
            return {};
        return { type: isToken(idp.type, 64) ? idp.type : 'other' };
    }));
}
const ISO_MIN = '0000-01-01T00:00:00.000Z';
const ISO_MAX = '9999-12-31T23:59:59.999Z';
/**
 * `access_service_tokens`: `expires_at`. createAccessServiceTokensCheck
 * (1587-1593) only evaluates `Date.parse(expires_at || expiresAt) < now` for
 * string values, so a parseable value is normalised to ISO (extended years are
 * clamped to the 4-digit range, which keeps the comparison) and anything else
 * is dropped (both give "not expired").
 */
export function projectServiceTokens(raw) {
    return runProjector(list(ServiceTokenData), () => rawItems(raw).map((serviceToken) => {
        const exp = serviceToken.expires_at || serviceToken.expiresAt;
        if (typeof exp !== 'string')
            return {};
        const time = Date.parse(exp);
        if (!Number.isFinite(time))
            return {};
        const isoTime = new Date(time).toISOString();
        const clamped = /^\d{4}-/.test(isoTime) ? isoTime : time < 0 ? ISO_MIN : ISO_MAX;
        return { expires_at: clamped };
    }));
}
/**
 * `gateway_rules`: `enabled:false` iff `=== false`, `filters` verbatim.
 * createGatewayRulesCheck (1655-1680) counts `enabled !== false` and prints
 * each filter with its count.
 */
export function projectGatewayRules(raw) {
    return runProjector(list(GatewayRuleData), () => rawItems(raw).map((rule) => {
        const out = {};
        if (rule.enabled === false)
            out.enabled = false;
        if (Array.isArray(rule.filters) && rule.filters.length > 0)
            out.filters = rule.filters;
        return out;
    }));
}
/**
 * `cfd_tunnels`: `status` and `is_deleted:true` iff `deleted_at || is_deleted
 * === true`. createCloudflareTunnelsCheck (1801-1814) only compares
 * `status.toLowerCase()` with fixed literals, so non-token statuses become `'other'`.
 */
export function projectTunnels(raw) {
    return runProjector(list(TunnelData), () => rawItems(raw).map((tunnel) => {
        const out = {};
        if (typeof tunnel.status === 'string' && tunnel.status !== '') {
            out.status = isToken(tunnel.status, 32) ? tunnel.status : 'other';
        }
        if (tunnel.deleted_at || tunnel.is_deleted === true)
            out.is_deleted = true;
        return out;
    }));
}
/**
 * `zone_setting`: `value` only, verbatim. Every setting check reads
 * `statusValue(read)` (= `.value`, 1053-1056) or `wafSetting.data.value`
 * (2324, 3535); minify (2905-2953) and HSTS (3695-3727) print sub-keys at
 * depth ≤ 2. Values are printed, so a non-token string, an array or a deeper
 * object fails the entry rather than being altered.
 */
export function projectZoneSetting(raw) {
    return runProjector(SettingData, () => {
        // `statusValue` reads `read.data?.value`, so a null result reads like an absent value.
        if (raw === null)
            return {};
        const value = rawObject(raw).value;
        return (value === undefined ? {} : { value });
    });
}
/**
 * `zone_dnssec`: `status, algorithm`. createDNSSECCheck (2114-2160) compares
 * and prints `status.toLowerCase()` and prints a truthy `algorithm`.
 */
export function projectDnssec(raw) {
    return runProjector(DnssecData, () => {
        const dnssec = rawObject(raw);
        return assignDefined({}, {
            status: printedIfTruthy(dnssec.status),
            algorithm: printedIfTruthy(dnssec.algorithm),
        });
    });
}
/**
 * `zone_dns_records` (all pages, API order): only `(A|AAAA) && content` records.
 * findOriginExposure and detectOriginIpReuseAcrossZones (3307-3373) read
 * `type`, `content` (as a Map key and through `maskIp`), `proxied` (truthiness)
 * and `name`, which is printed only for non-proxied records (3292-3297); proxied
 * names are only counted. `content` stays raw here and is pseudonymised by
 * `createIpPseudonymizer` (via `assembleSnapshot`) once every zone is collected.
 */
export function projectDnsRecords(raw) {
    return runProjector(list(DnsRecordData), () => {
        const records = rawItems(raw, Number.MAX_SAFE_INTEGER)
            .filter((record) => (record.type === 'A' || record.type === 'AAAA') && record.content)
            .map((record) => {
            const out = { type: record.type, content: record.content };
            if (record.proxied)
                out.proxied = true;
            else if (record.name)
                out.name = record.name;
            return out;
        });
        return records.length > SNAPSHOT_LIMITS.maxListItems ? tooLarge() : records;
    });
}
/**
 * `zone_universal_ssl`, `zone_total_tls`, `zone_page_shield`: `enabled`.
 * The checks read `enabled !== false` (3797) and `enabled === true` (2697,
 * 3832); a non-boolean `enabled` gives the same outcome as an absent one.
 */
export function projectEnabledOnly(raw) {
    return runProjector(enabledOnly, () => {
        const value = rawObject(raw);
        return typeof value.enabled === 'boolean' ? { enabled: value.enabled } : {};
    });
}
function rulesetHead(ruleset) {
    const out = { id: ruleset.id };
    if (ruleset.phase !== undefined && ruleset.phase !== null)
        out.phase = ruleset.phase;
    if (typeof ruleset.name === 'string' && ruleset.name.toLowerCase().includes('ddos'))
        out.name = 'ddos';
    return out;
}
/**
 * `zone_rulesets` heads: `id, phase`, `name:'ddos'` iff the name contains
 * "ddos"; `kind` and `version` are dropped. `name` is read only by
 * createDDoSCheck (2511); `phase` at 2326, 2511, 2559-2564, 3446 and 4019.
 */
export function projectRulesetHeads(raw) {
    return runProjector(z.array(RulesetHeadData).max(200), () => rawItems(raw, 200).map((head) => rulesetHead(head)));
}
const isManagedPhase = (phase) => phase !== undefined && phase.includes('managed');
/**
 * How many `wafAnalysis.issues` createWAFAnalysisFromRulesets (3979-4047)
 * reports for one rule: one when it is disabled in a managed phase, one when
 * it blocks everything (`block` with expression `true`), so 0, 1 or 2.
 */
function wafIssueCount(rule, managedPhase) {
    const action = typeof rule.action === 'string' ? rule.action.toLowerCase() : undefined;
    const disabledManaged = rule.enabled === false && managedPhase;
    const blocksAll = action === 'block' && typeof rule.expression === 'string' && rule.expression.trim().toLowerCase() === 'true';
    return (disabledManaged ? 1 : 0) + (blocksAll ? 1 : 0);
}
function isIssueRule(rule, phase) {
    return wafIssueCount(rule, isManagedPhase(phase)) > 0;
}
function projectRule(rule, phase) {
    const out = {};
    const action = typeof rule.action === 'string' ? rule.action : undefined;
    if (action !== undefined)
        out.action = RULE_ACTION_RE.test(action) ? action : 'other';
    if (rule.enabled === false)
        out.enabled = false;
    if (action?.toLowerCase() === 'block' && typeof rule.expression === 'string') {
        const expression = rule.expression.toLowerCase();
        if (expression.trim() === 'true')
            out.expression = 'true';
        else if (GEOIP_BLOCK_RE.test(expression))
            out.expression = 'ip.geoip.country in';
    }
    if (action === 'execute' && isObject(rule.action_parameters)) {
        const params = rule.action_parameters;
        const executeId = String(params.id || '');
        const executeName = String(params.name || '').toLowerCase();
        const needles = MANAGED_NAME_NEEDLES.filter((needle) => executeName.includes(needle));
        const actionParameters = {};
        if (MANAGED_RULESET_IDS.includes(executeId))
            actionParameters.id = executeId;
        if (needles.length > 0)
            actionParameters.name = needles.join(' | ');
        if (Object.keys(actionParameters).length > 0)
            out.action_parameters = actionParameters;
    }
    if (isIssueRule(rule, phase)) {
        // createWAFAnalysisFromRulesets prints `ruleId: rule.id` and
        // `ruleName: rule.description || rule.ref || 'Unnamed rule'` (4014-4034).
        if (rule.id !== undefined)
            out.id = rule.id;
        if (rule.description)
            out.description = rule.description;
        else if (rule.ref)
            out.ref = rule.ref;
    }
    return out;
}
/**
 * `zone_ruleset` detail: the head fields plus projected rules (not yet
 * encoded). Rules feed analyzeRulesets (3425-3460), createWAFAnalysisFromRulesets
 * (3979-4047), hasExecutedRuleset (2412-2429) and the rate-limiting count (2559-2564):
 * - `action` raw if `^[A-Za-z_]{1,32}$`, else `'other'` (never equal to a compared action);
 * - `enabled:false` iff `=== false`;
 * - `expression` only on block rules: `'true'` or the geoip sentinel, else omitted;
 * - `action_parameters` only on `execute`: a managed `id` and the matched name needles;
 * - `id`, `description` (or `ref` when `description` is empty) only on issue rules.
 * More than `maxRulesPerBody` rules, or more than `maxRunsPerBody` runs, fails
 * with `output_too_large`.
 */
export function projectRulesetDetail(raw) {
    return runProjector(ProjectedRulesetDetailData, () => {
        const ruleset = rawObject(raw);
        const head = rulesetHead(ruleset);
        const phase = typeof ruleset.phase === 'string' ? ruleset.phase : undefined;
        const rawRules = ruleset.rules === undefined || ruleset.rules === null ? [] : ruleset.rules;
        if (!Array.isArray(rawRules))
            return invalid();
        if (rawRules.length > SNAPSHOT_LIMITS.maxRulesPerBody)
            return tooLarge();
        const rules = rawRules.map((rule) => projectRule(rawObject(rule), phase));
        if (encodeRuleRuns(rules).length > SNAPSHOT_LIMITS.maxRunsPerBody)
            return tooLarge();
        return { ...head, rules };
    });
}
/**
 * `zone_page_rules`: one item per rule; `actions[].id` filtered to the five
 * legacy IDs (first occurrence order). createPageRulesCheck (3601-3650) reads
 * `.length` and detectDeprecatedPageRuleActions.
 */
export function projectPageRules(raw) {
    return runProjector(list(PageRuleData), () => rawItems(raw).map((rule) => {
        const ids = [];
        for (const action of Array.isArray(rule.actions) ? rule.actions : []) {
            const id = isObject(action) ? String(action.id || '') : '';
            if (LEGACY_PAGE_RULE_ACTIONS.includes(id) && !ids.includes(id))
                ids.push(id);
        }
        return (ids.length > 0 ? { actions: ids.map((id) => ({ id })) } : {});
    }));
}
function comparedPrimitives(source, keep) {
    const out = {};
    for (const [key, value] of Object.entries(source)) {
        if (!isToken(key, 64))
            return invalid();
        const kept = keep(key, value);
        if (kept !== undefined)
            out[key] = kept;
    }
    return out;
}
function comparedPrimitive(value) {
    if (typeof value === 'string')
        return comparedString(value);
    if (value === null || typeof value === 'boolean' || typeof value === 'number')
        return value;
    return undefined; // objects and arrays never equal a compared literal
}
/**
 * `zone_bot_management`. createBotManagementCheck (2815-2850) prints
 * `Boolean(fight_mode)` and `Boolean(enable_js)` (kept as `!!value`), tests
 * `Object.values(...).some(v === 'block' || v === 'enabled')` (other strings
 * become `''` or `'other'`, preserving truthiness) and prints
 * `Object.keys(stale_zone_configuration).join(', ')`. That key list is kept as
 * an array in exactly the `Object.keys` order the live scanner sees (integer-like
 * keys first, then insertion order); the replay client turns it back into
 * `{key: true}`. An object would lose the order, because the serialiser sorts
 * object keys.
 */
export function projectBotManagement(raw) {
    return runProjector(BotManagementData, () => comparedPrimitives(rawObject(raw), (key, value) => {
        if (key === 'fight_mode' || key === 'enable_js')
            return Boolean(value);
        if (key === 'stale_zone_configuration' && value !== null && typeof value === 'object') {
            return Object.keys(value);
        }
        return comparedPrimitive(value);
    }));
}
/**
 * `zone_schema_validation_settings`: top-level primitives (same string rule as
 * bot management). createApiShieldCheck (3858-3883) compares values with
 * `'block'`, `'log'` and `true`.
 */
export function projectSchemaValidationSettings(raw) {
    return runProjector(SchemaValidationSettingsData, () => comparedPrimitives(rawObject(raw), (_key, value) => comparedPrimitive(value)));
}
/**
 * One projector per collectable kind. Each takes the raw `cf` JSON exactly as
 * printed (the bare result object, or for lists the bare arrays of every page
 * concatenated in API order) and returns the snapshot data or a
 * `collection_failed` detail.
 */
export const PROJECTORS = {
    account: projectAccount,
    zones: projectZones,
    account_members: projectAccountMembers,
    account_rulesets: projectCounted,
    account_access_rules: projectAccountAccessRules,
    access_apps: projectCounted,
    access_identity_providers: projectIdentityProviders,
    access_service_tokens: projectServiceTokens,
    gateway_rules: projectGatewayRules,
    gateway_locations: projectCounted,
    cfd_tunnels: projectTunnels,
    zone_setting: projectZoneSetting,
    zone_dnssec: projectDnssec,
    zone_dns_records: projectDnsRecords,
    zone_universal_ssl: projectEnabledOnly,
    zone_total_tls: projectEnabledOnly,
    zone_rulesets: projectRulesetHeads,
    zone_ruleset: projectRulesetDetail,
    zone_access_rules: projectZoneAccessRules,
    zone_page_rules: projectPageRules,
    zone_bot_management: projectBotManagement,
    zone_page_shield: projectEnabledOnly,
    zone_schema_validation_settings: projectSchemaValidationSettings,
    zone_schema_validation_schemas: projectCounted,
};
export const NOT_COLLECTED_RESULT = Object.freeze({ ok: false, reason: 'not_collected' });
export function httpErrorResult(status) {
    return { ok: false, reason: 'http_error', status };
}
export function collectionFailedResult(detail) {
    return { ok: false, reason: 'collection_failed', detail };
}
/** Turn a projector outcome into an entry `result`. */
export function projectionToResult(projection) {
    return projection.ok ? { ok: true, data: projection.data } : collectionFailedResult(projection.detail);
}
// ============================================================================
// Rule runs and bodies
// ============================================================================
function canonicalJson(value) {
    if (Array.isArray(value))
        return `[${value.map(canonicalJson).join(',')}]`;
    if (value !== null && typeof value === 'object') {
        const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
        return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
    }
    return JSON.stringify(value);
}
/** Run-length encode consecutive identical projected rules into `{...rule, n}`. */
export function encodeRuleRuns(rules) {
    const runs = [];
    let previous;
    for (const rule of rules) {
        const canonical = canonicalJson(rule);
        const last = runs[runs.length - 1];
        if (last !== undefined && canonical === previous) {
            last.n = (last.n ?? 1) + 1;
        }
        else {
            runs.push({ ...rule });
            previous = canonical;
        }
    }
    return runs.map(({ n, ...rule }) => (n === undefined ? rule : { ...rule, n }));
}
/** Restore the exact rule order and count of a body; every rule is a fresh object. */
export function expandRuleRuns(runs) {
    const rules = [];
    for (const { n, ...rule } of runs) {
        for (let i = 0; i < (n ?? 1); i++)
            rules.push(JSON.parse(JSON.stringify(rule)));
    }
    return rules;
}
export function createRuleBodyEncoder() {
    const refsByContent = new Map();
    const bodies = {};
    return {
        add(rules) {
            if (rules.length === 0)
                return undefined;
            const runs = encodeRuleRuns(rules);
            const content = canonicalJson(runs);
            const existing = refsByContent.get(content);
            if (existing !== undefined)
                return existing;
            const ref = `r${refsByContent.size}`;
            refsByContent.set(content, ref);
            bodies[ref] = runs;
            return ref;
        },
        bodies: () => bodies,
    };
}
// ============================================================================
// IP pseudonymisation (`per_prefix_sequential/1`)
// ============================================================================
const MAX_PSEUDONYMS_PER_PREFIX = 65_535;
/**
 * Per-prefix sequential pseudonyms, applied in zone list order and record API
 * order. IPv6 (any `:`) keeps its first three `:`-groups and becomes
 * `${prefix}:ffff:${n.toString(16)}`; IPv4 (4 dot parts) keeps `a.b` and
 * becomes `a.b.${n >> 8}.${n & 255}`; `n` counts from 1 per prefix. The mapping
 * is a bijection on the snapshot, keeps Map insertion order, distinct prefixes
 * never collide, and scanner `maskIp` (4234-4240) output is byte-identical.
 */
export function createIpPseudonymizer() {
    const memo = new Map();
    const counters = new Map();
    return {
        pseudonymize(content) {
            const known = memo.get(content);
            if (known !== undefined)
                return { ok: true, data: known };
            if (content.length > 64 || !IP_CONTENT_RE.test(content))
                return { ok: false, detail: 'invalid_output' };
            let family;
            let prefix;
            if (content.includes(':')) {
                const groups = content.split(':');
                // Every IPv6 text form splits into at least three groups ("::" does);
                // fewer would change maskIp's three-group prefix.
                if (groups.length < 3)
                    return { ok: false, detail: 'invalid_output' };
                family = 6;
                prefix = groups.slice(0, 3).join(':');
            }
            else {
                const parts = content.split('.');
                if (parts.length !== 4)
                    return { ok: false, detail: 'invalid_output' };
                family = 4;
                prefix = `${parts[0]}.${parts[1]}`;
            }
            const counterKey = `${family}|${prefix}`;
            const n = (counters.get(counterKey) ?? 0) + 1;
            if (n > MAX_PSEUDONYMS_PER_PREFIX)
                return { ok: false, detail: 'output_too_large' };
            const pseudonym = family === 6 ? `${prefix}:ffff:${n.toString(16)}` : `${prefix}.${n >> 8}.${n & 255}`;
            if (pseudonym.length > 64)
                return { ok: false, detail: 'invalid_output' };
            counters.set(counterKey, n);
            memo.set(content, pseudonym);
            return { ok: true, data: pseudonym };
        },
    };
}
/**
 * Build the snapshot from collected entries. In order:
 * 1. Collector rule: a zone with any `collection_failed` ruleset detail gets its
 *    `zone_rulesets` rewritten to `collection_failed` with that detail (first
 *    failure in list order) and keeps no `zone_ruleset` entries, so the scanner
 *    never falls back to rule-less heads (874-891).
 * 2. IP pseudonymisation over `zone_dns_records` in zone list order and record
 *    order; a list with unrepresentable content becomes `collection_failed`.
 * 3. Canonical entry order (`expectedEntryKeys`); unexpected entries go last.
 * 4. Rule bodies encoded and deduplicated in that order.
 * The result is not validated: parse it with `CloudflareCheckupSnapshotV1Schema`.
 */
export function assembleSnapshot(input) {
    const { accountId } = input;
    const zonesEntry = input.entries.find((entry) => entry.kind === 'zones');
    if (!zonesEntry || zonesEntry.kind !== 'zones')
        throw new Error('assembleSnapshot requires the zones entry');
    const zoneIds = zonesEntry.result.data.map((zone) => zone.id);
    // 1. Collector rule for failed ruleset details.
    const headOrder = new Map();
    for (const entry of input.entries) {
        if (entry.kind === 'zone_rulesets' && entry.result.ok)
            headOrder.set(entry.zoneId, entry.result.data.map((head) => head.id));
    }
    const failedDetailByZone = new Map();
    const failures = input.entries
        .filter((entry) => entry.kind === 'zone_ruleset' && !entry.result.ok && entry.result.reason === 'collection_failed')
        .sort((a, b) => (headOrder.get(a.zoneId) ?? []).indexOf(a.rulesetId) - (headOrder.get(b.zoneId) ?? []).indexOf(b.rulesetId));
    for (const failure of failures) {
        if (headOrder.has(failure.zoneId) && !failedDetailByZone.has(failure.zoneId)) {
            failedDetailByZone.set(failure.zoneId, failure.result.detail);
        }
    }
    let entries = input.entries
        .filter((entry) => !(entry.kind === 'zone_ruleset' && (failedDetailByZone.has(entry.zoneId) || !headOrder.has(entry.zoneId))))
        .map((entry) => {
        if (entry.kind !== 'zone_rulesets')
            return entry;
        const detail = failedDetailByZone.get(entry.zoneId);
        return detail === undefined ? entry : { kind: 'zone_rulesets', zoneId: entry.zoneId, result: collectionFailedResult(detail) };
    });
    // 2. IP pseudonymisation.
    const pseudonymizer = createIpPseudonymizer();
    const pseudonymized = new Map();
    for (const zoneId of zoneIds) {
        for (const entry of entries) {
            if (entry.kind !== 'zone_dns_records' || entry.zoneId !== zoneId || !entry.result.ok)
                continue;
            const records = entry.result.data;
            const invalidContent = records.some((record) => !pseudonymFormatOk(record.content));
            let failed = invalidContent ? 'invalid_output' : undefined;
            const mapped = [];
            for (const record of failed ? [] : records) {
                const pseudonym = pseudonymizer.pseudonymize(record.content);
                if (!pseudonym.ok) {
                    failed = pseudonym.detail;
                    break;
                }
                mapped.push({ ...record, content: pseudonym.data });
            }
            pseudonymized.set(entry, {
                kind: 'zone_dns_records',
                zoneId,
                result: failed ? collectionFailedResult(failed) : { ok: true, data: mapped },
            });
        }
    }
    entries = entries.map((entry) => pseudonymized.get(entry) ?? entry);
    // 3. Canonical order.
    const listed = {};
    for (const entry of entries) {
        if (entry.kind === 'zone_rulesets' && entry.result.ok)
            listed[entry.zoneId] = entry.result.data.map((head) => head.id);
    }
    const rank = new Map(expectedEntryKeys(accountId, zoneIds, listed).map((key, index) => [key, index]));
    const ordered = entries
        .map((entry, index) => ({ entry, index, rank: rank.get(snapshotEntryKey(entry, accountId)) ?? Number.MAX_SAFE_INTEGER }))
        .sort((a, b) => a.rank - b.rank || a.index - b.index)
        .map(({ entry }) => entry);
    // 4. Rule bodies.
    const encoder = createRuleBodyEncoder();
    const finalEntries = ordered.map((entry) => {
        if (entry.kind !== 'zone_ruleset')
            return canonicalEntry(entry);
        if (!entry.result.ok) {
            return { kind: 'zone_ruleset', zoneId: entry.zoneId, rulesetId: entry.rulesetId, result: entry.result };
        }
        const { rules, ...head } = entry.result.data;
        const rulesBody = encoder.add(rules);
        const data = rulesBody === undefined ? { ...head } : { ...head, rulesBody };
        return { kind: 'zone_ruleset', zoneId: entry.zoneId, rulesetId: entry.rulesetId, result: { ok: true, data } };
    });
    return {
        schemaVersion: SNAPSHOT_SCHEMA_VERSION,
        projection: { version: SNAPSHOT_PROJECTION_VERSION, ipPseudonymization: IP_PSEUDONYMIZATION },
        accountId,
        collector: canonicalCollector(input.collector),
        entries: finalEntries,
        ruleBodies: encoder.bodies(),
    };
}
function pseudonymFormatOk(content) {
    if (content.length > 64 || !IP_CONTENT_RE.test(content))
        return false;
    return content.includes(':') ? content.split(':').length >= 3 : content.split('.').length === 4;
}
/** Rebuild an entry with a stable key order: kind, zoneId, settingId/rulesetId, result. */
function canonicalEntry(entry) {
    const out = { kind: entry.kind };
    if ('zoneId' in entry)
        out.zoneId = entry.zoneId;
    if ('settingId' in entry)
        out.settingId = entry.settingId;
    if ('rulesetId' in entry)
        out.rulesetId = entry.rulesetId;
    const r = entry.result;
    out.result = r.ok
        ? { ok: true, data: r.data }
        : r.reason === 'http_error'
            ? { ok: false, reason: 'http_error', status: r.status }
            : r.reason === 'collection_failed'
                ? { ok: false, reason: 'collection_failed', detail: r.detail }
                : { ok: false, reason: r.reason };
    return out;
}
function canonicalCollector(collector) {
    return {
        name: collector.name,
        version: collector.version,
        cf: { version: collector.cf.version },
        authSource: collector.authSource,
        platform: collector.platform,
        startedAt: collector.startedAt,
        finishedAt: collector.finishedAt,
        cfInvocations: collector.cfInvocations,
    };
}
/**
 * Stable snapshot formatting (§F7): top-level keys in a fixed order, one
 * compact entry and one compact rule body per line, trailing newline. Entry
 * keys keep a fixed order and data keys are sorted, so the output depends only
 * on content (`serializeSnapshot(parse(text)) === text`). The output is the
 * exact byte sequence the CLI writes, hashes and uploads.
 */
export function serializeSnapshot(snapshot) {
    const lines = ['{'];
    lines.push(`  "schemaVersion": ${JSON.stringify(snapshot.schemaVersion)},`);
    lines.push(`  "projection": ${JSON.stringify({ version: snapshot.projection.version, ipPseudonymization: snapshot.projection.ipPseudonymization })},`);
    lines.push(`  "accountId": ${JSON.stringify(snapshot.accountId)},`);
    lines.push(`  "collector": ${JSON.stringify(canonicalCollector(snapshot.collector))},`);
    const entryLines = snapshot.entries.map((entry) => `    ${entryJson(entry)}`);
    lines.push(entryLines.length === 0 ? '  "entries": [],' : `  "entries": [\n${entryLines.join(',\n')}\n  ],`);
    const bodyLines = Object.entries(snapshot.ruleBodies).map(([ref, runs]) => `    ${JSON.stringify(ref)}: ${canonicalJson(runs)}`);
    lines.push(bodyLines.length === 0 ? '  "ruleBodies": {}' : `  "ruleBodies": {\n${bodyLines.join(',\n')}\n  }`);
    lines.push('}');
    return `${lines.join('\n')}\n`;
}
function entryJson(entry) {
    const { kind, result: entryResult, ...ids } = canonicalEntry(entry);
    const head = [`"kind":${JSON.stringify(kind)}`];
    for (const [key, value] of Object.entries(ids))
        head.push(`${JSON.stringify(key)}:${JSON.stringify(value)}`);
    const r = entryResult;
    const resultJson = r.ok ? `{"ok":true,"data":${canonicalJson(r.data)}}` : JSON.stringify(r);
    return `{${head.join(',')},"result":${resultJson}}`;
}
// ============================================================================
// HTTP contract (responses are non-strict so the server may add fields)
// ============================================================================
export const LOCAL_ERROR_REASONS = ['session_invalid', 'session_expired', 'session_used', 'session_busy', 'session_not_ready',
    'run_closed', 'browser_origin', 'malformed_json', 'digest_mismatch', 'unsupported_media_type',
    'length_required', 'snapshot_too_large', 'snapshot_invalid', 'snapshot_incomplete', 'account_mismatch', 'snapshot_stale',
    'collector_unsupported', 'secret_like_value', 'evaluation_failed', 'rate_limited', 'local_mode_disabled', 'storage_unavailable',
    'request_invalid', 'email_invalid', 'code_invalid', 'verification_invalid', 'verification_expired'];
export const LOCAL_SESSION_STATES = ['awaiting_upload', 'processing', 'completed'];
const LocalAcceptsSchema = z.object({ schemaVersions: z.array(z.string()).min(1), maxBytes: z.number().int().positive(),
    maxZones: z.number().int().positive() });
// `accountId` is null until an uploaded snapshot binds the run (the CLI chooses the account); runs created
// with an account keep it, and a completed run reports the evaluated account.
export const LocalSessionResponseSchema = z.object({ success: z.literal(true), session: z.object({ accountId: hex32.nullable(),
        requester: z.string().max(260), expiresAt: iso, state: z.enum(LOCAL_SESSION_STATES),
        accepts: LocalAcceptsSchema, minCliVersion: semver }) });
// `GET local-session/availability`: local checkups are on, with what a checkup would accept. While they are
// off it answers 503 `local_mode_disabled`, like every local-session route.
export const LocalAvailabilityResponseSchema = z.object({ enabled: z.literal(true), minCliVersion: semver,
    accepts: LocalAcceptsSchema });
// `resultsUrl` is the run's results page; the CLI opens it only when it is on the upload origin.
export const LocalSnapshotResponseSchema = z.object({ success: z.literal(true), replayed: z.boolean(),
    snapshotSha256: z.string().regex(/^[0-9a-f]{64}$/),
    provenance: z.record(z.string(), z.unknown()), report: z.record(z.string(), z.unknown()),
    resultsUrl: z.url().max(2048).optional() });
// Email verification in the CLI. Request bodies are strict: the server refuses fields it does not know.
export const LocalRequestBodySchema = z.object({ email: z.string().min(3).max(254), locale: z.enum(LOCAL_EMAIL_LOCALES) }).strict();
export const LocalVerifyBodySchema = z.object({ verification: z.string().regex(VERIFICATION_TOKEN_REGEX),
    code: z.string().regex(EMAIL_CODE_REGEX) }).strict();
// `expiresAt` is when the emailed code (and so the token) stops working.
export const LocalRequestResponseSchema = z.object({ success: z.literal(true), verification: z.object({
        token: z.string().regex(VERIFICATION_TOKEN_REGEX), requester: z.string().max(260), expiresAt: iso
    }) });
export const LocalVerifyResponseSchema = z.object({ success: z.literal(true), session: z.object({
        code: z.string().regex(SESSION_CODE_REGEX), requester: z.string().max(260), expiresAt: iso
    }) });
export const LocalErrorResponseSchema = z.object({ success: z.literal(false), status: z.number().int(), code: z.string(),
    reason: z.enum(LOCAL_ERROR_REASONS).optional(), retryAfterSec: z.number().int().nonnegative().optional(),
    issues: z.array(z.object({ path: z.string().max(200), code: z.string().max(64) })).max(20).optional() });