Skip to main content

lex_api/
handlers.rs

1//! Request routing for the agent API.
2//!
3//! Each handler is a synchronous function that returns
4//! `Result<serde_json::Value, ApiError>`. The dispatcher wraps the result
5//! in an HTTP response — successes as 200 with the JSON body, structured
6//! errors as 4xx/5xx with a JSON envelope.
7
8use indexmap::IndexMap;
9use lex_ast::canonicalize_program;
10use lex_bytecode::{compile_program, vm::Vm, Value};
11use lex_runtime::{check_program as check_policy, DefaultHandler, Policy};
12use lex_store::Store;
13use lex_syntax::{load_program, load_program_from_str, Manifest};
14use lex_vcs::{MergeSession, MergeSessionId};
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, BTreeSet, HashMap};
17use std::path::PathBuf;
18use std::sync::{Arc, Mutex};
19use std::time::{SystemTime, UNIX_EPOCH};
20use tiny_http::{Header, Method, Request, Response};
21
22pub struct State {
23    pub store: Mutex<Store>,
24    /// Filesystem root of the store. Held alongside the `Store`
25    /// itself so handlers that need to read store-level files
26    /// (e.g. `users.json` for actor auth) don't have to round-
27    /// trip through the lock.
28    pub root: PathBuf,
29    /// In-memory merge sessions, keyed by MergeSessionId. Sessions
30    /// are ephemeral by design (#134 foundation): they live for the
31    /// lifetime of the server process and are GC'd on commit. A
32    /// future slice can persist them to disk so a session survives
33    /// process restarts. For now an agent that gets unlucky with a
34    /// restart re-runs `merge/start` and gets a fresh session.
35    pub sessions: Mutex<HashMap<MergeSessionId, ApiMergeSession>>,
36    /// Optional server-imposed ceiling on the effect policy honored
37    /// by `/v1/run` and `/v1/replay`. `None` (the default, used by
38    /// single-tenant `lex serve`) runs the caller's request policy
39    /// as-is — the operator *is* the caller there, so that's
40    /// intended. When `Some`, the request policy is clamped via
41    /// [`clamp_policy`] so it can only *narrow* the ceiling, never
42    /// widen it.
43    ///
44    /// Any embedder that exposes this API to untrusted callers — a
45    /// hosted, multi-tenant gateway like lex-hub — MUST set this.
46    /// Without it the request body can grant itself `[proc]`
47    /// (arbitrary subprocess spawn), `[fs_*]` over `/`, and
48    /// unrestricted `[net]`: arbitrary code execution as the server
49    /// process. See lex-hub#6.
50    ///
51    /// NOTE: an empty scope list means "any path/host" in the
52    /// runtime, so a ceiling that puts `fs_read`/`fs_write`/`net` in
53    /// `allow_effects` MUST also populate the matching scope list
54    /// (`allow_fs_read`, …) or it re-opens the wildcard. Granting
55    /// none of those kinds is the safe default.
56    pub policy_ceiling: Option<Policy>,
57}
58
59/// Server-side wrapper around [`MergeSession`] carrying the
60/// branch names that started the merge. The lex-vcs session
61/// itself only tracks `OpId` heads; commit needs the dst branch
62/// name to advance the right head, and the src branch name is
63/// kept for round-trip auditability ("which branch did we merge
64/// from?").
65pub struct ApiMergeSession {
66    pub inner: MergeSession,
67    pub src_branch: String,
68    pub dst_branch: String,
69}
70
71impl State {
72    pub fn open(root: PathBuf) -> anyhow::Result<Self> {
73        Self::open_with_ceiling(root, None)
74    }
75
76    /// Like [`State::open`] but installs a [`policy_ceiling`](State::policy_ceiling)
77    /// that `/v1/run` and `/v1/replay` clamp the caller's request
78    /// policy against. Embedders exposing this API to untrusted
79    /// callers must use this constructor (or set the field directly).
80    pub fn open_with_ceiling(
81        root: PathBuf,
82        policy_ceiling: Option<Policy>,
83    ) -> anyhow::Result<Self> {
84        Ok(Self {
85            store: Mutex::new(Store::open(&root)?),
86            root,
87            sessions: Mutex::new(HashMap::new()),
88            policy_ceiling,
89        })
90    }
91
92    /// Construct a per-tenant `State` by prefixing `store_root` with the
93    /// tenant id. Single-tenant `lex serve` is unaffected — it calls
94    /// `State::open` directly.
95    ///
96    /// `tenant_id` is restricted to `[A-Za-z0-9_-]{1,64}`: anything else
97    /// (path separators, `..`, NUL, absolute paths, dotfiles, empty
98    /// string) is rejected before touching the filesystem. Without this
99    /// `PathBuf::join("/etc")` would silently replace `store_root`, and
100    /// `PathBuf::join("../foo")` would escape the tenant root.
101    pub fn new_with_tenant(tenant_id: &str, store_root: PathBuf) -> anyhow::Result<Self> {
102        validate_tenant_id(tenant_id)?;
103        Self::open(store_root.join(tenant_id))
104    }
105
106    /// Multi-tenant constructor that also installs a policy ceiling
107    /// for `/v1/run` / `/v1/replay`. The path-traversal guard from
108    /// [`new_with_tenant`](State::new_with_tenant) and the effect
109    /// ceiling are the two halves a hosted gateway needs.
110    pub fn new_with_tenant_and_ceiling(
111        tenant_id: &str,
112        store_root: PathBuf,
113        policy_ceiling: Option<Policy>,
114    ) -> anyhow::Result<Self> {
115        validate_tenant_id(tenant_id)?;
116        Self::open_with_ceiling(store_root.join(tenant_id), policy_ceiling)
117    }
118}
119
120/// Clamp a caller-supplied [`Policy`] to a server-imposed `ceiling`
121/// so it can only *narrow* the granted capabilities, never widen
122/// them. Used by [`run_handler`] when [`State::policy_ceiling`] is
123/// set — i.e. when an embedder exposes `/v1/run` to untrusted
124/// callers and must not let the request body grant itself `[proc]`,
125/// arbitrary `[fs_*]` paths, or unrestricted `[net]`.
126///
127/// - **Effects**: set-intersection of request and ceiling. The
128///   caller may drop effects but never add one the ceiling withheld.
129/// - **Scopes** (fs paths, proc binaries, net hosts): taken from the
130///   ceiling outright. The caller cannot widen them, and — because an
131///   empty scope list means "any" in the runtime — we must not let a
132///   caller's empty list collapse the ceiling's restriction back to a
133///   wildcard.
134/// - **Budget**: the more restrictive (smaller) of the two.
135fn clamp_policy(requested: Policy, ceiling: &Policy) -> Policy {
136    let allow_effects: BTreeSet<String> = requested
137        .allow_effects
138        .intersection(&ceiling.allow_effects)
139        .cloned()
140        .collect();
141    let budget = match (requested.budget, ceiling.budget) {
142        (Some(r), Some(c)) => Some(r.min(c)),
143        (None, Some(c)) => Some(c),
144        (Some(r), None) => Some(r),
145        (None, None) => None,
146    };
147    Policy {
148        allow_effects,
149        allow_fs_read: ceiling.allow_fs_read.clone(),
150        allow_fs_write: ceiling.allow_fs_write.clone(),
151        allow_net_host: ceiling.allow_net_host.clone(),
152        allow_proc: ceiling.allow_proc.clone(),
153        budget,
154    }
155}
156
157fn validate_tenant_id(tenant_id: &str) -> anyhow::Result<()> {
158    if tenant_id.is_empty() {
159        anyhow::bail!("tenant_id must not be empty");
160    }
161    if tenant_id.len() > 64 {
162        anyhow::bail!("tenant_id must be at most 64 bytes");
163    }
164    if !tenant_id
165        .bytes()
166        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
167    {
168        anyhow::bail!(
169            "tenant_id {tenant_id:?} contains characters outside [A-Za-z0-9_-]"
170        );
171    }
172    Ok(())
173}
174
175#[derive(Debug, Serialize, Deserialize)]
176struct ErrorEnvelope {
177    error: String,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    detail: Option<serde_json::Value>,
180}
181
182fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
183    let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
184    Response::from_data(bytes)
185        .with_status_code(status)
186        .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())
187}
188
189fn error_response(status: u16, msg: impl Into<String>) -> Response<std::io::Cursor<Vec<u8>>> {
190    json_response(status, &serde_json::to_value(ErrorEnvelope {
191        error: msg.into(), detail: None,
192    }).unwrap())
193}
194
195fn error_with_detail(status: u16, msg: impl Into<String>, detail: serde_json::Value)
196    -> Response<std::io::Cursor<Vec<u8>>>
197{
198    json_response(status, &serde_json::to_value(ErrorEnvelope {
199        error: msg.into(), detail: Some(detail),
200    }).unwrap())
201}
202
203/// Map a `StoreError` from a write path (`apply_operation` /
204/// `apply_operation_checked`) to an HTTP response. The only special
205/// case today is `Contention` (#262 multi-writer CAS retries
206/// exhausted), which maps to 503 with a `Retry-After` header so
207/// clients back off rather than hammering the same branch tip.
208fn write_error_response(prefix: &str, err: lex_store::StoreError)
209    -> Response<std::io::Cursor<Vec<u8>>>
210{
211    if let lex_store::StoreError::Contention { branch, attempts } = &err {
212        let body = serde_json::to_vec(&ErrorEnvelope {
213            error: format!("{prefix}: branch '{branch}' is contended (attempts={attempts})"),
214            detail: Some(serde_json::json!({
215                "kind": "contention",
216                "branch": branch,
217                "attempts": attempts,
218            })),
219        }).unwrap_or_else(|_| b"{}".to_vec());
220        return Response::from_data(body)
221            .with_status_code(503)
222            .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())
223            .with_header(Header::from_bytes(&b"Retry-After"[..], &b"1"[..]).unwrap());
224    }
225    // #292 slice 3: budget overflow → 503 with `Retry-After: 0`.
226    // Unlike Contention (where a retry might land after another
227    // writer finishes), there's no point retrying a budget-
228    // exceeded op — the caller needs to raise the cap, switch
229    // sessions, or refactor the work. The `Retry-After: 0`
230    // signals "don't bother retrying as-is" while still using
231    // the canonical "service refused" status code.
232    if let lex_store::StoreError::BudgetExceeded { session_id, cap, spent_after } = &err {
233        let body = serde_json::to_vec(&ErrorEnvelope {
234            error: format!(
235                "{prefix}: session `{session_id}` budget exceeded \
236                 (spent_after={spent_after}, cap={cap})"
237            ),
238            detail: Some(serde_json::json!({
239                "kind": "budget_exceeded",
240                "session_id": session_id,
241                "cap": cap,
242                "spent_after": spent_after,
243            })),
244        }).unwrap_or_else(|_| b"{}".to_vec());
245        return Response::from_data(body)
246            .with_status_code(503)
247            .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap())
248            .with_header(Header::from_bytes(&b"Retry-After"[..], &b"0"[..]).unwrap());
249    }
250    error_response(500, format!("{prefix}: {err}"))
251}
252
253pub fn handle(state: Arc<State>, mut req: Request) -> std::io::Result<()> {
254    let method = req.method().clone();
255    let url = req.url().to_string();
256    let path = url.split('?').next().unwrap_or("").to_string();
257    let query = url.split_once('?').map(|(_, q)| q.to_string()).unwrap_or_default();
258
259    // `X-Lex-User` is the v3d session identifier — set by humans
260    // operating the web UI through whatever proxy fronts auth, or
261    // by AI agents calling the JSON API. We pluck it once here so
262    // every handler can take it as a borrowed string.
263    let x_lex_user = req.headers().iter()
264        .find(|h| h.field.equiv("x-lex-user"))
265        .map(|h| h.value.as_str().to_string());
266
267    // POST /v1/pkg/publish sends a raw tar.gz body — read bytes before routing.
268    if matches!(method, Method::Post) && path == "/v1/pkg/publish" {
269        let mut body_bytes: Vec<u8> = Vec::new();
270        let _ = req.as_reader().read_to_end(&mut body_bytes);
271        let resp = pkg_publish_handler(&state, &body_bytes);
272        return req.respond(resp);
273    }
274
275    let mut body = String::new();
276    let _ = req.as_reader().read_to_string(&mut body);
277
278    let resp = route(&state, &method, &path, &query, &body, x_lex_user.as_deref());
279    req.respond(resp)
280}
281
282/// Auth-gated entry point. Calls `auth(path, headers)` before routing;
283/// returns 401 JSON when it returns false. Keeps auth logic out of the
284/// product-agnostic core.
285pub fn handle_with_auth<F>(state: Arc<State>, req: Request, auth: F) -> std::io::Result<()>
286where
287    F: FnOnce(&str, &[Header]) -> bool,
288{
289    let path = req.url().split('?').next().unwrap_or("").to_string();
290    if !auth(&path, req.headers()) {
291        return req.respond(
292            Response::from_data(br#"{"error":"unauthorized"}"#.to_vec())
293                .with_status_code(401)
294                .with_header(
295                    Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
296                ),
297        );
298    }
299    handle(state, req)
300}
301
302fn route(
303    state: &State,
304    method: &Method,
305    path: &str,
306    query: &str,
307    body: &str,
308    x_lex_user: Option<&str>,
309) -> Response<std::io::Cursor<Vec<u8>>> {
310    match (method, path) {
311        // ---- lex-tea v2 (HTML browser) ------------------------
312        (Method::Get, "/") => crate::web::activity_handler(state),
313        (Method::Get, "/web/branches") => crate::web::branches_handler(state),
314        (Method::Get, "/web/trust") => crate::web::trust_handler(state),
315        (Method::Get, "/web/attention") => crate::web::attention_handler(state),
316        (Method::Get, p) if p.starts_with("/web/branch/") => {
317            let name = &p["/web/branch/".len()..];
318            crate::web::branch_handler(state, name)
319        }
320        (Method::Get, p) if p.starts_with("/web/stage/") => {
321            let id = &p["/web/stage/".len()..];
322            crate::web::stage_html_handler(state, id)
323        }
324        // lex-tea v3 human-triage actions (#172). HTML forms post
325        // to /web/stage/<id>/{pin,defer,block,unblock} with a
326        // `reason` body. All four share one handler; the verb in
327        // the path picks the AttestationKind.
328        (Method::Post, p) if p.starts_with("/web/stage/") && (
329            p.ends_with("/pin") || p.ends_with("/defer")
330            || p.ends_with("/block") || p.ends_with("/unblock")
331        ) => {
332            let prefix_len = "/web/stage/".len();
333            let last_slash = p.rfind('/').unwrap_or(p.len());
334            let id = &p[prefix_len..last_slash];
335            let verb = &p[last_slash + 1..];
336            let decision = match verb {
337                "pin"     => crate::web::WebStageDecision::Pin,
338                "defer"   => crate::web::WebStageDecision::Defer,
339                "block"   => crate::web::WebStageDecision::Block,
340                "unblock" => crate::web::WebStageDecision::Unblock,
341                _ => unreachable!("matched in outer guard"),
342            };
343            crate::web::stage_decision_handler(state, id, body, decision, x_lex_user)
344        }
345        // ---- JSON API -----------------------------------------
346        (Method::Get, "/v1/health") => json_response(200, &serde_json::json!({"ok": true})),
347        (Method::Post, "/v1/parse") => parse_handler(body),
348        (Method::Post, "/v1/check") => check_handler(body),
349        (Method::Post, "/v1/publish") => publish_handler(state, body),
350        (Method::Post, "/v1/patch") => patch_handler(state, body),
351        (Method::Get, p) if p.starts_with("/v1/stage/") => {
352            let suffix = &p["/v1/stage/".len()..];
353            // Match `/v1/stage/<id>/attestations` first so a literal
354            // stage_id of "attestations" can't be misrouted.
355            if let Some(id) = suffix.strip_suffix("/attestations") {
356                stage_attestations_handler(state, id)
357            } else {
358                stage_handler(state, suffix)
359            }
360        }
361        (Method::Post, "/v1/run") => run_handler(state, body, false),
362        (Method::Post, "/v1/replay") => run_handler(state, body, true),
363        (Method::Get, p) if p.starts_with("/v1/trace/") => {
364            let id = &p["/v1/trace/".len()..];
365            trace_handler(state, id)
366        }
367        (Method::Get, "/v1/diff") => diff_handler(state, query),
368        (Method::Post, "/v1/merge/start") => merge_start_handler(state, body),
369        (Method::Post, p) if p.starts_with("/v1/merge/") && p.ends_with("/resolve") => {
370            let id = &p["/v1/merge/".len()..p.len() - "/resolve".len()];
371            merge_resolve_handler(state, id, body)
372        }
373        (Method::Post, p) if p.starts_with("/v1/merge/") && p.ends_with("/commit") => {
374            let id = &p["/v1/merge/".len()..p.len() - "/commit".len()];
375            merge_commit_handler(state, id)
376        }
377        // ---- #242: append-only sync of op log + attestation log
378        (Method::Post, "/v1/ops/batch") => ops_batch_handler(state, body),
379        (Method::Post, "/v1/attestations/batch") => attestations_batch_handler(state, body),
380        // Probe endpoint for `lex op push` to discover the remote's
381        // current head before computing a delta. Returns
382        // `{ "head_op": Option<OpId> }`. `<branch>` is URL-encoded.
383        (Method::Get, p) if p.starts_with("/v1/branches/") && p.ends_with("/head") => {
384            let name = &p["/v1/branches/".len()..p.len() - "/head".len()];
385            branch_head_handler(state, name)
386        }
387        // ---- #260: append-only fetch (inverse of #242 push)
388        // Body is a JSON array of OperationRecords reachable from
389        // `branch.head_op` but not from `after`, oldest-first.
390        (Method::Get, "/v1/ops/since") => ops_since_handler(state, query),
391        (Method::Get, "/v1/attestations/since") => attestations_since_handler(state, query),
392        // ---- #4: package concept ----------------------------------
393        // POST /v1/pkg/publish is handled in handle() before route()
394        // (binary body), so it doesn't appear here.
395        (Method::Get, "/v1/pkg") => pkg_list_handler(state),
396        // Owner-only visibility toggle (authed via the front door). Must
397        // precede the generic `/v1/pkg/{name}` arms; it's a PUT, so it
398        // can't collide with the GET/DELETE arms regardless.
399        (Method::Put, p) if p.starts_with("/v1/pkg/") && p.ends_with("/visibility") => {
400            let name = &p["/v1/pkg/".len()..p.len() - "/visibility".len()];
401            pkg_set_visibility_handler(state, name, body)
402        }
403        (Method::Get, p) if p.starts_with("/v1/pkg/") && p.ends_with("/head") => {
404            let name = &p["/v1/pkg/".len()..p.len() - "/head".len()];
405            pkg_head_handler(state, name)
406        }
407        (Method::Get, p) if p.starts_with("/v1/pkg/") && p.ends_with("/versions") => {
408            let name = &p["/v1/pkg/".len()..p.len() - "/versions".len()];
409            pkg_versions_handler(state, name)
410        }
411        // /v1/pkg/{name}/{version}/archive — must match before the generic /{name}/{version}
412        (Method::Get, p) if p.starts_with("/v1/pkg/") && p.ends_with("/archive") => {
413            let inner = &p["/v1/pkg/".len()..p.len() - "/archive".len()];
414            // inner = "{name}/{version}"
415            if let Some((name, version)) = inner.split_once('/') {
416                pkg_archive_handler(state, name, version)
417            } else {
418                error_response(400, "expected /v1/pkg/{name}/{version}/archive")
419            }
420        }
421        // /v1/pkg/{name}/{version}
422        (Method::Get, p) if p.starts_with("/v1/pkg/") && p["/v1/pkg/".len()..].contains('/') => {
423            let inner = &p["/v1/pkg/".len()..];
424            if let Some((name, version)) = inner.split_once('/') {
425                pkg_get_version_handler(state, name, version)
426            } else {
427                error_response(400, "expected /v1/pkg/{name}/{version}")
428            }
429        }
430        (Method::Get, p) if p.starts_with("/v1/pkg/") => {
431            let name = &p["/v1/pkg/".len()..];
432            pkg_get_handler(state, name)
433        }
434        (Method::Delete, p) if p.starts_with("/v1/pkg/") => {
435            let name = &p["/v1/pkg/".len()..];
436            pkg_delete_handler(state, name)
437        }
438        _ => error_response(404, format!("unknown route: {method:?} {path}")),
439    }
440}
441
442#[derive(Deserialize)]
443struct ParseReq { source: String }
444
445fn parse_handler(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
446    let req: ParseReq = match serde_json::from_str(body) {
447        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
448    };
449    match load_program_from_str(&req.source) {
450        Ok(prog) => {
451            let stages = canonicalize_program(&prog);
452            json_response(200, &serde_json::to_value(&stages).unwrap())
453        }
454        Err(e) => error_response(400, format!("syntax error: {e}")),
455    }
456}
457
458pub(crate) fn check_handler(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
459    let req: ParseReq = match serde_json::from_str(body) {
460        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
461    };
462    let prog = match load_program_from_str(&req.source) {
463        Ok(p) => p, Err(e) => return error_response(400, format!("syntax error: {e}")),
464    };
465    let stages = canonicalize_program(&prog);
466    match lex_types::check_program(&stages) {
467        Ok(_) => json_response(200, &serde_json::json!({"ok": true})),
468        Err(errs) => json_response(422, &serde_json::to_value(&errs).unwrap()),
469    }
470}
471
472#[derive(Deserialize)]
473struct PublishReq { source: String, #[serde(default)] activate: bool }
474
475pub(crate) fn publish_handler(state: &State, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
476    let req: PublishReq = match serde_json::from_str(body) {
477        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
478    };
479    let prog = match load_program_from_str(&req.source) {
480        Ok(p) => p, Err(e) => return error_response(400, format!("syntax error: {e}")),
481    };
482    // #168: rewrite stdlib parse calls to parse_strict so the
483    // bytecode emitted from these stages enforces required-field
484    // checks at runtime.
485    let mut stages = canonicalize_program(&prog);
486    if let Err(errs) = lex_types::check_and_rewrite_program(&mut stages) {
487        return error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap());
488    }
489
490    let store = state.store.lock().unwrap();
491    let branch = store.current_branch();
492
493    // Compute diff between what's already on the branch and the new program.
494    let old_head = match store.branch_head(&branch) {
495        Ok(h) => h,
496        Err(e) => return error_response(500, format!("branch_head: {e}")),
497    };
498    let old_fns: std::collections::BTreeMap<String, lex_ast::FnDecl> = old_head.values()
499        .filter_map(|stg| store.get_ast(stg).ok())
500        .filter_map(|s| match s {
501            lex_ast::Stage::FnDecl(fd) => Some((fd.name.clone(), fd)),
502            _ => None,
503        })
504        .collect();
505    let new_fns: std::collections::BTreeMap<String, lex_ast::FnDecl> = stages.iter()
506        .filter_map(|s| match s {
507            lex_ast::Stage::FnDecl(fd) => Some((fd.name.clone(), fd.clone())),
508            _ => None,
509        })
510        .collect();
511    let report = lex_vcs::compute_diff(&old_fns, &new_fns, false);
512
513    // Build new imports map from any Import stages in the source.
514    let mut new_imports: lex_vcs::ImportMap = lex_vcs::ImportMap::new();
515    {
516        let entry = new_imports.entry("<source>".into()).or_default();
517        for s in &stages {
518            if let lex_ast::Stage::Import(im) = s {
519                entry.insert(im.reference.clone());
520            }
521        }
522    }
523
524    match store.publish_program(&branch, &stages, &report, &new_imports, req.activate) {
525        Ok(outcome) => json_response(200, &serde_json::json!({
526            "ops": outcome.ops,
527            "head_op": outcome.head_op,
528        })),
529        // The store-write gate (#130) also type-checks at the top
530        // of `publish_program`. The handler above already pre-checks,
531        // so this branch is reached only on a race or a state we
532        // didn't see at handler time. Surface the structured
533        // envelope (422) instead of a generic 500 — same shape the
534        // initial pre-check uses, so a client only has one error
535        // contract to handle.
536        Err(lex_store::StoreError::TypeError(errs)) => {
537            error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap())
538        }
539        Err(e) => write_error_response("publish_program", e),
540    }
541}
542
543#[derive(Deserialize)]
544struct PatchReq {
545    stage_id: String,
546    patch: lex_ast::Patch,
547    #[serde(default)] activate: bool,
548}
549
550/// POST /v1/patch — apply a structured edit to a stored stage's
551/// canonical AST, type-check the result, and publish a new stage.
552fn patch_handler(state: &State, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
553    let req: PatchReq = match serde_json::from_str(body) {
554        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
555    };
556    let store = state.store.lock().unwrap();
557
558    // 1. Load.
559    let original = match store.get_ast(&req.stage_id) {
560        Ok(s) => s, Err(e) => return error_response(404, format!("stage: {e}")),
561    };
562
563    // 2. Apply.
564    let patched = match lex_ast::apply_patch(&original, &req.patch) {
565        Ok(s) => s,
566        Err(e) => return error_with_detail(422, "patch failed",
567            serde_json::to_value(&e).unwrap_or_default()),
568    };
569
570    // 3. Type-check the new stage in isolation.
571    let stages = vec![patched.clone()];
572    if let Err(errs) = lex_types::check_program(&stages) {
573        return error_with_detail(422, "type errors after patch",
574            serde_json::to_value(&errs).unwrap_or_default());
575    }
576
577    // Routing through apply_operation so /v1/patch participates in
578    // the op DAG. We know this op is always a body change on the
579    // existing sig (a patch can't add a brand-new fn).
580    let branch = store.current_branch();
581
582    // Find the sig — patched stage's sig must match the original's.
583    let sig = match lex_ast::sig_id(&patched) {
584        Some(s) => s,
585        None => return error_response(500, "patched stage has no sig_id"),
586    };
587
588    let new_id = match store.publish(&patched) {
589        Ok(id) => id, Err(e) => return error_response(500, format!("publish: {e}")),
590    };
591    if req.activate {
592        if let Err(e) = store.activate(&new_id) {
593            return error_response(500, format!("activate: {e}"));
594        }
595    }
596
597    // Determine op kind: ChangeEffectSig if effects differ, ModifyBody otherwise.
598    let original_effects: std::collections::BTreeSet<String> = match &original {
599        lex_ast::Stage::FnDecl(fd) => fd.effects.iter().map(|e| e.name.clone()).collect(),
600        _ => std::collections::BTreeSet::new(),
601    };
602    let patched_effects: std::collections::BTreeSet<String> = match &patched {
603        lex_ast::Stage::FnDecl(fd) => fd.effects.iter().map(|e| e.name.clone()).collect(),
604        _ => std::collections::BTreeSet::new(),
605    };
606    let head_now = match store.get_branch(&branch) {
607        Ok(b) => b.and_then(|b| b.head_op),
608        Err(e) => return error_response(500, format!("get_branch: {e}")),
609    };
610    let kind = if original_effects != patched_effects {
611        // #247: budget delta is part of the canonical payload now.
612        // Patch endpoints don't currently rehydrate the AST to
613        // recompute budgets, so leave them None — clients that
614        // need budget tracking should publish through the diff
615        // pipeline (`lex publish`) where `compute_diff` populates
616        // them.
617        let from_budget = lex_vcs::operation_budget_from_effects(&original_effects);
618        let to_budget = lex_vcs::operation_budget_from_effects(&patched_effects);
619        lex_vcs::OperationKind::ChangeEffectSig {
620            sig_id: sig.clone(),
621            from_stage_id: req.stage_id.clone(),
622            to_stage_id: new_id.clone(),
623            from_effects: original_effects,
624            to_effects: patched_effects,
625            from_budget,
626            to_budget,
627        }
628    } else {
629        let budget = lex_vcs::operation_budget_from_effects(&original_effects);
630        lex_vcs::OperationKind::ModifyBody {
631            sig_id: sig.clone(),
632            from_stage_id: req.stage_id.clone(),
633            to_stage_id: new_id.clone(),
634            from_budget: budget,
635            to_budget: budget,
636        }
637    };
638    let transition = lex_vcs::StageTransition::Replace {
639        sig_id: sig.clone(),
640        from: req.stage_id.clone(),
641        to: new_id.clone(),
642    };
643    let op = lex_vcs::Operation::new(
644        kind,
645        head_now.into_iter().collect::<Vec<_>>(),
646    );
647    let op_id = match store.apply_operation(&branch, op, transition) {
648        Ok(id) => id,
649        Err(e) => return write_error_response("apply_operation", e),
650    };
651
652    let status = format!("{:?}",
653        store.get_status(&new_id).unwrap_or(lex_store::StageStatus::Draft)).to_lowercase();
654    json_response(200, &serde_json::json!({
655        "old_stage_id": req.stage_id,
656        "new_stage_id": new_id,
657        "sig_id": sig,
658        "status": status,
659        "op_id": op_id,
660    }))
661}
662
663pub(crate) fn stage_handler(state: &State, id: &str) -> Response<std::io::Cursor<Vec<u8>>> {
664    let store = state.store.lock().unwrap();
665    let meta = match store.get_metadata(id) {
666        Ok(m) => m, Err(e) => return error_response(404, format!("{e}")),
667    };
668    let ast = match store.get_ast(id) {
669        Ok(a) => a, Err(e) => return error_response(404, format!("{e}")),
670    };
671    let status = format!("{:?}", store.get_status(id).unwrap_or(lex_store::StageStatus::Draft)).to_lowercase();
672    json_response(200, &serde_json::json!({
673        "metadata": meta,
674        "ast": ast,
675        "status": status,
676    }))
677}
678
679/// `GET /v1/stage/<id>/attestations` — every persisted attestation
680/// for this stage, newest-first by timestamp. Issue #132's
681/// queryable-evidence consumer surface.
682///
683/// 404s on unknown stage_id (matches `/v1/stage/<id>`'s shape so a
684/// caller round-tripping both endpoints sees consistent errors).
685/// Empty list (200) is *evidence of absence*: the stage exists but
686/// no producer has attested it.
687pub(crate) fn stage_attestations_handler(state: &State, id: &str) -> Response<std::io::Cursor<Vec<u8>>> {
688    let store = state.store.lock().unwrap();
689    if let Err(e) = store.get_metadata(id) {
690        return error_response(404, format!("{e}"));
691    }
692    let log = match store.attestation_log() {
693        Ok(l) => l,
694        Err(e) => return error_response(500, format!("attestation log: {e}")),
695    };
696    let mut listing = match log.list_for_stage(&id.to_string()) {
697        Ok(v) => v,
698        Err(e) => return error_response(500, format!("list_for_stage: {e}")),
699    };
700    listing.sort_by_key(|a| std::cmp::Reverse(a.timestamp));
701    json_response(200, &serde_json::json!({"attestations": listing}))
702}
703
704#[derive(Deserialize, Default)]
705struct PolicyJson {
706    #[serde(default)] allow_effects: Vec<String>,
707    #[serde(default)] allow_fs_read: Vec<String>,
708    #[serde(default)] allow_fs_write: Vec<String>,
709    #[serde(default)] budget: Option<u64>,
710}
711
712impl PolicyJson {
713    fn into_policy(self) -> Policy {
714        Policy {
715            allow_effects: self.allow_effects.into_iter().collect::<BTreeSet<_>>(),
716            allow_fs_read: self.allow_fs_read.into_iter().map(PathBuf::from).collect(),
717            allow_fs_write: self.allow_fs_write.into_iter().map(PathBuf::from).collect(),
718            allow_net_host: Vec::new(),
719            allow_proc: Vec::new(),
720            budget: self.budget,
721        }
722    }
723}
724
725#[derive(Deserialize)]
726struct RunReq {
727    source: String,
728    #[serde(rename = "fn")] func: String,
729    #[serde(default)] args: Vec<serde_json::Value>,
730    #[serde(default)] policy: PolicyJson,
731    #[serde(default)] overrides: IndexMap<String, serde_json::Value>,
732}
733
734pub(crate) fn run_handler(state: &State, body: &str, with_overrides: bool) -> Response<std::io::Cursor<Vec<u8>>> {
735    let req: RunReq = match serde_json::from_str(body) {
736        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
737    };
738    let prog = match load_program_from_str(&req.source) {
739        Ok(p) => p, Err(e) => return error_response(400, format!("syntax error: {e}")),
740    };
741    let stages = canonicalize_program(&prog);
742    if let Err(errs) = lex_types::check_program(&stages) {
743        return error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap());
744    }
745    let bc = compile_program(&stages);
746    let mut policy = req.policy.into_policy();
747    // When a server-imposed ceiling is present (multi-tenant
748    // embedders like lex-hub), the request policy can only narrow
749    // it — never grant itself proc/fs/net beyond what the operator
750    // allowed. Single-tenant `lex serve` leaves the ceiling unset
751    // and runs the caller's policy verbatim.
752    if let Some(ceiling) = &state.policy_ceiling {
753        policy = clamp_policy(policy, ceiling);
754    }
755    if let Err(violations) = check_policy(&bc, &policy) {
756        return error_with_detail(403, "policy violation", serde_json::to_value(&violations).unwrap());
757    }
758
759    let mut recorder = lex_trace::Recorder::new();
760    if with_overrides && !req.overrides.is_empty() {
761        recorder = recorder.with_overrides(req.overrides);
762    }
763    let handle = recorder.handle();
764    let handler = DefaultHandler::new(policy);
765    let mut vm = Vm::with_handler(&bc, Box::new(handler));
766    vm.set_tracer(Box::new(recorder));
767
768    let vargs: Vec<Value> = req.args.iter().map(json_to_value).collect();
769    let started = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
770    let result = vm.call(&req.func, vargs);
771    let ended = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
772
773    let store = state.store.lock().unwrap();
774    let (root_out, root_err, status) = match &result {
775        Ok(v) => (Some(value_to_json(v)), None, 200u16),
776        Err(e) => (None, Some(format!("{e}")), 200u16),
777    };
778    let tree = handle.finalize(req.func.clone(), serde_json::Value::Null,
779        root_out.clone(), root_err.clone(), started, ended);
780    let run_id = match store.save_trace(&tree) {
781        Ok(id) => id,
782        Err(e) => return error_response(500, format!("save_trace: {e}")),
783    };
784
785    let mut body = serde_json::json!({
786        "run_id": run_id,
787        "output": root_out,
788    });
789    if let Some(err) = root_err {
790        body["error"] = serde_json::Value::String(err);
791    }
792    json_response(status, &body)
793}
794
795fn trace_handler(state: &State, id: &str) -> Response<std::io::Cursor<Vec<u8>>> {
796    let store = state.store.lock().unwrap();
797    match store.load_trace(id) {
798        Ok(t) => json_response(200, &serde_json::to_value(&t).unwrap()),
799        Err(e) => error_response(404, format!("{e}")),
800    }
801}
802
803fn diff_handler(state: &State, query: &str) -> Response<std::io::Cursor<Vec<u8>>> {
804    let mut a = None;
805    let mut b = None;
806    for kv in query.split('&') {
807        if let Some((k, v)) = kv.split_once('=') {
808            match k { "a" => a = Some(v.to_string()), "b" => b = Some(v.to_string()), _ => {} }
809        }
810    }
811    let (Some(a), Some(b)) = (a, b) else {
812        return error_response(400, "missing a or b query params");
813    };
814    let store = state.store.lock().unwrap();
815    let ta = match store.load_trace(&a) { Ok(t) => t, Err(e) => return error_response(404, format!("a: {e}")) };
816    let tb = match store.load_trace(&b) { Ok(t) => t, Err(e) => return error_response(404, format!("b: {e}")) };
817    match lex_trace::diff_runs(&ta, &tb) {
818        Some(d) => json_response(200, &serde_json::to_value(&d).unwrap()),
819        None => json_response(200, &serde_json::json!({"divergence": null})),
820    }
821}
822
823fn json_to_value(v: &serde_json::Value) -> Value { Value::from_json(v) }
824
825fn value_to_json(v: &Value) -> serde_json::Value { v.to_json() }
826
827#[derive(Deserialize)]
828struct MergeStartReq {
829    src_branch: String,
830    dst_branch: String,
831}
832
833/// `POST /v1/merge/start` (#134) — open a stateful merge between two
834/// branch heads and return the conflicts the agent needs to
835/// resolve. Auto-resolved sigs (one-sided changes, identical
836/// changes both sides) are returned for audit but don't block
837/// commit.
838///
839/// Response: `{ merge_id, src_head, dst_head, lca, conflicts,
840/// auto_resolved_count }`. The session is held in process memory
841/// keyed by `merge_id` for subsequent `resolve` / `commit` calls
842/// (next slices).
843fn merge_start_handler(state: &State, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
844    let req: MergeStartReq = match serde_json::from_str(body) {
845        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
846    };
847    let store = state.store.lock().unwrap();
848    let src_head = match store.get_branch(&req.src_branch) {
849        Ok(Some(b)) => b.head_op,
850        Ok(None) => return error_response(404, format!("unknown src branch `{}`", req.src_branch)),
851        Err(e) => return error_response(500, format!("src branch read: {e}")),
852    };
853    let dst_head = match store.get_branch(&req.dst_branch) {
854        Ok(Some(b)) => b.head_op,
855        Ok(None) => return error_response(404, format!("unknown dst branch `{}`", req.dst_branch)),
856        Err(e) => return error_response(500, format!("dst branch read: {e}")),
857    };
858    let log = match lex_vcs::OpLog::open(store.root()) {
859        Ok(l) => l,
860        Err(e) => return error_response(500, format!("op log: {e}")),
861    };
862    // Caller doesn't choose merge_ids — minted server-side from
863    // wall clock + a per-process counter avoids leaking session
864    // ids' shape into the public surface.
865    let merge_id = mint_merge_id();
866    let session = match MergeSession::start(
867        merge_id.clone(),
868        &log,
869        src_head.as_ref(),
870        dst_head.as_ref(),
871    ) {
872        Ok(s) => s,
873        Err(e) => return error_response(500, format!("merge start: {e}")),
874    };
875    let conflicts: Vec<&lex_vcs::ConflictRecord> = session.remaining_conflicts();
876    let auto_resolved_count = session.auto_resolved.len();
877    let body = serde_json::json!({
878        "merge_id": merge_id,
879        "src_head": session.src_head,
880        "dst_head": session.dst_head,
881        "lca":      session.lca,
882        "conflicts": conflicts,
883        "auto_resolved_count": auto_resolved_count,
884    });
885    drop(conflicts);
886    drop(store);
887    let wrapped = ApiMergeSession {
888        inner: session,
889        src_branch: req.src_branch,
890        dst_branch: req.dst_branch,
891    };
892    state.sessions.lock().unwrap().insert(merge_id, wrapped);
893    json_response(200, &body)
894}
895
896#[derive(Deserialize)]
897struct MergeResolveReq {
898    /// Each entry is `(conflict_id, resolution)`. The resolution is
899    /// the same shape as `lex_vcs::Resolution`'s tagged JSON form
900    /// — `{"kind":"take_ours"}`, `{"kind":"take_theirs"}`,
901    /// `{"kind":"defer"}`, or `{"kind":"custom","op":{...}}`.
902    resolutions: Vec<MergeResolveEntry>,
903}
904
905#[derive(Deserialize)]
906struct MergeResolveEntry {
907    conflict_id: String,
908    resolution: lex_vcs::Resolution,
909}
910
911/// `POST /v1/merge/<id>/resolve` (#134) — submit batched
912/// resolutions against the conflicts surfaced by `merge/start`.
913/// Returns one verdict per input: accepted (recorded against the
914/// session) or rejected (with structured reason). The session
915/// stays alive across calls so an agent can iterate.
916///
917/// Errors:
918/// - 404 if `merge_id` doesn't refer to a live session (a typo
919///   or a session GC'd by a server restart).
920/// - 400 on malformed body.
921fn merge_resolve_handler(
922    state: &State,
923    merge_id: &str,
924    body: &str,
925) -> Response<std::io::Cursor<Vec<u8>>> {
926    let req: MergeResolveReq = match serde_json::from_str(body) {
927        Ok(r) => r, Err(e) => return error_response(400, format!("bad request: {e}")),
928    };
929    let mut sessions = state.sessions.lock().unwrap();
930    let Some(wrapped) = sessions.get_mut(merge_id) else {
931        return error_response(404, format!("unknown merge_id `{merge_id}`"));
932    };
933    let pairs: Vec<(String, lex_vcs::Resolution)> = req.resolutions.into_iter()
934        .map(|e| (e.conflict_id, e.resolution))
935        .collect();
936    let verdicts = wrapped.inner.resolve(pairs);
937    let remaining: Vec<&lex_vcs::ConflictRecord> = wrapped.inner.remaining_conflicts();
938    let body = serde_json::json!({
939        "verdicts": verdicts,
940        "remaining_conflicts": remaining,
941    });
942    json_response(200, &body)
943}
944
945/// `POST /v1/merge/<id>/commit` (#134) — finalize a merge
946/// session. Builds a `Merge` op from the auto-resolved sigs +
947/// the conflict resolutions, applies it to the dst branch, and
948/// returns the new head op id. The session is dropped on
949/// success; the caller would re-run `merge/start` to land
950/// further changes.
951///
952/// Errors:
953/// - 404: unknown `merge_id`.
954/// - 422: conflicts remaining (pass `Defer` or just don't
955///   resolve a conflict and you land here). Body carries the
956///   list so the caller knows which still need attention.
957/// - 422: a `Custom` resolution was used. The data layer
958///   supports them but landing them via HTTP needs an extra
959///   pass to apply the custom op against the dst branch
960///   first; deferred to a follow-up slice. Use TakeOurs /
961///   TakeTheirs for now.
962/// - 500: filesystem error while landing the merge op.
963fn merge_commit_handler(
964    state: &State,
965    merge_id: &str,
966) -> Response<std::io::Cursor<Vec<u8>>> {
967    use std::collections::BTreeMap;
968    let wrapped = match state.sessions.lock().unwrap().remove(merge_id) {
969        Some(w) => w,
970        None => return error_response(404, format!("unknown merge_id `{merge_id}`")),
971    };
972    let dst_branch = wrapped.dst_branch.clone();
973    let src_head = wrapped.inner.src_head.clone();
974    let dst_head = wrapped.inner.dst_head.clone();
975    let auto_resolved = wrapped.inner.auto_resolved.clone();
976
977    // Translate auto-resolved + resolutions into the StageTransition::Merge
978    // entries map. Only sigs whose head changes relative to dst go in.
979    let mut entries: BTreeMap<lex_vcs::SigId, Option<lex_vcs::StageId>> = BTreeMap::new();
980
981    // Auto-resolved: only `Src` (one-sided change on src) modifies dst.
982    for outcome in &auto_resolved {
983        if let lex_vcs::MergeOutcome::Src { sig_id, stage_id } = outcome {
984            entries.insert(sig_id.clone(), stage_id.clone());
985        }
986    }
987
988    // Conflict resolutions.
989    let resolved = match wrapped.inner.commit() {
990        Ok(r) => r,
991        Err(lex_vcs::CommitError::ConflictsRemaining(ids)) => {
992            // Re-insert isn't possible since we removed above; the
993            // caller will need to re-start. That's acceptable: a
994            // commit-with-unresolved-conflicts is operator error.
995            return error_with_detail(
996                422,
997                "conflicts remaining",
998                serde_json::json!({"unresolved": ids}),
999            );
1000        }
1001    };
1002
1003    for (conflict_id, resolution) in resolved {
1004        match resolution {
1005            lex_vcs::Resolution::TakeOurs => {
1006                // Dst already has its head. No entry needed.
1007            }
1008            lex_vcs::Resolution::TakeTheirs => {
1009                // Find the conflict's `theirs` stage_id in the
1010                // session snapshot. We don't have direct access to
1011                // it post-commit (commit consumed the session); but
1012                // we can reconstruct from `auto_resolved` plus the
1013                // session's pre-commit conflict map. Since we
1014                // already moved the inner session, the cleanest fix
1015                // for this slice is to rebuild from the on-disk
1016                // graph: walk src_head, find the latest stage for
1017                // the conflict's sig.
1018                match resolve_take_theirs(state, &src_head, &conflict_id) {
1019                    Ok(stage_id) => {
1020                        entries.insert(conflict_id.clone(), stage_id);
1021                    }
1022                    Err(e) => return error_response(500, format!("resolve take_theirs: {e}")),
1023                }
1024            }
1025            lex_vcs::Resolution::Custom { op } => {
1026                // The agent's brand-new op carries the merge target
1027                // in its kind (e.g. ModifyBody.to_stage_id). The op
1028                // itself isn't separately recorded in the log here
1029                // — its head-map effect is folded into the merge
1030                // op's entries map. Callers that want the op as a
1031                // first-class history entry should publish it via
1032                // /v1/publish first and submit a TakeTheirs/TakeOurs
1033                // resolution against the resulting head.
1034                match op.kind.merge_target() {
1035                    Some((sig, stage)) => {
1036                        if sig != conflict_id {
1037                            return error_with_detail(
1038                                422,
1039                                "custom op targets a different sig than the conflict",
1040                                serde_json::json!({
1041                                    "conflict_id": conflict_id,
1042                                    "op_targets": sig,
1043                                }),
1044                            );
1045                        }
1046                        entries.insert(conflict_id, stage);
1047                    }
1048                    None => {
1049                        return error_with_detail(
1050                            422,
1051                            "custom op kind doesn't yield a single sig→stage delta",
1052                            serde_json::json!({
1053                                "conflict_id": conflict_id,
1054                                "kind": serde_json::to_value(&op.kind).unwrap_or(serde_json::Value::Null),
1055                            }),
1056                        );
1057                    }
1058                }
1059            }
1060            lex_vcs::Resolution::Defer => {
1061                // Unreachable: commit() rejects Defer above.
1062                return error_response(500, "internal: Defer slipped past commit gate");
1063            }
1064        }
1065    }
1066
1067    let resolved_count = entries.len();
1068    let mut parents: Vec<lex_vcs::OpId> = Vec::new();
1069    if let Some(d) = dst_head { parents.push(d); }
1070    if let Some(s) = src_head { parents.push(s); }
1071    let op = lex_vcs::Operation::new(
1072        lex_vcs::OperationKind::Merge { resolved: resolved_count },
1073        parents,
1074    );
1075    let transition = lex_vcs::StageTransition::Merge { entries };
1076    let store = state.store.lock().unwrap();
1077    match store.apply_operation(&dst_branch, op, transition) {
1078        Ok(new_head_op) => json_response(200, &serde_json::json!({
1079            "new_head_op": new_head_op,
1080            "dst_branch": dst_branch,
1081        })),
1082        Err(e) => write_error_response("apply merge op", e),
1083    }
1084}
1085
1086/// Walk the op log from `src_head` backwards to find the latest
1087/// stage assigned to `sig`. Used by the commit handler to figure
1088/// out what stage `TakeTheirs` should land. `Ok(None)` means src
1089/// removed the sig.
1090fn resolve_take_theirs(
1091    state: &State,
1092    src_head: &Option<lex_vcs::OpId>,
1093    sig: &lex_vcs::SigId,
1094) -> std::io::Result<Option<lex_vcs::StageId>> {
1095    let store = state.store.lock().unwrap();
1096    let log = lex_vcs::OpLog::open(store.root())?;
1097    let Some(head) = src_head.as_ref() else { return Ok(None); };
1098    // Walk forward from root → head, replaying each op's transition
1099    // for `sig`; the last assignment wins.
1100    let mut current: Option<lex_vcs::StageId> = None;
1101    for record in log.walk_forward(head, None)? {
1102        match &record.produces {
1103            lex_vcs::StageTransition::Create { sig_id, stage_id }
1104                if sig_id == sig => { current = Some(stage_id.clone()); }
1105            lex_vcs::StageTransition::Replace { sig_id, to, .. }
1106                if sig_id == sig => { current = Some(to.clone()); }
1107            lex_vcs::StageTransition::Remove { sig_id, .. }
1108                if sig_id == sig => { current = None; }
1109            lex_vcs::StageTransition::Rename { from, to, body_stage_id }
1110                if from == sig || to == sig => {
1111                if from == sig { current = None; }
1112                if to == sig   { current = Some(body_stage_id.clone()); }
1113            }
1114            lex_vcs::StageTransition::Merge { entries } => {
1115                if let Some(opt) = entries.get(sig) {
1116                    current = opt.clone();
1117                }
1118            }
1119            _ => {}
1120        }
1121    }
1122    Ok(current)
1123}
1124
1125fn mint_merge_id() -> MergeSessionId {
1126    use std::sync::atomic::{AtomicU64, Ordering};
1127    static COUNTER: AtomicU64 = AtomicU64::new(0);
1128    let nanos = SystemTime::now()
1129        .duration_since(UNIX_EPOCH)
1130        .map(|d| d.as_nanos())
1131        .unwrap_or(0);
1132    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1133    format!("merge_{nanos:x}_{n:x}")
1134}
1135
1136// ---- #242: append-only sync ---------------------------------------
1137
1138/// `POST /v1/ops/batch` (#242). Server endpoint for `lex op push`.
1139///
1140/// Body: a JSON array of `OperationRecord`s. The handler validates
1141/// DAG integrity by checking that every op's `parents` either
1142/// already exist on the remote *or* appear earlier in the same
1143/// batch. This lets a client send a topologically-ordered slice
1144/// without first probing for what's already there.
1145///
1146/// Response shape:
1147///
1148/// ```json
1149/// { "received": N, "added": M, "skipped": (N-M), "added_ids": [...] }
1150/// ```
1151///
1152/// Failure modes:
1153///
1154/// * `400` — body isn't a JSON array of op records.
1155/// * `422` with `{ "error": "MissingParent", "detail": { "op_id":
1156///   ..., "missing_parent": ... } }` if a parent is unreachable.
1157///   The whole batch is rejected; nothing is persisted. The client
1158///   should backfill the missing op and retry.
1159/// * `409` if the supplied `op_id` doesn't match the canonical
1160///   hash of the record's payload — content addressing must hold
1161///   over the wire.
1162///
1163/// Idempotency: a record whose `op_id` already exists is silently
1164/// skipped (not added, not rejected). Pushing the same payload
1165/// twice is `received == N, added == 0` on the second call.
1166pub(crate) fn ops_batch_handler(state: &State, body: &str)
1167    -> Response<std::io::Cursor<Vec<u8>>>
1168{
1169    let records: Vec<lex_vcs::OperationRecord> = match serde_json::from_str(body) {
1170        Ok(r) => r,
1171        Err(e) => return error_response(400,
1172            format!("body must be a JSON array of OperationRecord: {e}")),
1173    };
1174    let store = state.store.lock().unwrap();
1175    let log = match lex_vcs::OpLog::open(store.root()) {
1176        Ok(l) => l,
1177        Err(e) => return error_response(500, format!("opening op log: {e}")),
1178    };
1179
1180    // Validate every record before persisting any of them.
1181    //
1182    // 1. Content-addressing: the supplied `op_id` must match the
1183    //    canonical hash of `record.op`. Otherwise the client is
1184    //    sending a forged or corrupted record.
1185    // 2. DAG integrity: every parent must either already exist in
1186    //    the local log OR appear earlier in this batch.
1187    let mut batch_ids: std::collections::BTreeSet<lex_vcs::OpId> =
1188        std::collections::BTreeSet::new();
1189    for rec in &records {
1190        let expected = rec.op.op_id();
1191        if expected != rec.op_id {
1192            return error_with_detail(409, "OpIdMismatch", serde_json::json!({
1193                "supplied": rec.op_id,
1194                "expected": expected,
1195            }));
1196        }
1197        for parent in &rec.op.parents {
1198            let known = match log.get(parent) {
1199                Ok(Some(_)) => true,
1200                Ok(None) => false,
1201                Err(e) => return error_response(500, format!("op log read: {e}")),
1202            };
1203            if !known && !batch_ids.contains(parent) {
1204                return error_with_detail(422, "MissingParent", serde_json::json!({
1205                    "op_id": rec.op_id,
1206                    "missing_parent": parent,
1207                }));
1208            }
1209        }
1210        batch_ids.insert(rec.op_id.clone());
1211    }
1212
1213    // Persist. `OpLog::put` is idempotent so a re-push is a no-op
1214    // for already-present records.
1215    let mut added = 0usize;
1216    let mut added_ids: Vec<&lex_vcs::OpId> = Vec::new();
1217    for rec in &records {
1218        let already_present = matches!(log.get(&rec.op_id), Ok(Some(_)));
1219        match log.put(rec) {
1220            Ok(()) => {
1221                if !already_present {
1222                    added += 1;
1223                    added_ids.push(&rec.op_id);
1224                }
1225            }
1226            Err(e) => return error_response(500, format!("op log write: {e}")),
1227        }
1228    }
1229
1230    json_response(200, &serde_json::json!({
1231        "received": records.len(),
1232        "added": added,
1233        "skipped": records.len() - added,
1234        "added_ids": added_ids,
1235    }))
1236}
1237
1238/// `POST /v1/attestations/batch` (#242). Server endpoint for `lex
1239/// attest push`.
1240///
1241/// Body: a JSON array of `Attestation`s. Validates that each
1242/// attestation's `op_id` (when set) refers to an op that already
1243/// exists on the remote — `attestation_id` is then re-derivable
1244/// from the canonical form, so cross-store dedup just works.
1245///
1246/// Response: same shape as `ops_batch_handler` but `added_ids` is
1247/// the list of accepted `attestation_id`s.
1248///
1249/// Failure modes:
1250///
1251/// * `400` for malformed JSON.
1252/// * `422` with `{ "error": "UnknownOp", "detail": { ... } }` if
1253///   an attestation's `op_id` references an op the remote doesn't
1254///   know about. Whole batch rejected.
1255/// * `409` `AttestationIdMismatch` if the supplied id doesn't
1256///   match the canonical hash.
1257///
1258/// Idempotency: same as the ops endpoint — content-addressed dedup.
1259pub(crate) fn attestations_batch_handler(state: &State, body: &str)
1260    -> Response<std::io::Cursor<Vec<u8>>>
1261{
1262    let attestations: Vec<lex_vcs::Attestation> = match serde_json::from_str(body) {
1263        Ok(a) => a,
1264        Err(e) => return error_response(400,
1265            format!("body must be a JSON array of Attestation: {e}")),
1266    };
1267    let store = state.store.lock().unwrap();
1268    let log = match store.attestation_log() {
1269        Ok(l) => l,
1270        Err(e) => return error_response(500, format!("opening attestation log: {e}")),
1271    };
1272    let op_log = match lex_vcs::OpLog::open(store.root()) {
1273        Ok(l) => l,
1274        Err(e) => return error_response(500, format!("opening op log: {e}")),
1275    };
1276
1277    // Validate before persisting any record.
1278    for att in &attestations {
1279        // Content-addressing: re-derive attestation_id from the
1280        // payload and reject mismatches.
1281        let expected = lex_vcs::Attestation::with_timestamp(
1282            att.stage_id.clone(),
1283            att.op_id.clone(),
1284            att.intent_id.clone(),
1285            att.kind.clone(),
1286            att.result.clone(),
1287            att.produced_by.clone(),
1288            att.cost.clone(),
1289            att.timestamp,
1290        ).attestation_id;
1291        if expected != att.attestation_id {
1292            return error_with_detail(409, "AttestationIdMismatch", serde_json::json!({
1293                "supplied": att.attestation_id,
1294                "expected": expected,
1295            }));
1296        }
1297        // The op_id field, if set, must point at an op the remote
1298        // knows about. Without this check, attestations would
1299        // dangle into a future sync that never lands the op.
1300        if let Some(op_id) = &att.op_id {
1301            match op_log.get(op_id) {
1302                Ok(Some(_)) => {}
1303                Ok(None) => return error_with_detail(422, "UnknownOp", serde_json::json!({
1304                    "attestation_id": att.attestation_id,
1305                    "op_id": op_id,
1306                })),
1307                Err(e) => return error_response(500, format!("op log read: {e}")),
1308            }
1309        }
1310    }
1311
1312    // Persist. `AttestationLog::put` is idempotent on
1313    // `attestation_id` and the by-stage index is rewritten as a
1314    // marker file, also idempotent.
1315    let mut added = 0usize;
1316    let mut added_ids: Vec<&lex_vcs::AttestationId> = Vec::new();
1317    for att in &attestations {
1318        let already_present = matches!(log.get(&att.attestation_id), Ok(Some(_)));
1319        match log.put(att) {
1320            Ok(()) => {
1321                if !already_present {
1322                    added += 1;
1323                    added_ids.push(&att.attestation_id);
1324                }
1325            }
1326            Err(e) => return error_response(500, format!("attestation log write: {e}")),
1327        }
1328    }
1329
1330    json_response(200, &serde_json::json!({
1331        "received": attestations.len(),
1332        "added": added,
1333        "skipped": attestations.len() - added,
1334        "added_ids": added_ids,
1335    }))
1336}
1337
1338/// `GET /v1/branches/<name>/head` (#242 follow-up). Probe endpoint
1339/// the `lex op push` client uses to discover the remote head before
1340/// computing a delta against `OpLog::ops_since`.
1341///
1342/// Response: `{ "branch": "main", "head_op": Option<OpId> }`.
1343/// Returns 200 even when the branch doesn't exist locally — the
1344/// answer in that case is `head_op: null`, which is the right
1345/// signal for "send everything you have."
1346pub(crate) fn branch_head_handler(state: &State, name: &str)
1347    -> Response<std::io::Cursor<Vec<u8>>>
1348{
1349    let store = state.store.lock().unwrap();
1350    let head = match store.get_branch(name) {
1351        Ok(Some(b)) => b.head_op,
1352        Ok(None) => None,
1353        Err(e) => return error_response(500, format!("get_branch: {e}")),
1354    };
1355    json_response(200, &serde_json::json!({
1356        "branch": name,
1357        "head_op": head,
1358    }))
1359}
1360
1361/// `GET /v1/ops/since?after=<op_id>&branch=<name>&limit=<n>` (#260).
1362/// Server endpoint for `lex op pull`.
1363///
1364/// Returns a JSON array of `OperationRecord`s reachable from
1365/// `branch.head_op` but not from `<after>`, sorted **oldest-first**
1366/// so the client can apply them in topological order without
1367/// re-sorting. Empty array when:
1368///
1369/// * The branch doesn't exist on the remote.
1370/// * The branch's `head_op` is `None`.
1371/// * `after == branch.head_op` (caller is already at the remote's head).
1372/// * `after` is *ahead of* the remote's head (caller is past the
1373///   remote — the symmetric "remote behind" case from #260).
1374///
1375/// `branch` defaults to `main`. `limit` caps the response — useful
1376/// for chunked pulls of large gaps; clients re-issue with the next
1377/// `after` once the prefix has landed.
1378///
1379/// Failure modes:
1380///
1381/// * `400` if the query string is malformed.
1382/// * `200` with `[]` for any of the empty-result cases above. "Caller
1383///   is already up to date" is a normal answer, not an error.
1384pub(crate) fn ops_since_handler(state: &State, query: &str)
1385    -> Response<std::io::Cursor<Vec<u8>>>
1386{
1387    let mut after: Option<String> = None;
1388    let mut branch = String::from("main");
1389    let mut limit: Option<usize> = None;
1390    for kv in query.split('&') {
1391        let Some((k, v)) = kv.split_once('=') else { continue };
1392        match k {
1393            "after" => after = Some(v.to_string()),
1394            "branch" => branch = v.to_string(),
1395            "limit" => {
1396                limit = Some(match v.parse::<usize>() {
1397                    Ok(n) => n,
1398                    Err(_) => return error_response(400,
1399                        format!("limit must be a positive integer, got `{v}`")),
1400                });
1401            }
1402            _ => {}
1403        }
1404    }
1405
1406    let store = state.store.lock().unwrap();
1407    let log = match lex_vcs::OpLog::open(store.root()) {
1408        Ok(l) => l,
1409        Err(e) => return error_response(500, format!("opening op log: {e}")),
1410    };
1411    let head = match store.get_branch(&branch) {
1412        Ok(Some(b)) => b.head_op,
1413        Ok(None) => None,
1414        Err(e) => return error_response(500, format!("get_branch: {e}")),
1415    };
1416    let Some(head) = head else {
1417        return json_response(200, &serde_json::json!([]));
1418    };
1419
1420    let ops_since = match log.ops_since(&head, after.as_ref()) {
1421        Ok(o) => o,
1422        Err(e) => return error_response(500, format!("ops_since: {e}")),
1423    };
1424    // ops_since walks newest-first; reverse so the client receives
1425    // oldest-first and can apply them in topological order with
1426    // `OpLog::put` straight through.
1427    let mut ops = ops_since;
1428    ops.reverse();
1429    if let Some(n) = limit {
1430        ops.truncate(n);
1431    }
1432
1433    json_response(200, &serde_json::to_value(&ops).unwrap_or_default())
1434}
1435
1436/// `GET /v1/attestations/since?after-op=<op_id>&limit=<n>` (#260).
1437/// Mirror of `ops_since_handler` for the attestation log.
1438///
1439/// Returns attestations whose `op_id` field is reachable from
1440/// **any** branch's head — not just one — and not in `after_op`'s
1441/// ancestry. The cross-branch fan-out matches the push side:
1442/// attestations are stage-keyed, not branch-keyed, so a single
1443/// "since this op" filter is the right shape.
1444///
1445/// Attestations with `op_id: None` (e.g. `Override`,
1446/// `ProducerBlock`) are always included — the cutoff doesn't apply.
1447/// `--limit` caps the response.
1448pub(crate) fn attestations_since_handler(state: &State, query: &str)
1449    -> Response<std::io::Cursor<Vec<u8>>>
1450{
1451    let mut after_op: Option<String> = None;
1452    let mut limit: Option<usize> = None;
1453    for kv in query.split('&') {
1454        let Some((k, v)) = kv.split_once('=') else { continue };
1455        match k {
1456            "after-op" => after_op = Some(v.to_string()),
1457            "limit" => {
1458                limit = Some(match v.parse::<usize>() {
1459                    Ok(n) => n,
1460                    Err(_) => return error_response(400,
1461                        format!("limit must be a positive integer, got `{v}`")),
1462                });
1463            }
1464            _ => {}
1465        }
1466    }
1467
1468    let store = state.store.lock().unwrap();
1469    let log = match store.attestation_log() {
1470        Ok(l) => l,
1471        Err(e) => return error_response(500, format!("opening attestation log: {e}")),
1472    };
1473
1474    // Build the exclude set: every op_id reachable from `after_op`,
1475    // inclusive. Attestations whose op_id is in this set were
1476    // already known to the caller.
1477    let exclude: std::collections::BTreeSet<String> = match &after_op {
1478        None => std::collections::BTreeSet::new(),
1479        Some(cutoff) => {
1480            let op_log = match lex_vcs::OpLog::open(store.root()) {
1481                Ok(l) => l,
1482                Err(e) => return error_response(500, format!("opening op log: {e}")),
1483            };
1484            match op_log.walk_back(cutoff, None) {
1485                Ok(records) => records.into_iter().map(|r| r.op_id).collect(),
1486                Err(_) => {
1487                    // Cutoff op doesn't exist on this remote. Treat
1488                    // as "no exclude" — caller will get every
1489                    // attestation. They may dedup client-side.
1490                    std::collections::BTreeSet::new()
1491                }
1492            }
1493        }
1494    };
1495
1496    let all = match log.list_all() {
1497        Ok(v) => v,
1498        Err(e) => return error_response(500, format!("listing attestations: {e}")),
1499    };
1500    let mut filtered: Vec<lex_vcs::Attestation> = all
1501        .into_iter()
1502        .filter(|a| match &a.op_id {
1503            Some(op_id) => !exclude.contains(op_id),
1504            // No op_id = doesn't participate in the cutoff; always
1505            // ship it on the first pull, server-side idempotency
1506            // dedupes on the client.
1507            None => true,
1508        })
1509        .collect();
1510    // Stable order: oldest-first by `timestamp`, then by
1511    // `attestation_id` for ties. Lets the client land them
1512    // deterministically.
1513    filtered.sort_by(|a, b| {
1514        a.timestamp.cmp(&b.timestamp)
1515            .then_with(|| a.attestation_id.cmp(&b.attestation_id))
1516    });
1517    if let Some(n) = limit {
1518        filtered.truncate(n);
1519    }
1520
1521    json_response(200, &serde_json::to_value(&filtered).unwrap_or_default())
1522}
1523
1524// ── Package concept (#4) ────────────────────────────────────────────────────
1525
1526/// Per-version record stored at `{store_root}/packages/{name}/{version}.json`.
1527#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1528struct PkgRecord {
1529    name: String,
1530    version: String,
1531    head_op: Option<String>,
1532    published_at: u64,
1533    /// Function names introduced or updated by this version (for retract).
1534    function_names: Vec<String>,
1535    /// Raw op JSON from each file in this publish.
1536    ops: Vec<serde_json::Value>,
1537}
1538
1539/// Whether a package is reachable without authentication.
1540///
1541/// Per-package (applies across all versions), GitHub-style: an org's
1542/// store can hold a mix of public and private packages. Defaults to
1543/// `Private`, so an index written before this field existed (or any
1544/// freshly published package) is private until an owner opts in.
1545#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
1546#[serde(rename_all = "lowercase")]
1547pub enum Visibility {
1548    #[default]
1549    Private,
1550    Public,
1551}
1552
1553/// Index stored at `{store_root}/packages/{name}/index.json`.
1554///
1555/// Tracks which versions have been published and which is "latest", so
1556/// consumers can resolve `{name}@latest` without listing every file.
1557#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
1558struct PkgIndex {
1559    /// The most recently published version string (human label, not OpId).
1560    latest: Option<String>,
1561    /// All published versions, newest-last.
1562    versions: Vec<PkgVersionSummary>,
1563    /// Public/private flag. `#[serde(default)]` keeps pre-existing
1564    /// `index.json` files (which lack the field) deserializing as
1565    /// `Private`.
1566    #[serde(default)]
1567    visibility: Visibility,
1568}
1569
1570#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1571struct PkgVersionSummary {
1572    version: String,
1573    head_op: Option<String>,
1574    published_at: u64,
1575}
1576
1577fn pkg_name_dir(root: &std::path::Path, name: &str) -> PathBuf {
1578    root.join("packages").join(name)
1579}
1580
1581fn pkg_index_path(root: &std::path::Path, name: &str) -> PathBuf {
1582    pkg_name_dir(root, name).join("index.json")
1583}
1584
1585fn pkg_version_path(root: &std::path::Path, name: &str, version: &str) -> PathBuf {
1586    pkg_name_dir(root, name).join(format!("{version}.json"))
1587}
1588
1589fn pkg_archive_path(root: &std::path::Path, name: &str, version: &str) -> PathBuf {
1590    pkg_name_dir(root, name).join(format!("{version}.tar.gz"))
1591}
1592
1593fn load_pkg_index(root: &std::path::Path, name: &str) -> Option<PkgIndex> {
1594    let bytes = std::fs::read(pkg_index_path(root, name)).ok()?;
1595    serde_json::from_slice(&bytes).ok()
1596}
1597
1598fn load_pkg_record(root: &std::path::Path, name: &str, version: &str) -> Option<PkgRecord> {
1599    let bytes = std::fs::read(pkg_version_path(root, name, version)).ok()?;
1600    serde_json::from_slice(&bytes).ok()
1601}
1602
1603fn load_latest_pkg_record(root: &std::path::Path, name: &str) -> Option<PkgRecord> {
1604    let index = load_pkg_index(root, name)?;
1605    let latest = index.latest.clone()?;
1606    load_pkg_record(root, name, &latest)
1607}
1608
1609/// A package is public iff its index exists and is marked `Public`.
1610/// A missing index (unknown package) is treated as private, so the
1611/// public surface never distinguishes "private" from "does not exist".
1612fn pkg_is_public(root: &std::path::Path, name: &str) -> bool {
1613    load_pkg_index(root, name).map(|i| i.visibility) == Some(Visibility::Public)
1614}
1615
1616/// Reject package/version path segments that could escape the
1617/// `packages/` directory or otherwise aren't valid names. Mirrors the
1618/// tenant-id guard's spirit (defense in depth — lex-hub validates the
1619/// tenant, this validates the package/version).
1620fn valid_pkg_segment(s: &str) -> bool {
1621    !s.is_empty()
1622        && s.len() <= 128
1623        && s != "."
1624        && s != ".."
1625        && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
1626}
1627
1628#[derive(Deserialize)]
1629struct VisibilityReq {
1630    visibility: Visibility,
1631}
1632
1633/// `PUT /v1/pkg/{name}/visibility` — set a package public or private.
1634///
1635/// Authorization is implicit: this runs against a single tenant's store,
1636/// and the caller only reaches *this* store because the front door
1637/// (lex-hub) authenticated their token and selected it. A caller can
1638/// therefore only change visibility of packages they own.
1639fn pkg_set_visibility_handler(
1640    state: &State,
1641    name: &str,
1642    body: &str,
1643) -> Response<std::io::Cursor<Vec<u8>>> {
1644    if !valid_pkg_segment(name) {
1645        return error_response(400, format!("invalid package name {name:?}"));
1646    }
1647    let req: VisibilityReq = match serde_json::from_str(body) {
1648        Ok(r) => r,
1649        Err(e) => return error_response(400, format!("bad request: {e}")),
1650    };
1651    let mut index = match load_pkg_index(&state.root, name) {
1652        Some(i) => i,
1653        None => return error_response(404, format!("package {name:?} not found")),
1654    };
1655    index.visibility = req.visibility;
1656    let bytes = serde_json::to_vec_pretty(&index).unwrap_or_default();
1657    match std::fs::write(pkg_index_path(&state.root, name), bytes) {
1658        Ok(()) => json_response(
1659            200,
1660            &serde_json::json!({ "name": name, "visibility": index.visibility }),
1661        ),
1662        Err(e) => error_response(500, format!("write index: {e}")),
1663    }
1664}
1665
1666/// Names of the tenant's PUBLIC packages, sorted. Pure (filesystem in,
1667/// names out) so the visibility filter is unit-testable.
1668fn public_pkg_names(root: &std::path::Path) -> Vec<String> {
1669    list_pkg_names(root)
1670        .into_iter()
1671        .filter(|name| pkg_is_public(root, name))
1672        .collect()
1673}
1674
1675/// `GET /v1/public/<tenant>` (org page) — list only the tenant's PUBLIC
1676/// packages (latest version of each). Private packages are omitted, so
1677/// their existence is not revealed.
1678fn public_pkg_list_handler(state: &State) -> Response<std::io::Cursor<Vec<u8>>> {
1679    let packages: Vec<serde_json::Value> = public_pkg_names(&state.root)
1680        .iter()
1681        .filter_map(|name| {
1682            let r = load_latest_pkg_record(&state.root, name)?;
1683            Some(serde_json::json!({
1684                "name": r.name,
1685                "version": r.version,
1686                "head_op": r.head_op,
1687                "published_at": r.published_at,
1688            }))
1689        })
1690        .collect();
1691    json_response(200, &serde_json::json!({ "packages": packages }))
1692}
1693
1694/// A resolved public read target. Separated from response formatting so
1695/// the routing/guard logic is unit-testable without constructing HTTP
1696/// responses. `Err(status)` is a guard failure (405 = non-GET,
1697/// 404 = invalid/unknown route).
1698#[derive(Debug, PartialEq, Eq)]
1699enum PublicTarget {
1700    List,
1701    Latest(String),
1702    Versions(String),
1703    Head(String),
1704    Version(String, String),
1705    Archive(String, String),
1706}
1707
1708impl PublicTarget {
1709    /// The package name a target refers to, if any (`List` has none).
1710    fn pkg_name(&self) -> Option<&str> {
1711        match self {
1712            PublicTarget::List => None,
1713            PublicTarget::Latest(n)
1714            | PublicTarget::Versions(n)
1715            | PublicTarget::Head(n)
1716            | PublicTarget::Version(n, _)
1717            | PublicTarget::Archive(n, _) => Some(n),
1718        }
1719    }
1720}
1721
1722/// Pure routing decision for `/v1/public/<tenant>` reads. `path` is the
1723/// portion after the tenant (leading `/` ok). Enforces GET-only and
1724/// per-segment name validation; does NOT consult the store (visibility
1725/// is checked by the caller, which has store access).
1726fn resolve_public(method: &Method, path: &str) -> Result<PublicTarget, u16> {
1727    if !matches!(method, Method::Get) {
1728        return Err(405);
1729    }
1730    let rest = path.trim_matches('/');
1731    if rest.is_empty() {
1732        return Ok(PublicTarget::List);
1733    }
1734    let segs: Vec<&str> = rest.split('/').collect();
1735    if !segs.iter().all(|s| valid_pkg_segment(s)) {
1736        return Err(404);
1737    }
1738    match segs.as_slice() {
1739        [n] => Ok(PublicTarget::Latest(n.to_string())),
1740        [n, "versions"] => Ok(PublicTarget::Versions(n.to_string())),
1741        [n, "head"] => Ok(PublicTarget::Head(n.to_string())),
1742        [n, v, "archive"] => Ok(PublicTarget::Archive(n.to_string(), v.to_string())),
1743        [n, v] => Ok(PublicTarget::Version(n.to_string(), v.to_string())),
1744        _ => Err(404),
1745    }
1746}
1747
1748/// Unauthenticated, read-only access to **public** packages in `state`'s
1749/// store. `path` is the portion of the URL after `/v1/public/<tenant>`
1750/// (with a leading `/`); lex-hub resolves `<tenant>` → store and calls
1751/// this. Visibility gating, GET-only enforcement, and segment validation
1752/// all live here so the whole public surface is auditable in one place.
1753///
1754/// Everything served here is package-scoped — manifests and the source
1755/// archive of a public package's own publish — so it cannot leak code
1756/// from a private package that happens to share content-addressed stages
1757/// in the same store.
1758pub fn route_public(
1759    state: &State,
1760    method: &Method,
1761    path: &str,
1762    _query: &str,
1763) -> Response<std::io::Cursor<Vec<u8>>> {
1764    let target = match resolve_public(method, path) {
1765        Ok(t) => t,
1766        Err(405) => return error_response(405, "public read is GET-only"),
1767        Err(_) => return error_response(404, "not found"),
1768    };
1769    // The org listing already filters to public packages itself.
1770    if let PublicTarget::List = target {
1771        return public_pkg_list_handler(state);
1772    }
1773    // Single 404 for both "private" and "absent" — never reveal which.
1774    if let Some(name) = target.pkg_name() {
1775        if !pkg_is_public(&state.root, name) {
1776            return error_response(404, format!("package {name:?} not found"));
1777        }
1778    }
1779    match target {
1780        PublicTarget::List => unreachable!("handled above"),
1781        PublicTarget::Latest(n) => pkg_get_handler(state, &n),
1782        PublicTarget::Versions(n) => pkg_versions_handler(state, &n),
1783        PublicTarget::Head(n) => pkg_head_handler(state, &n),
1784        PublicTarget::Version(n, v) => pkg_get_version_handler(state, &n, &v),
1785        PublicTarget::Archive(n, v) => pkg_archive_handler(state, &n, &v),
1786    }
1787}
1788
1789fn save_pkg_record(
1790    root: &std::path::Path,
1791    record: &PkgRecord,
1792    archive: &[u8],
1793) -> std::io::Result<()> {
1794    let dir = pkg_name_dir(root, &record.name);
1795    std::fs::create_dir_all(&dir)?;
1796
1797    // Per-version record.
1798    let rec_bytes = serde_json::to_vec_pretty(record).unwrap_or_default();
1799    std::fs::write(pkg_version_path(root, &record.name, &record.version), rec_bytes)?;
1800
1801    // Archive (tar.gz) for the download endpoint.
1802    std::fs::write(pkg_archive_path(root, &record.name, &record.version), archive)?;
1803
1804    // Update the index.
1805    let mut index = load_pkg_index(root, &record.name).unwrap_or_default();
1806    index.latest = Some(record.version.clone());
1807    if !index.versions.iter().any(|v| v.version == record.version) {
1808        index.versions.push(PkgVersionSummary {
1809            version: record.version.clone(),
1810            head_op: record.head_op.clone(),
1811            published_at: record.published_at,
1812        });
1813    }
1814    let idx_bytes = serde_json::to_vec_pretty(&index).unwrap_or_default();
1815    std::fs::write(pkg_index_path(root, &record.name), idx_bytes)
1816}
1817
1818fn list_pkg_names(root: &std::path::Path) -> Vec<String> {
1819    let dir = root.join("packages");
1820    let Ok(entries) = std::fs::read_dir(&dir) else {
1821        return Vec::new();
1822    };
1823    let mut names: Vec<String> = entries
1824        .filter_map(|e| e.ok())
1825        .filter(|e| e.path().is_dir())
1826        .filter_map(|e| e.file_name().into_string().ok())
1827        .collect();
1828    names.sort();
1829    names
1830}
1831
1832fn collect_lex_files(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
1833    let Ok(entries) = std::fs::read_dir(dir) else { return };
1834    let mut entries: Vec<_> = entries.filter_map(|e| e.ok()).collect();
1835    entries.sort_by_key(|e| e.path());
1836    for entry in entries {
1837        let path = entry.path();
1838        if path.is_dir() {
1839            collect_lex_files(&path, out);
1840        } else if path.extension().and_then(|x| x.to_str()) == Some("lex") {
1841            out.push(path);
1842        }
1843    }
1844}
1845
1846/// `POST /v1/pkg/publish` — publish a multi-file package from a `.tar.gz`
1847/// archive containing `lex.toml` and `src/**/*.lex`.
1848fn pkg_publish_handler(state: &State, body: &[u8]) -> Response<std::io::Cursor<Vec<u8>>> {
1849    let tmp = match tempfile::TempDir::new() {
1850        Ok(t) => t,
1851        Err(e) => return error_response(500, format!("create temp dir: {e}")),
1852    };
1853    {
1854        let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(body));
1855        let mut ar = tar::Archive::new(gz);
1856        if let Err(e) = ar.unpack(tmp.path()) {
1857            return error_response(400, format!("unpack archive: {e}"));
1858        }
1859    }
1860
1861    let toml_path = tmp.path().join("lex.toml");
1862    if !toml_path.exists() {
1863        return error_response(400, "archive must contain lex.toml at root");
1864    }
1865    let manifest = match Manifest::load(&toml_path) {
1866        Ok(m) => m,
1867        Err(e) => return error_response(400, format!("lex.toml: {e}")),
1868    };
1869    let (pkg_name, pkg_version) = match &manifest.package {
1870        Some(m) => (m.name.clone(), m.version.clone()),
1871        None => return error_response(400, "lex.toml must have a [package] section"),
1872    };
1873
1874    let src_dir = tmp.path().join("src");
1875    if !src_dir.exists() {
1876        return error_response(400, "archive must contain a src/ directory");
1877    }
1878    let mut lex_files: Vec<PathBuf> = Vec::new();
1879    collect_lex_files(&src_dir, &mut lex_files);
1880    if lex_files.is_empty() {
1881        return error_response(400, "no .lex files found in src/");
1882    }
1883
1884    let store = state.store.lock().unwrap();
1885    let branch = store.current_branch();
1886
1887    let mut all_ops: Vec<serde_json::Value> = Vec::new();
1888    let mut final_head_op: Option<String> = None;
1889    let mut all_function_names: Vec<String> = Vec::new();
1890
1891    for lex_path in &lex_files {
1892        let prog = match load_program(lex_path) {
1893            Ok(p) => p,
1894            Err(e) => return error_response(400, format!("load {}: {e}", lex_path.display())),
1895        };
1896        let mut stages = canonicalize_program(&prog);
1897        if let Err(errs) = lex_types::check_and_rewrite_program(&mut stages) {
1898            return error_with_detail(
1899                422,
1900                format!("type errors in {}", lex_path.display()),
1901                serde_json::to_value(&errs).unwrap(),
1902            );
1903        }
1904
1905        let old_head = match store.branch_head(&branch) {
1906            Ok(h) => h,
1907            Err(e) => return error_response(500, format!("branch_head: {e}")),
1908        };
1909        let old_fns: BTreeMap<String, lex_ast::FnDecl> = old_head.values()
1910            .filter_map(|stg| store.get_ast(stg).ok())
1911            .filter_map(|s| match s {
1912                lex_ast::Stage::FnDecl(fd) => Some((fd.name.clone(), fd)),
1913                _ => None,
1914            })
1915            .collect();
1916        let new_fns: BTreeMap<String, lex_ast::FnDecl> = stages.iter()
1917            .filter_map(|s| match s {
1918                lex_ast::Stage::FnDecl(fd) => Some((fd.name.clone(), fd.clone())),
1919                _ => None,
1920            })
1921            .collect();
1922
1923        for name in new_fns.keys() {
1924            if !all_function_names.contains(name) {
1925                all_function_names.push(name.clone());
1926            }
1927        }
1928
1929        let report = lex_vcs::compute_diff(&old_fns, &new_fns, false);
1930
1931        let file_key = lex_path
1932            .strip_prefix(tmp.path())
1933            .unwrap_or(lex_path)
1934            .display()
1935            .to_string();
1936        let mut new_imports = lex_vcs::ImportMap::new();
1937        {
1938            let entry = new_imports.entry(file_key).or_default();
1939            for s in &stages {
1940                if let lex_ast::Stage::Import(im) = s {
1941                    entry.insert(im.reference.clone());
1942                }
1943            }
1944        }
1945
1946        match store.publish_program(&branch, &stages, &report, &new_imports, false) {
1947            Ok(outcome) => {
1948                let ops_json = serde_json::to_value(&outcome.ops).unwrap_or_default();
1949                if let serde_json::Value::Array(arr) = ops_json {
1950                    all_ops.extend(arr);
1951                }
1952                if let Some(h) = outcome.head_op {
1953                    final_head_op = Some(h);
1954                }
1955            }
1956            Err(lex_store::StoreError::TypeError(errs)) => {
1957                return error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap());
1958            }
1959            Err(e) => return write_error_response("publish_program", e),
1960        }
1961    }
1962
1963    // Reject re-publish of the same (name, version) to keep the op log stable.
1964    if load_pkg_record(&state.root, &pkg_name, &pkg_version).is_some() {
1965        return error_response(
1966            409,
1967            format!(
1968                "package {pkg_name}@{pkg_version} already published; \
1969                 bump the version in lex.toml to publish a new release"
1970            ),
1971        );
1972    }
1973
1974    let now = SystemTime::now()
1975        .duration_since(UNIX_EPOCH)
1976        .map(|d| d.as_secs())
1977        .unwrap_or(0);
1978    let record = PkgRecord {
1979        name: pkg_name.clone(),
1980        version: pkg_version,
1981        head_op: final_head_op.clone(),
1982        published_at: now,
1983        function_names: all_function_names,
1984        ops: all_ops.clone(),
1985    };
1986    if let Err(e) = save_pkg_record(&state.root, &record, body) {
1987        return error_response(500, format!("save package index: {e}"));
1988    }
1989
1990    json_response(200, &serde_json::json!({
1991        "package": pkg_name,
1992        "ops": all_ops,
1993        "head_op": final_head_op,
1994    }))
1995}
1996
1997/// `GET /v1/pkg` — list packages published by this tenant (latest version of each).
1998fn pkg_list_handler(state: &State) -> Response<std::io::Cursor<Vec<u8>>> {
1999    let names = list_pkg_names(&state.root);
2000    let packages: Vec<serde_json::Value> = names.iter()
2001        .filter_map(|name| {
2002            let idx = load_pkg_index(&state.root, name)?;
2003            let latest = idx.latest.as_deref()?;
2004            let r = load_pkg_record(&state.root, name, latest)?;
2005            Some(serde_json::json!({
2006                "name": r.name,
2007                "version": r.version,
2008                "head_op": r.head_op,
2009                "published_at": r.published_at,
2010            }))
2011        })
2012        .collect();
2013    json_response(200, &serde_json::json!({ "packages": packages }))
2014}
2015
2016/// `GET /v1/pkg/{name}` — latest version details for a package.
2017fn pkg_get_handler(state: &State, name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2018    match load_latest_pkg_record(&state.root, name) {
2019        Some(r) => json_response(200, &serde_json::json!({
2020            "name": r.name,
2021            "version": r.version,
2022            "head_op": r.head_op,
2023            "published_at": r.published_at,
2024            "function_names": r.function_names,
2025            "ops": r.ops,
2026        })),
2027        None => error_response(404, format!("package {name:?} not found")),
2028    }
2029}
2030
2031/// `GET /v1/pkg/{name}/versions` — all published versions for a package.
2032fn pkg_versions_handler(state: &State, name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2033    match load_pkg_index(&state.root, name) {
2034        Some(idx) => json_response(200, &serde_json::json!({
2035            "name": name,
2036            "latest": idx.latest,
2037            "versions": idx.versions,
2038        })),
2039        None => error_response(404, format!("package {name:?} not found")),
2040    }
2041}
2042
2043/// `GET /v1/pkg/{name}/{version}` — specific version details.
2044fn pkg_get_version_handler(state: &State, name: &str, version: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2045    match load_pkg_record(&state.root, name, version) {
2046        Some(r) => json_response(200, &serde_json::json!({
2047            "name": r.name,
2048            "version": r.version,
2049            "head_op": r.head_op,
2050            "published_at": r.published_at,
2051            "function_names": r.function_names,
2052            "ops": r.ops,
2053        })),
2054        None => error_response(404, format!("package {name:?}@{version:?} not found")),
2055    }
2056}
2057
2058/// `GET /v1/pkg/{name}/{version}/archive` — download the source tar.gz.
2059fn pkg_archive_handler(state: &State, name: &str, version: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2060    let path = pkg_archive_path(&state.root, name, version);
2061    match std::fs::read(&path) {
2062        Ok(bytes) => Response::from_data(bytes)
2063            .with_status_code(200)
2064            .with_header(
2065                tiny_http::Header::from_bytes(
2066                    &b"Content-Type"[..],
2067                    &b"application/gzip"[..],
2068                )
2069                .unwrap(),
2070            ),
2071        Err(_) => error_response(404, format!("archive for {name:?}@{version:?} not found")),
2072    }
2073}
2074
2075/// `GET /v1/pkg/{name}/head` — head op for a package's latest version.
2076fn pkg_head_handler(state: &State, name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2077    match load_latest_pkg_record(&state.root, name) {
2078        Some(r) => json_response(200, &serde_json::json!({
2079            "name": r.name,
2080            "version": r.version,
2081            "head_op": r.head_op,
2082        })),
2083        None => error_response(404, format!("package {name:?} not found")),
2084    }
2085}
2086
2087/// `DELETE /v1/pkg/{name}` — retract the latest version of a package.
2088fn pkg_delete_handler(state: &State, name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
2089    let record = match load_latest_pkg_record(&state.root, name) {
2090        Some(r) => r,
2091        None => return error_response(404, format!("package {name:?} not found")),
2092    };
2093
2094    let store = state.store.lock().unwrap();
2095    let branch = store.current_branch();
2096
2097    let head = match store.branch_head(&branch) {
2098        Ok(h) => h,
2099        Err(e) => return error_response(500, format!("branch_head: {e}")),
2100    };
2101
2102    // Build old_fns from this package's function names that are still on the branch.
2103    let old_fns: BTreeMap<String, lex_ast::FnDecl> = head.values()
2104        .filter_map(|stage_id| store.get_ast(stage_id).ok())
2105        .filter_map(|s| match s {
2106            lex_ast::Stage::FnDecl(fd)
2107                if record.function_names.contains(&fd.name) => Some((fd.name.clone(), fd)),
2108            _ => None,
2109        })
2110        .collect();
2111
2112    let new_fns: BTreeMap<String, lex_ast::FnDecl> = BTreeMap::new();
2113    let report = lex_vcs::compute_diff(&old_fns, &new_fns, false);
2114    let empty_imports = lex_vcs::ImportMap::new();
2115
2116    match store.publish_program(&branch, &[], &report, &empty_imports, false) {
2117        Ok(outcome) => {
2118            // Remove the version record and archive, then update the index.
2119            let ver = record.version.clone();
2120            let _ = std::fs::remove_file(pkg_version_path(&state.root, name, &ver));
2121            let _ = std::fs::remove_file(pkg_archive_path(&state.root, name, &ver));
2122            // Update index: remove this version, set latest to previous if any.
2123            if let Some(mut idx) = load_pkg_index(&state.root, name) {
2124                idx.versions.retain(|v| v.version != ver);
2125                idx.latest = idx.versions.last().map(|v| v.version.clone());
2126                if idx.versions.is_empty() {
2127                    let _ = std::fs::remove_dir_all(pkg_name_dir(&state.root, name));
2128                } else {
2129                    let bytes = serde_json::to_vec_pretty(&idx).unwrap_or_default();
2130                    let _ = std::fs::write(pkg_index_path(&state.root, name), bytes);
2131                }
2132            }
2133            json_response(200, &serde_json::json!({
2134                "deleted": name,
2135                "version": ver,
2136                "ops": outcome.ops,
2137                "head_op": outcome.head_op,
2138            }))
2139        }
2140        Err(lex_store::StoreError::TypeError(errs)) => {
2141            error_with_detail(422, "type errors", serde_json::to_value(&errs).unwrap())
2142        }
2143        Err(e) => write_error_response("retract package", e),
2144    }
2145}
2146
2147#[cfg(test)]
2148mod policy_ceiling_tests {
2149    use super::*;
2150    use lex_runtime::Policy;
2151    use std::path::PathBuf;
2152
2153    /// A maximally-permissive policy of the kind a malicious caller
2154    /// would put in a `/v1/run` body: every dangerous effect plus
2155    /// fs over `/`.
2156    fn permissive_request() -> Policy {
2157        Policy {
2158            allow_effects: ["io", "fs_read", "fs_write", "net", "proc"]
2159                .iter()
2160                .map(|s| s.to_string())
2161                .collect(),
2162            allow_fs_read: vec![PathBuf::from("/")],
2163            allow_fs_write: vec![PathBuf::from("/")],
2164            allow_net_host: Vec::new(),
2165            allow_proc: Vec::new(),
2166            budget: None,
2167        }
2168    }
2169
2170    #[test]
2171    fn ceiling_drops_effects_the_caller_was_not_granted() {
2172        let ceiling = Policy {
2173            allow_effects: ["io", "time"].iter().map(|s| s.to_string()).collect(),
2174            ..Policy::default()
2175        };
2176        let got = clamp_policy(permissive_request(), &ceiling);
2177        assert!(got.allow_effects.contains("io"));
2178        assert!(!got.allow_effects.contains("proc"), "proc must not survive a ceiling without it");
2179        assert!(!got.allow_effects.contains("fs_write"));
2180        assert!(!got.allow_effects.contains("net"));
2181        // `time` is in the ceiling but not the request → intersection drops it.
2182        assert!(!got.allow_effects.contains("time"));
2183    }
2184
2185    #[test]
2186    fn ceiling_scopes_override_caller_scopes() {
2187        let ceiling = Policy {
2188            allow_effects: ["fs_read"].iter().map(|s| s.to_string()).collect(),
2189            allow_fs_read: vec![PathBuf::from("/srv/tenant")],
2190            ..Policy::default()
2191        };
2192        let got = clamp_policy(permissive_request(), &ceiling);
2193        // Caller asked for "/" but only the ceiling's scope survives —
2194        // an empty/wider caller list can never widen the ceiling.
2195        assert_eq!(got.allow_fs_read, vec![PathBuf::from("/srv/tenant")]);
2196        assert!(got.allow_fs_write.is_empty());
2197        assert!(got.allow_proc.is_empty());
2198        assert!(got.allow_net_host.is_empty());
2199    }
2200
2201    #[test]
2202    fn ceiling_caps_budget_and_prefers_the_smaller() {
2203        // Caller wants unlimited; ceiling caps it.
2204        let mut req = permissive_request();
2205        req.budget = None;
2206        let ceiling = Policy { budget: Some(1_000), ..Policy::default() };
2207        assert_eq!(clamp_policy(req, &ceiling).budget, Some(1_000));
2208
2209        // Caller asks for less than the ceiling → keep the caller's.
2210        let mut req2 = permissive_request();
2211        req2.budget = Some(50);
2212        let ceiling2 = Policy { budget: Some(1_000), ..Policy::default() };
2213        assert_eq!(clamp_policy(req2, &ceiling2).budget, Some(50));
2214    }
2215
2216    #[test]
2217    fn empty_ceiling_is_pure_only() {
2218        let got = clamp_policy(permissive_request(), &Policy::default());
2219        assert!(got.allow_effects.is_empty(), "an empty ceiling grants nothing");
2220        assert!(got.allow_proc.is_empty());
2221        assert!(got.allow_fs_write.is_empty());
2222    }
2223}
2224
2225#[cfg(test)]
2226mod public_read_tests {
2227    use super::*;
2228
2229    /// Write a minimal package (index + per-version record + an archive
2230    /// blob) straight into a temp store, bypassing the publish pipeline.
2231    fn seed_pkg(root: &std::path::Path, name: &str, version: &str) {
2232        let record = PkgRecord {
2233            name: name.to_string(),
2234            version: version.to_string(),
2235            head_op: Some(format!("op-{name}")),
2236            published_at: 1,
2237            function_names: vec![format!("{name}.f")],
2238            ops: vec![],
2239        };
2240        save_pkg_record(root, &record, format!("ARCHIVE:{name}@{version}").as_bytes())
2241            .expect("seed package");
2242    }
2243
2244    #[test]
2245    fn new_package_defaults_to_private() {
2246        let tmp = tempfile::TempDir::new().unwrap();
2247        seed_pkg(tmp.path(), "lex-schema", "0.9.2");
2248        assert!(!pkg_is_public(tmp.path(), "lex-schema"));
2249        // Unknown packages are also "not public" — never distinguished.
2250        assert!(!pkg_is_public(tmp.path(), "does-not-exist"));
2251    }
2252
2253    #[test]
2254    fn set_visibility_round_trips_and_index_persists() {
2255        let tmp = tempfile::TempDir::new().unwrap();
2256        let state = State::open(tmp.path().to_path_buf()).unwrap();
2257        seed_pkg(tmp.path(), "lex-schema", "0.9.2");
2258
2259        let _ = pkg_set_visibility_handler(&state, "lex-schema", r#"{"visibility":"public"}"#);
2260        assert!(pkg_is_public(tmp.path(), "lex-schema"));
2261        // The version list survives the index rewrite (we don't clobber it).
2262        let idx = load_pkg_index(tmp.path(), "lex-schema").unwrap();
2263        assert_eq!(idx.latest.as_deref(), Some("0.9.2"));
2264        assert_eq!(idx.versions.len(), 1);
2265
2266        let _ = pkg_set_visibility_handler(&state, "lex-schema", r#"{"visibility":"private"}"#);
2267        assert!(!pkg_is_public(tmp.path(), "lex-schema"));
2268    }
2269
2270    #[test]
2271    fn set_visibility_on_unknown_package_is_a_noop() {
2272        let tmp = tempfile::TempDir::new().unwrap();
2273        let state = State::open(tmp.path().to_path_buf()).unwrap();
2274        // No package seeded → handler returns 404 and writes nothing.
2275        let _ = pkg_set_visibility_handler(&state, "ghost", r#"{"visibility":"public"}"#);
2276        assert!(load_pkg_index(tmp.path(), "ghost").is_none());
2277    }
2278
2279    #[test]
2280    fn public_listing_omits_private_packages() {
2281        let tmp = tempfile::TempDir::new().unwrap();
2282        let state = State::open(tmp.path().to_path_buf()).unwrap();
2283        seed_pkg(tmp.path(), "pub-pkg", "1.0.0");
2284        seed_pkg(tmp.path(), "priv-pkg", "1.0.0");
2285        let _ = pkg_set_visibility_handler(&state, "pub-pkg", r#"{"visibility":"public"}"#);
2286
2287        let names = public_pkg_names(tmp.path());
2288        assert_eq!(names, vec!["pub-pkg".to_string()]);
2289    }
2290
2291    #[test]
2292    fn resolve_public_maps_routes() {
2293        let get = Method::Get;
2294        assert_eq!(resolve_public(&get, "").unwrap(), PublicTarget::List);
2295        assert_eq!(resolve_public(&get, "/").unwrap(), PublicTarget::List);
2296        assert_eq!(
2297            resolve_public(&get, "/lex-schema").unwrap(),
2298            PublicTarget::Latest("lex-schema".into())
2299        );
2300        assert_eq!(
2301            resolve_public(&get, "/lex-schema/versions").unwrap(),
2302            PublicTarget::Versions("lex-schema".into())
2303        );
2304        assert_eq!(
2305            resolve_public(&get, "/lex-schema/head").unwrap(),
2306            PublicTarget::Head("lex-schema".into())
2307        );
2308        assert_eq!(
2309            resolve_public(&get, "/lex-schema/0.9.2").unwrap(),
2310            PublicTarget::Version("lex-schema".into(), "0.9.2".into())
2311        );
2312        assert_eq!(
2313            resolve_public(&get, "/lex-schema/0.9.2/archive").unwrap(),
2314            PublicTarget::Archive("lex-schema".into(), "0.9.2".into())
2315        );
2316    }
2317
2318    #[test]
2319    fn resolve_public_rejects_bad_method_and_traversal() {
2320        // Non-GET → 405.
2321        assert_eq!(resolve_public(&Method::Put, "/lex-schema"), Err(405));
2322        assert_eq!(resolve_public(&Method::Post, "").err(), Some(405));
2323        // Path traversal / invalid segments → 404, never a filesystem touch.
2324        assert_eq!(resolve_public(&Method::Get, "/.."), Err(404));
2325        assert_eq!(resolve_public(&Method::Get, "/lex-schema/../etc"), Err(404));
2326        assert_eq!(resolve_public(&Method::Get, "/a/b/c/d"), Err(404));
2327        // Slashes elsewhere can't smuggle a deep path: each segment is checked.
2328        assert!(resolve_public(&Method::Get, "/lex schema").is_err());
2329    }
2330}