/**
 * Config-bundle export sanitization.
 *
 * Phase 3 of the secrets refactor: every export path (the
 * `/api/config/bundle` GET endpoint and the `studiograph push --remote`
 * CLI) routes through this module so the produced bundles are
 * byte-identical and the registry's `exportable` flag is the single
 * source of truth for "what may leave the trust boundary."
 *
 * Rules driven by the registry:
 *   - bootstrap classification          → never exportable
 *   - any *-secret classification       → never exportable (defense-in-depth;
 *                                         registry already says exportable=false)
 *   - exportable=true entries           → included
 *   - non-registered top-level fields   → included by default (they're the
 *                                         workspace shape: name, slug, repos,
 *                                         etc.) UNLESS they look like a known
 *                                         secret-shape (URL credentials,
 *                                         prefix-matched tokens). Phase 9b
 *                                         tightens this to a strict-allowlist
 *                                         schema; Phase 3 keeps the seam wide
 *                                         enough to ship.
 *
 * H1 (Codex): the sanitizer + the import gate share a SINGLE detector
 * (`isSecretLeak()` below). They walk every object/array to arbitrary
 * depth — the previous top-level-only loop missed nested
 * `r2_config.secret_access_key` and friends. A small static legacy-path
 * map (`LEGACY_SECRET_PATHS`) covers known nested slots that pre-date
 * the registry's flat naming so e.g. `r2_config.secret_access_key` is
 * recognized as the registry's `r2.secret_access_key`.
 *
 * H2 (Codex): connectors get the same treatment with stricter rules —
 * any string value at a secret-named key (`looksLikeSecretFieldName`)
 * MUST be a `$ENV_VAR` placeholder. Raw values are dropped on export
 * and rejected on import.
 */
import { REGISTRY, getKeyDef } from './registry.js';
/**
 * Names of `auth-seed.json` fields that MUST NOT cross the trust
 * boundary. Sanitization is registry-driven for the most part, but the
 * AuthSeed top-level fields are a fixed shape — easier to hard-code
 * which of them are bootstrap-class and to drop them before the bundle
 * leaves the workspace.
 */
const SEED_BOOTSTRAP_FIELDS = new Set([
    'jwtSecret',
    // 'apiKey' is the deprecated workspace-wide bearer token; gone in
    // current code, but pre-existing seeds may still carry it. Strip it
    // either way — it's strictly worse than the per-user api_tokens flow.
    'apiKey',
]);
/**
 * Static map of legacy nested paths to canonical registry names.
 *
 * The registry uses flat dotted keys (`r2.secret_access_key`), but
 * `workspace.json` historically stored R2 credentials nested under
 * `r2_config.*`. We map the obvious legacy paths so the recursive
 * walker can recognize a nested secret slot even if the user supplied
 * a literal value there.
 *
 * Path components are joined with `.` — arrays are not part of the
 * known legacy shape, so we don't need an array-index syntax here.
 */
const LEGACY_SECRET_PATHS = new Map([
    ['r2_config.secret_access_key', 'r2.secret_access_key'],
    ['r2_config.access_key_id', 'r2.access_key_id'],
    // `auth_secret` and `jwt_secret` show up under different spellings in
    // older configs; both belong to bootstrap-class entries that are
    // never exportable.
    ['jwt_secret', 'jwt.secret'],
    ['jwtSecret', 'jwt.secret'],
    ['auth_secret', 'jwt.secret'],
    ['master_key', 'master.key'],
    ['masterKey', 'master.key'],
]);
/**
 * Heuristic: does this string look like a raw secret value that should
 * never appear in a bundle? Cheap prefix + URL-credential check.
 * Placeholder shape (`$ENV_VAR`) is explicitly NOT a leak.
 */
export function looksLikeRawSecret(s) {
    if (typeof s !== 'string')
        return false;
    if (/^\$[A-Z_][A-Z0-9_]*$/.test(s))
        return false; // placeholder
    return (/^(sk-|ghp_|ghu_|ghs_|xox[bp]-|AKIA|glpat-|Bearer |Basic )/.test(s) ||
        /https?:\/\/[^:]+:[^@]+@/.test(s));
}
/**
 * Heuristic: does this field name suggest its value is a secret?
 * Used by the connector detector to require `$ENV_VAR` placeholders
 * for any secret-named key.
 */
export function looksLikeSecretFieldName(name) {
    return /(?:authorization|cookie|token|key|secret|auth|password|credential)/i.test(name);
}
/**
 * Validate a connector URL doesn't carry embedded credentials.
 */
export function urlContainsCredentials(s) {
    return typeof s === 'string' && /^https?:\/\/[^:/?#@]+:[^@/?#]+@/.test(s);
}
/**
 * Look up a registry entry by either:
 *   - a flat key as exposed in workspace.json (`team_name`, etc.) —
 *     tried verbatim, prefixed `workspace.`, and prefixed `instance.`
 *   - a dotted nested path resolved through the legacy map
 *     (`r2_config.secret_access_key` → `r2.secret_access_key`)
 */
function lookupByPath(path) {
    // Direct match first — handles flat dotted keys.
    const direct = getKeyDef(path) ?? getKeyDef(`workspace.${path}`) ?? getKeyDef(`instance.${path}`);
    if (direct)
        return direct;
    // Try the legacy mapping — exact path match and tail-match.
    // Tail-match handles cases where the path includes intermediate
    // structure not represented in the legacy table.
    const legacy = LEGACY_SECRET_PATHS.get(path);
    if (legacy)
        return getKeyDef(legacy);
    // Fall back to walking suffixes: `a.b.c` → try `b.c`, then `c`.
    const parts = path.split('.');
    for (let i = 1; i < parts.length; i++) {
        const tail = parts.slice(i).join('.');
        const legacyTail = LEGACY_SECRET_PATHS.get(tail);
        if (legacyTail)
            return getKeyDef(legacyTail);
    }
    return undefined;
}
/**
 * The single secret-detector used by both the export sanitizer and the
 * import gate. Returns `true` if this (path, value) pair represents a
 * leak — i.e. the value MUST NOT cross the trust boundary.
 *
 * Asymmetry between export and import would itself be a finding;
 * symmetry here is load-bearing.
 */
export function isSecretLeak(path, value) {
    // Registry/legacy-path-driven: any path that resolves to a secret or
    // bootstrap entry is a leak regardless of value shape.
    const def = lookupByPath(path);
    if (def && (def.classification === 'bootstrap' || def.classification.endsWith('-secret'))) {
        return true;
    }
    // Top-level seed bootstrap fields by name — covers `authSeed.jwtSecret`
    // even when the registry path lookup misses (legacy spellings like
    // `apiKey`). The path comes in as `authSeed.jwtSecret` so we look
    // at the last segment.
    const tail = path.split('.').pop() ?? '';
    if (SEED_BOOTSTRAP_FIELDS.has(tail))
        return true;
    // Value-shape: only kicks in for plain strings. Treats raw token
    // prefixes and URL credentials as leaks even when the path is
    // unknown.
    if (typeof value === 'string' && looksLikeRawSecret(value))
        return true;
    return false;
}
/**
 * Walk an object/array tree and return an array of `(path, value)`
 * pairs flagged by `isSecretLeak`. Used by `bundleContainsBootstrapFields`
 * to produce a complete list for error messages.
 *
 * `prefix` is the dotted path so far; the root caller passes `''`.
 */
export function findSecretLeaks(prefix, value) {
    const out = [];
    walkLeaks(prefix, value, out);
    return out;
}
function walkLeaks(prefix, value, out) {
    if (Array.isArray(value)) {
        for (let i = 0; i < value.length; i++) {
            walkLeaks(prefix ? `${prefix}.${i}` : String(i), value[i], out);
        }
        return;
    }
    if (value && typeof value === 'object') {
        for (const [k, v] of Object.entries(value)) {
            const path = prefix ? `${prefix}.${k}` : k;
            // Probe at the field level — covers registry-named slots and
            // bootstrap field names regardless of value shape.
            if (isSecretLeak(path, v)) {
                out.push({ path, reason: 'registry' });
                // Don't recurse into a flagged subtree — the leak is the whole
                // value. (Recursing would just double-report.)
                continue;
            }
            walkLeaks(path, v, out);
        }
        return;
    }
    // Primitive: probe value-shape only.
    if (typeof value === 'string' && looksLikeRawSecret(value)) {
        out.push({ path: prefix, reason: 'value-shape' });
    }
}
/**
 * Recursively sanitize an object/array tree by dropping every
 * `(path, value)` pair flagged by `isSecretLeak`.
 *
 * Returns a fresh structure — input is not mutated.
 */
function sanitizeRecursive(prefix, value) {
    if (Array.isArray(value)) {
        return value.map((v, i) => sanitizeRecursive(prefix ? `${prefix}.${i}` : String(i), v));
    }
    if (value && typeof value === 'object') {
        const out = {};
        for (const [k, v] of Object.entries(value)) {
            const path = prefix ? `${prefix}.${k}` : k;
            if (isSecretLeak(path, v))
                continue;
            out[k] = sanitizeRecursive(path, v);
        }
        return out;
    }
    return value;
}
/**
 * Drop any field from the seed that the registry classifies as
 * bootstrap. `users[].password_hash` is intentionally retained — see
 * docs/SECURITY.md for the product decision.
 *
 * Walks recursively so nested secret-shaped values inside `users[]` or
 * `folder_access[]` are also stripped.
 */
export function sanitizeAuthSeed(seed) {
    const out = { users: [] };
    for (const [k, v] of Object.entries(seed)) {
        if (SEED_BOOTSTRAP_FIELDS.has(k))
            continue;
        // Don't recurse into users[] — their fields (email, password_hash)
        // are intentional product output. Other top-level entries get the
        // recursive treatment.
        if (k === 'users' || k === 'folder_access') {
            out[k] = v;
            continue;
        }
        out[k] = sanitizeRecursive(k, v);
    }
    if (!out.users)
        out.users = seed.users ?? [];
    return out;
}
/**
 * Filter a workspace.json-ish object by walking the entire tree and
 * dropping any (path, value) pair flagged by `isSecretLeak`.
 *
 * Phase 3 keeps unknown leaf fields by default; Phase 9b tightens via
 * WorkspaceConfigSchema.
 */
export function sanitizeWorkspaceConfig(workspace) {
    if (!workspace)
        return workspace;
    return sanitizeRecursive('', workspace);
}
/**
 * Connector configs use placeholder substitution (`$ENV_VAR`) for any
 * secret-bearing field. Walk to arbitrary depth and reject any value
 * at a secret-named key that isn't a placeholder. URLs with embedded
 * credentials are dropped wholesale.
 *
 * Same detector as the import gate — see `connectorImportLeaks`.
 */
export function sanitizeConnectorConfigs(connectors) {
    return connectors.map((c) => sanitizeConnectorRecursive('', c));
}
/**
 * Connector-specific predicate: in committed/imported config, secret
 * slots must be `$ENV_VAR` placeholders. Returns `true` if the value
 * at this slot is a leak (literal value where placeholder is required,
 * or a URL with embedded creds).
 */
function isConnectorLeak(key, value, fullPath) {
    // URLs with embedded creds, anywhere.
    if (typeof value === 'string' && urlContainsCredentials(value))
        return true;
    if (key === 'url' && typeof value === 'string' && urlContainsCredentials(value))
        return true;
    // Inside any header / query / env / oauth.client_secret / token /
    // args slot, a secret-named field must hold a placeholder.
    if (typeof value === 'string' && looksLikeSecretFieldName(key)) {
        if (!/^\$[A-Z_][A-Z0-9_]*$/.test(value))
            return true;
    }
    // stdio `args` — array of strings, each scanned for literal token
    // prefixes. The match is wide, but bundles have no legitimate need
    // for raw tokens in argv.
    if (typeof value === 'string' && /\.args\.[0-9]+$/.test(fullPath)) {
        if (looksLikeRawSecret(value) && !/^\$[A-Z_][A-Z0-9_]*$/.test(value)) {
            return true;
        }
    }
    // env-block (stdio) values: parent path ends in `.env` and the leaf
    // value is a plain string. Demand placeholder for secret-named keys.
    if (typeof value === 'string' &&
        /(\.env)\.[^.]+$/.test(fullPath) &&
        looksLikeSecretFieldName(key) &&
        !/^\$[A-Z_][A-Z0-9_]*$/.test(value)) {
        return true;
    }
    // Last-resort value-shape check anywhere in the connector subtree.
    if (typeof value === 'string' && looksLikeRawSecret(value))
        return true;
    return false;
}
function sanitizeConnectorRecursive(prefix, value) {
    if (Array.isArray(value)) {
        return value
            .map((v, i) => {
            const childPath = prefix ? `${prefix}.${i}` : String(i);
            if (isConnectorLeak(String(i), v, childPath))
                return undefined;
            return sanitizeConnectorRecursive(childPath, v);
        })
            .filter((v) => v !== undefined);
    }
    if (value && typeof value === 'object') {
        const out = {};
        for (const [k, v] of Object.entries(value)) {
            const childPath = prefix ? `${prefix}.${k}` : k;
            if (typeof v === 'string' && isConnectorLeak(k, v, childPath))
                continue;
            out[k] = sanitizeConnectorRecursive(childPath, v);
        }
        return out;
    }
    return value;
}
/**
 * Walk every connector and return the list of (path, key, reason)
 * triples flagged by `isConnectorLeak`. Used by the import gate to
 * reject bundles carrying literal connector secrets.
 */
export function connectorImportLeaks(connectors) {
    const out = [];
    for (let i = 0; i < connectors.length; i++) {
        walkConnectorLeaks(`connectors.${i}`, connectors[i], out);
    }
    return out;
}
function walkConnectorLeaks(prefix, value, out) {
    if (Array.isArray(value)) {
        for (let i = 0; i < value.length; i++) {
            const childPath = `${prefix}.${i}`;
            if (isConnectorLeak(String(i), value[i], childPath)) {
                out.push(childPath);
                continue;
            }
            walkConnectorLeaks(childPath, value[i], out);
        }
        return;
    }
    if (value && typeof value === 'object') {
        for (const [k, v] of Object.entries(value)) {
            const childPath = `${prefix}.${k}`;
            if (typeof v === 'string' && isConnectorLeak(k, v, childPath)) {
                out.push(childPath);
                continue;
            }
            walkConnectorLeaks(childPath, v, out);
        }
        return;
    }
}
/**
 * Compose an export-ready bundle from raw workspace materials. Used by
 * both the GET /api/config/bundle endpoint and the `studiograph push
 * --remote` CLI so they produce byte-identical output for the same
 * workspace state.
 */
export function buildConfigBundle(input) {
    return {
        workspace: sanitizeWorkspaceConfig(input.workspace),
        about: input.about,
        authSeed: sanitizeAuthSeed(input.authSeed),
        sources: input.sources,
        connectors: sanitizeConnectorConfigs(input.connectors),
    };
}
/**
 * Re-sanitize an inbound bundle (POST /api/config/bundle). Returns the
 * list of paths within the bundle that carried bootstrap-class or
 * secret-class material. Caller rejects the request when the result
 * is non-empty.
 *
 * Walks the auth seed, workspace, and every connector to arbitrary
 * depth using the same detectors as the export sanitizer. Any
 * asymmetry between export and import would itself be a finding.
 */
export function bundleContainsBootstrapFields(bundle) {
    const found = [];
    if (bundle.authSeed) {
        for (const k of Object.keys(bundle.authSeed)) {
            if (SEED_BOOTSTRAP_FIELDS.has(k)) {
                found.push(`authSeed.${k}`);
            }
        }
        // Also walk inside the seed for nested secret-shaped material.
        // Skip `users` and `folder_access` (their fields are intentional).
        for (const [k, v] of Object.entries(bundle.authSeed)) {
            if (SEED_BOOTSTRAP_FIELDS.has(k))
                continue;
            if (k === 'users' || k === 'folder_access')
                continue;
            for (const leak of findSecretLeaks(`authSeed.${k}`, v)) {
                found.push(leak.path);
            }
        }
    }
    if (bundle.workspace) {
        for (const leak of findSecretLeaks('workspace', bundle.workspace)) {
            found.push(leak.path);
        }
    }
    // H2: connectors get walked too. The previous gate skipped them
    // entirely, so a bundle with `connectors[0].headers["X-API-Key"]:
    // "real-token"` was accepted.
    if (Array.isArray(bundle.connectors)) {
        for (const path of connectorImportLeaks(bundle.connectors)) {
            found.push(path);
        }
    }
    // De-dupe in case a path is reported by multiple detectors.
    return [...new Set(found)];
}
/**
 * Re-export the registry's `exportable` filter for tests.
 */
export function exportableRegistryEntries() {
    return REGISTRY.filter((k) => k.exportable && !k.classification.endsWith('-secret') && k.classification !== 'bootstrap');
}
//# sourceMappingURL=bundle.js.map