"use strict";
/**
 * Cypher schema validation for the graph-mcp proxy.
 *
 * Tokenises a cypher string for labels (`:Label`) and relationship types
 * (`[:REL_TYPE]`, including `[r:TYPE]`, `[:TYPE*1..5]`, `[:A|B]`) and checks
 * each against a schema snapshot. Unknown tokens are returned with the
 * Levenshtein-nearest known tokens of the same kind as suggestions.
 *
 * Design posture:
 *   - Defence layer, not a cypher correctness gate. Pass-through on any
 *     unparseable pattern — Neo4j remains the syntax authority.
 *   - Empty schema snapshot (cache not ready, or Neo4j unreachable at boot)
 *     fails OPEN: ok=true, no rejections. The boot-race treatment is a
 *     deliberate choice — refusing all cypher would wedge the admin agent
 *     harder than the typo class this layer prevents. The caller emits
 *     `validated=false` on the existing [graph-query] line so operators
 *     still see the bypass.
 *   - String literals are stripped before tokenisation to avoid matching
 *     `:Foo` inside quoted content (e.g. `WHERE n.note CONTAINS ':Foo'`).
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.stripStringLiterals = stripStringLiterals;
exports.validate = validate;
// DDL / admin patterns that the database-operator's raw write surface must
// reject. The graph MCP shim is the only enforcement point — Neo4j Community
// has no per-tool ACL, so the shim filters by syntactic shape before
// forwarding the JSON-RPC line to the upstream cypher driver. Each pattern
// is applied to a string-literal-stripped cypher body so that prose inside
// quoted strings cannot trip a false positive.
//
// CALL apoc.* is intentionally NOT forbidden — apoc.refactor.mergeNodes is
// the documented dedup primitive. CALL db.labels() and other read-only db.*
// calls are allowed; only db.create* (createLabel, createProperty,
// createRelationshipType) is forbidden because those are schema-mutating.
const FORBIDDEN_PATTERNS = [
    {
        kind: "drop-database",
        pattern: /\bDROP\s+(?:DATABASE|ALIAS|USER|ROLE)\b/i,
        hint: "DROP DATABASE/USER/ROLE/ALIAS is admin DDL — not permitted on the operator surface.",
    },
    {
        kind: "create-index",
        pattern: /\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:RANGE\s+|TEXT\s+|POINT\s+|LOOKUP\s+|FULLTEXT\s+|BTREE\s+|VECTOR\s+)?INDEX\b/i,
        hint: "Index DDL is admin-owned (seed-neo4j.sh applies indexes). Not permitted on the operator surface.",
    },
    {
        kind: "create-constraint",
        pattern: /\bCREATE\s+(?:OR\s+REPLACE\s+)?CONSTRAINT\b/i,
        hint: "Constraint DDL is admin-owned (seed-neo4j.sh applies constraints). Not permitted on the operator surface.",
    },
    {
        kind: "call-dbms",
        pattern: /\bCALL\s+dbms\./i,
        hint: "CALL dbms.* is admin/security surface. Not permitted on the operator surface.",
    },
    {
        kind: "call-db-create",
        pattern: /\bCALL\s+db\.create/i,
        hint: "CALL db.create* mutates schema (label/property/type registration). Not permitted on the operator surface.",
    },
];
// Bracket content containing a type reference. Examples this matches:
//   [:PART_OF]
//   [r:PART_OF]
//   [:PART_OF*1..5]
//   [r:PART_OF*]
//   [:A|B]
// The captured group is the TYPE(|TYPE)* alternation; the consumer splits it.
const EDGE_PATTERN = /\[[^\]]*?:([A-Z_][A-Za-z0-9_]*(?:\|[A-Z_][A-Za-z0-9_]*)*)[^\]]*?\]/g;
// Label reference in a node pattern. Applied to a remainder string that has
// already had edge-bracket substrings stripped, so there is no overlap with
// the edge pattern. The [A-Z] anchor excludes lowercase map keys.
const LABEL_PATTERN = /:([A-Z][A-Za-z0-9_]*)/g;
// Exported so the post-write audit (graph-write/audit.ts) can reuse the same
// literal-stripping pass before its own static parsing — avoids duplicate
// false-positive treatment across the two surfaces.
function stripStringLiterals(cypher) {
    // Replace single- and double-quoted literals with empty quotes. Preserves
    // positional structure without retaining content that could match the
    // label regex (e.g. ':SomeLabel' inside a string).
    return cypher.replace(/'[^']*'|"[^"]*"/g, '""');
}
function extractTokens(cypher) {
    const cleaned = stripStringLiterals(cypher);
    const edges = new Set();
    let match;
    const edgePattern = new RegExp(EDGE_PATTERN.source, EDGE_PATTERN.flags);
    while ((match = edgePattern.exec(cleaned)) !== null) {
        for (const type of match[1].split("|")) {
            const clean = type.trim();
            if (clean)
                edges.add(clean);
        }
    }
    const remainder = cleaned.replace(edgePattern, "");
    const labels = new Set();
    const labelPattern = new RegExp(LABEL_PATTERN.source, LABEL_PATTERN.flags);
    while ((match = labelPattern.exec(remainder)) !== null) {
        labels.add(match[1]);
    }
    return { labels, edges };
}
function levenshtein(a, b) {
    if (a === b)
        return 0;
    if (a.length === 0)
        return b.length;
    if (b.length === 0)
        return a.length;
    let prev = new Array(b.length + 1);
    let curr = new Array(b.length + 1);
    for (let j = 0; j <= b.length; j++)
        prev[j] = j;
    for (let i = 1; i <= a.length; i++) {
        curr[0] = i;
        for (let j = 1; j <= b.length; j++) {
            const cost = a[i - 1] === b[j - 1] ? 0 : 1;
            curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
        }
        [prev, curr] = [curr, prev];
    }
    return prev[b.length];
}
function nearestMatches(token, known, limit = 3) {
    if (known.size === 0)
        return [];
    const scored = [];
    for (const k of known)
        scored.push([k, levenshtein(token, k)]);
    scored.sort((a, b) => a[1] - b[1] || a[0].localeCompare(b[0]));
    return scored.slice(0, limit).map(([k]) => k);
}
function hintFor(token, kind, nearest) {
    const didYouMean = nearest.length > 0 ? `Did you mean :${nearest[0]}? ` : "";
    return `${didYouMean}Unknown ${kind} '${token}' — not in the current Neo4j schema. See .docs/neo4j.md for the canonical taxonomy.`;
}
function detectForbidden(cypher) {
    // Strip literals so a property value like 'CREATE INDEX archive' cannot
    // trip a false rejection. The shared `stripStringLiterals` keeps this
    // treatment uniform with token extraction and audit static parsing.
    const cleaned = stripStringLiterals(cypher);
    const out = [];
    for (const { kind, pattern, hint } of FORBIDDEN_PATTERNS) {
        const found = cleaned.match(pattern);
        if (found) {
            out.push({ kind, match: found[0], hint });
        }
    }
    return out;
}
function validate(cypher, snapshot, options = {}) {
    const mode = options.mode ?? "read";
    const forbidden = mode === "write" ? detectForbidden(cypher) : [];
    const { labels, edges } = extractTokens(cypher);
    // Fail-open when the snapshot is empty. An empty snapshot means "schema
    // cache not loaded" (boot race, Neo4j unreachable). Rejecting every token
    // would wedge the admin agent; letting it through preserves the observable
    // `validated=false` signal on the existing [graph-query] line.
    // Forbidden DDL/admin patterns still apply — those are syntactic, not
    // schema-derived.
    if (snapshot.labels.size === 0 && snapshot.relationshipTypes.size === 0) {
        return {
            ok: forbidden.length === 0,
            unknown: [],
            forbidden,
            labelTokens: [...labels],
            edgeTokens: [...edges],
        };
    }
    const unknown = [];
    for (const token of labels) {
        if (!snapshot.labels.has(token)) {
            const nearest = nearestMatches(token, snapshot.labels);
            unknown.push({ token, kind: "label", nearest, hint: hintFor(token, "label", nearest) });
        }
    }
    for (const token of edges) {
        if (!snapshot.relationshipTypes.has(token)) {
            const nearest = nearestMatches(token, snapshot.relationshipTypes);
            unknown.push({ token, kind: "relationship", nearest, hint: hintFor(token, "relationship", nearest) });
        }
    }
    // Write-mode caller treats `unknown` as a soft warning (audit), not a
    // hard reject — operators legitimately introduce new labels via REMOVE
    // n:Old SET n:New. Read-mode preserves prior behaviour: any unknown
    // → ok=false.
    const tokensOk = mode === "write" ? true : unknown.length === 0;
    return {
        ok: tokensOk && forbidden.length === 0,
        unknown,
        forbidden,
        labelTokens: [...labels],
        edgeTokens: [...edges],
    };
}
//# sourceMappingURL=cypher-validate.js.map