diff --git a/.agents/skills/uteke-memory/SKILL.md b/.agents/skills/uteke-memory/SKILL.md index 7e36d071..eba1e4d8 100644 --- a/.agents/skills/uteke-memory/SKILL.md +++ b/.agents/skills/uteke-memory/SKILL.md @@ -5,7 +5,7 @@ description: "Persistent memory engine for AI agents via the uteke CLI — remem # Uteke Memory Skill Persistent memory engine for AI agents via the `uteke` CLI. -Version: **0.21.0** — SQLite + HNSW vector index (usearch default, or vecq) + FTS5 hybrid search (RRF k=60); `fusion` (weighted RRF of the vector and hybrid rankings) is the default recall strategy since 0.16.0. `unsafe_code = "forbid"` workspace-wide. +Version: **0.21.1** — SQLite + HNSW vector index (usearch default, or vecq) + FTS5 hybrid search (RRF k=60); `fusion` (weighted RRF of the vector and hybrid rankings) is the default recall strategy since 0.16.0. `unsafe_code = "forbid"` workspace-wide. > This skill ships with each release — its version tracks the CLI version > (a CI gate fails when they drift, see `skill-version-parity` test). diff --git a/.github/workflows/release-signature-reminder.yml b/.github/workflows/release-signature-reminder.yml new file mode 100644 index 00000000..e8ba80a0 --- /dev/null +++ b/.github/workflows/release-signature-reminder.yml @@ -0,0 +1,47 @@ +name: Release Signature Reminder + +# Opens (and re-pings) a "Sign release vX.Y.Z (minisign)" issue while the latest +# release has no checksums-sha256.txt.minisig, and closes it once the signature +# is uploaded. It signs nothing and holds no signing secret: the minisign key +# stays offline (scripts/sign-release.sh, AGENTS.md "Release procedure" step 6). +# +# workflow_run is used because a release created with GITHUB_TOKEN does not +# trigger `release` events; the schedule re-pings and notices the signature. + +on: + workflow_run: + workflows: ["Release"] + types: [completed] + schedule: + - cron: "17 */6 * * *" + workflow_dispatch: + +concurrency: + group: release-signature-reminder + cancel-in-progress: false + +permissions: + contents: read + issues: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + remind: + name: Remind to sign + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Check the latest release for a signature + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_SIGNER: ajianaz + REMINDER_AFTER_HOURS: "12" + run: bash scripts/release-signature-reminder.sh diff --git a/AGENTS.md b/AGENTS.md index fa28498e..11ecf184 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,31 @@ Rust workspace: uteke-core (engine), uteke-cli, uteke-server, uteke-mcp, docgen. the tag at the previous release (v0.15.0). The Release workflow takes ~35 min (the legacy-ORT build is the long pole) and publishes GitHub assets, crates.io (uteke-core/cli/mcp), Docker tags and opens the Homebrew tap PR. -6. **After the tag.** Merge the homebrew-tap PR the workflow opened (its title/body +6. **Sign the checksums (offline).** After the Release workflow is green, run + `scripts/sign-release.sh vX.Y.Z` on the owner's machine (`--dry-run` stops before + upload). It first verifies the release's cosign bundle (so `cosign` must be installed on the + signing machine), shows the checksums and asks for confirmation, then signs + `checksums-sha256.txt` with the minisign key (trusted comment + `uteke vX.Y.Z`), verifies it against `scripts/uteke-release.pub` and uploads + `checksums-sha256.txt.minisig` after a confirmation. Key custody: password + manager plus an offline backup; never in CI or GitHub secrets. It can only sign releases that carry a cosign + bundle (v0.21.0 and later). Test it with `bash scripts/tests/sign-release.test.sh` + (stub gh/cosign, throwaway key; not wired into CI). Verify the + `.minisig` is on the release before announcing. Until it is uploaded, + `uteke upgrade` fails closed for that release (#1327). + Reminder: `.github/workflows/release-signature-reminder.yml` runs after the + Release workflow and every 6 hours. While the latest release has a bundle but no + `.minisig` it opens the issue `Sign release vX.Y.Z (minisign)` (assigned to the + owner), re-pings it every 12 h and closes it itself once the `.minisig` is + uploaded. Reminders for older releases are closed automatically, as superseded + (`uteke upgrade` only installs the latest release) or when their `.minisig` + appears. "Latest" follows GitHub's definition (by creation date, the same release + `uteke upgrade` installs), so a backport patch published after a newer minor + supersedes the newer release's reminder. Only issues and comments authored by the + Actions bot or the owner are acted on. It signs nothing and holds no key. Test its + script with `bash scripts/tests/release-signature-reminder.test.sh` (stub gh, not + wired into CI). +7. **After the tag.** Merge the homebrew-tap PR the workflow opened (its title/body follow the tap's rules since #1384, and the job already re-verified every SHA256 against `checksums-sha256.txt`; Cora there was fixed in homebrew-tap#6), then `brew update && brew upgrade codecoradev/tap/uteke`. Verify crates.io and the diff --git a/CHANGELOG.md b/CHANGELOG.md index c87e859c..05a4b9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## [Unreleased] +## [0.21.1] - 2026-10-15 + +Patch release. Theme: **`uteke upgrade` verifies a minisign signature, Homebrew +installs keep their embeddings, and `--json` output stays clean.** 0.21.1 is the +first release whose `checksums-sha256.txt` carries a minisign signature. A +0.21.0 binary only checks the SHA-256 and never verifies the signature, so the +signature protects `uteke upgrade` from 0.21.1 on. + +### ⚠️ Behavior Change + +- **`uteke upgrade` is stricter (#1327, #1462)** - it installs only stable releases (`vX.Y.Z`), refuses to downgrade, and fails closed unless the release has a valid minisign signature for `checksums-sha256.txt`. A release without a `.minisig` cannot be installed by `uteke upgrade` until the maintainer signs it. +- **CLI log lines go to stderr (#1461, #1471)** - scripts that read log lines from stdout must read stderr instead; `--json` output is now parseable even when a warning is logged. + +### Security + +- **`uteke upgrade` verifies a minisign signature over `checksums-sha256.txt` (#1327)** - the release maintainer signs the checksums offline (`scripts/sign-release.sh`) and uploads `checksums-sha256.txt.minisig`; the embedded public key (`scripts/uteke-release.pub`) and the trusted comment `uteke ` are checked before any hash in the file is trusted, so a tampered file is rejected, one release's files cannot be served as another's (the signature is bound to the tag), and `uteke upgrade` now refuses to downgrade to an older release. It also installs only stable releases (`vX.Y.Z`), never a pre-release or build-metadata tag. The signature is checked by `uteke upgrade` only: `install.sh`, `install.ps1` and Homebrew do not verify it (verify manually per SECURITY.md). A missing or invalid signature is a hard failure; `UTEKE_UPGRADE_SKIP_CHECKSUM=1` skips it too. Manual steps are in SECURITY.md. +- **rustls 0.23.46 (#1455)** - fixes RUSTSEC-2026-0346 to RUSTSEC-2026-0349 in the TLS stack used by the HTTP client. + +### Fixed + +- **`ORT_LIB_PATH` pointing at a directory no longer leaves uteke without embeddings (#1459)** - the Homebrew shims exported the `libexec` directory (v0.19.0 onwards), `dlopen` failed on it, and every such install silently stored memories FTS5-only (`embedding_written: false`, `uteke verify` MISMATCH). A directory that holds the platform library (`libonnxruntime.so`, `libonnxruntime.dylib` or `onnxruntime.dll`) is now accepted with a warning; a directory without it is a clear error that says to set the library file. The tap fix is codecoradev/homebrew-tap#10 (`brew update && brew reinstall codecoradev/tap/uteke`). Docker and the release tarballs were not affected. +- **CLI log lines no longer corrupt `--json` output (#1461)** - the console log layer wrote to stdout, so any WARN or ERROR (for example a failed embedding) was printed in front of the command's JSON and `json.loads(stdout)` failed. Console logs now go to stderr; the log file and the command output are unchanged. + +### Changed + +- Internal: the server dispatch is split into one named handler per route group (#1344, #1458); dependabot batch for `toml`, `usearch`, `uuid` and `serde_json` (#1457). +- Internal: `release-signature-reminder` workflow and script open and re-ping a `Sign release vX.Y.Z (minisign)` issue until the offline minisign signature is on the release, and close reminders for superseded releases automatically (#1327). + ## [0.21.0] - 2026-10-13 Minor release. Theme: **honest recall feedback, one access policy for MCP over diff --git a/Cargo.lock b/Cargo.lock index 09be8a12..bfc9565d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,7 +679,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1347,6 +1347,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -1706,7 +1712,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1986,14 +1992,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -2116,9 +2122,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -2129,9 +2135,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -2355,7 +2361,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2523,9 +2529,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -2538,27 +2544,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -2756,9 +2762,9 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "usearch" -version = "2.26.3" +version = "2.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae1c08a924aecdf8280c6d14f8bcc3677092a1f33d7c254f0f70fdc632ae694" +checksum = "1086fc5e6b4ee87a5bac4fd80fcf66670526ca21b77d5f95b68de01b15329c67" dependencies = [ "cxx", "cxx-build", @@ -2767,7 +2773,7 @@ dependencies = [ [[package]] name = "uteke-cli" -version = "0.21.0" +version = "0.21.1" dependencies = [ "chrono", "clap", @@ -2775,6 +2781,7 @@ dependencies = [ "ctrlc", "dirs", "flate2", + "minisign-verify", "reqwest", "serde", "serde_json", @@ -2793,7 +2800,7 @@ dependencies = [ [[package]] name = "uteke-core" -version = "0.21.0" +version = "0.21.1" dependencies = [ "chrono", "dirs", @@ -2818,7 +2825,7 @@ dependencies = [ [[package]] name = "uteke-mcp" -version = "0.21.0" +version = "0.21.1" dependencies = [ "chrono", "dirs", @@ -2830,7 +2837,7 @@ dependencies = [ [[package]] name = "uteke-server" -version = "0.21.0" +version = "0.21.1" dependencies = [ "chrono", "ctrlc", @@ -2861,9 +2868,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -3033,7 +3040,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ec1e2db2..9e06b787 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.21.0" +version = "0.21.1" edition = "2024" license = "Apache-2.0" repository = "https://github.com/codecoradev/uteke" diff --git a/README.id.md b/README.id.md index 70600003..5a924573 100644 --- a/README.id.md +++ b/README.id.md @@ -392,7 +392,7 @@ Bisa. Uteke punya MCP server yang langsung pakai dengan Claude Code, Cursor, dan
Sudah production-ready? -Uteke sekarang v0.21.0 dengan 200+ test, CI/CD di setiap commit, dan benchmark harness. Dipakai production oleh tim CodeCora dan early adopter lain. Masih di versi 0.x, jadi mungkin ada rough edges, tapi core-nya udah stabil. +Uteke sekarang v0.21.1 dengan 200+ test, CI/CD di setiap commit, dan benchmark harness. Dipakai production oleh tim CodeCora dan early adopter lain. Masih di versi 0.x, jadi mungkin ada rough edges, tapi core-nya udah stabil.
--- diff --git a/README.md b/README.md index 1851fad2..1d16bde1 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,7 @@ Yes. Uteke ships with an MCP server that works with Claude Code, Cursor, and Her
Is it production-ready? -Uteke is at v0.21.0 with 200+ tests, CI/CD on every commit, and a benchmark harness. It's used in production by the CodeCora team and other early adopters. Still in 0.x, so expect rough edges, but the core is stable. +Uteke is at v0.21.1 with 200+ tests, CI/CD on every commit, and a benchmark harness. It's used in production by the CodeCora team and other early adopters. Still in 0.x, so expect rough edges, but the core is stable.
--- diff --git a/SECURITY.md b/SECURITY.md index f9b1b9b2..f7ec7d41 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -68,6 +68,34 @@ cosign verify-blob \ sha256sum --check --ignore-missing checksums-sha256.txt ``` -`uteke upgrade` does not verify this signature yet; it still checks the archive -against the unsigned `checksums-sha256.txt` of the same release (corruption -detection only). +### minisign signature + +The release maintainer also signs `checksums-sha256.txt` offline with a +[minisign](https://jedisct1.github.io/minisign/) key that never leaves their +machine (CI holds no signing key). The signature is published as +`checksums-sha256.txt.minisig`; its trusted comment is `uteke `, binding it +to one release. The public key is `scripts/uteke-release.pub`, key id +`96924B76E31B048B`. + +```bash +curl -fsSLO "https://github.com/codecoradev/uteke/releases/download/$VER/checksums-sha256.txt.minisig" + +minisign -Vm checksums-sha256.txt \ + -P RWSLBBvjdkuSlvaQ/yoyoMlpQAOLTt5jACZGTgSOCKb+/1JoqhjOmT8G +# the output must show: Trusted comment: uteke vX.Y.Z (your $VER) +``` + +Since the release that ships this change, `uteke upgrade` verifies this +minisign signature over `checksums-sha256.txt` before trusting any hash in it, +then checks the archive against that file. The signature is bound to the tag +(the trusted comment must be `uteke `), so one release's files cannot be +served as another's, and `uteke upgrade` refuses to downgrade to an older +release. It installs only stable, published releases (`vX.Y.Z`): a pre-release +or build-metadata tag is refused. A missing or invalid signature is a hard failure, never a silent +downgrade to checksum-only. Setting `UTEKE_UPGRADE_SKIP_CHECKSUM=1` skips both +checks (not recommended). Releases published before the signature existed +cannot be installed with `uteke upgrade` from a build that verifies it. + +The minisign signature is checked by `uteke upgrade` ONLY. `install.sh`, +`install.ps1` and Homebrew do not verify it: Homebrew relies on the SHA256s in +the formula, and for the scripts you should verify manually as described above. diff --git a/crates/uteke-cli/Cargo.toml b/crates/uteke-cli/Cargo.toml index 56be8536..c9916d59 100644 --- a/crates/uteke-cli/Cargo.toml +++ b/crates/uteke-cli/Cargo.toml @@ -22,7 +22,7 @@ usearch = ["uteke-core/usearch"] vecq = ["uteke-core/vecq"] [dependencies] -uteke-core = { path = "../uteke-core", version = "0.21.0", default-features = false, features = ["onnx"] } +uteke-core = { path = "../uteke-core", version = "0.21.1", default-features = false, features = ["onnx"] } clap = { version = "4", features = ["derive"] } serde_json = "1" tracing = "0.1" @@ -41,6 +41,7 @@ tar = "0.4" urlencoding = "2.1.3" chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] } tempfile = "3" +minisign-verify = "0.3.0" [dev-dependencies] serial_test = "3" diff --git a/crates/uteke-cli/assets/uteke-memory-skill.md b/crates/uteke-cli/assets/uteke-memory-skill.md index 7e36d071..eba1e4d8 100644 --- a/crates/uteke-cli/assets/uteke-memory-skill.md +++ b/crates/uteke-cli/assets/uteke-memory-skill.md @@ -5,7 +5,7 @@ description: "Persistent memory engine for AI agents via the uteke CLI — remem # Uteke Memory Skill Persistent memory engine for AI agents via the `uteke` CLI. -Version: **0.21.0** — SQLite + HNSW vector index (usearch default, or vecq) + FTS5 hybrid search (RRF k=60); `fusion` (weighted RRF of the vector and hybrid rankings) is the default recall strategy since 0.16.0. `unsafe_code = "forbid"` workspace-wide. +Version: **0.21.1** — SQLite + HNSW vector index (usearch default, or vecq) + FTS5 hybrid search (RRF k=60); `fusion` (weighted RRF of the vector and hybrid rankings) is the default recall strategy since 0.16.0. `unsafe_code = "forbid"` workspace-wide. > This skill ships with each release — its version tracks the CLI version > (a CI gate fails when they drift, see `skill-version-parity` test). diff --git a/crates/uteke-cli/src/commands/upgrade.rs b/crates/uteke-cli/src/commands/upgrade.rs index 2d12d234..144403f7 100644 --- a/crates/uteke-cli/src/commands/upgrade.rs +++ b/crates/uteke-cli/src/commands/upgrade.rs @@ -26,6 +26,22 @@ const MAX_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024; /// read bounded. const MAX_CHECKSUMS_BYTES: u64 = 1024 * 1024; +/// A minisign signature file is ~4 lines and about 330 bytes; 4 KiB is generous. +const MAX_SIGNATURE_BYTES: u64 = 4 * 1024; + +/// Minisign public keys (base64 line of the `.pub` file) trusted to sign +/// `checksums-sha256.txt`. Mirrors `scripts/uteke-release.pub` (a test keeps +/// them in sync). It is a slice so a backup key can be added later. +/// +/// Key rotation requires a uteke release: binaries already installed only +/// trust the keys compiled into them, so they cannot learn a new key by +/// themselves and must be upgraded (manually) to a build that carries it. +const RELEASE_PUBLIC_KEYS: &[&str] = &["RWSLBBvjdkuSlvaQ/yoyoMlpQAOLTt5jACZGTgSOCKb+/1JoqhjOmT8G"]; + +/// Appended to every signature failure so the user can verify by hand. +const SIGNATURE_HELP: &str = "See the \"Verifying a release\" section of SECURITY.md \ + (https://github.com/codecoradev/uteke/blob/main/SECURITY.md) to verify the release manually."; + /// Entry point for `uteke upgrade`. pub fn run(yes: bool) -> Result<(), String> { // 1. Detect current version @@ -47,15 +63,28 @@ pub fn run(yes: bool) -> Result<(), String> { let arch = detect_arch(); // 4. Get latest release version - let latest_version = get_latest_version()?; + // Only stable releases are ever installed (owner rule): enforced here, + // before the prompt and any download, with no override. The tag is + // normalised to `vX.Y.Z` (the API fallback may return it without `v`). + let latest_version = ensure_stable_tag(&get_latest_version()?)?; // 5. Check if already up to date. The release tag carries a leading `v` // that CARGO_PKG_VERSION does not — compare normalized, or an up-to-date // install is re-offered the same release (#1245). - let latest_clean = latest_version.trim_start_matches('v'); - if latest_clean == current_version { - println!("[INFO] Already up to date ({current_version})"); - return Ok(()); + match compare_versions(current_version, &latest_version)? { + std::cmp::Ordering::Equal => { + println!("[INFO] Already up to date ({current_version})"); + return Ok(()); + } + std::cmp::Ordering::Greater => { + return Err(format!( + "Refusing to downgrade from {current_version} to {}. The release \ + pointed to by \"latest\" is older than this install; if that is \ + intended, reinstall the older version by hand.", + latest_version.trim_start_matches('v') + )); + } + std::cmp::Ordering::Less => {} } println!("[INFO] Latest version: {latest_version}"); @@ -113,30 +142,13 @@ pub fn run(yes: bool) -> Result<(), String> { .timeout(std::time::Duration::from_secs(300)) .build() .map_err(|e| format!("HTTP client build failed: {e}"))?; - let mut resp = client - .get(&download_url) - .send() - .map_err(|e| format!("Download failed: {e}"))?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().unwrap_or_default(); - return Err(format!("Download failed (HTTP {status}): {body}")); - } - - // Cheap early reject from the header, then enforce the cap while - // streaming: Content-Length may be absent (chunked) or simply wrong. - check_declared_len(resp.content_length(), MAX_ARCHIVE_BYTES, "Release archive")?; - let mut file = fs::File::create(&archive_path) - .map_err(|e| format!("Failed to create archive file: {e}"))?; - copy_capped(&mut resp, &mut file, MAX_ARCHIVE_BYTES, "Release archive")?; - drop(file); - - // 8. Verify checksum — fail-hard to prevent MITM on unchecked binaries. - // Use --no-verify (via env UTEKE_UPGRADE_SKIP_CHECKSUM=1) to opt out. + // 8. Authenticate the checksums BEFORE the (up to 128 MiB) archive download: + // fetch checksums + minisign signature, verify, and only then trust a hash. + // Use UTEKE_UPGRADE_SKIP_CHECKSUM=1 to opt out of both checks. let checksums_url = format!( "https://github.com/{REPO}/releases/download/{latest_version}/checksums-sha256.txt" ); + let signature_url = format!("{checksums_url}.minisig"); println!("[INFO] Verifying checksum ..."); @@ -144,13 +156,15 @@ pub fn run(yes: bool) -> Result<(), String> { .map(|v| v == "1" || v == "true") .unwrap_or(false); - if skip_checksum { + let expected_hash: Option = if skip_checksum { eprintln!("[WARN] ============================================================"); eprintln!("[WARN] UTEKE_UPGRADE_SKIP_CHECKSUM is set: the downloaded archive is"); - eprintln!("[WARN] NOT verified. A corrupted, tampered or malicious download will"); + eprintln!("[WARN] NOT verified, and neither is the minisign signature of the"); + eprintln!("[WARN] checksums file. A corrupted, tampered or malicious download will"); eprintln!("[WARN] be installed and executed as-is. Unset it unless you fully"); eprintln!("[WARN] trust the network path and the release source."); eprintln!("[WARN] ============================================================"); + None } else { let checksums_resp = client.get(&checksums_url).send().map_err(|e| { format!("Failed to download checksums: {e}. Set UTEKE_UPGRADE_SKIP_CHECKSUM=1 to skip.") @@ -178,17 +192,40 @@ pub fn run(yes: bool) -> Result<(), String> { MAX_CHECKSUMS_BYTES, "Checksums file", )?; - let checksums_text = String::from_utf8(checksums_buf) - .map_err(|e| format!("Checksums file is not valid UTF-8: {e}"))?; - let expected = parse_checksum(&checksums_text, &archive_name).ok_or_else(|| { - format!( - "Checksum for '{archive_name}' not found in checksums file. \ - Refusing to install unverified binary. \ - Set UTEKE_UPGRADE_SKIP_CHECKSUM=1 to bypass." - ) - })?; + println!("[INFO] Verifying checksums signature (minisign) ..."); + let signature = fetch_signature(&client, &signature_url, &latest_version); + let hash = verified_expected_hash( + &checksums_buf, + signature, + &latest_version, + RELEASE_PUBLIC_KEYS, + &archive_name, + )?; + println!("[INFO] Signature verified (uteke {latest_version})"); + Some(hash) + }; + + let mut resp = client + .get(&download_url) + .send() + .map_err(|e| format!("Download failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + return Err(format!("Download failed (HTTP {status}): {body}")); + } + // Cheap early reject from the header, then enforce the cap while + // streaming: Content-Length may be absent (chunked) or simply wrong. + check_declared_len(resp.content_length(), MAX_ARCHIVE_BYTES, "Release archive")?; + let mut file = fs::File::create(&archive_path) + .map_err(|e| format!("Failed to create archive file: {e}"))?; + copy_capped(&mut resp, &mut file, MAX_ARCHIVE_BYTES, "Release archive")?; + drop(file); + + if let Some(expected) = expected_hash { let actual = sha256_file(&archive_path)?; if actual != expected { return Err(format!( @@ -621,6 +658,222 @@ fn is_safe_archive_path(path: &Path) -> bool { .all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) } +/// Download the `.minisig` for the release, bounded and fail-closed. +fn fetch_signature( + client: &reqwest::blocking::Client, + url: &str, + tag: &str, +) -> Result { + let resp = client.get(url).send().map_err(|e| { + format!( + "Failed to download the checksums signature: {e}. \ + Refusing to install an unverified binary. {SIGNATURE_HELP}" + ) + })?; + if !resp.status().is_success() { + let status = resp.status(); + return Err(format!( + "No checksums signature for {tag} (HTTP {status}); the release may not be signed yet. \ + Refusing to install an unverified binary. {SIGNATURE_HELP}" + )); + } + check_declared_len(resp.content_length(), MAX_SIGNATURE_BYTES, "Signature file")?; + let mut resp = resp; + let mut buf: Vec = Vec::new(); + copy_capped(&mut resp, &mut buf, MAX_SIGNATURE_BYTES, "Signature file")?; + String::from_utf8(buf).map_err(|e| { + format!("Signature file is not valid UTF-8: {e}. Refusing to install. {SIGNATURE_HELP}") + }) +} + +/// Fail-closed glue between the signature fetch and the verifier: a fetch +/// error (404, network, oversize) is an error, never a skipped check. +fn authenticate_checksums( + checksums: &[u8], + signature: Result, + tag: &str, + trusted_keys: &[&str], +) -> Result<(), String> { + let signature_text = signature?; + verify_checksums_signature(checksums, &signature_text, tag, trusted_keys) +} + +/// The only way `run()` obtains an expected archive hash: the checksums bytes +/// must first be authenticated by the signature (fail closed), and only then +/// is the hash for `archive_name` read from them. +fn verified_expected_hash( + checksums: &[u8], + signature: Result, + tag: &str, + trusted_keys: &[&str], + archive_name: &str, +) -> Result { + authenticate_checksums(checksums, signature, tag, trusted_keys)?; + let text = std::str::from_utf8(checksums) + .map_err(|e| format!("Checksums file is not valid UTF-8: {e}"))?; + parse_checksum(text, archive_name).ok_or_else(|| { + format!( + "Checksum for '{archive_name}' not found in checksums file. \ + Refusing to install unverified binary. \ + Set UTEKE_UPGRADE_SKIP_CHECKSUM=1 to bypass." + ) + }) +} + +/// Accept only a stable release tag: `vX.Y.Z` (the `v` may be missing, it is +/// normalised) with numeric components only; no pre-release, no build +/// metadata. Returns the normalised `vX.Y.Z`. +fn ensure_stable_tag(tag: &str) -> Result { + let bare = tag.strip_prefix('v').unwrap_or(tag); + let parts: Vec<&str> = bare.split('.').collect(); + let stable = parts.len() == 3 + && parts + .iter() + .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())); + if stable { + Ok(format!("v{bare}")) + } else { + Err(format!("Refusing to install a non-stable release: {tag}")) + } +} + +/// Parsed `major.minor.patch[-pre][+build]`. +fn parse_version(v: &str) -> Option<(u64, u64, u64, Option>)> { + let v = v.trim().trim_start_matches('v'); + let v = v.split('+').next()?; + let (core, pre) = match v.split_once('-') { + Some((c, p)) => (c, Some(p)), + None => (v, None), + }; + let mut it = core.split('.'); + let mut num = || -> Option { + let part = it.next()?; + if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + part.parse().ok() + }; + let (ma, mi, pa) = (num()?, num()?, num()?); + if it.next().is_some() { + return None; + } + let pre = match pre { + None => None, + Some(p) => { + let ids: Vec = p.split('.').map(str::to_string).collect(); + if ids.iter().any(|i| i.is_empty()) { + return None; + } + Some(ids) + } + }; + Some((ma, mi, pa, pre)) +} + +/// Semver ordering of `latest` relative to `current` (`Less` means latest is +/// newer, i.e. an upgrade). Unparseable versions are an error. +fn compare_versions(current: &str, latest: &str) -> Result { + use std::cmp::Ordering; + let c = + parse_version(current).ok_or_else(|| format!("Unparseable current version '{current}'"))?; + let l = + parse_version(latest).ok_or_else(|| format!("Unparseable release version '{latest}'"))?; + let core = (c.0, c.1, c.2).cmp(&(l.0, l.1, l.2)); + if core != Ordering::Equal { + return Ok(core); + } + Ok(match (&c.3, &l.3) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => { + let mut ord = Ordering::Equal; + for (x, y) in a.iter().zip(b.iter()) { + let o = match (x.parse::(), y.parse::()) { + (Ok(x), Ok(y)) => x.cmp(&y), + (Ok(_), Err(_)) => Ordering::Less, + (Err(_), Ok(_)) => Ordering::Greater, + (Err(_), Err(_)) => x.cmp(y), + }; + if o != Ordering::Equal { + ord = o; + break; + } + } + if ord == Ordering::Equal { + a.len().cmp(&b.len()) + } else { + ord + } + } + }) +} + +/// Verify that `signature_text` (a minisign `.minisig`) is a valid signature +/// of `checksums` by one of `trusted_keys`, and that its trusted comment is +/// exactly `uteke {tag}`. The comment is covered by the signature, so binding +/// it to the tag stops an older, validly signed release being replayed as a +/// newer one. Legacy (non-prehashed) signatures are rejected. +fn verify_checksums_signature( + checksums: &[u8], + signature_text: &str, + tag: &str, + trusted_keys: &[&str], +) -> Result<(), String> { + use minisign_verify::{Error as MsError, PublicKey, Signature}; + + let signature = Signature::decode(signature_text).map_err(|e| { + format!("The checksums signature is not a valid minisign signature ({e}). {SIGNATURE_HELP}") + })?; + + // Prefer reporting a key that matched but failed over "wrong key". + let mut failure: Option = None; + let mut verified = false; + for key_b64 in trusted_keys { + let key = PublicKey::from_base64(key_b64).map_err(|e| { + format!("Embedded release public key is invalid ({e}). {SIGNATURE_HELP}") + })?; + match key.verify(checksums, &signature, false) { + Ok(()) => { + verified = true; + break; + } + Err(MsError::UnexpectedKeyId) if failure.is_some() => {} + Err(e) => failure = Some(e), + } + } + if !verified { + let why = match failure { + Some(MsError::UnexpectedKeyId) | None => { + "the signature was made by a different key than the trusted release key (wrong key)" + .to_string() + } + Some(MsError::InvalidSignature) => { + "the signature does not match checksums-sha256.txt (data mismatch: the file was \ + modified or is not the signed one)" + .to_string() + } + Some(MsError::UnexpectedAlgorithm) => { + "legacy (non-prehashed) signatures are not accepted".to_string() + } + Some(e) => format!("{e}"), + }; + return Err(format!( + "Checksums signature verification failed: {why}. Refusing to install. {SIGNATURE_HELP}" + )); + } + + let expected = format!("uteke {tag}"); + let got = signature.trusted_comment(); + if got != expected { + return Err(format!( + "Checksums signature is for a different release (trusted comment mismatch): \ + expected \"{expected}\", got \"{got}\". Refusing to install. {SIGNATURE_HELP}" + )); + } + Ok(()) +} + fn parse_checksum(checksums_text: &str, archive_name: &str) -> Option { for line in checksums_text.lines() { let parts: Vec<&str> = line.split_whitespace().collect(); @@ -894,3 +1147,222 @@ mod tests { let _ = fs::remove_dir_all(&dir2); } } + +#[cfg(test)] +mod signature_tests { + use super::*; + + const CHECKSUMS: &str = include_str!("../../tests/fixtures/minisign/checksums-sha256.txt"); + const OK_SIG: &str = include_str!("../../tests/fixtures/minisign/ok.minisig"); + const OLD_TAG_SIG: &str = include_str!("../../tests/fixtures/minisign/oldtag.minisig"); + const OTHER_KEY_SIG: &str = include_str!("../../tests/fixtures/minisign/otherkey.minisig"); + const LEGACY_SIG: &str = include_str!("../../tests/fixtures/minisign/legacy.minisig"); + const TRAILING_SIG: &str = include_str!("../../tests/fixtures/minisign/trailing.minisig"); + const DOUBLE_SIG: &str = include_str!("../../tests/fixtures/minisign/double.minisig"); + // Throwaway test key (its secret half was deleted after signing). + const TEST_KEY: &str = include_str!("../../tests/fixtures/minisign/test-key.b64"); + + fn keys() -> Vec<&'static str> { + vec![TEST_KEY.trim()] + } + + fn check(data: &str, sig: &str, tag: &str) -> Result<(), String> { + verify_checksums_signature(data.as_bytes(), sig, tag, &keys()) + } + + #[test] + fn valid_signature_for_the_right_tag_passes() { + assert_eq!(check(CHECKSUMS, OK_SIG, "v9.9.9"), Ok(())); + } + + #[test] + fn a_second_trusted_key_is_tried() { + let two = [RELEASE_PUBLIC_KEYS[0], TEST_KEY.trim()]; + assert_eq!( + verify_checksums_signature(CHECKSUMS.as_bytes(), OK_SIG, "v9.9.9", &two), + Ok(()) + ); + } + + #[test] + fn tampered_data_is_a_data_mismatch() { + let bad = CHECKSUMS.replacen("a", "c", 1); + let e = check(&bad, OK_SIG, "v9.9.9").unwrap_err(); + assert!(e.contains("data mismatch"), "{e}"); + assert!(e.contains("SECURITY.md"), "{e}"); + } + + #[test] + fn signature_for_another_tag_is_rejected() { + let e = check(CHECKSUMS, OLD_TAG_SIG, "v9.9.9").unwrap_err(); + assert!(e.contains("trusted comment mismatch"), "{e}"); + assert!( + e.contains("expected \"uteke v9.9.9\", got \"uteke v1.0.0\""), + "{e}" + ); + assert!(e.contains("SECURITY.md"), "{e}"); + } + + #[test] + fn signature_by_a_different_key_is_rejected() { + let e = check(CHECKSUMS, OTHER_KEY_SIG, "v9.9.9").unwrap_err(); + assert!(e.contains("wrong key"), "{e}"); + assert!(e.contains("SECURITY.md"), "{e}"); + } + + #[test] + fn garbage_empty_and_truncated_signatures_are_rejected() { + for sig in [ + "this is not a signature", + "", + "untrusted comment: x\nnot-base64!!\n", + ] { + let e = check(CHECKSUMS, sig, "v9.9.9").unwrap_err(); + assert!(e.contains("not a valid minisign signature"), "{sig:?}: {e}"); + assert!(e.contains("SECURITY.md"), "{e}"); + } + // Truncated: drop the last (global signature) line, and cut mid-line. + let lines: Vec<&str> = OK_SIG.lines().collect(); + let no_global = lines[..3].join("\n"); + let e = check(CHECKSUMS, &no_global, "v9.9.9").unwrap_err(); + assert!(e.contains("not a valid minisign signature"), "{e}"); + let cut = &OK_SIG[..OK_SIG.len() / 2]; + let e = check(CHECKSUMS, cut, "v9.9.9").unwrap_err(); + assert!(e.contains("not a valid minisign signature"), "{e}"); + } + + #[test] + fn legacy_signature_is_rejected() { + let e = check(CHECKSUMS, LEGACY_SIG, "v9.9.9").unwrap_err(); + assert!(e.contains("legacy"), "{e}"); + assert!(e.contains("SECURITY.md"), "{e}"); + } + + #[test] + fn trusted_comment_must_match_exactly() { + for sig in [TRAILING_SIG, DOUBLE_SIG] { + let e = check(CHECKSUMS, sig, "v9.9.9").unwrap_err(); + assert!(e.contains("trusted comment mismatch"), "{e}"); + } + } + + #[test] + fn authenticate_fails_closed_on_fetch_errors() { + let k = keys(); + let e = authenticate_checksums( + CHECKSUMS.as_bytes(), + Err("No checksums signature for v9.9.9 (HTTP 404 Not Found)".into()), + "v9.9.9", + &k, + ) + .unwrap_err(); + assert!(e.contains("404"), "{e}"); + assert_eq!( + authenticate_checksums(CHECKSUMS.as_bytes(), Ok(OK_SIG.into()), "v9.9.9", &k), + Ok(()) + ); + assert!( + authenticate_checksums(CHECKSUMS.as_bytes(), Ok(String::new()), "v9.9.9", &k).is_err() + ); + } + + #[test] + fn version_comparison() { + use std::cmp::Ordering::*; + let c = |a, b| compare_versions(a, b).unwrap(); + assert_eq!(c("0.21.0", "v0.21.0"), Equal); + assert_eq!(c("0.21.0", "v0.21.1"), Less); + assert_eq!(c("0.21.1", "v0.21.0"), Greater); + assert_eq!(c("0.21.0", "v0.22.0"), Less); + assert_eq!(c("0.22.0", "v0.21.9"), Greater); + assert_eq!(c("0.21.0", "v1.0.0"), Less); + assert_eq!(c("1.0.0", "v0.99.99"), Greater); + assert_eq!(c("0.9.0", "v0.10.0"), Less); + assert_eq!(c("0.10.0", "v0.9.0"), Greater); + assert_eq!(c("0.22.0-rc.1", "v0.22.0"), Less); + assert_eq!(c("0.22.0", "v0.22.0-rc.1"), Greater); + assert_eq!(c("0.22.0-rc.1", "v0.22.0-rc.2"), Less); + assert_eq!(c("0.22.0-rc.2", "v0.22.0-rc.10"), Less); + assert_eq!(c("0.22.0-alpha", "v0.22.0-alpha.1"), Less); + assert_eq!(c("0.22.0-1", "v0.22.0-alpha"), Less); + for bad in ["vfoo", "v1.2", "v1.2.3.4", "v1.2.x", "v1..3", "v1.2.3-", ""] { + assert!(compare_versions("0.21.0", bad).is_err(), "{bad}"); + } + } + + #[test] + fn whitespace_only_signature_is_rejected() { + let e = check(CHECKSUMS, " \n", "v9.9.9").unwrap_err(); + assert!(e.contains("not a valid minisign signature"), "{e}"); + } + + const ARCHIVE: &str = "uteke-x86_64-apple-darwin-v9.9.9.tar.gz"; + + fn hash(data: &str, sig: Result, archive: &str) -> Result { + verified_expected_hash(data.as_bytes(), sig, "v9.9.9", &keys(), archive) + } + + #[test] + fn expected_hash_requires_a_good_signature() { + let ok = || Ok(OK_SIG.to_string()); + assert_eq!(hash(CHECKSUMS, ok(), ARCHIVE), Ok("a".repeat(64))); + assert!(hash(CHECKSUMS, Err("HTTP 404".into()), ARCHIVE).is_err()); + assert!(hash(CHECKSUMS, Ok(String::new()), ARCHIVE).is_err()); + assert!(hash(CHECKSUMS, Ok(OLD_TAG_SIG.into()), ARCHIVE).is_err()); + assert!(hash(CHECKSUMS, Ok(OTHER_KEY_SIG.into()), ARCHIVE).is_err()); + // Signature for other bytes: tampered checksums never yield a hash. + let tampered = CHECKSUMS.replacen("aaaa", "cccc", 1); + let e = hash(&tampered, ok(), ARCHIVE).unwrap_err(); + assert!(e.contains("data mismatch"), "{e}"); + let e = hash(CHECKSUMS, ok(), "uteke-missing.tar.gz").unwrap_err(); + assert!(e.contains("not found in checksums file"), "{e}"); + } + + #[test] + fn only_stable_tags_are_installable() { + assert_eq!(ensure_stable_tag("v0.22.0").unwrap(), "v0.22.0"); + assert_eq!(ensure_stable_tag("0.22.0").unwrap(), "v0.22.0"); + for bad in [ + "v0.23.0-rc.1", + "v0.23.0-beta", + "v0.23.0-alpha.2", + "v0.23.0+build", + "v0.23.0-rc.1+build", + "0.23.0-rc.1", + "vv0.23.0", + "v1.2", + "1.2", + "v1.2.3.4", + "v1..3", + "v1.2.", + "v.1.2", + "v1.2.x", + "v-1.2.3", + "", + "v", + ] { + let e = ensure_stable_tag(bad).unwrap_err(); + assert_eq!( + e, + format!("Refusing to install a non-stable release: {bad}") + ); + } + } + + #[test] + fn stable_current_refuses_prerelease_latest_and_prerelease_current_upgrades() { + use std::cmp::Ordering::*; + // 0.22.0 vs latest 0.22.0-rc.1 is a downgrade (and rc.1 is not stable anyway). + assert_eq!(compare_versions("0.22.0", "v0.22.0-rc.1"), Ok(Greater)); + assert!(ensure_stable_tag("v0.22.0-rc.1").is_err()); + // A dev/pre-release build upgrades to a newer stable. + assert_eq!(compare_versions("0.22.0-dev", "v0.22.0"), Ok(Less)); + assert_eq!(compare_versions("0.22.0-dev", "v0.23.0"), Ok(Less)); + assert!(ensure_stable_tag("v0.23.0").is_ok()); + } + + #[test] + fn the_embedded_release_key_is_a_valid_minisign_key() { + assert!(minisign_verify::PublicKey::from_base64(RELEASE_PUBLIC_KEYS[0]).is_ok()); + } +} diff --git a/crates/uteke-cli/src/logging.rs b/crates/uteke-cli/src/logging.rs index 432029da..294d9c00 100644 --- a/crates/uteke-cli/src/logging.rs +++ b/crates/uteke-cli/src/logging.rs @@ -1,6 +1,6 @@ //! Logging setup: console + daily rotating file. //! -//! Console shows WARN by default (DEBUG with --verbose). +//! Console (stderr) shows WARN by default (DEBUG with --verbose). //! File always captures DEBUG to `{uteke_home}/uteke.log` with daily rotation. use tracing::Level; @@ -24,7 +24,10 @@ pub fn init(verbose: bool) -> Box { .with_filter(EnvFilter::from_default_env().add_directive(Level::DEBUG.into())); let console_level = if verbose { Level::DEBUG } else { Level::WARN }; + // stderr, never stdout: stdout carries the command's own output (including + // `--json`), and a log line in front of it makes it unparseable (#1461). let console_layer = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) .with_filter(EnvFilter::from_default_env().add_directive(console_level.into())); tracing_subscriber::registry() diff --git a/crates/uteke-cli/tests/fixtures/minisign/checksums-sha256.txt b/crates/uteke-cli/tests/fixtures/minisign/checksums-sha256.txt new file mode 100644 index 00000000..bbf74d9a --- /dev/null +++ b/crates/uteke-cli/tests/fixtures/minisign/checksums-sha256.txt @@ -0,0 +1,2 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa uteke-x86_64-apple-darwin-v9.9.9.tar.gz +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb uteke-aarch64-apple-darwin-v9.9.9.tar.gz diff --git a/crates/uteke-cli/tests/fixtures/minisign/double.minisig b/crates/uteke-cli/tests/fixtures/minisign/double.minisig new file mode 100644 index 00000000..def59e8d --- /dev/null +++ b/crates/uteke-cli/tests/fixtures/minisign/double.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURe1ma1MHSzRmeRy7AR4JvLyep6Z1cw1ejIAOmWKVt5rFCHiIa4FbK+uHazyoEyASo0MbWIj+714sxp8uDxJ4JPdi/MghWuBQA= +trusted comment: uteke v9.9.9 +uxe4dNgqU+/WpoqCl/sJ889SSKeOEgpRyoaHOqAH1wwnFTLBOJ9UAetvsEtdiUI9eskDhMQPMYjVw7jg0gwhDA== diff --git a/crates/uteke-cli/tests/fixtures/minisign/legacy.minisig b/crates/uteke-cli/tests/fixtures/minisign/legacy.minisig new file mode 100644 index 00000000..2cde5199 --- /dev/null +++ b/crates/uteke-cli/tests/fixtures/minisign/legacy.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RWRe1ma1MHSzRth/2Od5UOtstuzqDH/uphDEKfCH1BhguePd8PXEbnDbc/96Ihsl8Rr/OdkkN+d06z1ke343kWBzRubQLGEVXQw= +trusted comment: uteke v9.9.9 +IjsSavvDExyQZ8WwRZfxHrDiryhkMYr3VHpt79zmLJX2Z33E1W+Sk8EU1BnanLsGfnBHrRvDdD8FpaW1xWWEDA== diff --git a/crates/uteke-cli/tests/fixtures/minisign/ok.minisig b/crates/uteke-cli/tests/fixtures/minisign/ok.minisig new file mode 100644 index 00000000..e9de781c --- /dev/null +++ b/crates/uteke-cli/tests/fixtures/minisign/ok.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURe1ma1MHSzRmeRy7AR4JvLyep6Z1cw1ejIAOmWKVt5rFCHiIa4FbK+uHazyoEyASo0MbWIj+714sxp8uDxJ4JPdi/MghWuBQA= +trusted comment: uteke v9.9.9 +OXhwgg0FaOz3yyLqCmzbrjTUnMbTxqpgwWUOZs1z05PyD4G29yaVMum/kNusdQ2HmR5AA6Y3q1hSw2MnlDi9DA== diff --git a/crates/uteke-cli/tests/fixtures/minisign/oldtag.minisig b/crates/uteke-cli/tests/fixtures/minisign/oldtag.minisig new file mode 100644 index 00000000..2f7d1bec --- /dev/null +++ b/crates/uteke-cli/tests/fixtures/minisign/oldtag.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURe1ma1MHSzRmeRy7AR4JvLyep6Z1cw1ejIAOmWKVt5rFCHiIa4FbK+uHazyoEyASo0MbWIj+714sxp8uDxJ4JPdi/MghWuBQA= +trusted comment: uteke v1.0.0 +v9qtxKfOJU4/K1ptFYV+9wAT5KBWBBkST1jZpfmy01rcQ8K+/1PKNrHZocuYQwWWtZsSp/Fm+tLqbUWoYCpLDA== diff --git a/crates/uteke-cli/tests/fixtures/minisign/otherkey.minisig b/crates/uteke-cli/tests/fixtures/minisign/otherkey.minisig new file mode 100644 index 00000000..4515ac44 --- /dev/null +++ b/crates/uteke-cli/tests/fixtures/minisign/otherkey.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RUSbadKDsNhIK0Ogsca393YvwJx3+CZVdH2oVDPgi+Vi2yu4h2RTTrimnmZ/orF9FgERVUh29GM+463nYtngZQA/WXILAgBCCAg= +trusted comment: uteke v9.9.9 +ewyfDxMZBkKtrbBXT0QzjTRbh5qJHvxsfYvaOYQBOXKIaaYDIHJkEYx93ouBnLhZjVq8EZc/1glL/Yt/rG7+AA== diff --git a/crates/uteke-cli/tests/fixtures/minisign/test-key.b64 b/crates/uteke-cli/tests/fixtures/minisign/test-key.b64 new file mode 100644 index 00000000..047aa0e1 --- /dev/null +++ b/crates/uteke-cli/tests/fixtures/minisign/test-key.b64 @@ -0,0 +1 @@ +RWRe1ma1MHSzRjClgfh5tgdAhCu1FS3V98HYYA9gp10cS/rb09n0PfnX diff --git a/crates/uteke-cli/tests/fixtures/minisign/trailing.minisig b/crates/uteke-cli/tests/fixtures/minisign/trailing.minisig new file mode 100644 index 00000000..526c52f2 --- /dev/null +++ b/crates/uteke-cli/tests/fixtures/minisign/trailing.minisig @@ -0,0 +1,4 @@ +untrusted comment: signature from minisign secret key +RURe1ma1MHSzRmeRy7AR4JvLyep6Z1cw1ejIAOmWKVt5rFCHiIa4FbK+uHazyoEyASo0MbWIj+714sxp8uDxJ4JPdi/MghWuBQA= +trusted comment: uteke v9.9.9 +lbmf7O2fLq9D4D/PozHaeO33MkKZDNO1hoynxPYlXN+w0WLfVioka6kjeO1CeTl+8a3tvcTbJKX3cjUUlTWdDw== diff --git a/crates/uteke-cli/tests/json_output_stays_clean.rs b/crates/uteke-cli/tests/json_output_stays_clean.rs new file mode 100644 index 00000000..0c23bf77 --- /dev/null +++ b/crates/uteke-cli/tests/json_output_stays_clean.rs @@ -0,0 +1,59 @@ +//! `--json` output must be parseable JSON on stdout, whatever the logs say. +//! +//! The CLI console log layer used to write to stdout, so any WARN printed before +//! the command's own output and broke `json.loads(stdout)` (#1461). Diagnostics +//! belong on stderr. + +use std::process::Command; + +/// Run `uteke --store --json remember ...` with an ONNX library path that +/// does not exist: embedding fails, which logs WARN/ERROR lines, but the +/// memory is still stored and the command prints its JSON result. +fn remember_with_failing_embedder() -> (String, String) { + let tmp = std::env::temp_dir().join(format!("uteke_json_clean_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let home = tmp.join("home"); + std::fs::create_dir_all(&home).unwrap(); + // Server routing off, so the command runs against the local store. + std::fs::write(home.join("uteke.toml"), "[server]\nenabled = false\n").unwrap(); + + let out = Command::new(env!("CARGO_BIN_EXE_uteke")) + .args(["--store"]) + .arg(tmp.join("store")) + .args(["--json", "remember", "json output check"]) + .env("UTEKE_HOME", &home) + .env("UTEKE_NO_UPDATE_CHECK", "1") + .env("ORT_LIB_PATH", "/nonexistent/uteke-test/libonnxruntime.so") + .env_remove("UTEKE_AUTH_TOKEN") + .output() + .expect("run uteke"); + let _ = std::fs::remove_dir_all(&tmp); + assert!( + out.status.success(), + "uteke failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + ( + String::from_utf8(out.stdout).unwrap(), + String::from_utf8(out.stderr).unwrap(), + ) +} + +#[test] +fn json_stdout_is_clean_when_logs_are_emitted() { + let (stdout, stderr) = remember_with_failing_embedder(); + + let json: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout is not a single JSON value ({e}): {stdout:?}")); + assert_eq!(json["embedding_written"], false, "{json}"); + + assert!( + stderr.contains("WARN") || stderr.contains("ERROR"), + "the embedding failure should be logged on stderr, got: {stderr:?}" + ); + assert!( + !stdout.contains("WARN") && !stdout.contains("ERROR"), + "log lines leaked onto stdout: {stdout:?}" + ); +} diff --git a/crates/uteke-cli/tests/release_key_matches_pub_file.rs b/crates/uteke-cli/tests/release_key_matches_pub_file.rs new file mode 100644 index 00000000..c326b054 --- /dev/null +++ b/crates/uteke-cli/tests/release_key_matches_pub_file.rs @@ -0,0 +1,32 @@ +//! The key embedded in `uteke upgrade` must equal `scripts/uteke-release.pub`. +//! +//! `uteke-cli` is a binary crate, so the constant is not importable: this test +//! checks that the key line of the pub file appears in `RELEASE_PUBLIC_KEYS`. +//! The script lives outside the crate directory, so `include_str!` would break +//! `cargo package`/`cargo publish`; it is read at runtime instead and the test +//! is skipped (with a message) when the file is absent. + +use std::path::Path; + +#[test] +fn embedded_release_key_matches_scripts_pub_file() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let pub_path = root.join("../../scripts/uteke-release.pub"); + let Ok(text) = std::fs::read_to_string(&pub_path) else { + eprintln!("skipped: {} not found", pub_path.display()); + return; + }; + let key_line = text.lines().nth(1).expect("pub file has a key line").trim(); + assert!(!key_line.is_empty()); + + let src = std::fs::read_to_string(root.join("src/commands/upgrade.rs")).unwrap(); + let decl = src + .split("const RELEASE_PUBLIC_KEYS") + .nth(1) + .and_then(|rest| rest.split(';').next()) + .expect("RELEASE_PUBLIC_KEYS declaration"); + assert!( + decl.contains(&format!("\"{key_line}\"")), + "embedded key does not match scripts/uteke-release.pub" + ); +} diff --git a/crates/uteke-core/src/embed/ort_init.rs b/crates/uteke-core/src/embed/ort_init.rs index 1801a8b7..66e86570 100644 --- a/crates/uteke-core/src/embed/ort_init.rs +++ b/crates/uteke-core/src/embed/ort_init.rs @@ -5,6 +5,7 @@ //! //! Resolution order: //! 1. `ORT_LIB_PATH` env var — explicit override (for testing / custom setups). +//! Names the library file; a directory holding the library is accepted too. //! 2. If AVX2 is detected → `/libonnxruntime.so` (AVX2 build, ships in standard bundle). //! If not found, falls through to step 4 (does NOT error immediately). //! 3. If AVX2 is NOT detected → `/ort-legacy/libonnxruntime.so` (SSE4.2 build, sidecar). @@ -61,6 +62,38 @@ pub fn has_avx2() -> bool { } } +/// Resolve an explicit `ORT_LIB_PATH` value to the library file to load. +/// +/// The value should name the library file. A directory is accepted too, as long +/// as the platform library name is inside it: the Homebrew shims exported the +/// `libexec` directory from v0.19.0 on, and `dlopen` fails on a directory, so +/// every such install silently ran without embeddings (#1459). +fn resolve_explicit_ort_path(path: &std::path::Path) -> Result { + if path.is_dir() { + let inside = path.join(ORT_LIB_NAME); + if inside.exists() { + tracing::warn!( + "ORT_LIB_PATH '{}' is a directory; using {}. Set it to the library file.", + path.display(), + inside.display() + ); + return Ok(inside); + } + return Err(format!( + "ORT_LIB_PATH '{}' is a directory with no {ORT_LIB_NAME} inside; \ + set it to the library file", + path.display() + )); + } + if path.exists() { + return Ok(path.to_path_buf()); + } + Err(format!( + "ORT_LIB_PATH set to '{}' but file does not exist", + path.display() + )) +} + /// Resolve the path to the ONNX Runtime shared library. /// /// Search order: @@ -77,21 +110,15 @@ pub fn has_avx2() -> bool { pub fn resolve_ort_lib() -> Result { // 1. Explicit env var override if let Ok(env_path) = std::env::var("ORT_LIB_PATH") { - let path = PathBuf::from(&env_path); - if path.exists() { - tracing::info!( - "ORT: using explicit library path from ORT_LIB_PATH: {}", - path.display() - ); - return Ok(OrtLibInfo { - lib_path: path, - has_avx2: has_avx2(), - }); - } - return Err(format!( - "ORT_LIB_PATH set to '{}' but file does not exist", - env_path - )); + let lib_path = resolve_explicit_ort_path(&PathBuf::from(env_path))?; + tracing::info!( + "ORT: using explicit library path from ORT_LIB_PATH: {}", + lib_path.display() + ); + return Ok(OrtLibInfo { + lib_path, + has_avx2: has_avx2(), + }); } let exe_dir = exe_dir()?; @@ -386,6 +413,64 @@ mod tests { assert!(result.unwrap_err().contains("does not exist")); } + /// Unique empty temp dir for one test (the file name keeps parallel tests apart). + fn scratch_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("uteke_ort_explicit_{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn explicit_path_to_a_file_is_used_as_is() { + let dir = scratch_dir("file"); + let lib = dir.join("custom-name.bin"); + std::fs::write(&lib, b"fake").unwrap(); + + assert_eq!(resolve_explicit_ort_path(&lib).unwrap(), lib); + std::fs::remove_dir_all(&dir).ok(); + } + + /// The Homebrew shims exported ORT_LIB_PATH= (a directory) from + /// v0.19.0 on; dlopen fails on a directory, so every install silently ran + /// without embeddings (#1459). + #[test] + fn explicit_path_to_a_directory_finds_the_library_inside() { + let dir = scratch_dir("dir_with_lib"); + let lib = dir.join(ORT_LIB_NAME); + std::fs::write(&lib, b"fake").unwrap(); + + assert_eq!(resolve_explicit_ort_path(&dir).unwrap(), lib); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn explicit_path_to_a_directory_without_the_library_is_an_error() { + let dir = scratch_dir("dir_without_lib"); + + let err = resolve_explicit_ort_path(&dir).unwrap_err(); + assert_eq!( + err, + format!( + "ORT_LIB_PATH '{}' is a directory with no {ORT_LIB_NAME} inside; \ + set it to the library file", + dir.display() + ) + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn explicit_path_that_does_not_exist_is_an_error() { + let missing = PathBuf::from("/nonexistent/path/libonnxruntime.so"); + + let err = resolve_explicit_ort_path(&missing).unwrap_err(); + assert_eq!( + err, + "ORT_LIB_PATH set to '/nonexistent/path/libonnxruntime.so' but file does not exist" + ); + } + #[test] fn exe_dir_returns_parent() { let dir = exe_dir().unwrap(); diff --git a/crates/uteke-mcp/Cargo.toml b/crates/uteke-mcp/Cargo.toml index 732f209c..c56ac77a 100644 --- a/crates/uteke-mcp/Cargo.toml +++ b/crates/uteke-mcp/Cargo.toml @@ -29,7 +29,7 @@ usearch = ["uteke-core/usearch"] vecq = ["uteke-core/vecq"] [dependencies] -uteke-core = { path = "../uteke-core", version = "0.21.0", default-features = false, features = ["onnx"] } +uteke-core = { path = "../uteke-core", version = "0.21.1", default-features = false, features = ["onnx"] } serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "7" diff --git a/crates/uteke-server/Cargo.toml b/crates/uteke-server/Cargo.toml index 41c10774..f13bcc40 100644 --- a/crates/uteke-server/Cargo.toml +++ b/crates/uteke-server/Cargo.toml @@ -27,8 +27,8 @@ name = "uteke-serve" path = "src/main.rs" [dependencies] -uteke-core = { path = "../uteke-core", version = "0.21.0", default-features = false, features = ["onnx"] } -uteke-mcp = { path = "../uteke-mcp", version = "0.21.0", default-features = false } +uteke-core = { path = "../uteke-core", version = "0.21.1", default-features = false, features = ["onnx"] } +uteke-mcp = { path = "../uteke-mcp", version = "0.21.1", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = { version = "1", optional = true } diff --git a/crates/uteke-server/src/api_registry.rs b/crates/uteke-server/src/api_registry.rs index 37fb59e1..ea4e3e68 100644 --- a/crates/uteke-server/src/api_registry.rs +++ b/crates/uteke-server/src/api_registry.rs @@ -61,7 +61,7 @@ pub struct Endpoint { } /// Complete API route registry. -/// This must be kept in sync with `handlers.rs`. +/// This must be kept in sync with the dispatch match in `handlers/mod.rs`. pub const ENDPOINTS: &[Endpoint] = &[ // ── Health & Info ──────────────────────────────────────────────────── Endpoint { diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs deleted file mode 100644 index 8563a0fd..00000000 --- a/crates/uteke-server/src/handlers.rs +++ /dev/null @@ -1,5296 +0,0 @@ -//! Route dispatcher — all HTTP endpoint handlers. -//! -//! This is the core router that dispatches incoming requests to the -//! appropriate handler based on method + path. Each endpoint's logic -//! lives inline in the match arms (no separate handler functions yet). - -use std::io::{Cursor, Read as IoRead}; -use std::sync::Mutex; - -use serde::Deserialize; -use tiny_http::{Header, Method, Request, Response, StatusCode}; -use tracing::{error, warn}; - -use uteke_core::Uteke; - -use crate::api_registry; -use crate::context::{self, ApiRole, AuthResult, ReqCtx}; -use crate::types::*; - -/// Current API version constant — used by health and versioned routes. -const API_LATEST: &str = "v2"; -const API_VERSIONS: &[&str] = &["v1", "v2"]; - -pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response>> { - let method = req.method().clone(); - let raw_path = req.url().to_string(); - - // ── API Versioning (#737): parse /api/vN/ prefix ──────────────────── - let (api_version, path) = match ApiVersion::from_path(&raw_path) { - Some((ver, stripped)) => (Some(ver), stripped.to_string()), - None => (None, raw_path.clone()), - }; - - // Route matching uses path-only (without query string). - // Handler functions still receive the full `path` with query params intact. - let route_path = path.split('?').next().unwrap_or(&path); - - // DNS-rebinding guard (#1326): runs before everything else, preflight and - // health included, since a rebinding page can hit any route. - let host_header = req - .headers() - .iter() - .find(|h| h.field.equiv("Host")) - .map(|h| h.value.as_str().to_string()); - if !ctx.host_guard.allows(host_header.as_deref()) { - return ctx.error_response_for(req, 403, "Host not allowed"); - } - - // CORS preflight — no auth required - if method == Method::Options { - return Response::new( - StatusCode::from(204), - ctx.preflight_headers(req), - Cursor::new(Vec::new()), - None, - None, - ); - } - - // Health endpoint — reachable WITHOUT a token (load balancers, uptime - // probes), but anonymous callers get a minimal body: version, memory - // counts, and update info leak the instance fingerprint to scanners (#1252). - let is_health = matches!((&method, route_path), (Method::Get, "/health")); - - // Authenticate every request; a tokenless health probe degrades to the - // Anonymous role instead of a 401 so uptime checks keep working. - let mut health_anonymous = false; - let auth_role = if !is_health { - match context::check_auth(req, ctx) { - Ok(role) => role, - Err(resp) => return resp, - } - } else { - match context::check_auth(req, ctx) { - Ok(role) => role, - Err(_) => { - health_anonymous = true; - AuthResult::Anonymous - } - } - }; - - // Enforce read-only restriction (#409, #524): - // ReadOnly tokens can use GET endpoints + read-only POST endpoints. - // Write operations (POST /remember, POST /forget, etc.) are blocked. - let read_only = matches!(auth_role, AuthResult::Authenticated(ApiRole::ReadOnly)); - if read_only { - // The gate is derived from the registry's `writes` flag (#1344): GET - // plus the POST rows that do not write (semantic search, list, - // stats). Exact path match avoids prefix-based bypass (/recallfoo). - if !api_registry::read_only_allows(method.as_str(), &path) { - return ctx.error_response_for( - req, - 403, - "Read-only token cannot perform write operations", - ); - } - } - - // The registry is the route table (#1344): a (method, path) without a row - // is a 404 whatever arms the match below still carries, so a handler - // cannot be reachable while unregistered (or ungated). - if api_registry::find(method.as_str(), route_path).is_none() { - return ctx.error_response_for(req, 404, "Not found"); - } - - // Lock the Uteke instance for the duration of this request. - // This serializes requests but prevents data races on the SQLite connection. - // Future: use rwlock for read-heavy workloads. - // A handler panic poisons the mutex; recover the guard instead of failing - // every later request. SQLite transactions roll back on unwind, so the - // underlying state stays consistent. - let uteke = uteke.lock().unwrap_or_else(|e| e.into_inner()); - - match (method, route_path) { - // ── Health ────────────────────────────────────────────────────── - (Method::Get, "/health") => { - // #1252: anonymous probe gets liveness only — no version, no - // counts, no update info. Authenticated (or auth-disabled) - // callers get the full payload, unchanged. - if health_anonymous { - #[derive(serde::Serialize)] - struct MinimalHealthResponse { - status: &'static str, - } - return ctx.ok_response_for(req, &MinimalHealthResponse { status: "ok" }); - } - let total = uteke.count(None).unwrap_or(0); - let namespaces = uteke.list_namespaces().unwrap_or_default().len(); - // Populate update_available from cache (non-blocking, no network). - let update_available = uteke_core::update_check::check_cached() - .filter(|i| i.is_update_available()) - .map(|i| i.latest); - ctx.ok_response_for( - req, - &HealthResponse { - status: "ok", - version: env!("CARGO_PKG_VERSION"), - memories: total, - namespaces, - api_versions: Some(API_VERSIONS.to_vec()), - api_latest: Some(API_LATEST), - update_available, - }, - ) - } - - // ── Memory Tools Guide (#1010) ────────────────────────────────── - (Method::Get, "/guide") => { - #[derive(serde::Serialize)] - struct GuideResponse<'a> { - guide: &'a str, - } - ctx.ok_response_for( - req, - &GuideResponse { - guide: &uteke_core::guide::default_guide(), - }, - ) - } - - // ── Route Introspection (#1289) ───────────────────────────────── - // Machine-readable API spec: serialize the api_registry ENDPOINTS - // table — the same single source of truth docgen renders into - // docs/api-reference.md — so consumers can validate against the - // real contract instead of stale notes. GET is inherently - // read-only, so the ReadOnly token gate needs no extra entry. - (Method::Get, "/routes") => ctx.ok_response_for(req, &api_registry::ENDPOINTS), - - // ── Remember ─────────────────────────────────────────────────── - (Method::Post, "/remember") => match read_body::(req.as_reader()) { - Ok(raw) => { - // Validation (content/tags, author_type, type) and the - // metadata map live in the typed request shared with MCP and - // the CLI (#1343). Order: content/tags, author_type, type; all - // before any write (#1083, #1302). - let req_data = match raw.decode() { - Ok(t) => t, - Err(e) => return ctx.error_response_for(req, 400, e.message().to_string()), - }; - let tag_refs = req_data.tag_refs(); - let metadata = req_data.metadata.clone(); - - let result: Result = - if req_data.detect_contradiction { - uteke - .remember_with_contradiction( - &req_data.content, - &tag_refs, - metadata, - ns(&req_data.namespace), - req_data.memory_type.as_deref(), - true, - 0.65, - ) - .map(|(outcome, _)| outcome) - } else if let Some(t) = req_data.memory_type.as_deref() { - uteke.remember_typed_detailed( - &req_data.content, - &tag_refs, - metadata, - ns(&req_data.namespace), - t, - ) - } else { - uteke.remember_detailed( - &req_data.content, - &tag_refs, - metadata, - ns(&req_data.namespace), - ) - }; - - match result { - Ok(outcome) => { - let id = outcome.id; - // Set source provenance after storage (#682) — matches CLI. - if req_data.source.is_some() || req_data.source_type.is_some() { - let st = req_data.source_type.as_deref().unwrap_or("user"); - if let Err(e) = uteke.set_source(&id, req_data.source.as_deref(), st) { - error!("Failed to set source for {id}: {e}"); - } - } - // Set author type after storage (#1083). Validate before - // writing so invalid values fail loudly (400, not silent). - if let Some(at) = req_data.author_type.as_deref() { - if let Err(e) = uteke.set_author_type(&id, at) { - return ctx.error_response_for(req, 400, e.to_string()); - } - } - // Echo author_type so clients can confirm what was - // recorded (#1106) — data was already stored correctly, - // the response just omitted the field. Default mirrors - // the schema default (author_type DEFAULT 'agent', #1083). - // #1273: honest embedding status — never a bare success - // when the vector write failed. - ctx.ok_response_for( - req, - &serde_json::json!({ - "id": id, - "author_type": req_data.author_type.clone().unwrap_or_else(|| "agent".to_string()), - "embedding_written": outcome.embedding_written, - "warning": uteke_core::namespace_deprecation::merge_warning( - outcome.warning, - uteke_core::namespace_deprecation::namespace_write_warning( - ns(&req_data.namespace), - ), - ), - }), - ) - } - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Recall (semantic search) ──────────────────────────────────── - (Method::Post, "/recall") => match read_body::(req.as_reader()) { - Ok(req_data) => { - // Defaults, the limit cap (#903), the blank-query rejection (#907) - // and the RFC3339 parsing of at/after/before live in the typed - // request shared with MCP and the CLI (#1343). - let typed = match req_data.decode() { - Ok(t) => t, - Err(e) => return ctx.error_response_for(req, 400, e.to_string()), - }; - let limit = typed.limit; - let tag_vec = typed.tag_filter(); - let tags_filter = tag_vec.as_deref(); - // Threshold: min_score > [recall] min_score(_strict) > built-in - // (strict 0.5, otherwise 0.0). - let min_score = typed.resolve_min_score( - ctx.recall_config.as_ref().and_then(|r| r.min_score), - ctx.recall_config.as_ref().and_then(|r| r.min_score_strict), - ); - // Entity/category filters are now pushed into the core recall - // candidate loop (#663) — no 10x fetch amplification needed. - - // Time-travel mode: recall_at_time - let point_in_time = typed.at; - - let entity_filter = req_data.entity.as_deref(); - let category_filter = req_data.category.as_deref(); - - // #902: temporal range filters (before/after). - let after_ts = typed.after; - let before_ts = typed.before; - let has_temporal = after_ts.is_some() || before_ts.is_some(); - - // Resolve recall strategy ONCE for every path (#1034): - // request `strategy` > config `[recall] default_strategy` > - // Hybrid — matching the CLI default. Unknown values are a - // loud 400 on all paths, never a silent no-op. - let strategy = match typed.strategy( - ctx.recall_config - .as_ref() - .and_then(|r| r.default_strategy.as_deref()), - ) { - Ok(s) => s, - Err(e) => return ctx.error_response_for(req, 400, e.to_string()), - }; - - // Explain mode (#1160): memory-only recall with per-result - // ranking signals. Mutually exclusive with the unified / - // time-travel / temporal surfaces. - if req_data.explain { - if req_data.search_type.is_some() { - return ctx.error_response_for( - req, - 400, - "explain is memory-only: omit search_type", - ); - } - if point_in_time.is_some() || has_temporal { - return ctx.error_response_for( - req, - 400, - "explain cannot be combined with at/before/after", - ); - } - if entity_filter.is_some() || category_filter.is_some() || req_data.enrich { - return ctx.error_response_for( - req, - 400, - "explain cannot be combined with entity/category/enrich", - ); - } - return match uteke.recall_explained( - &req_data.query, - limit, - tags_filter, - ns(&req_data.namespace), - strategy, - min_score, - ) { - Ok(explained) => ctx.ok_response_for(req, &explained), - Err(e) => { - error!("Explain recall error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }; - } - - // Budgeted context pack (#1281 Phase 1): unified recall + - // greedy character-budget fill; deterministic, LLM-free, - // rank-order preserving. Honours the caller's search_type - // (validated with the same 400 as the plain path). - if req_data.pack { - // Loud reject like the CLI: pack is not composable with - // point-in-time / range filters (the plain unified path - // consumes them further down, after this early return). - if req_data.at.is_some() - || req_data.after.is_some() - || req_data.before.is_some() - { - return ctx.error_response_for( - req, - 400, - "pack is not supported with at/after/before filters; rerun without time filters", - ); - } - let parsed_search_type = match typed.search_type() { - Ok(t) => t, - Err(e) => return ctx.error_response_for(req, 400, e.to_string()), - }; - let exclude_ids = typed.exclude_ids.clone(); - return match uteke.recall_unified_packed( - &req_data.query, - limit, - tags_filter, - ns(&req_data.namespace), - min_score, - parsed_search_type, - entity_filter, - category_filter, - req_data.enrich, - strategy, - typed.budget_chars_or_default(), - &exclude_ids, - ) { - Ok(pack) => ctx.ok_response_for(req, &pack), - Err(e) => { - error!("Pack recall error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }; - } - - // Unified search path (#531): when search_type is specified, - // use recall_unified. Entity/category filters are passed - // through to the core recall candidate loop (#663). - let unified_result = if req_data.search_type.is_some() && point_in_time.is_none() { - let search_type = match typed.search_type() { - Ok(t) => t, - Err(e) => return ctx.error_response_for(req, 400, e.to_string()), - }; - Some(uteke.recall_unified( - &req_data.query, - limit, - tags_filter, - ns(&req_data.namespace), - min_score, - search_type, - entity_filter, - category_filter, - req_data.enrich, - strategy, - )) - } else { - None - }; - - // Prefer unified results when available (#531) - match unified_result { - Some(Ok(mut results)) => { - // #902: Post-filter by temporal range (before/after). - if has_temporal { - results.retain(|r| { - let ts = r.created_at.as_ref(); - ts.is_some_and(|t| { - after_ts.is_none_or(|a| t >= &a) - && before_ts.is_none_or(|b| t <= &b) - }) - }); - } - if api_version == Some(ApiVersion::V1) { - // v1: flat format [{id, content, score, ...}] - let v1_results: Vec = - results.iter().map(to_v1_flat).collect(); - ctx.ok_response_for(req, &v1_results) - } else { - ctx.ok_response_for(req, &results) - } - } - Some(Err(e)) => { - error!("Unified search error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - None => { - // Memory-only recall path (#1034): route through - // recall_hybrid so the resolved strategy applies to - // the default path too — not just when search_type is - // present. recall_hybrid has no native entity/category - // support, so apply them as post-filters with 3× - // over-fetch (same pattern as recall_unified_memories). - let needs_post_filter = - entity_filter.is_some() || category_filter.is_some(); - let fetch_limit = if needs_post_filter { - (limit.saturating_mul(3)).max(limit + 10) - } else { - limit - }; - let recall_result = if let Some(pit) = point_in_time { - // Time-travel: strategy doesn't apply to - // recall_at_time (point-in-time semantics). - uteke.recall_at_time( - &req_data.query, - limit, - tags_filter, - ns(&req_data.namespace), - pit, - min_score, - entity_filter, - category_filter, - ) - } else { - uteke.recall_hybrid( - &req_data.query, - fetch_limit, - tags_filter, - ns(&req_data.namespace), - strategy, - min_score, - ) - }; - // Entity/category post-filter (memory results only) — - // mirrors the CLI recall post-filter and core - // recall_unified_memories (#663, #900). - match recall_result { - Ok(mut results) => { - if needs_post_filter && point_in_time.is_none() { - results.retain(|sr| { - entity_filter.is_none_or(|ent| { - sr.memory - .metadata - .get("entity") - .and_then(|v| v.as_str()) - .is_some_and(|e| e == ent) - }) && category_filter.is_none_or(|cat| { - sr.memory - .metadata - .get("category") - .and_then(|v| v.as_str()) - .is_some_and(|c| c == cat) - }) - }); - results.truncate(limit); - } - // #902: Post-filter by temporal range (before/after). - if has_temporal { - results.retain(|r| { - let t = &r.memory.created_at; - after_ts.is_none_or(|a| t >= &a) - && before_ts.is_none_or(|b| t <= &b) - }); - } - if results.is_empty() && min_score > 0.0 { - ctx.ok_response_for( - req, - &serde_json::json!({ - "results": [], - "total": 0, - "threshold": min_score, - "message": "No memories above similarity threshold" - }), - ) - } else { - ctx.ok_response_for(req, &results) - } - } - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Search (keyword) ──────────────────────────────────────────── - (Method::Post, "/search") => match read_body::(req.as_reader()) { - Ok(req_data) => { - let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect(); - let tags_filter = if tag_refs.is_empty() { - None - } else { - Some(tag_refs.as_slice()) - }; - match uteke.search( - &req_data.query, - req_data.limit, - tags_filter, - ns(&req_data.namespace), - ) { - Ok(results) => ctx.ok_response_for(req, &results), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── List ──────────────────────────────────────────────────────── - (Method::Post, "/list") => match read_body::(req.as_reader()) { - Ok(params) => { - // #1343: limit cap and `at` parsing live in the typed request. - let list_req = match params.decode() { - Ok(r) => r, - Err(e) => return ctx.error_response_for(req, 400, e.to_string()), - }; - // Time-travel mode: use list_at_time - let list_result = match list_req.at { - Some(pit) => uteke.list_at_time( - list_req.tag.as_deref(), - list_req.limit, - list_req.offset, - ns(&list_req.namespace), - pit, - ), - None => uteke.list( - list_req.tag.as_deref(), - list_req.limit, - list_req.offset, - ns(&list_req.namespace), - ), - }; - match list_result { - Ok(memories) => { - // #1188: opt-in pagination envelope. The default - // response stays a bare array for existing clients. - if list_req.wants_envelope() { - // Total matching rows (same filters as the page). - let total = match &list_req.tag { - Some(tag) => uteke.count_by_tag(tag, ns(&list_req.namespace)), - None => uteke.count(ns(&list_req.namespace)), - } - .unwrap_or(0); - let meta = list_req.page_meta(total, memories.len()); - return ctx.ok_response_for( - req, - &serde_json::json!({ - "memories": memories, - "total": total, - "has_more": meta.has_more, - "next_offset": meta.next_offset, - }), - ); - } - ctx.ok_response_for(req, &memories) - } - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Forget by ID or tag (DELETE /forget?id=xxx or ?tag=xxx) ──── - (Method::Delete, "/forget") => { - let query = path.split('?').nth(1).unwrap_or(""); - // Typed request shared with MCP and the CLI (#1343). - let request = match uteke_core::ForgetRequest::decode( - uteke_core::ForgetInput::from_http_query(query), - uteke_core::ForgetPolicy::HTTP, - ) { - Ok(r) => r, - Err(e) => return ctx.error_response_for(req, 400, e.message()), - }; - match request { - uteke_core::ForgetRequest::Id(_) => { - // Accept full UUID or short ID prefix (#794); the - // existence check (#762) is part of the HTTP policy - // because forget() silently returns Ok(()) for unknown ids. - let resolved_id = match request.resolve(&uteke, uteke_core::ForgetPolicy::HTTP) - { - Ok(Some(id)) => id, - Ok(None) => unreachable!("an id request resolves to an id"), - Err(e) => { - return match e.kind() { - uteke_core::ForgetErrorKind::NotFound => { - ctx.error_response_for(req, 404, e.message()) - } - _ => { - error!("Resolve error: {e}"); - ctx.error_response_for(req, 400, e.message()) - } - }; - } - }; - match uteke.forget(&resolved_id) { - Ok(()) => { - ctx.ok_response_for(req, &serde_json::json!({"forgotten": resolved_id})) - } - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - uteke_core::ForgetRequest::Tag { tag, namespace } => { - match uteke.bulk_forget_by_tag(&tag, namespace.as_deref()) { - Ok(result) => ctx.ok_response_for(req, &result), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - } - } - - // ── Stats (GET = all or ?namespace=) ─────────────────── - (Method::Get, "/stats") => { - // Parse ?namespace= query parameter for scoped stats (#382). - let query = req.url().split('?').nth(1).unwrap_or(""); - let params: std::collections::HashMap = query - .split('&') - .filter_map(|pair| { - let mut kv = pair.splitn(2, '='); - Some((kv.next()?.to_string(), kv.next()?.to_string())) - }) - .collect(); - let ns_param = params.get("namespace").map(|s| s.as_str()); - match uteke.stats(ns_param) { - Ok(stats) => ctx.ok_response_for(req, &stats), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - (Method::Post, "/stats") => { - #[derive(Deserialize)] - struct StatsReq { - namespace: Option, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.stats(ns(&req_data.namespace)) { - Ok(stats) => ctx.ok_response_for(req, &stats), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Lifecycle (#936): status, cycle, promote ────────────────── - (Method::Get, "/lifecycle/status") => { - let query = req.url().split('?').nth(1).unwrap_or(""); - let params: std::collections::HashMap = query - .split('&') - .filter_map(|pair| { - let mut kv = pair.splitn(2, '='); - Some((kv.next()?.to_string(), kv.next()?.to_string())) - }) - .collect(); - let ns_param = params.get("namespace").map(|s| s.as_str()); - let active = match uteke.store().count_active(ns_param) { - Ok(v) => v, - Err(e) => { - error!("lifecycle status error: {e}"); - return ctx.error_response_for(req, 500, "Internal server error"); - } - }; - let deprecated = match uteke.store().count_deprecated(ns_param) { - Ok(v) => v, - Err(e) => { - error!("lifecycle status error: {e}"); - return ctx.error_response_for(req, 500, "Internal server error"); - } - }; - #[derive(serde::Serialize)] - struct LifecycleStatusResponse { - active: usize, - deprecated: usize, - } - ctx.ok_response_for(req, &LifecycleStatusResponse { active, deprecated }) - } - - (Method::Get, "/lifecycle/deprecated") => { - let query = req.url().split('?').nth(1).unwrap_or(""); - let params: std::collections::HashMap = query - .split('&') - .filter_map(|pair| { - let mut kv = pair.splitn(2, '='); - Some((kv.next()?.to_string(), kv.next()?.to_string())) - }) - .collect(); - let ns_param = params.get("namespace").map(|s| s.as_str()); - let limit: u32 = params - .get("limit") - .and_then(|s| s.parse().ok()) - .unwrap_or(100) - .min(MAX_LIST_LIMIT as u32); - match uteke.store().list_deprecated(ns_param, limit) { - Ok(items) => { - #[derive(serde::Serialize)] - struct DeprecatedListResponse { - deprecated: Vec, - count: usize, - } - let count = items.len(); - ctx.ok_response_for( - req, - &DeprecatedListResponse { - deprecated: items, - count, - }, - ) - } - Err(e) => { - error!("lifecycle deprecated list error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - (Method::Post, "/lifecycle/cycle") => { - #[derive(Deserialize)] - struct CycleReq { - namespace: Option, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.lifecycle_cycle(ns(&req_data.namespace)) { - Ok(result) => ctx.ok_response_for(req, &result), - Err(e) => { - error!("lifecycle cycle error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - (Method::Post, "/lifecycle/promote") => { - #[derive(Deserialize)] - struct PromoteReq { - id: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.promote(&req_data.id) { - Ok(restored) => { - #[derive(serde::Serialize)] - struct PromoteResponse { - promoted: bool, - id: String, - } - ctx.ok_response_for( - req, - &PromoteResponse { - promoted: restored, - id: req_data.id, - }, - ) - } - Err(e) => { - error!("lifecycle promote error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Namespaces ────────────────────────────────────────────────── - (Method::Get, "/namespaces") => { - let with_counts = path.contains("with_counts=true"); - if with_counts { - match uteke.list_namespaces_with_lifecycle_counts() { - Ok(counts) => { - #[derive(serde::Serialize)] - struct NamespaceCount { - name: String, - count: usize, - /// Memories not deprecated (#1181). - active: usize, - /// Deprecated memories — additive fields, `count` keeps - /// the total so existing clients stay correct (#1181). - deprecated: usize, - } - let result: Vec = counts - .into_iter() - .map(|(name, active, deprecated)| NamespaceCount { - count: active + deprecated, - name, - active, - deprecated, - }) - .collect(); - ctx.ok_response_for(req, &result) - } - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } else { - match uteke.list_namespaces() { - Ok(namespaces) => ctx.ok_response_for(req, &namespaces), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - } - - // ── Namespace Rename/Merge (#1181) ────────────────────────────── - (Method::Post, "/namespaces/rename") => { - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.rename_namespace(&req_data.from, &req_data.to) { - Ok(result) => ctx.ok_response_for(req, &result), - Err(uteke_core::Error::Validation(msg)) => { - ctx.error_response_for(req, 400, msg) - } - Err(e) => { - error!("Namespace rename error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Namespace Delete with explicit strategy (#1181) ───────────── - (Method::Post, "/namespaces/delete") => { - match read_body::(req.as_reader()) { - Ok(req_data) => { - match uteke.delete_namespace( - &req_data.name, - &req_data.strategy, - req_data.target.as_deref(), - ) { - Ok(result) => ctx.ok_response_for(req, &result), - Err(uteke_core::Error::Validation(msg)) => { - // `refuse` on a non-empty namespace → 409 Conflict; - // other validation failures → 400. - let status = if req_data.strategy == "refuse" { - 409 - } else { - 400 - }; - ctx.error_response_for(req, status, msg) - } - Err(e) => { - error!("Namespace delete error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Recent (#528) ────────────────────────────────────────────── - (Method::Get, "/recent") => { - let ns = parse_query_namespace(&path); - let query = path.split('?').nth(1).unwrap_or(""); - let limit = parse_query_param(query, "limit") - .and_then(|v| v.parse::().ok()) - .unwrap_or(20) - .min(MAX_LIST_LIMIT); - let offset = parse_query_param(query, "offset") - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - match uteke.list(None, limit, offset, ns.as_deref()) { - Ok(memories) => ctx.ok_response_for(req, &memories), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Graph Visualization (#408) ─────────────────────────────────── - (Method::Get, "/graph") => { - let ns = parse_query_namespace(&path); - let query = path.split('?').nth(1).unwrap_or(""); - // `node_id` = memory id whose neighbourhood to return (viewers); - // `limit` bounds the number of edges (default 500, max 5000). - let node_id = parse_query_param(query, "node_id").filter(|s| !s.is_empty()); - let limit = parse_query_param(query, "limit") - .and_then(|v| v.parse::().ok()) - .unwrap_or(uteke_core::graph_view::GRAPH_VIEW_DEFAULT_LIMIT); - match uteke.graph_view(ns.as_deref(), node_id.as_deref(), limit) { - Ok(data) => ctx.ok_response_for(req, &data), - Err(e) => { - error!("Graph data error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Graph Mutation: Add Edge (#542) ────────────────────────────── - (Method::Post, "/graph/edge") => match read_body::(req.as_reader()) { - Ok(req_data) => { - // Reject self-loops - if req_data.source == req_data.target { - return ctx.error_response_for( - req, - 400, - "Self-loop edges are not allowed (source == target)", - ); - } - - let conn = uteke.graph_store(); - let gs = uteke_core::graph::GraphStore::new(conn); - - // Resolve an input ID to its graph node without creating - // anything: memory-linked nodes first, then explicit node IDs - // (clients may pass IDs from GET /graph) (#1180). - let resolve_node = |id: &str| -> Result, uteke_core::Error> { - if let Some(nid) = gs.node_id_for_memory(id)? { - return Ok(Some(nid)); - } - gs.get_node(id).map(|n| n.map(|node| node.id)) - }; - - // Validate both sides exist as memories or graph nodes — - // memory IDs are ensured into nodes below (#1180, relaxes the - // memory-only #542 check that made every valid call 500). - for (role, id) in [("Source", &req_data.source), ("Target", &req_data.target)] { - if matches!(uteke.get_by_id(id), Ok(Some(_))) { - continue; - } - match resolve_node(id) { - Ok(Some(_)) => {} - Ok(None) => { - return ctx.error_response_for( - req, - 404, - format!("{role} memory not found: {id}"), - ); - } - Err(e) => { - error!("Graph resolve error: {e}"); - return ctx.error_response_for(req, 500, "Internal server error"); - } - } - } - - let relation = req_data.edge_type.as_deref().unwrap_or("related"); - let weight = req_data.weight.unwrap_or(1.0); - - // Map memory IDs → graph node IDs before insertion (#1180). - // `graph_edges.source_id/target_id` carry FKs to - // `graph_nodes(id)`, so inserting raw memory IDs violates the - // FK and always 500s. Validation above guarantees each ID is - // a memory (node gets ensured) or an existing node. - let ensure = |id: &str| -> Result { - match resolve_node(id)? { - Some(nid) => Ok(nid), - None => gs.ensure_node_for_memory(id), - } - }; - let (src_node, tgt_node) = - match (ensure(&req_data.source), ensure(&req_data.target)) { - (Ok(s), Ok(t)) => (s, t), - (Err(e), _) | (_, Err(e)) => { - error!("Graph ensure_node error: {e}"); - return ctx.error_response_for(req, 500, "Internal server error"); - } - }; - - match gs.add_edge(&src_node, &tgt_node, relation, weight) { - Ok(()) => ctx.ok_response_for( - req, - &serde_json::json!({ - "ok": true, - "source_node": src_node, - "target_node": tgt_node, - }), - ), - Err(e) => { - error!("Graph add_edge error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Graph Mutation: Remove Edge (#542) ──────────────────────────── - (Method::Delete, "/graph/edge") => { - let query = path.split('?').nth(1).unwrap_or(""); - let source = parse_query_param(query, "source"); - let target = parse_query_param(query, "target"); - - match (&source, &target) { - (Some(src), Some(tgt)) => { - let conn = uteke.graph_store(); - let gs = uteke_core::graph::GraphStore::new(conn); - // Accept either memory IDs or graph node IDs (#1180) — - // resolve both sides to node IDs before deleting. - let resolve = |id: &str| -> Result, uteke_core::Error> { - if let Some(nid) = gs.node_id_for_memory(id)? { - return Ok(Some(nid)); - } - gs.get_node(id).map(|n| n.map(|node| node.id)) - }; - let (src_node, tgt_node) = match (resolve(src), resolve(tgt)) { - (Ok(Some(s)), Ok(Some(t))) => (s, t), - (Ok(_), Ok(_)) => { - return ctx.error_response_for( - req, - 404, - format!("Edge not found: {src} -> {tgt}"), - ); - } - (Err(e), _) | (_, Err(e)) => { - error!("Graph node resolve error: {e}"); - return ctx.error_response_for(req, 500, "Internal server error"); - } - }; - match gs.remove_edge(&src_node, &tgt_node) { - Ok(true) => ctx.ok_response_for(req, &serde_json::json!({"ok": true})), - Ok(false) => ctx.error_response_for( - req, - 404, - format!("Edge not found: {src} -> {tgt}"), - ), - Err(e) => { - error!("Graph remove_edge error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - _ => ctx.error_response_for( - req, - 400, - "Provide both ?source=...&target=... query parameters", - ), - } - } - - // ── Room Summary ──────────────────────────────────────────────── - (Method::Post, "/room/summary") => { - #[derive(Deserialize)] - struct RoomSummaryRequest { - room_id: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.room_summary(&req_data.room_id) { - Ok(Some(summary)) => ctx.ok_response_for(req, &summary), - Ok(None) => ctx.error_response_for( - req, - 404, - format!("Room not found: {}", req_data.room_id), - ), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Room Summary Document ──────────────────────────────────────── - (Method::Post, "/room/summary-document") => { - #[derive(Deserialize)] - struct RoomSummaryDocumentRequest { - room_id: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.room_summary_document(&req_data.room_id) { - Ok(Some(doc)) => ctx.ok_response_for(req, &doc), - Ok(None) => ctx.error_response_for( - req, - 404, - format!("Room not found: {}", req_data.room_id), - ), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Room Consolidation (#1088) ─────────────────────────────────── - // Dry-run by default; `apply: true` executes with a hard budget cap. - // Blocked for read-only tokens (not in read_only_post_paths). - (Method::Post, "/room/consolidate") => { - #[derive(Deserialize)] - struct RoomConsolidateRequest { - room_id: String, - /// Execute the plan (LLM calls + writes). Default false = dry-run. - #[serde(default)] - apply: bool, - /// Max LLM requests for this run. Default 10, hard cap 100. - #[serde(default = "default_max_calls")] - max_calls: usize, - } - fn default_max_calls() -> usize { - 10 - } - match read_body::(req.as_reader()) { - Ok(req_data) => { - let max_calls = req_data.max_calls.min(100); - let result = if req_data.apply { - let ext = resolve_extraction_config(ctx, None, None, None); - if ext.api_key.is_empty() { - return ctx.error_response_for( - req, - 503, - "Consolidation apply requires server-side extraction LLM config", - ); - } - match uteke_core::consolidation_api::consolidate_room( - &uteke, - &req_data.room_id, - &ext, - max_calls, - ) { - Ok(exec) => serde_json::to_value(&exec).unwrap_or_default(), - Err(e) => { - error!("Internal error: {e}"); - return ctx.error_response_for(req, 500, "Internal server error"); - } - } - } else { - match uteke_core::consolidation_api::plan_room(&uteke, &req_data.room_id) { - Ok(dry) => serde_json::json!({ - "room_id": req_data.room_id, - "dry_run": true, - "total_memories": dry.plan.total_memories, - "batches": dry.plan.batches.len(), - "estimated_llm_calls": dry.plan.batches.len().min(max_calls), - "plan": dry.plan, - }), - Err(e) => { - error!("Internal error: {e}"); - return ctx.error_response_for(req, 500, "Internal server error"); - } - } - }; - ctx.ok_response_for(req, &result) - } - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── DEPRECATED: POST /room/document → /room/summary-document (#735) - (Method::Post, "/room/document") => { - warn!( - "DEPRECATED: POST /room/document is renamed to POST /room/summary-document (see #735)" - ); - #[derive(Deserialize)] - struct RoomDocumentRequest { - room_id: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.room_summary_document(&req_data.room_id) { - Ok(Some(doc)) => ctx.ok_response_for(req, &doc), - Ok(None) => ctx.error_response_for( - req, - 404, - format!("Room not found: {}", req_data.room_id), - ), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Get memory by ID ────────────────────────────────────────── - (Method::Get, "/memory") => { - let query = path.split('?').nth(1).unwrap_or(""); - let id = parse_query_param(query, "id").unwrap_or_default(); - // Validate UUID format - if uuid::Uuid::parse_str(&id).is_err() { - return ctx.error_response_for(req, 400, format!("Invalid UUID format: {id}")); - } - match uteke.get_by_id(&id) { - Ok(Some(memory)) => ctx.ok_response_for(req, &memory), - Ok(None) => ctx.error_response_for(req, 404, format!("Memory not found: {id}")), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Memory Pin/Unpin (#660) ────────────────────────────────────── - (Method::Post, "/memory/pin") => match read_body::(req.as_reader()) { - Ok(req_data) => { - if uuid::Uuid::parse_str(&req_data.id).is_err() { - return ctx.error_response_for( - req, - 400, - format!("Invalid UUID format: {}", req_data.id), - ); - } - let result = if req_data.pinned { - uteke.pin(&req_data.id) - } else { - uteke.unpin(&req_data.id) - }; - match result { - Ok(true) => match uteke.get_by_id(&req_data.id) { - Ok(Some(memory)) => ctx.ok_response_for(req, &memory), - Ok(None) => ctx.error_response_for( - req, - 500, - "Memory updated but could not be retrieved", - ), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Ok(false) => ctx.error_response_for( - req, - 404, - format!("Memory not found: {}", req_data.id), - ), - Err(e) => { - error!("Pin/unpin error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Memory Set Importance (#660) ────────────────────────────────── - (Method::Post, "/memory/importance") => { - match read_body::(req.as_reader()) { - Ok(req_data) => { - if uuid::Uuid::parse_str(&req_data.id).is_err() { - return ctx.error_response_for( - req, - 400, - format!("Invalid UUID format: {}", req_data.id), - ); - } - match uteke.set_importance(&req_data.id, req_data.importance) { - Ok(true) => match uteke.get_by_id(&req_data.id) { - Ok(Some(memory)) => ctx.ok_response_for(req, &memory), - Ok(None) => ctx.error_response_for( - req, - 500, - "Memory updated but could not be retrieved", - ), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Ok(false) => ctx.error_response_for( - req, - 404, - format!("Memory not found: {}", req_data.id), - ), - Err(e) => match e { - uteke_core::Error::Validation(_) => { - ctx.error_response_for(req, 400, e.to_string()) - } - _ => { - error!("Set importance error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - } - } - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Memory Feedback (Trust Scoring) (#718) ──────────────────── - (Method::Post, "/memory/feedback") => { - match read_body::(req.as_reader()) { - Ok(req_data) => { - if uuid::Uuid::parse_str(&req_data.id).is_err() { - return ctx.error_response_for( - req, - 400, - format!("Invalid UUID format: {}", req_data.id), - ); - } - let result = match req_data.feedback.as_str() { - "helpful" => uteke.feedback_helpful(&req_data.id).map(|imp| { - serde_json::json!({ - "id": req_data.id, - "feedback": "helpful", - "delta": 0.05, - "importance": imp, - }) - }), - "unhelpful" => uteke.feedback_unhelpful(&req_data.id).map(|imp| { - serde_json::json!({ - "id": req_data.id, - "feedback": "unhelpful", - "delta": -0.10, - "importance": imp, - }) - }), - _ => { - return ctx.error_response_for( - req, - 400, - "Invalid feedback value. Use 'helpful' or 'unhelpful'.".to_string(), - ); - } - }; - match result { - Ok(data) => ctx.ok_response_for(req, &data), - Err(e) => { - error!("Feedback error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Update memory by ID (PUT /memory, #659) ────────────────── - (Method::Put, "/memory") => match read_body::(req.as_reader()) { - Ok(req_data) => { - // Validate UUID format - if uuid::Uuid::parse_str(&req_data.id).is_err() { - return ctx.error_response_for( - req, - 400, - format!("Invalid UUID format: {}", req_data.id), - ); - } - // Check that at least one field is provided - if req_data.content.is_none() - && req_data.tags.is_none() - && req_data.metadata.is_none() - && req_data.importance.is_none() - && req_data.pinned.is_none() - && req_data.memory_type.is_none() - && req_data.namespace.is_none() - { - return ctx.error_response_for( - req, - 400, - "No fields to update. Provide at least one of: content, tags, metadata, importance, pinned, memory_type, namespace", - ); - } - let tag_refs: Option> = req_data.tags; - let tag_slice: Option<&[String]> = tag_refs.as_deref(); - match uteke.update_memory( - &req_data.id, - req_data.content.as_deref(), - tag_slice, - req_data.metadata.as_ref(), - req_data.importance, - req_data.pinned, - req_data.memory_type.as_deref(), - ) { - Ok(true) => { - // Namespace move (#1181): separate op after the field - // update — plain column change, no re-embed needed. - if let Some(ns) = req_data.namespace.as_deref() { - if let Err(e) = uteke.move_memory(&req_data.id, ns) { - let status = match e { - uteke_core::Error::Validation(_) => 400, - _ => 500, - }; - return ctx.error_response_for(req, status, e.to_string()); - } - } - ctx.ok_response_for( - req, - &with_namespace_warning( - serde_json::json!({"updated": req_data.id}), - req_data.namespace.as_deref(), - ), - ) - } - Ok(false) => ctx.error_response_for( - req, - 404, - format!("Memory not found: {}", req_data.id), - ), - Err(e) => { - error!("Update memory error: {e}"); - // Propagate validation errors with proper status codes - let status = match e { - uteke_core::Error::Validation(_) => 400, - _ => 500, - }; - ctx.error_response_for(req, status, e.to_string()) - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Room Memories (chronological listing — GET /room/memories) ──── - (Method::Get, "/room/memories") => { - let query_str = path.split('?').nth(1); - let room_id = query_str.and_then(|q| parse_query_param(q, "room_id")); - let room_id = match room_id { - Some(id) => id, - None => { - return ctx.error_response_for( - req, - 400, - "Missing required parameter: room_id. Usage: GET /room/memories?room_id=[&author=&namespace=&limit=]", - ); - } - }; - let limit = query_str - .and_then(|q| parse_query_param(q, "limit")) - .and_then(|v| v.parse::().ok()) - .unwrap_or(100); - let author = query_str.and_then(|q| parse_query_param(q, "author")); - let namespace = query_str.and_then(|q| parse_query_param(q, "namespace")); - match uteke.recall_room_scoped(&room_id, author.as_deref(), namespace.as_deref(), limit) - { - Ok(memories) => ctx.ok_response_for(req, &memories), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Room Recall (semantic with optional fallback to chronological) ── - (Method::Post, "/room/recall") => match read_body::(req.as_reader()) { - Ok(req_data) => { - // Time-travel: parse & validate `at` before any query so an - // invalid timestamp fails loudly (400) instead of being - // silently ignored (#1082). - let point_in_time = match req_data.at.as_deref() { - Some(at_str) => match chrono::DateTime::parse_from_rfc3339(at_str) { - Ok(dt) => Some(dt.with_timezone(&chrono::Utc)), - Err(_) => { - return ctx.error_response_for( - req, - 400, - format!( - "Invalid 'at' timestamp: {at_str}. Use RFC3339 format (e.g. 2026-06-01T12:00:00Z)" - ), - ); - } - }, - None => None, - }; - let query = req_data.query.as_deref().unwrap_or("").trim(); - if query.is_empty() { - // No query provided — fall back to chronological recall (#785) - // Over-fetch when time-traveling: the SQL LIMIT applies - // before the temporal post-filter, so fetch extra rows - // and truncate after filtering (cora MAJOR on #1085). - let fetch_limit = if point_in_time.is_some() && req_data.limit > 0 { - req_data.limit.saturating_mul(3).max(req_data.limit + 10) - } else { - req_data.limit - }; - match uteke.recall_room( - &req_data.room_id, - req_data.author.as_deref(), - fetch_limit, - ) { - Ok(memories) => { - let mut memories = match point_in_time { - Some(pit) => filter_room_memories_at_time(memories, pit), - None => memories, - }; - if point_in_time.is_some() && req_data.limit > 0 { - memories.truncate(req_data.limit); - } - ctx.ok_response_for(req, &memories) - } - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } else { - // Semantic recall with query - let min_score = req_data.min_score.unwrap_or( - ctx.recall_config - .as_ref() - .and_then(|r| r.min_score) - .unwrap_or(DEFAULT_MIN_SCORE as f64) as f32, - ); - match uteke.recall_room_semantic( - &req_data.room_id, - query, - req_data.limit, - req_data.author.as_deref(), - min_score, - ) { - Ok(results) => { - let results = match point_in_time { - Some(pit) => { - let mut kept: Vec<_> = results - .into_iter() - .filter(|sr| memory_exists_at(&sr.memory, pit)) - .collect(); - kept.truncate(req_data.limit.max(1)); - kept - } - None => results, - }; - ctx.ok_response_for(req, &results) - } - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Room management endpoints (#395) ──────────────────────────── - (Method::Post, "/room/create") => { - #[derive(Deserialize)] - struct RoomCreateRequest { - room_id: String, - #[serde(default)] - title: Option, - #[serde(default = "default_namespace")] - namespace: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => { - match uteke.create_room( - &req_data.room_id, - req_data.title.as_deref(), - &req_data.namespace, - ) { - Ok(()) => ctx.ok_response_for( - req, - &with_namespace_warning( - serde_json::json!({ - "created": req_data.room_id, - "namespace": req_data.namespace - }), - Some(req_data.namespace.as_str()), - ), - ), - Err(e) => { - let msg = format!("Failed to create room: {e}"); - ctx.error_response_for(req, 400, &msg) - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // POST /room/rename — rename a room, moving all member/document - // links in one transaction (#1202) - (Method::Post, "/room/rename") => { - #[derive(Deserialize)] - struct RoomRenameRequest { - from: String, - to: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.rename_room(&req_data.from, &req_data.to) { - Ok(room) => ctx.ok_response_for( - req, - &serde_json::json!({ - "renamed": req_data.from, - "to": req_data.to, - "room": room, - }), - ), - Err(e) => { - let status = match e { - uteke_core::Error::Validation(_) => 400, - _ => 500, - }; - ctx.error_response_for(req, status, e.to_string()) - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // POST /room/update — update room title/description (#1202) - (Method::Post, "/room/update") => { - #[derive(Deserialize)] - struct RoomUpdateRequest { - room_id: String, - #[serde(default)] - title: Option, - #[serde(default)] - description: Option, - } - match read_body::(req.as_reader()) { - Ok(req_data) => { - match uteke.update_room( - &req_data.room_id, - req_data.title.as_deref(), - req_data.description.as_deref(), - ) { - Ok(Some(room)) => { - ctx.ok_response_for(req, &serde_json::json!({ "room": room })) - } - Ok(None) => ctx.error_response_for( - req, - 404, - format!("Room not found: {}", req_data.room_id), - ), - Err(e) => { - let status = match e { - uteke_core::Error::Validation(_) => 400, - _ => 500, - }; - ctx.error_response_for(req, status, e.to_string()) - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // POST /room/memory/move — move a memory from one room to another, - // preserving link author/role/joined_at (#1202) - (Method::Post, "/room/memory/move") => { - #[derive(Deserialize)] - struct RoomMemoryMoveRequest { - memory_id: String, - from_room: String, - to_room: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => { - match uteke.move_memory_to_room( - &req_data.memory_id, - &req_data.from_room, - &req_data.to_room, - ) { - Ok(1) => ctx.ok_response_for( - req, - &serde_json::json!({ - "moved": req_data.memory_id, - "from_room": req_data.from_room, - "to_room": req_data.to_room, - }), - ), - Ok(0) => ctx.error_response_for( - req, - 404, - format!( - "Memory {} has no link in room {}", - req_data.memory_id, req_data.from_room - ), - ), - Ok(_) => unreachable!("move returns 0 or 1"), - Err(e) => { - let status = match e { - uteke_core::Error::Validation(_) => 400, - _ => 500, - }; - ctx.error_response_for(req, status, e.to_string()) - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // POST /room/remember — store memory and link to room (#762) - (Method::Post, "/room/remember") => { - match read_body::(req.as_reader()) { - Ok(req_data) => { - if let Err(e) = uteke_core::validate_input(&req_data.content, &req_data.tags) { - return ctx.error_response_for(req, 400, e.to_string()); - } - let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect(); - let memory_type = req_data.r#type.as_deref().unwrap_or("fact"); - let author = req_data.author.as_deref().unwrap_or("user"); - match uteke.remember_in_room( - &req_data.content, - &tag_refs, - req_data.metadata.clone(), - ns(&req_data.namespace), - memory_type, - &req_data.room_id, - author, - ) { - Ok(outcome) => ctx.ok_response_for( - req, - &serde_json::json!({ - "id": outcome.id, - "room_id": req_data.room_id, - "embedding_written": outcome.embedding_written, - "warning": uteke_core::namespace_deprecation::merge_warning( - outcome.warning, - uteke_core::namespace_deprecation::namespace_write_warning( - ns(&req_data.namespace), - ), - ), - }), - ), - Err(e) => { - error!("room/remember error: {e}"); - let status = match e { - uteke_core::Error::Validation(_) => 400, - _ => 500, - }; - ctx.error_response_for(req, status, e.to_string()) - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - (Method::Get, "/room/list") => { - let ns_param = parse_query_namespace(&path); - match uteke.list_rooms(ns_param.as_deref()) { - Ok(rooms) => ctx.ok_response_for(req, &rooms), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - (Method::Post, "/room/stats") => { - #[derive(Deserialize)] - struct RoomStatsRequest { - room_id: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.room_stats(&req_data.room_id) { - Ok(Some(stats)) => ctx.ok_response_for(req, &stats), - Ok(None) => ctx.error_response_for( - req, - 404, - format!("Room not found: {}", req_data.room_id), - ), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Room ↔ Document junction (#689) ────────────────────────────── - // POST /room/document/list — list documents linked to a room - (Method::Post, "/room/document/list") => { - #[derive(Deserialize)] - struct RoomDocListReq { - room_id: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.room_list_documents(&req_data.room_id) { - Ok(slugs) => ctx.ok_response_for( - req, - &serde_json::json!({ "room_id": req_data.room_id, "doc_slugs": slugs }), - ), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // PUT /room/document/add — link a document to a room - (Method::Put, "/room/document/add") => { - #[derive(Deserialize)] - struct RoomDocAddReq { - room_id: String, - doc_slug: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.room_add_document(&req_data.room_id, &req_data.doc_slug) { - Ok(()) => ctx.ok_response_for(req, &serde_json::json!({ "status": "linked", "room_id": req_data.room_id, "doc_slug": req_data.doc_slug })), - Err(e) => match e { - uteke_core::Error::Validation(_) => { - ctx.error_response_for(req, 400, e.to_string()) - } - _ => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // DELETE /room/document/remove — unlink a document from a room - (Method::Delete, "/room/document/remove") => { - #[derive(Deserialize)] - struct RoomDocRemoveReq { - room_id: String, - doc_slug: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.room_remove_document(&req_data.room_id, &req_data.doc_slug) { - Ok(()) => ctx.ok_response_for(req, &serde_json::json!({ "status": "unlinked", "room_id": req_data.room_id, "doc_slug": req_data.doc_slug })), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // POST /doc/room/list — list rooms linked to a document - (Method::Post, "/doc/room/list") => { - #[derive(Deserialize)] - struct DocRoomListReq { - doc_slug: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.document_list_rooms(&req_data.doc_slug) { - Ok(room_ids) => ctx.ok_response_for( - req, - &serde_json::json!({ "doc_slug": req_data.doc_slug, "room_ids": room_ids }), - ), - Err(e) => { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - (Method::Delete, "/room/delete") => { - let room_id = if let Some(q) = path.split('?').nth(1) { - parse_query_param(q, "room_id") - } else { - // Try reading from query params in headers or body - None - }; - let room_id = match room_id { - Some(id) => id, - None => { - // Try body as JSON - #[derive(Deserialize)] - struct RoomDeleteRequest { - room_id: String, - } - match read_body::(req.as_reader()) { - Ok(data) => data.room_id, - Err(_) => { - return ctx.error_response_for(req, 400, "Missing 'room_id' parameter"); - } - } - } - }; - match uteke.delete_room(&room_id) { - Ok(unlinked) => ctx.ok_response_for( - req, - &serde_json::json!({ - "deleted": room_id, - "unlinked_memories": unlinked, - "note": "memories and documents are preserved in their namespaces, no longer linked to any room" - }), - ), - Err(e) => { - let msg = format!("{e}"); - if msg.contains("not found") { - ctx.error_response_for(req, 404, &msg) - } else { - error!("Internal error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - } - // ── Context Summary (#442) ─────────────────────────────────────── - (Method::Post, "/context") => match read_body::(req.as_reader()) { - Ok(body) => { - let ns = body.get("namespace").and_then(|v| v.as_str()); - match uteke.build_context(ns) { - Ok(context) => { - let resp = serde_json::json!({ "context": context }); - ctx.ok_response_for(req, &resp) - } - Err(e) => { - error!("Context error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(_) => ctx.error_response_for(req, 400, "Invalid JSON body"), - }, - - // ── Dream Cycle (#442) ───────────────────────────────────────────── - (Method::Post, "/dream") => match read_body::(req.as_reader()) { - Ok(body) => { - let ns = body.get("namespace").and_then(|v| v.as_str()); - let dry_run = body - .get("dry_run") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - match uteke.dream(ns, dry_run, &[]) { - Ok(report) => ctx.ok_response_for(req, &report), - Err(e) => { - error!("Dream error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(_) => ctx.error_response_for(req, 400, "Invalid JSON body"), - }, - - (Method::Post, "/mcp") => { - // Enforce a body size limit to prevent memory exhaustion - // (CodeCora #397). 1 MiB is generous for JSON-RPC. - const MAX_MCP_BODY: u64 = 1024 * 1024; - // Check Content-Length and reject oversized requests. - let content_length = req - .headers() - .iter() - .find(|h| h.field.equiv("Content-Length")) - .and_then(|h| h.value.as_str().parse::().ok()) - .unwrap_or(0); - if content_length > MAX_MCP_BODY { - return ctx.error_response_for(req, 413, "Payload too large"); - } - // Read one byte past the cap so a chunked/unsized oversized body is - // a 413 instead of a silently truncated JSON parse error (#1328). - let mut body = String::new(); - if let Err(e) = req - .as_reader() - .take(MAX_MCP_BODY + 1) - .read_to_string(&mut body) - { - return ctx.error_response_for(req, 400, format!("Failed to read body: {e}")); - } - if body.len() as u64 > MAX_MCP_BODY { - return ctx.error_response_for(req, 413, "Payload too large"); - } - let cors = ctx.cors_headers_for(req); - // None = notification (no response per JSON-RPC 2.0 §4.1) → 204 No Content - // Read-only tokens reach /mcp (#1439); uteke-mcp refuses the - // tools outside READ_ONLY_TOOLS as tool errors. - let access = if read_only { - uteke_mcp::Access::ReadOnly - } else { - uteke_mcp::Access::Full - }; - let mut resp = match uteke_mcp::handle_jsonrpc_as(&uteke, &body, access) { - Some(response) => tiny_http::Response::from_string(response) - .with_header( - tiny_http::Header::from_bytes( - &b"Content-Type"[..], - &b"application/json"[..], - ) - .unwrap(), - ) - .with_header( - tiny_http::Header::from_bytes( - &b"MCP-Protocol-Version"[..], - &b"2025-06-18"[..], - ) - .unwrap(), - ), - None => tiny_http::Response::from_string("") - .with_status_code(204) - .with_header( - tiny_http::Header::from_bytes( - &b"MCP-Protocol-Version"[..], - &b"2025-06-18"[..], - ) - .unwrap(), - ), - }; - for h in cors { - resp.add_header(h); - } - resp - } - - // ── Document: Create / Upsert ──────────────────────────────────── - (Method::Post, "/doc/create") => match read_body::(req.as_reader()) { - Ok(req_data) => { - let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect(); - let parent = req_data.parent.as_deref(); - match uteke.doc_upsert_with_parent( - &req_data.slug, - req_data.title.as_deref().unwrap_or(""), - &req_data.content, - &tag_refs, - None, - parent, - ) { - Ok(id) => ctx.ok_response_for( - req, - &serde_json::json!({"id": id, "slug": req_data.slug}), - ), - Err(e) => { - if e.to_string().contains("already exists") { - ctx.error_response_for( - req, - 409, - format!("document slug '{}' already exists", req_data.slug), - ) - } else if e.to_string().contains("maximum") - || e.to_string().contains("parent") - { - ctx.error_response_for(req, 400, e.to_string()) - } else { - error!("doc create error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Document: Get ─────────────────────────────────────────────── - (Method::Post, "/doc/get") => match read_body::(req.as_reader()) { - Ok(req_data) => match resolve_doc_id(&req_data) { - Ok(id_or_slug) => match uteke.doc_get(id_or_slug) { - Ok(Some(doc)) => ctx.ok_response_for(req, &doc), - Ok(None) => ctx.error_response_for( - req, - 404, - format!("document not found: {id_or_slug}"), - ), - Err(e) => { - error!("doc get error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - }, - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Document: Update (partial) ──────────────────────────────── - (Method::Post, "/doc/update") => match read_body::(req.as_reader()) { - Ok(req_data) => match resolve_doc_id_update(&req_data) { - Ok(id_or_slug) => { - let title = req_data.title.as_deref(); - let content = req_data.content.as_deref(); - let tags = req_data.tags.as_deref(); - let metadata = req_data.metadata.as_ref(); - match uteke.doc_update(id_or_slug, title, content, tags, metadata) { - Ok(Some(doc)) => ctx.ok_response_for(req, &doc), - Ok(None) => ctx.error_response_for( - req, - 404, - format!("document not found: {id_or_slug}"), - ), - Err(e) => { - error!("doc update error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Document: List ───────────────────────────────────────────── - (Method::Post, "/doc/list") => match read_body::(req.as_reader()) { - Ok(mut params) => { - params.limit = params.limit.min(MAX_LIST_LIMIT); - let result = if params.roots_only { - uteke.doc_list_roots(params.namespace.as_deref(), params.limit) - } else if let Some(ref parent) = params.parent { - uteke.doc_list_children(parent, params.limit) - } else { - uteke.doc_list(params.namespace.as_deref(), params.limit) - }; - match result { - Ok(docs) => ctx.ok_response_for(req, &docs), - Err(e) => { - error!("doc list error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Document: Search ──────────────────────────────────────────── - (Method::Post, "/doc/search") => match read_body::(req.as_reader()) { - Ok(req_data) => { - match uteke.doc_search(&req_data.query, req_data.limit, &req_data.mode) { - Ok(results) => ctx.ok_response_for(req, &results), - Err(e) => { - if e.to_string().contains("embed") { - ctx.error_response_for( - req, - 503, - "embedding model not available for semantic search", - ) - } else { - error!("doc search error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Document: Move ─────────────────────────────────────────────── - (Method::Post, "/doc/move") => match read_body::(req.as_reader()) { - Ok(req_data) => match resolve_doc_id_move(&req_data) { - Ok(id_or_slug) => { - let new_parent = req_data.new_parent.as_deref(); - let new_sort_order = req_data.new_sort_order; - match uteke.doc_move(id_or_slug, new_parent, new_sort_order) { - Ok(moved) => ctx.ok_response_for(req, &serde_json::json!({"moved": moved})), - Err(e) => { - if e.to_string().contains("not found") { - ctx.error_response_for(req, 404, e.to_string()) - } else { - error!("doc move error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Document: Delete ───────────────────────────────────────────── - (Method::Delete, "/doc/delete") => { - // Extract query string only — req.url() returns full URL which - // parse_query_param() cannot handle (#776). - let query = req.url().split('?').nth(1).unwrap_or(""); - let id = parse_query_param(query, "id"); - let slug = parse_query_param(query, "slug"); - - let id_or_slug = match (&id, &slug) { - (Some(id), _) => id.as_str(), - (_, Some(slug)) => slug.as_str(), - _ => { - return ctx.error_response_for( - req, - 400, - "provide either 'id' or 'slug' query parameter", - ); - } - }; - - match uteke.doc_delete(id_or_slug) { - Ok((deleted, subtree)) => ctx.ok_response_for( - req, - &serde_json::json!({"deleted": deleted, "subtree_size": subtree}), - ), - Err(e) => { - error!("doc delete error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Cross-entity references (#689) ─────────────────────────────── - // POST /memory/doc-refs — get document slugs referenced by a memory - (Method::Post, "/memory/doc-refs") => { - #[derive(Deserialize)] - struct MemoryDocRefsReq { - memory_id: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.recall_documents_for_memory(&req_data.memory_id) { - Ok(slugs) => ctx.ok_response_for( - req, - &serde_json::json!({ - "memory_id": req_data.memory_id, - "doc_slugs": slugs, - }), - ), - Err(e) => { - error!("memory/doc-refs error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // POST /doc/mem-refs — get memory IDs that reference a document - (Method::Post, "/doc/mem-refs") => { - #[derive(Deserialize)] - struct DocMemRefsReq { - doc_slug: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.recall_memories_for_document(&req_data.doc_slug) { - Ok(memory_ids) => ctx.ok_response_for( - req, - &serde_json::json!({ - "doc_slug": req_data.doc_slug, - "memory_ids": memory_ids, - }), - ), - Err(e) => { - error!("doc/mem-refs error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Tags: List with counts ─────────────────────────────────────── - (Method::Get, "/tags") => { - let ns = parse_query_namespace(&path); - match uteke.tags_with_counts(ns.as_deref()) { - Ok(tags) => ctx.ok_response_for(req, &tags), - Err(e) => { - error!("Tags list error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Tags: Rename ───────────────────────────────────────────────── - (Method::Post, "/tags/rename") => match read_body::(req.as_reader()) { - Ok(req_data) => { - let count = - match uteke.rename_tag(&req_data.old, &req_data.new, ns(&req_data.namespace)) { - Ok(count) => count, - Err(e) => { - error!("Tag rename error: {e}"); - return ctx.error_response_for(req, 500, "Internal server error"); - } - }; - ctx.ok_response_for( - req, - &serde_json::json!({ - "renamed": count > 0, - "count": count, - "old": req_data.old, - "new": req_data.new, - }), - ) - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Tags: Delete ───────────────────────────────────────────────── - (Method::Post, "/tags/delete") => match read_body::(req.as_reader()) { - Ok(req_data) => { - let count = match uteke.delete_tag(&req_data.tag, ns(&req_data.namespace)) { - Ok(count) => count, - Err(e) => { - error!("Tag delete error: {e}"); - return ctx.error_response_for(req, 500, "Internal server error"); - } - }; - ctx.ok_response_for( - req, - &serde_json::json!({ - "deleted": count > 0, - "count": count, - "tag": req_data.tag, - }), - ) - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Pin ─────────────────────────────────────────────────────────── - (Method::Post, "/pin") => match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.pin(&req_data.id) { - Ok(true) => ctx.ok_response_for(req, &serde_json::json!({"pinned": req_data.id})), - Ok(false) => { - ctx.error_response_for(req, 404, format!("Memory not found: {}", req_data.id)) - } - Err(e) => { - error!("Pin error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Unpin ───────────────────────────────────────────────────────── - (Method::Post, "/unpin") => match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.unpin(&req_data.id) { - Ok(true) => ctx.ok_response_for(req, &serde_json::json!({"unpinned": req_data.id})), - Ok(false) => { - ctx.error_response_for(req, 404, format!("Memory not found: {}", req_data.id)) - } - Err(e) => { - error!("Unpin error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Provenance chain (#1172) ───────────────────────────────────── - (Method::Get, "/provenance") => { - let query = path.split('?').nth(1).unwrap_or(""); - let id = match parse_query_param(query, "id") { - Some(id) => id, - None => return ctx.error_response_for(req, 400, "Missing 'id' query parameter"), - }; - match uteke.provenance(&id) { - Ok(Some(report)) => ctx.ok_response_for(req, &report), - Ok(None) => ctx.error_response_for(req, 404, format!("Memory not found: {id}")), - Err(e) => { - error!("Provenance error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Contradiction resolutions ledger (#1172) ──────────────────── - (Method::Get, "/contradictions") => { - let query = path.split('?').nth(1).unwrap_or(""); - let ns = parse_query_namespace(&path); - let limit = parse_query_param(query, "limit") - .and_then(|v| v.parse::().ok()) - .unwrap_or(50) - .min(MAX_LIST_LIMIT); - match uteke.contradiction_resolutions(ns.as_deref(), limit) { - Ok(resolutions) => ctx.ok_response_for(req, &resolutions), - Err(e) => { - error!("Contradiction resolutions error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Undo a contradiction resolution (#1172) ───────────────────── - (Method::Post, "/contradictions/undo") => { - #[derive(Deserialize)] - struct ContradictionUndoRequest { - /// ID of the retired (superseded) memory to restore. - id: String, - } - match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.undo_supersession(&req_data.id) { - Ok(Some(winner)) => ctx.ok_response_for( - req, - &serde_json::json!({ - "undone": true, - "restored": req_data.id, - "was_superseded_by": winner, - }), - ), - Ok(None) => ctx.error_response_for( - req, - 404, - format!("No supersession found for memory: {}", req_data.id), - ), - Err(e) => { - error!("Contradiction undo error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Timeline ───────────────────────────────────────────────────── - (Method::Get, "/timeline") => { - let query = path.split('?').nth(1).unwrap_or(""); - let id = match parse_query_param(query, "id") { - Some(id) => id, - None => return ctx.error_response_for(req, 400, "Missing 'id' query parameter"), - }; - let limit = parse_query_param(query, "limit") - .and_then(|v| v.parse::().ok()) - .unwrap_or(50); - match uteke.timeline(&id, limit) { - Ok(events) => ctx.ok_response_for(req, &events), - Err(e) => { - error!("Timeline error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Edges ──────────────────────────────────────────────────────── - (Method::Get, "/edges") => { - let query = path.split('?').nth(1).unwrap_or(""); - let id = match parse_query_param(query, "id") { - Some(id) => id, - None => return ctx.error_response_for(req, 400, "Missing 'id' query parameter"), - }; - match uteke.edges_for(&id) { - Ok(edges) => ctx.ok_response_for(req, &edges), - Err(e) => { - error!("Edges error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Extract (LLM fact extraction → store) ──────────────────────── - (Method::Post, "/extract") => match read_body::(req.as_reader()) { - Ok(req_data) => { - if validate_content_size(&req_data.content, 1_048_576).is_err() { - return ctx.error_response_for(req, 413, "Content too large (max 1MB)"); - } - if let Err(e) = uteke_core::validate_input(&req_data.content, &req_data.tags) { - return ctx.error_response_for(req, 400, e.to_string()); - } - - let ext_config = resolve_extraction_config( - ctx, - req_data.model.as_deref(), - req_data.max_facts, - None, // api_key from config only (not from request body) - ); - - let extractor = match uteke_core::extraction::Extractor::new(&ext_config) { - Ok(e) => e, - Err(e) => { - error!("Extractor init error: {e}"); - return ctx.error_response_for(req, 400, e.to_string()); - } - }; - - let facts = match extractor.extract(&req_data.content) { - Ok(f) => f, - Err(e) => { - error!("Extraction error: {e}"); - return ctx.error_response_for(req, 502, format!("Extraction failed: {e}")); - } - }; - - // Store each extracted fact as a memory - let mut stored_ids = Vec::new(); - let fact_ns = ns(&req_data.namespace); - - for fact in &facts { - // Build tags: caller-provided tags + scene tag if present (#1009). - let mut all_tags: Vec = - req_data.tags.iter().map(|s| s.to_string()).collect(); - if let Some(ref scene) = fact.scene { - let scene_tag = format!("scene:{}", scene); - if !all_tags.contains(&scene_tag) { - all_tags.push(scene_tag); - } - } - let tag_refs: Vec<&str> = all_tags.iter().map(|s| s.as_str()).collect(); - - let mut meta = serde_json::Map::new(); - meta.insert( - "source".into(), - serde_json::Value::String("extraction".into()), - ); - let metadata = if meta.is_empty() { - None - } else { - Some(serde_json::Value::Object(meta)) - }; - - // Use remember_typed when the LLM or request provided a type (#1009). - let effective_type = fact.fact_type.as_deref().or(req_data.r#type.as_deref()); - let result = if let Some(ft) = effective_type { - uteke.remember_typed(&fact.content, &tag_refs, metadata, fact_ns, ft) - } else { - uteke.remember(&fact.content, &tag_refs, metadata, fact_ns) - }; - - if let Ok(id) = result { - // Set importance if the LLM provided a priority score (#1009). - if let Some(priority) = fact.priority { - let _ = uteke.set_importance(&id, priority); - } - // Auto-populate source provenance (#1013). - let _ = uteke.set_source(&id, Some("api:extraction"), "extract"); - stored_ids.push(id); - } - } - - ctx.ok_response_for( - req, - &with_namespace_warning( - serde_json::json!({ - "facts": facts.iter().map(|f| &f.content).collect::>(), - "count": facts.len(), - "stored": stored_ids.len(), - "stored_ids": stored_ids, - }), - fact_ns, - ), - ) - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Import (JSONL) ────────────────────────────────────────────── - (Method::Post, "/import") => match read_body::(req.as_reader()) { - Ok(req_data) => { - // Same ceiling `read_body` enforces on the whole request, so the - // limit the caller is told about is the limit that applies (#1328). - let max_import = uteke_core::MAX_PAYLOAD_SIZE; - if validate_content_size(&req_data.content, max_import).is_err() { - return ctx.error_response_for( - req, - 413, - format!("Content too large (max {}MB)", max_import / 1_048_576), - ); - } - - // Merge request tags into the JSONL entries. - // The core import method handles re-embedding. - let import_ns = ns(&req_data.namespace); - match uteke.import(&req_data.content, import_ns) { - Ok(result) => ctx.ok_response_for( - req, - &with_namespace_warning( - serde_json::json!({ - "imported": result.imported, - "skipped": result.skipped, - }), - import_ns, - ), - ), - Err(e) => { - error!("Import error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Export (JSONL) ────────────────────────────────────────────── - (Method::Get, "/export") => { - let export_ns = parse_query_namespace(&path); - match uteke.export(export_ns.as_deref()) { - Ok(jsonl) => { - let mut headers = ctx.cors_headers_for(req); - headers.push( - Header::from_bytes(&b"Content-Type"[..], &b"application/x-ndjson"[..]) - .unwrap(), - ); - Response::new( - StatusCode::from(200), - headers, - Cursor::new(jsonl.into_bytes()), - None, - None, - ) - } - Err(e) => { - error!("Export error: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - - // ── Prune (maintenance) ─────────────────────────────────────────── - // ── Verify / Repair (index consistency, #1266) ─────────────────── - // POST /verify → VerifyReport (read-only check, write token like - // other maintenance endpoints). POST /repair → rebuilds the vector - // index from SQLite embeddings so stores can recover from desync - // (e.g. version-lagged serve binaries, #1245 class) WITHOUT a - // restart — the previous runbook required stopping uteke-serve - // (pitfall 0f lock contention). - (Method::Post, "/verify") => match uteke.verify() { - Ok(r) => ctx.ok_response_for(req, &r), - Err(e) => { - error!("Request failed: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - - (Method::Post, "/repair") => match uteke.repair() { - Ok(r) => ctx.ok_response_for(req, &r), - Err(e) => { - error!("Request failed: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - - (Method::Post, "/prune") => match read_body::(req.as_reader()) { - Ok(req_data) => { - let result = - uteke.prune(req_data.ttl_days, ns(&req_data.namespace), req_data.dry_run); - match result { - Ok(r) => ctx.ok_response_for(req, &r), - Err(e) => { - error!("Request failed: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Consolidate (maintenance) ──────────────────────────────────── - (Method::Post, "/consolidate") => match read_body::(req.as_reader()) { - Ok(req_data) => { - if req_data.dry_run { - let pairs = uteke.find_duplicates(ns(&req_data.namespace), req_data.threshold); - match pairs { - Ok(p) => ctx.ok_response_for(req, &p), - Err(e) => { - error!("Request failed: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } else { - let result = - uteke.consolidate(ns(&req_data.namespace), req_data.threshold, false); - match result { - Ok(r) => ctx.ok_response_for(req, &r), - Err(e) => { - error!("Request failed: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // Per-pair dedup control (#1076): caller picks the survivor. - (Method::Post, "/consolidate/pair") => { - match read_body::(req.as_reader()) { - Ok(req_data) => { - let result = uteke.consolidate_pair( - &req_data.id_keep, - &req_data.id_remove, - req_data.hard, - ); - match result { - Ok(r) => ctx.ok_response_for(req, &r), - Err(e) => { - let status = if matches!(e, uteke_core::Error::Validation(_)) { - 400 - } else { - 500 - }; - ctx.error_response_for(req, status, e.to_string()) - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── Aging (maintenance) ───────────────────────────────────────── - (Method::Post, "/aging") => match read_body::(req.as_reader()) { - Ok(req_data) => { - let result = match req_data.action.as_str() { - "status" => { - let status = uteke.aging_status(ns(&req_data.namespace)); - status.map(|s| serde_json::json!(s)) - } - "preview" => { - let older = req_data.older_than_days.unwrap_or(90); - let max_acc = req_data.max_access_count.unwrap_or(1); - let mems = uteke.aging_preview(older, max_acc, ns(&req_data.namespace)); - mems.map(|m| serde_json::json!(m)) - } - "cleanup" => { - if req_data.dry_run { - let older = req_data.older_than_days.unwrap_or(90); - let max_acc = req_data.max_access_count.unwrap_or(1); - let mems = uteke.aging_preview(older, max_acc, ns(&req_data.namespace)); - mems.map(|m| serde_json::json!({ "dry_run": true, "candidates": m.len(), "memories": m })) - } else { - let older = req_data.older_than_days.unwrap_or(90); - let max_acc = req_data.max_access_count.unwrap_or(1); - let r = uteke.aging_cleanup(older, max_acc, ns(&req_data.namespace)); - r.map(|c| serde_json::json!(c)) - } - } - other => { - return ctx.error_response_for( - req, - 400, - format!("Unknown action: {other}. Use: status, preview, cleanup"), - ); - } - }; - match result { - Ok(r) => ctx.ok_response_for(req, &r), - Err(e) => { - error!("Request failed: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Importance (monitoring) ──────────────────────────────────────── - (Method::Post, "/importance") => match read_body::(req.as_reader()) { - Ok(_req_data) => match uteke.recompute_importance() { - Ok(count) => ctx.ok_response_for(req, &serde_json::json!({ "updated": count })), - Err(e) => { - error!("Request failed: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Orphans (monitoring — read-only) ───────────────────────────── - (Method::Post, "/orphans") => match read_body::(req.as_reader()) { - Ok(req_data) => { - match uteke.find_orphans( - ns(&req_data.namespace), - req_data.threshold, - req_data.limit, - ) { - Ok(orphans) => ctx.ok_response_for(req, &orphans), - Err(e) => { - error!("Request failed: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - } - } - Err(e) => ctx.error_response_for(req, 400, e), - }, - - // ── Rebuild Backlinks (monitoring) ──────────────────────────────── - (Method::Post, "/rebuild-backlinks") => { - match read_body::(req.as_reader()) { - Ok(_req_data) => match uteke.rebuild_backlinks() { - Ok(count) => { - ctx.ok_response_for(req, &serde_json::json!({ "backlinks_created": count })) - } - Err(e) => { - error!("Request failed: {e}"); - ctx.error_response_for(req, 500, "Internal server error") - } - }, - Err(e) => ctx.error_response_for(req, 400, e), - } - } - - // ── 404 ───────────────────────────────────────────────────────── - // Unreachable while every registry row has an arm (enforced by the - // `every_registry_row_has_a_handler_arm` test); kept for exhaustiveness. - _ => { - #[cfg(test)] - UNHANDLED_ROW.with(|c| c.set(true)); - ctx.error_response_for(req, 404, "Not found") - } - } -} - -// Test probe: set when a registry row reached the match fallback, i.e. it -// has no handler arm. -#[cfg(test)] -thread_local! { - static UNHANDLED_ROW: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// Helper: validate content for extract/import to prevent abuse. -fn validate_content_size(content: &str, max_bytes: usize) -> Result<(), &'static str> { - if content.len() > max_bytes { - Err("Content too large") - } else { - Ok(()) - } -} - -/// Helper: resolve extraction config with per-request overrides. -fn resolve_extraction_config( - ctx: &ReqCtx, - req_model: Option<&str>, - req_max_facts: Option, - req_api_key: Option<&str>, -) -> uteke_core::extraction::ExtractionConfig { - let base = ctx.extraction_config.clone().unwrap_or_default(); - uteke_core::extraction::ExtractionConfig { - mode: base.mode, - model: req_model.map(String::from).unwrap_or(base.model), - api_key: req_api_key.map(String::from).unwrap_or(base.api_key), - base_url: base.base_url, - endpoint_path: base.endpoint_path, - max_facts: req_max_facts - .unwrap_or(base.max_facts) - .min(MAX_EXTRACT_FACTS), - } -} - -/// Point-in-time predicate for room time-travel (#1082). -/// Mirrors the core `recall_at_time` temporal rules: memory must have been -/// created at or before `pit`, not yet invalidated (valid_until), not -/// deprecated, and valid_from must not be in the future relative to `pit`. -/// -/// Known limitation (#1086): the schema stores no deprecation timestamp, -/// so a memory deprecated *after* `pit` is also excluded here — time-travel -/// treats `deprecated` as "never existed". Same rule as core `recall_at_time`. -fn memory_exists_at( - memory: &uteke_core::memory::types::Memory, - pit: chrono::DateTime, -) -> bool { - if memory.created_at > pit { - return false; - } - if let Some(valid_until) = memory.valid_until { - if valid_until <= pit { - return false; - } - } - if memory.deprecated { - // Deprecated before the point-in-time → did not exist then; - // deprecated after it → existed (#1086). Matches core semantics: - // NULL deprecated_at is treated as "deprecated after the pit" - // (the v17 migration backfills a timestamp onto every deprecated - // row, so NULL + deprecated should not occur in practice). - let gone_by_then = match memory.deprecated_at { - Some(dep_at) => dep_at <= pit, - None => false, - }; - if gone_by_then { - return false; - } - } - if let Some(valid_from) = memory.valid_from { - if valid_from > pit { - return false; - } - } - true -} - -/// Apply the time-travel predicate to chronological room recall results -/// (no-query path), preserving chronological order (#1082). -fn filter_room_memories_at_time( - memories: Vec, - pit: chrono::DateTime, -) -> Vec { - memories - .into_iter() - .filter(|m| memory_exists_at(m, pit)) - .collect() -} - -/// Add the namespace deprecation `warning` (#1427) to a write response that -/// has no warning field of its own. Nothing is added for the default -/// namespace, so those responses stay byte-identical. -fn with_namespace_warning( - mut body: serde_json::Value, - namespace: Option<&str>, -) -> serde_json::Value { - if let Some(w) = uteke_core::namespace_deprecation::namespace_write_warning(namespace) - && let Some(obj) = body.as_object_mut() - { - obj.insert("warning".into(), serde_json::Value::String(w.to_string())); - } - body -} - -#[cfg(test)] -mod room_memories_namespace_tests { - use super::*; - use tiny_http::TestRequest; - - /// Minimal harness mirroring ContradictionApp: no embedder (CI-safe), - /// route() called directly (#1288). - struct RoomMemoriesApp { - uteke: Mutex, - } - - impl RoomMemoriesApp { - fn new() -> Self { - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - } - } - - fn call( - &self, - method: Method, - url: &str, - body: Option, - ) -> (u16, serde_json::Value) { - let mut req = match body { - Some(b) => { - let leaked: &'static str = Box::leak(b.into_boxed_str()); - TestRequest::new() - .with_method(method) - .with_path(url) - .with_body(leaked) - .into() - } - None => TestRequest::new().with_method(method).with_path(url).into(), - }; - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - } - - #[test] - fn room_memories_namespace_filter_and_param_observable() { - let app = RoomMemoriesApp::new(); - - let body = serde_json::json!({ "room_id": "ns-room", "namespace": "default" }).to_string(); - let (status, resp) = app.call(Method::Post, "/room/create", Some(body)); - assert_eq!(status, 200, "{resp}"); - - let mut ids: Vec = Vec::new(); - for (ns, author) in [("cmo", "cmo-agent"), ("hermes", "hermes-agent")] { - let body = serde_json::json!({ - "room_id": "ns-room", - "content": format!("note from {ns}"), - "namespace": ns, - "author": author - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/room/remember", Some(body)); - assert_eq!(status, 200, "{resp}"); - ids.push(resp["id"].as_str().expect("memory id").to_string()); - } - - // URL constants for the query-string URLs used below. - const URL_ALL: &str = "/room/memories?room_id=ns-room"; - const URL_CMO: &str = "/room/memories?room_id=ns-room&namespace=cmo"; - const URL_CMO_OTHER_AUTHOR: &str = - "/room/memories?room_id=ns-room&namespace=cmo&author=hermes-agent"; - - // Unfiltered: cross-namespace default — both entries. - let (status, resp) = app.call(Method::Get, URL_ALL, None); - assert_eq!(status, 200, "{resp}"); - let arr = resp.as_array().expect("bare JSON array response"); - assert_eq!( - arr.len(), - 2, - "cross-namespace default must return both: {resp}" - ); - - // namespace=cmo → exactly the cmo entry (#1288 — was silently ignored). - let (status, resp) = app.call(Method::Get, URL_CMO, None); - assert_eq!(status, 200, "{resp}"); - let arr = resp.as_array().expect("bare JSON array response"); - assert_eq!(arr.len(), 1, "namespace filter must narrow results: {resp}"); - assert_eq!(arr[0]["namespace"], serde_json::json!("cmo")); - assert_eq!(arr[0]["content"], serde_json::json!("note from cmo")); - - // namespace + author combined. - let (status, resp) = app.call(Method::Get, URL_CMO_OTHER_AUTHOR, None); - assert_eq!(status, 200, "{resp}"); - let arr = resp.as_array().expect("bare JSON array response"); - assert!( - arr.is_empty(), - "conflicting filters must yield empty: {resp}" - ); - } -} - -#[cfg(test)] -mod room_recall_at_tests { - use super::*; - use chrono::Utc; - - fn mem(created_offset_secs: i64) -> uteke_core::memory::types::Memory { - uteke_core::memory::types::Memory { - id: uuid::Uuid::new_v4().to_string(), - content: format!("m{created_offset_secs}"), - embedding: Vec::new(), - tags: Vec::new(), - metadata: serde_json::Value::Object(serde_json::Map::new()), - created_at: Utc::now() + chrono::Duration::seconds(created_offset_secs), - updated_at: Utc::now(), - namespace: "default".into(), - access_count: 0, - recall_count: 0, - last_accessed: None, - deprecated: false, - deprecated_at: None, - valid_from: None, - valid_until: None, - memory_type: "fact".into(), - importance: 0.5, - pinned: false, - content_type: "text".into(), - slug: None, - source: None, - source_type: "user".into(), - author_type: "agent".into(), - } - } - - #[test] - fn at_time_excludes_future_and_invalidated() { - let now = Utc::now(); - let old = mem(-3600); // created an hour ago — existed at `now` - let future = mem(3600); // created an hour from now — must be excluded - let mut invalidated = mem(-7200); - invalidated.valid_until = Some(now - chrono::Duration::seconds(60)); // expired before `now` - let mut not_yet_valid = mem(-7200); - not_yet_valid.valid_from = Some(now + chrono::Duration::seconds(60)); - - assert!(memory_exists_at(&old, now)); - assert!(!memory_exists_at(&future, now)); - assert!(!memory_exists_at(&invalidated, now)); - assert!(!memory_exists_at(¬_yet_valid, now)); - } - - #[test] - fn at_time_excludes_deprecated() { - let now = Utc::now(); - let mut dep = mem(-60); - dep.deprecated = true; - dep.deprecated_at = Some(now - chrono::Duration::seconds(30)); // deprecated before pit - assert!(!memory_exists_at(&dep, now)); - } - - #[test] - fn at_time_includes_deprecated_after_pit() { - // #1086: deprecated AFTER the pit — the memory existed then. - let now = Utc::now(); - let mut dep = mem(-60); - dep.deprecated = true; - dep.deprecated_at = Some(now + chrono::Duration::seconds(30)); // deprecated after pit - assert!(memory_exists_at(&dep, now)); - } - - #[test] - fn at_time_boundary_inclusive_created_at() { - let exact = mem(0); - // created_at == pit: memory existed at that instant (inclusive, matches core recall_at_time rule `> pit → reject`) - assert!(memory_exists_at(&exact, exact.created_at)); - } - - #[test] - fn filter_room_memories_preserves_order() { - let now = Utc::now(); - let older = mem(-100); - let newer = mem(-50); - let future = mem(100); - let out = filter_room_memories_at_time(vec![older, newer, future], now); - assert_eq!(out.len(), 2); - assert_eq!(out[0].content, "m-100"); - assert_eq!(out[1].content, "m-50"); - } - - #[test] - fn room_recall_request_deserializes_at() { - // Regression for #1082: `at` must be a recognized field, not silently dropped. - let req: RoomRecallRequest = - serde_json::from_str(r#"{"room_id":"r1","limit":3,"at":"2020-01-01T00:00:00Z"}"#) - .expect("at field must deserialize"); - assert_eq!(req.at.as_deref(), Some("2020-01-01T00:00:00Z")); - assert_eq!(req.room_id, "r1"); - // Omitted → None (backwards compatible) - let req: RoomRecallRequest = - serde_json::from_str(r#"{"room_id":"r1"}"#).expect("no-at body must parse"); - assert!(req.at.is_none()); - } - - /// #1076: ConsolidatePairRequest deserialization — happy path + defaults. - #[test] - fn consolidate_pair_request_parses() { - let req: ConsolidatePairRequest = - serde_json::from_str(r#"{"id_keep":"abc","id_remove":"def","hard":true}"#) - .expect("pair body must parse"); - assert_eq!(req.id_keep, "abc"); - assert_eq!(req.id_remove, "def"); - assert!(req.hard); - - // hard defaults to false - let req: ConsolidatePairRequest = - serde_json::from_str(r#"{"id_keep":"abc","id_remove":"def"}"#).unwrap(); - assert!(!req.hard); - - // missing id_remove → 400 at deserialize - assert!(serde_json::from_str::(r#"{"id_keep":"abc"}"#).is_err()); - } - - // ── /graph/edge memory-ID contract (#1180) ───────────────────────── - - use tiny_http::TestRequest; - - struct GraphEdgeApp { - uteke: Mutex, - } - - impl GraphEdgeApp { - fn new() -> Self { - // No embedder: these endpoints are storage-only, and tests must - // run in CI builds without the ONNX runtime lib. - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - } - } - - fn call( - &self, - method: Method, - url: &str, - body: Option, - ) -> (u16, serde_json::Value) { - let mut req = match body { - Some(b) => { - let leaked: &'static str = Box::leak(b.into_boxed_str()); - TestRequest::new() - .with_method(method) - .with_path(url) - .with_body(leaked) - .into() - } - None => TestRequest::new().with_method(method).with_path(url).into(), - }; - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - - fn remember(&self, content: &str) -> String { - let body = serde_json::json!({ "content": content }).to_string(); - let (status, resp) = self.call(Method::Post, "/remember", Some(body)); - assert_eq!(status, 200, "remember must succeed: {resp}"); - resp["id"] - .as_str() - .unwrap_or_else(|| panic!("remember response must carry id: {resp}")) - .to_string() - } - - fn add_edge(&self, source: &str, target: &str) -> (u16, serde_json::Value) { - let body = serde_json::json!({ "source": source, "target": target }).to_string(); - self.call(Method::Post, "/graph/edge", Some(body)) - } - - fn delete_edge(&self, source: &str, target: &str) -> (u16, serde_json::Value) { - let url = format!("/graph/edge?source={source}&target={target}"); - self.call(Method::Delete, &url, None) - } - } - - #[test] - fn graph_edge_post_with_memory_ids_returns_ok() { - let app = GraphEdgeApp::new(); - let id1 = app.remember("Uteke is a local-first memory engine"); - let id2 = app.remember("Corin renders the memory graph"); - - let (status, resp) = app.add_edge(&id1, &id2); - assert_eq!( - status, 200, - "valid memory IDs must not 500 (FK mismatch #1180): {resp}" - ); - assert_eq!(resp["ok"], serde_json::json!(true)); - } - - #[test] - fn graph_edge_post_resolves_memory_ids_to_nodes() { - let app = GraphEdgeApp::new(); - let id1 = app.remember("alpha memory"); - let id2 = app.remember("beta memory"); - - let (status, resp) = app.add_edge(&id1, &id2); - assert_eq!(status, 200, "{resp}"); - - // POST still reports the graph-node ids it created (#1180)... - assert!(resp["source_node"].as_str().is_some()); - assert!(resp["target_node"].as_str().is_some()); - - // ...but GET /graph keys nodes by MEMORY id (#1366) so a viewer can - // open them, and keeps `memory_id` for clients that map back. - let (_, graph) = app.call(Method::Get, "/graph", None); - let nodes = graph["nodes"].as_array().expect("nodes array"); - for (id, text) in [(&id1, "alpha memory"), (&id2, "beta memory")] { - assert!( - nodes.iter().any(|n| n["id"] == serde_json::json!(id) - && n["memory_id"] == serde_json::json!(id) - && n["label"].as_str().is_some_and(|l| l.starts_with(text))), - "node for memory {id} missing: {graph}" - ); - } - - // The edge is visible with both long and short endpoint names. - let edges = graph["edges"].as_array().expect("edges array"); - assert!( - edges - .iter() - .any(|e| e["source_id"] == serde_json::json!(id1) - && e["target_id"] == serde_json::json!(id2) - && e["source"] == serde_json::json!(id1) - && e["target"] == serde_json::json!(id2) - && e["relation"] == serde_json::json!("related")), - "edge must appear in GET /graph: {graph}" - ); - assert_eq!(graph["truncated"], serde_json::json!(false)); - } - - #[test] - fn graph_node_id_and_limit_query_params() { - let app = GraphEdgeApp::new(); - let a = app.remember("hub memory"); - let b = app.remember("spoke one"); - let c = app.remember("spoke two"); - let d = app.remember("elsewhere one"); - let e = app.remember("elsewhere two"); - for (s, t) in [(&a, &b), (&a, &c), (&d, &e)] { - let (status, resp) = app.add_edge(s, t); - assert_eq!(status, 200, "{resp}"); - } - - let hood_url = format!("/graph?node_id={a}"); - let (_, hood) = app.call(Method::Get, &hood_url, None); - let touching: Vec<&serde_json::Value> = hood["edges"] - .as_array() - .unwrap() - .iter() - .filter(|e| e["relation"] == serde_json::json!("related")) - .collect(); - assert!( - touching - .iter() - .all(|e| e["source"] == serde_json::json!(a) || e["target"] == serde_json::json!(a)), - "node_id must restrict to that memory's edges: {hood}" - ); - assert!(touching.len() >= 2, "{hood}"); - - let capped_url = "/graph?limit=1"; - let (_, capped) = app.call(Method::Get, capped_url, None); - assert_eq!(capped["edges"].as_array().unwrap().len(), 1, "{capped}"); - assert_eq!(capped["truncated"], serde_json::json!(true)); - } - - #[test] - fn graph_edge_post_accepts_graph_node_ids_too() { - let app = GraphEdgeApp::new(); - let id1 = app.remember("source memory"); - let id2 = app.remember("target memory"); - - let (_, first) = app.add_edge(&id1, &id2); - let src_node = first["source_node"].as_str().unwrap().to_string(); - - // A client holding IDs from GET /graph must still be able to link a - // graph node directly (e.g. entity nodes without a memory link). - let (status, resp) = app.add_edge(&src_node, &id2); - assert_eq!(status, 200, "node IDs must stay accepted: {resp}"); - } - - #[test] - fn graph_edge_post_with_unknown_memory_returns_404() { - let app = GraphEdgeApp::new(); - let ghost1 = "00000000-0000-4000-8000-000000000000"; - let ghost2 = "11111111-1111-4111-8111-111111111111"; - - let (status, resp) = app.add_edge(ghost1, ghost2); - assert_eq!(status, 404, "unknown source must 404: {resp}"); - } - - #[test] - fn graph_edge_post_self_loop_rejected() { - let app = GraphEdgeApp::new(); - let id1 = app.remember("self loop probe"); - - let (status, _) = app.add_edge(&id1, &id1); - assert_eq!(status, 400, "self-loop must stay rejected"); - } - - #[test] - fn graph_edge_delete_accepts_memory_ids() { - let app = GraphEdgeApp::new(); - let id1 = app.remember("edge delete probe a"); - let id2 = app.remember("edge delete probe b"); - - let (status, resp) = app.add_edge(&id1, &id2); - assert_eq!(status, 200, "{resp}"); - - // DELETE with the same memory IDs must resolve back to the nodes. - let (status, resp) = app.delete_edge(&id1, &id2); - assert_eq!(status, 200, "delete by memory IDs: {resp}"); - assert_eq!(resp["ok"], serde_json::json!(true)); - - // Second delete: edge gone → 404, not 500. - let (status, resp) = app.delete_edge(&id1, &id2); - assert_eq!(status, 404, "deleting a removed edge must 404: {resp}"); - } - - #[test] - fn graph_edge_delete_accepts_node_ids_and_unknown_ids() { - let app = GraphEdgeApp::new(); - let id1 = app.remember("node id delete probe a"); - let id2 = app.remember("node id delete probe b"); - - let (_, resp) = app.add_edge(&id1, &id2); - let src_node = resp["source_node"].as_str().unwrap().to_string(); - let tgt_node = resp["target_node"].as_str().unwrap().to_string(); - - // Node IDs (from GET /graph payloads) must keep working. - let (status, resp) = app.delete_edge(&src_node, &tgt_node); - assert_eq!(status, 200, "delete by node IDs: {resp}"); - - // Fully unknown IDs must 404 — never 500. - let (status, resp) = app.delete_edge("ghost-a", "ghost-b"); - assert_eq!(status, 404, "unknown IDs must 404: {resp}"); - } - - #[test] - fn graph_edge_delete_requires_both_params() { - let app = GraphEdgeApp::new(); - let url = "/graph/edge?source=only-one"; - let (status, _) = app.call(Method::Delete, url, None); - assert_eq!(status, 400, "missing target param must 400"); - } - - // ── Namespace management API (#1181) ─────────────────────────────── - - struct NamespaceApp { - uteke: Mutex, - } - - impl NamespaceApp { - fn new() -> Self { - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - } - } - - fn call( - &self, - method: Method, - url: &str, - body: Option, - ) -> (u16, serde_json::Value) { - let mut req = match body { - Some(b) => { - let leaked: &'static str = Box::leak(b.into_boxed_str()); - TestRequest::new() - .with_method(method) - .with_path(url) - .with_body(leaked) - .into() - } - None => TestRequest::new().with_method(method).with_path(url).into(), - }; - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - - fn remember_in(&self, content: &str, namespace: &str) -> String { - let body = - serde_json::json!({ "content": content, "namespace": namespace }).to_string(); - let (status, resp) = self.call(Method::Post, "/remember", Some(body)); - assert_eq!(status, 200, "remember must succeed: {resp}"); - resp["id"].as_str().expect("id").to_string() - } - } - - #[test] - fn namespace_put_memory_moves_namespace() { - let app = NamespaceApp::new(); - let id = app.remember_in("to be moved", "alpha"); - - let body = serde_json::json!({ "id": id, "namespace": "beta" }).to_string(); - let (status, resp) = app.call(Method::Put, "/memory", Some(body)); - assert_eq!(status, 200, "PUT with namespace must succeed: {resp}"); - - // The move is visible via GET /memory. - let get_url = format!("/memory?id={id}"); - let (status, resp) = app.call(Method::Get, &get_url, None); - assert_eq!(status, 200); - assert_eq!(resp["namespace"], serde_json::json!("beta")); - - // The old name vanishes from listings (derived view). - let (_, list) = app.call(Method::Get, "/namespaces", None); - let names: Vec<&str> = list - .as_array() - .expect("namespaces array") - .iter() - .filter_map(|v| v.as_str()) - .collect(); - assert!(!names.contains(&"alpha"), "old namespace gone: {list:?}"); - assert!(names.contains(&"beta"), "{list:?}"); - } - - #[test] - fn namespace_rename_and_merge_endpoint() { - let app = NamespaceApp::new(); - app.remember_in("one", "old"); - app.remember_in("two", "old"); - app.remember_in("three", "existing"); - - let body = serde_json::json!({ "from": "old", "to": "existing" }).to_string(); - let (status, resp) = app.call(Method::Post, "/namespaces/rename", Some(body)); - assert_eq!(status, 200, "{resp}"); - assert_eq!(resp["moved"], serde_json::json!(2)); - assert_eq!(resp["target_existed"], serde_json::json!(true)); - assert_eq!(resp["from"], serde_json::json!("old")); - assert_eq!(resp["to"], serde_json::json!("existing")); - } - - #[test] - fn namespace_delete_refuse_returns_409() { - let app = NamespaceApp::new(); - app.remember_in("keep me", "busy"); - - let body = serde_json::json!({ "name": "busy" }).to_string(); - let (status, resp) = app.call(Method::Post, "/namespaces/delete", Some(body)); - assert_eq!( - status, 409, - "refuse on non-empty namespace must 409: {resp}" - ); - assert!( - resp["error"] - .as_str() - .unwrap_or_default() - .to_lowercase() - .contains("refus") - ); - } - - #[test] - fn room_rename_update_move_endpoints_roundtrip() { - let app = NamespaceApp::new(); - - // Create rooms + one room memory. - let body = serde_json::json!({ "room_id": "ws", "namespace": "default" }).to_string(); - let (status, resp) = app.call(Method::Post, "/room/create", Some(body)); - assert_eq!(status, 200, "{resp}"); - let body = serde_json::json!({ "room_id": "ws-3", "namespace": "default" }).to_string(); - let (status, resp) = app.call(Method::Post, "/room/create", Some(body)); - assert_eq!(status, 200, "{resp}"); - - let body = serde_json::json!({ - "content": "room note", - "tags": [], - "room_id": "ws", - "author": "tester" - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/room/remember", Some(body)); - assert_eq!(status, 200, "{resp}"); - let memory_id = resp["id"].as_str().expect("memory id").to_string(); - - // Rename ws → ws-2. - let body = serde_json::json!({ "from": "ws", "to": "ws-2" }).to_string(); - let (status, resp) = app.call(Method::Post, "/room/rename", Some(body)); - assert_eq!(status, 200, "{resp}"); - assert_eq!(resp["renamed"], serde_json::json!("ws")); - assert_eq!(resp["room"]["id"], serde_json::json!("ws-2")); - - // Update title/description on ws-2. - let body = serde_json::json!({ - "room_id": "ws-2", - "title": "Workspace", - "description": "Renamed workspace" - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/room/update", Some(body)); - assert_eq!(status, 200, "{resp}"); - assert_eq!( - resp["room"]["description"], - serde_json::json!("Renamed workspace") - ); - - // Move the memory ws-2 → ws-3. - let body = serde_json::json!({ - "memory_id": memory_id, - "from_room": "ws-2", - "to_room": "ws-3" - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/room/memory/move", Some(body.clone())); - assert_eq!(status, 200, "{resp}"); - assert_eq!(resp["moved"], serde_json::json!(memory_id)); - - // Moving again → 404 (no link left in source). - let (status, resp) = app.call(Method::Post, "/room/memory/move", Some(body)); - assert_eq!(status, 404, "{resp}"); - assert!( - resp["error"] - .as_str() - .unwrap_or_default() - .contains("no link") - ); - } - - #[test] - fn room_update_missing_room_returns_404() { - let app = NamespaceApp::new(); - let body = serde_json::json!({ "room_id": "ghost", "title": "x" }).to_string(); - let (status, resp) = app.call(Method::Post, "/room/update", Some(body)); - assert_eq!(status, 404, "{resp}"); - } - - #[test] - fn namespace_delete_merge_removes_namespace() { - let app = NamespaceApp::new(); - app.remember_in("m one", "doomed"); - app.remember_in("m two", "doomed"); - - let body = serde_json::json!({ "name": "doomed", "strategy": "merge", "target": "safe" }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/namespaces/delete", Some(body)); - assert_eq!(status, 200, "{resp}"); - assert_eq!(resp["affected"], serde_json::json!(2)); - assert_eq!(resp["empty"], serde_json::json!(true)); - - let (_, list) = app.call(Method::Get, "/namespaces", None); - let names: Vec<&str> = list - .as_array() - .expect("namespaces array") - .iter() - .filter_map(|v| v.as_str()) - .collect(); - assert!(!names.contains(&"doomed"), "{list:?}"); - assert!(names.contains(&"safe"), "{list:?}"); - } - - #[test] - fn namespace_with_counts_reports_lifecycle_split() { - let app = NamespaceApp::new(); - let id = app.remember_in("ghost food", "ghosted"); - let _ = id; - let body = serde_json::json!({ "name": "ghosted", "strategy": "deprecate" }).to_string(); - let (status, resp) = app.call(Method::Post, "/namespaces/delete", Some(body)); - assert_eq!(status, 200, "{resp}"); - assert_eq!(resp["affected"], serde_json::json!(1)); - assert_eq!(resp["empty"], serde_json::json!(false)); - - let counts_url = "/namespaces?with_counts=true"; - let (_, counts) = app.call(Method::Get, counts_url, None); - let arr = counts.as_array().expect("counts array"); - let ghost = arr - .iter() - .find(|n| n["name"] == serde_json::json!("ghosted")) - .expect("ghost namespace stays listed"); - assert_eq!(ghost["active"], serde_json::json!(0)); - assert_eq!(ghost["deprecated"], serde_json::json!(1)); - assert_eq!(ghost["count"], serde_json::json!(1)); - } - - // ── Provenance API (#1172) ───────────────────────────────────────── - - #[test] - fn provenance_endpoint_reports_chain_and_hash() { - let app = NamespaceApp::new(); - let id = app.remember_in("provenance surface probe", "audit"); - - let get_url = format!("/provenance?id={id}"); - let (status, report) = app.call(Method::Get, &get_url, None); - assert_eq!(status, 200, "{report}"); - assert_eq!(report["id"], serde_json::json!(id)); - assert_eq!(report["namespace"], serde_json::json!("audit")); - assert_eq!(report["author_type"], serde_json::json!("agent")); - assert_eq!(report["trust_tier"], serde_json::json!("agent")); - assert_eq!(report["source_hash"], report["content_hash_now"]); - let events = report["events"].as_array().expect("events array"); - assert!( - events - .iter() - .any(|e| e["event_type"] == serde_json::json!("created")), - "created event must be in the chain: {report}" - ); - - // Unknown ID → 404. - let (status, _) = app.call( - Method::Get, - "/provenance?id=00000000-0000-4000-8000-000000000000", - None, - ); - assert_eq!(status, 404); - - // Missing param → 400. - let (status, _) = app.call(Method::Get, "/provenance", None); - assert_eq!(status, 400); - } -} - -// ── Contradiction ledger API (#1172 Fase 2) ───────────────────────── - -#[cfg(test)] -mod contradiction_api_tests { - use super::*; - use tiny_http::TestRequest; - - struct ContradictionApp { - uteke: Mutex, - } - - impl ContradictionApp { - fn new() -> Self { - // No embedder: storage-only endpoints, CI-safe without ONNX. - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - } - } - - fn call( - &self, - method: Method, - url: &str, - body: Option, - ) -> (u16, serde_json::Value) { - let mut req = match body { - Some(b) => { - let leaked: &'static str = Box::leak(b.into_boxed_str()); - TestRequest::new() - .with_method(method) - .with_path(url) - .with_body(leaked) - .into() - } - None => TestRequest::new().with_method(method).with_path(url).into(), - }; - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - - fn remember_in(&self, content: &str, namespace: &str) -> String { - let body = - serde_json::json!({ "content": content, "namespace": namespace }).to_string(); - let (status, resp) = self.call(Method::Post, "/remember", Some(body)); - assert_eq!(status, 200, "remember must succeed: {resp}"); - resp["id"] - .as_str() - .unwrap_or_else(|| panic!("remember response must carry id: {resp}")) - .to_string() - } - - fn supersede(&self, old: &str, new: &str) { - // Setup via the core API — the server has no supersede route; - // the surfaces under test are the ledger + undo endpoints. - self.uteke - .lock() - .expect("poisoned mutex") - .supersede(old, new, None) - .expect("supersede must succeed"); - } - } - - #[test] - fn ledger_lists_superseded_and_undo_restores() { - let app = ContradictionApp::new(); - let old = app.remember_in("deploy target is VM-1", "ops"); - let new = app.remember_in("deploy target is VM-2", "ops"); - app.supersede(&old, &new); - - let list_url = "/contradictions?namespace=ops"; - let (status, ledger) = app.call(Method::Get, list_url, None); - assert_eq!(status, 200, "{ledger}"); - let entries = ledger.as_array().expect("ledger array"); - assert_eq!( - entries.len(), - 1, - "one supersession must be listed: {ledger}" - ); - assert_eq!(entries[0]["id"], serde_json::json!(old)); - let reason = entries[0]["deprecate_reason"].as_str().unwrap_or(""); - assert!( - reason.contains("superseded"), - "reason must mention superseded: {reason}" - ); - - // Undo via POST body. - let undo_body = serde_json::json!({ "id": old }).to_string(); - let (status, resp) = app.call(Method::Post, "/contradictions/undo", Some(undo_body)); - assert_eq!(status, 200, "{resp}"); - assert_eq!(resp["undone"], serde_json::json!(true)); - assert_eq!(resp["restored"], serde_json::json!(old)); - assert_eq!(resp["was_superseded_by"], serde_json::json!(new)); - - // Ledger is clean after the undo. - let (status, ledger) = app.call(Method::Get, list_url, None); - assert_eq!(status, 200); - assert_eq!( - ledger.as_array().expect("ledger array").len(), - 0, - "undo must clear the ledger: {ledger}" - ); - } - - #[test] - fn undo_unknown_supersession_is_404() { - let app = ContradictionApp::new(); - let id = app.remember_in("never superseded", "ops"); - - let body = serde_json::json!({ "id": id }).to_string(); - let (status, resp) = app.call(Method::Post, "/contradictions/undo", Some(body)); - assert_eq!(status, 404, "undo without a supersession must 404: {resp}"); - - // Malformed body → 400. - let (status, _) = app.call( - Method::Post, - "/contradictions/undo", - Some("not-json".into()), - ); - assert_eq!(status, 400); - } -} - -// ── Explain recall API (#1160) ────────────────────────────────────── - -#[cfg(test)] -mod pack_recall_api_tests { - //! #1281 Phase 1: `pack` recall returns a budgeted ContextPack envelope. - use super::*; - use tiny_http::TestRequest; - - struct PackApp { - uteke: Mutex, - } - - impl PackApp { - fn new() -> Self { - // No embedder: tests use the fts5 strategy, which needs no - // query embedding (CI-safe without ONNX). - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - } - } - - fn call( - &self, - method: Method, - url: &str, - body: Option, - ) -> (u16, serde_json::Value) { - let mut req = match body { - Some(b) => { - let leaked: &'static str = Box::leak(b.into_boxed_str()); - TestRequest::new() - .with_method(method) - .with_path(url) - .with_body(leaked) - .into() - } - None => TestRequest::new().with_method(method).with_path(url).into(), - }; - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - - fn remember_in(&self, content: &str, namespace: &str) -> String { - let body = - serde_json::json!({ "content": content, "namespace": namespace }).to_string(); - let (status, resp) = self.call(Method::Post, "/remember", Some(body)); - assert_eq!(status, 200, "remember must succeed: {resp}"); - resp["id"] - .as_str() - .unwrap_or_else(|| panic!("remember response must carry id: {resp}")) - .to_string() - } - } - - #[test] - fn pack_returns_envelope_honours_budget_and_excludes() { - let app = PackApp::new(); - let fox_id = app.remember_in("The quick brown fox jumps over the lazy dog", "pack-ns"); - app.remember_in( - "Completely unrelated content about gardening tools", - "pack-ns", - ); - - // Normal pack: envelope shape with a generous budget. - let body = serde_json::json!({ - "query": "quick brown fox", - "limit": 5, - "namespace": "pack-ns", - "strategy": "fts5", - "pack": true, - "budget_chars": 10000 - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/recall", Some(body)); - assert_eq!(status, 200, "{resp}"); - assert!( - resp["selected"].is_array(), - "pack must carry selected: {resp}" - ); - assert!( - !resp["selected"].as_array().unwrap().is_empty(), - "fts5 must find the fox" - ); - assert_eq!(resp["budget_chars"], serde_json::json!(10000)); - assert!(resp["budget_used"].as_u64().unwrap() > 0, "{resp}"); - assert!(resp["skipped"].is_array(), "{resp}"); - - // exclude_ids: the fox is already injected → skipped, reason excluded. - let body2 = serde_json::json!({ - "query": "quick brown fox", - "limit": 5, - "namespace": "pack-ns", - "strategy": "fts5", - "pack": true, - "budget_chars": 10000, - "exclude_ids": [fox_id] - }) - .to_string(); - let (status, resp2) = app.call(Method::Post, "/recall", Some(body2)); - assert_eq!(status, 200, "{resp2}"); - let skipped = resp2["skipped"].as_array().unwrap(); - assert!( - skipped - .iter() - .any(|s| s["memory_id"] == serde_json::json!(fox_id) - && s["reason"] == serde_json::json!("excluded")), - "excluded fox must be reported: {resp2}" - ); - - // Impossible budget → nothing selected, everything skipped as budget. - let body3 = serde_json::json!({ - "query": "quick brown fox", - "limit": 5, - "namespace": "pack-ns", - "strategy": "fts5", - "pack": true, - "budget_chars": 1 - }) - .to_string(); - let (status, resp3) = app.call(Method::Post, "/recall", Some(body3)); - assert_eq!(status, 200, "{resp3}"); - assert!(resp3["selected"].as_array().unwrap().is_empty()); - assert_eq!(resp3["budget_used"], serde_json::json!(0)); - assert!( - !resp3["skipped"].as_array().unwrap().is_empty(), - "oversized items must be reported: {resp3}" - ); - - // pack + time filters → 400 (loud, same contract as the CLI). - let body4 = serde_json::json!({ - "query": "quick brown fox", - "namespace": "pack-ns", - "strategy": "fts5", - "pack": true, - "budget_chars": 10000, - "at": "2026-01-01T00:00:00Z" - }) - .to_string(); - let (status, resp4) = app.call(Method::Post, "/recall", Some(body4)); - assert_eq!(status, 400, "{resp4}"); - assert!( - resp4["error"] - .as_str() - .unwrap_or_default() - .contains("pack is not supported"), - "{resp4}" - ); - } -} - -#[cfg(test)] -mod explain_recall_api_tests { - use super::*; - use tiny_http::TestRequest; - - struct ExplainApp { - uteke: Mutex, - } - - impl ExplainApp { - fn new() -> Self { - // No embedder: tests use the fts5 strategy, which needs no - // query embedding (CI-safe without ONNX). - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - } - } - - fn call( - &self, - method: Method, - url: &str, - body: Option, - ) -> (u16, serde_json::Value) { - let mut req = match body { - Some(b) => { - let leaked: &'static str = Box::leak(b.into_boxed_str()); - TestRequest::new() - .with_method(method) - .with_path(url) - .with_body(leaked) - .into() - } - None => TestRequest::new().with_method(method).with_path(url).into(), - }; - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - - fn remember_in(&self, content: &str, namespace: &str) -> String { - let body = - serde_json::json!({ "content": content, "namespace": namespace }).to_string(); - let (status, resp) = self.call(Method::Post, "/remember", Some(body)); - assert_eq!(status, 200, "remember must succeed: {resp}"); - resp["id"] - .as_str() - .unwrap_or_else(|| panic!("remember response must carry id: {resp}")) - .to_string() - } - } - - #[test] - fn explain_fts5_returns_signals_and_guards() { - let app = ExplainApp::new(); - let fox_id = app.remember_in("The quick brown fox jumps over the lazy dog", "explain-ns"); - app.remember_in( - "Completely unrelated content about gardening tools", - "explain-ns", - ); - - let body = serde_json::json!({ - "query": "quick brown fox", - "limit": 5, - "namespace": "explain-ns", - "strategy": "fts5", - "explain": true - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/recall", Some(body)); - assert_eq!(status, 200, "{resp}"); - let arr = resp.as_array().expect("explained results array"); - assert!(!arr.is_empty(), "fts5 must find the fox"); - let first = &arr[0]; - assert_eq!(first["memory"]["id"], serde_json::json!(fox_id)); - let ex = &first["explanation"]; - assert_eq!(ex["strategy"], serde_json::json!("fts5")); - assert_eq!(ex["fts_rank"], serde_json::json!(1)); - assert!(ex["final_score"].is_number(), "final_score must exist"); - - // explain + search_type → 400 (memory-only feature). - let bad = serde_json::json!({ - "query": "quick brown fox", - "search_type": "all", - "explain": true - }) - .to_string(); - let (status, _) = app.call(Method::Post, "/recall", Some(bad)); - assert_eq!(status, 400, "explain + search_type must 400"); - - // explain + at → 400. - let bad = serde_json::json!({ - "query": "quick brown fox", - "at": "2026-01-01T00:00:00Z", - "explain": true - }) - .to_string(); - let (status, _) = app.call(Method::Post, "/recall", Some(bad)); - assert_eq!(status, 400, "explain + at must 400"); - - // explain + entity → 400 (filter would be silently dropped). - let bad = serde_json::json!({ - "query": "quick brown fox", - "entity": "staging", - "explain": true - }) - .to_string(); - let (status, _) = app.call(Method::Post, "/recall", Some(bad)); - assert_eq!(status, 400, "explain + entity must 400"); - } -} - -// ── /list pagination metadata (#1188) ─────────────────────────────── - -#[cfg(test)] -mod list_pagination_tests { - use super::*; - use tiny_http::TestRequest; - - struct ListApp { - uteke: Mutex, - } - - impl ListApp { - fn new() -> Self { - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - } - } - - fn call( - &self, - method: Method, - url: &str, - body: Option, - ) -> (u16, serde_json::Value) { - let mut req = match body { - Some(b) => { - let leaked: &'static str = Box::leak(b.into_boxed_str()); - TestRequest::new() - .with_method(method) - .with_path(url) - .with_body(leaked) - .into() - } - None => TestRequest::new().with_method(method).with_path(url).into(), - }; - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - - fn remember_in(&self, content: &str, namespace: &str) { - let body = - serde_json::json!({ "content": content, "namespace": namespace }).to_string(); - let (status, resp) = self.call(Method::Post, "/remember", Some(body)); - assert_eq!(status, 200, "seed remember must succeed: {resp}"); - } - } - - #[test] - fn list_meta_envelope_and_bare_default() { - let app = ListApp::new(); - for i in 0..7 { - app.remember_in(&format!("pagination probe {i}"), "pg"); - } - - // Default (no include_meta): bare array — unchanged shape. - let body = serde_json::json!({ "namespace": "pg", "limit": 100 }).to_string(); - let (status, resp) = app.call(Method::Post, "/list", Some(body)); - assert_eq!(status, 200); - assert!(resp.is_array(), "default must stay a bare array: {resp}"); - assert_eq!(resp.as_array().unwrap().len(), 7); - - // include_meta: envelope with total/has_more/next_offset. - let body = serde_json::json!({ - "namespace": "pg", - "limit": 3, - "offset": 0, - "include_meta": true - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/list", Some(body)); - assert_eq!(status, 200); - assert!(resp.is_object(), "envelope expected: {resp}"); - assert_eq!(resp["total"], serde_json::json!(7)); - assert_eq!(resp["has_more"], serde_json::json!(true)); - assert_eq!(resp["next_offset"], serde_json::json!(3)); - assert_eq!(resp["memories"].as_array().expect("page rows").len(), 3); - - // Last page: has_more false, next_offset null. - let body = serde_json::json!({ - "namespace": "pg", - "limit": 10, - "offset": 6, - "include_meta": true - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/list", Some(body)); - assert_eq!(status, 200); - assert_eq!(resp["has_more"], serde_json::json!(false)); - assert!(resp["next_offset"].is_null()); - assert_eq!( - resp["memories"].as_array().expect("page rows").len(), - 1, - "offset 6 of 7 rows returns the last row" - ); - - // include_meta + at: `at` wins — bare array (documented). - let body = serde_json::json!({ - "namespace": "pg", - "include_meta": true, - "at": "2100-01-01T00:00:00Z" - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/list", Some(body)); - assert_eq!(status, 200); - assert!(resp.is_array(), "at-mode stays a bare array: {resp}"); - } -} - -#[cfg(test)] -mod payload_conformance_tests { - use super::*; - use std::io::Read as IoRead; - - // ── #1233: raw-payload conformance — recall responses carry the FULL - // payload on every surface (HTTP route tested here; CLI --json prints - // the same serde SearchResult; MCP unified path reuses these types) ── - #[test] - fn recall_http_payload_conformance() { - use uteke_core::Uteke; - - // No embedder: these tests are payload-shape conformance and must run - // in CI builds without the ONNX runtime lib (same pattern as the - // graph-edge tests). Keyword (fts5) recall needs no vectors. - let uteke = Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"); - let shared = std::sync::Mutex::new(uteke); - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: vec![], - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - - let remember_body: &'static str = Box::leak(r#"{"content":"Payload conformance probe memory #1233 with distinctive tokens zebraquartz","namespace":"conf"}"#.to_string().into_boxed_str()); - let mut remember_req = tiny_http::TestRequest::new() - .with_method(tiny_http::Method::Post) - .with_path("/remember") - .with_body(remember_body) - .into(); - let remember_resp = route(&shared, &ctx, &mut remember_req); - let mut rbuf = String::new(); - remember_resp - .into_reader() - .read_to_string(&mut rbuf) - .unwrap(); - assert!(rbuf.contains("\"id\""), "remember must succeed: {rbuf}"); - - let mut req = tiny_http::TestRequest::new() - .with_method(tiny_http::Method::Post) - .with_path("/recall") - .with_body(r#"{"query":"zebraquartz","limit":5,"min_score":0.0,"namespace":"conf","strategy":"fts5"}"#) - .into(); - let resp = route(&shared, &ctx, &mut req); - let mut buf = String::new(); - resp.into_reader().read_to_string(&mut buf).unwrap(); - let v: serde_json::Value = serde_json::from_str(&buf).expect("valid JSON"); - - // Raw payload must be an array of FULL SearchResult objects. - let arr = v - .as_array() - .expect("recall response must be a top-level JSON array"); - assert!(!arr.is_empty(), "expected at least one hit"); - let first = &arr[0]; - // Full payload = the memory object itself, not a summary stub. - let mem = first - .get("memory") - .expect("each hit must carry the full memory object"); - assert!(mem.get("id").is_some(), "hit must include memory.id"); - let content = mem - .get("content") - .and_then(|c| c.as_str()) - .expect("hit must include memory.content"); - assert!( - content.contains("zebraquartz"), - "content must be the FULL memory text" - ); - assert!( - mem.get("created_at").is_some(), - "hit must include memory metadata" - ); - assert!( - first.get("score").and_then(|s| s.as_f64()).is_some(), - "hit must include score" - ); - // No stub markers anywhere. - let s = v.to_string(); - assert!( - !s.contains("raw_hits"), - "hit-count stub leaked into payload" - ); - } - - #[test] - fn recall_http_empty_result_has_no_stub() { - use uteke_core::Uteke; - - // No embedder: these tests are payload-shape conformance and must run - // in CI builds without the ONNX runtime lib (same pattern as the - // graph-edge tests). Keyword (fts5) recall needs no vectors. - let uteke = Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"); - let shared = std::sync::Mutex::new(uteke); - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: vec![], - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let mut req = tiny_http::TestRequest::new() - .with_method(tiny_http::Method::Post) - .with_path("/recall") - .with_body(r#"{"query":"totally-unique-missing-query-xyz","limit":5,"min_score":0.0,"namespace":"conf","strategy":"fts5"}"#) - .into(); - let resp = route(&shared, &ctx, &mut req); - let mut buf = String::new(); - resp.into_reader().read_to_string(&mut buf).unwrap(); - let v: serde_json::Value = serde_json::from_str(&buf).expect("valid JSON"); - // Contract: an empty result set is a bare EMPTY JSON ARRAY — the same - // full-payload shape as a populated response, just with zero hits. - // A "N hits" summary string or a {"raw_hits": N} stub would fail this. - let arr = v - .as_array() - .expect("recall response must be a top-level JSON array, never a summary string/stub"); - assert!(arr.is_empty(), "expected an empty array for a no-hit query"); - } - /// #1252: an unauthenticated GET /health gets liveness only — no version, - /// no counts — while an authenticated request (or auth-disabled server) - /// still receives the full payload. - #[test] - fn test_health_anonymous_gets_minimal_payload() { - struct HealthAuthApp { - uteke: Mutex, - admin_token: Option<&'static str>, - } - impl HealthAuthApp { - fn with_auth(token: Option<&'static str>) -> Self { - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - admin_token: token, - } - } - - fn call(&self, auth_header: Option<&str>) -> (u16, serde_json::Value) { - let mut builder = tiny_http::TestRequest::new() - .with_method(tiny_http::Method::Get) - .with_path("/health"); - if let Some(h) = auth_header { - builder = builder - .with_header(tiny_http::Header::from_bytes("Authorization", h).unwrap()); - } - let mut req: tiny_http::Request = builder.into(); - let ctx = ReqCtx { - auth_token_hash: self.admin_token.map(|t| { - use sha2::{Digest, Sha256}; - Sha256::digest(t.as_bytes()).into() - }), - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - } - - // Auth ENABLED, no token -> minimal body only. - let app = HealthAuthApp::with_auth(Some("secret-token")); - let (status, json) = app.call(None); - assert_eq!(status, 200); - assert_eq!(json["status"], "ok", "liveness must stay available"); - assert!( - json.get("version").is_none() - && json.get("memories").is_none() - && json.get("namespaces").is_none() - && json.get("update_available").is_none(), - "anonymous health must not leak instance details: {json}" - ); - - // Auth ENABLED, valid token -> full payload. - let (status, json) = app.call(Some("Bearer secret-token")); - assert_eq!(status, 200); - assert_eq!(json["status"], "ok"); - assert!( - json.get("version").is_some(), - "authenticated health carries version: {json}" - ); - assert!( - json.get("memories").is_some(), - "authenticated health carries counts: {json}" - ); - - // Auth ENABLED, wrong token -> same minimal body as anonymous: - // an invalid credential must not see more than a scanner would, - // and uptime probes keep a 200 while tokens are being rotated. - let (status, json) = app.call(Some("Bearer wrong-token")); - assert_eq!(status, 200); - assert!( - json.get("version").is_none() && json.get("memories").is_none(), - "invalid credentials must get the minimal body: {json}" - ); - - // Auth DISABLED, no token -> full payload (single-operator local mode). - let app = HealthAuthApp::with_auth(None); - let (status, json) = app.call(None); - assert_eq!(status, 200); - assert!( - json.get("memories").is_some(), - "auth-disabled health stays full: {json}" - ); - } -} - -// ── /routes machine-readable registry (#1289) ────────────────────────── - -#[cfg(test)] -mod routes_introspection_tests { - use super::*; - use sha2::{Digest, Sha256}; - - struct RoutesApp { - uteke: Mutex, - read_only_token_hash: Option<[u8; 32]>, - } - - impl RoutesApp { - fn new() -> Self { - // No embedder: storage-only server tests must run in CI builds - // without the ONNX runtime lib. - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - read_only_token_hash: None, - } - } - - fn with_read_only_token(token: &str) -> Self { - let mut app = Self::new(); - app.read_only_token_hash = Some(Sha256::digest(token.as_bytes()).into()); - app - } - - fn call( - &self, - method: Method, - url: &str, - auth_header: Option<&str>, - ) -> (u16, serde_json::Value) { - let mut builder = tiny_http::TestRequest::new() - .with_method(method) - .with_path(url); - if let Some(h) = auth_header { - builder = - builder.with_header(tiny_http::Header::from_bytes("Authorization", h).unwrap()); - } - let mut req: tiny_http::Request = builder.into(); - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: self.read_only_token_hash, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - } - - #[test] - fn routes_returns_full_registry() { - let app = RoutesApp::new(); - let (status, json) = app.call(Method::Get, "/routes", None); - assert_eq!(status, 200, "{json}"); - - let entries = json.as_array().expect("routes payload is a JSON array"); - assert_eq!( - entries.len(), - crate::api_registry::ENDPOINTS.len(), - "one entry per registered endpoint" - ); - - // Self-describing: /routes lists itself. - let self_entry = entries - .iter() - .find(|e| e["path"] == "/routes" && e["method"] == "GET") - .expect("/routes must list itself"); - assert!(self_entry["tier"].is_string(), "{self_entry}"); - assert!(self_entry["description"].is_string(), "{self_entry}"); - - // Entry shape matches the registry struct fields. - let first = &entries[0]; - for field in ["method", "path", "tier", "description", "issues"] { - assert!( - first.get(field).is_some(), - "registry entry missing field '{field}': {first}" - ); - } - } - - #[test] - fn routes_respects_auth_roles() { - // RO-only server: no token -> 401. - let app = RoutesApp::with_read_only_token("ro-secret"); - let (status, _) = app.call(Method::Get, "/routes", None); - assert_eq!(status, 401, "anonymous must not read the registry"); - - // Valid RO token -> allowed (GET is inherently read-only). - let (status, json) = app.call(Method::Get, "/routes", Some("Bearer ro-secret")); - assert_eq!(status, 200, "{json}"); - assert!(json.as_array().is_some(), "RO token gets the registry"); - } - - fn method_of(name: &str) -> Method { - match name { - "GET" => Method::Get, - "POST" => Method::Post, - "PUT" => Method::Put, - "DELETE" => Method::Delete, - other => panic!("unexpected registry method {other}"), - } - } - - /// Every registry row must have a handler arm: the router consults the - /// table first, so a row whose arm is missing would silently 404 through - /// the fallback. Built from the table, not from source text (#1344). - #[test] - fn every_registry_row_has_a_handler_arm() { - let app = RoutesApp::new(); - for e in crate::api_registry::ENDPOINTS { - UNHANDLED_ROW.with(|c| c.set(false)); - let _ = app.call(method_of(e.method), e.path, None); - assert!( - !UNHANDLED_ROW.with(|c| c.get()), - "{} {} is registered but has no handler arm", - e.method, - e.path - ); - } - } - - /// A route that is not in the table is a 404 that never reaches a - /// handler, even when the path exists under another verb. - #[test] - fn unregistered_method_path_pairs_are_404_before_dispatch() { - let app = RoutesApp::new(); - for (method, path) in [ - (Method::Put, "/list"), - (Method::Delete, "/recall"), - (Method::Get, "/remember"), - (Method::Post, "/health"), - (Method::Get, "/no-such-route"), - ] { - UNHANDLED_ROW.with(|c| c.set(false)); - let (status, json) = app.call(method.clone(), path, None); - assert_eq!(status, 404, "{method} {path}: {json}"); - assert!( - !UNHANDLED_ROW.with(|c| c.get()), - "{method} {path} must be refused by the table, not the match fallback" - ); - } - } - - /// The read-only token is refused (403, gate message) on exactly the - /// rows the table marks as writes, and never on the others. - #[test] - fn read_only_token_gets_403_on_exactly_the_writing_rows() { - const GATE_MSG: &str = "Read-only token cannot perform write operations"; - let app = RoutesApp::with_read_only_token("ro-secret"); - let mut gated = 0; - for e in crate::api_registry::ENDPOINTS { - let (status, json) = app.call(method_of(e.method), e.path, Some("Bearer ro-secret")); - let refused = status == 403 && json["error"] == GATE_MSG; - let expect_refused = e.method != "GET" && !(e.method == "POST" && !e.writes); - assert_eq!( - refused, expect_refused, - "{} {}: {status} {json}", - e.method, e.path - ); - if e.writes { - assert!(refused, "{} {} writes but was not gated", e.method, e.path); - gated += 1; - } - } - assert!(gated > 0, "table must contain writing rows"); - } - - #[test] - fn routes_post_is_not_routed() { - let app = RoutesApp::new(); - let (status, _) = app.call(Method::Post, "/routes", None); - assert_eq!(status, 404, "only GET /routes exists (catch-all 404)"); - } -} - -// ── #1302: plain /remember must honor (not silently normalize) `type` ────── - -#[cfg(test)] -mod plain_remember_type_tests { - use super::*; - use tiny_http::TestRequest; - - /// Minimal harness mirroring the other in-memory route tests: - /// no embedder (CI-safe), route() called directly (#1288). - struct PlainRememberApp { - uteke: Mutex, - } - - impl PlainRememberApp { - fn new() -> Self { - Self { - uteke: Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ), - } - } - - fn call( - &self, - method: Method, - url: &str, - body: Option, - ) -> (u16, serde_json::Value) { - let mut req = match body { - Some(b) => { - let leaked: &'static str = Box::leak(b.into_boxed_str()); - TestRequest::new() - .with_method(method) - .with_path(url) - .with_body(leaked) - .into() - } - None => TestRequest::new().with_method(method).with_path(url).into(), - }; - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(&self.uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - } - - #[test] - fn plain_remember_honors_explicit_type() { - let app = PlainRememberApp::new(); - - // Issue repro: explicit `decision` on the plain path must survive — - // silently downgrading it to `fact` broke typed writers (tole #143). - let body = serde_json::json!({ - "content": "ISOLATION-TEST decision typing contract", - "namespace": "repo-mem-e2e", - "type": "decision", - "tags": ["test", "isolation"], - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/remember", Some(body)); - assert_eq!(status, 200, "{resp}"); - let id = resp["id"].as_str().expect("memory id").to_string(); - - let (status, mem) = app.call(Method::Get, &format!("/memory?id={id}"), None); - assert_eq!(status, 200, "{mem}"); - assert_eq!( - mem["memory_type"].as_str(), - Some("decision"), - "explicit type must be stored verbatim, not normalized to fact: {mem}" - ); - // Metadata copy retained (back-compat for metadata-based readers). - assert_eq!(mem["metadata"]["type"].as_str(), Some("decision")); - } - - #[test] - fn plain_remember_rejects_unknown_type_loudly() { - let app = PlainRememberApp::new(); - - let body = serde_json::json!({ - "content": "ISOLATION-TEST unknown type must 400", - "type": "journal", - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/remember", Some(body)); - assert_eq!(status, 400, "unknown type must fail loudly: {resp}"); - let err = resp["error"].as_str().unwrap_or_default(); - assert!(err.contains("Unknown memory type"), "{err}"); - assert!( - err.contains("decision"), - "error must list the supported vocabulary: {err}" - ); - } - - #[test] - fn plain_remember_rejects_unknown_type_even_with_contradiction() { - let app = PlainRememberApp::new(); - - // #1302 review follow-up: type validation runs BEFORE the branch, so - // the contradiction arm is equally covered — no path may store an - // invalid type. (Honoring a valid type on the contradiction arm is - // verified by live E2E: it forwards the type to the core, which has - // no embedder here — CI-safe validation-only assertion.) - let body = serde_json::json!({ - "content": "ISOLATION-TEST unknown type contradiction arm", - "type": "journal", - "detect_contradiction": true, - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/remember", Some(body)); - assert_eq!( - status, 400, - "type validation must precede the contradiction arm: {resp}" - ); - assert!( - resp["error"] - .as_str() - .unwrap_or_default() - .contains("Unknown memory type") - ); - } - - #[test] - fn plain_remember_without_type_still_auto_infers() { - let app = PlainRememberApp::new(); - - // No explicit type → legacy auto-inference path unchanged. - let body = serde_json::json!({ - "content": "ISOLATION-TEST auto infer: we decided to keep the contract", - }) - .to_string(); - let (status, resp) = app.call(Method::Post, "/remember", Some(body)); - assert_eq!(status, 200, "{resp}"); - let id = resp["id"].as_str().expect("memory id").to_string(); - - let (status, mem) = app.call(Method::Get, &format!("/memory?id={id}"), None); - assert_eq!(status, 200, "{mem}"); - assert_eq!( - mem["memory_type"].as_str(), - Some("decision"), - "content contains 'we decided' → auto-inference must still run: {mem}" - ); - } -} - -#[cfg(test)] -mod host_guard_route_tests { - use super::*; - use crate::context::HostGuard; - use tiny_http::{Header, TestRequest}; - - fn status(guard: HostGuard, host: Option<&str>, path: &str) -> u16 { - let uteke = Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ); - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: guard, - }; - let mut t = TestRequest::new().with_method(Method::Get).with_path(path); - if let Some(h) = host { - t = t.with_header(Header::from_bytes(&b"Host"[..], h.as_bytes()).unwrap()); - } - let mut req = t.into(); - route(&uteke, &ctx, &mut req).status_code().0 - } - - // #1326 acceptance: a loopback bind rejects `Host: evil.example`. - #[test] - fn loopback_bind_rejects_foreign_host() { - let g = HostGuard::new("127.0.0.1", &[]); - assert_eq!(status(g.clone(), Some("evil.example:8767"), "/health"), 403); - assert_eq!(status(g.clone(), Some("evil.example"), "/stats"), 403); - assert_eq!(status(g.clone(), Some("127.0.0.1:8767"), "/health"), 200); - assert_eq!(status(g, Some("localhost:8767"), "/health"), 200); - } - - // #1326 acceptance: a Docker-style bind works, and allowed_hosts is honoured. - #[test] - fn docker_style_bind_and_allowed_hosts() { - let open = HostGuard::new("0.0.0.0", &[]); - assert_eq!(status(open, Some("uteke:8767"), "/health"), 200); - - let listed = HostGuard::new("0.0.0.0", &["uteke".to_string()]); - assert_eq!(status(listed.clone(), Some("uteke:8767"), "/health"), 200); - assert_eq!(status(listed, Some("evil.example"), "/health"), 403); - } -} - -#[cfg(test)] -mod mcp_http_hardening_tests { - use super::*; - use tiny_http::{Header, TestRequest}; - - fn app() -> Mutex { - Mutex::new( - Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"), - ) - } - - fn ctx(origins: Vec) -> ReqCtx { - ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: origins, - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - } - } - - // #1328: an oversized /mcp body is a 413, not a truncated JSON parse error. - #[test] - fn mcp_oversized_body_is_413() { - let big: &'static str = Box::leak("x".repeat(1024 * 1024 + 8).into_boxed_str()); - let mut req = TestRequest::new() - .with_method(Method::Post) - .with_path("/mcp") - .with_body(big) - .into(); - let resp = route(&app(), &ctx(Vec::new()), &mut req); - assert_eq!(resp.status_code().0, 413); - } - - // #1328: /mcp responses carry CORS headers like every other endpoint. - #[test] - fn mcp_response_has_cors_headers() { - let origin = "https://app.example"; - let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#; - let mut req = TestRequest::new() - .with_method(Method::Post) - .with_path("/mcp") - .with_header(Header::from_bytes(&b"Origin"[..], origin.as_bytes()).unwrap()) - .with_body(body) - .into(); - let resp = route(&app(), &ctx(vec![origin.to_string()]), &mut req); - assert!( - resp.headers() - .iter() - .any(|h| h.field.equiv("Access-Control-Allow-Origin") - && h.value.as_str() == origin), - "missing ACAO on /mcp: {:?}", - resp.headers() - ); - } -} - -// #1439: /mcp is open to read-only tokens; the per-tool gate inside uteke-mcp -// refuses the write tools, write tokens are unchanged. -#[cfg(test)] -mod mcp_read_only_tests { - use super::*; - use sha2::{Digest, Sha256}; - use tiny_http::TestRequest; - - fn ro_ctx() -> ReqCtx { - ReqCtx { - auth_token_hash: Some(Sha256::digest(b"rw-secret").into()), - read_only_token_hash: Some(Sha256::digest(b"ro-secret").into()), - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - } - } - - fn mcp(uteke: &Mutex, token: &str, tool: &str, args: &str) -> (u16, serde_json::Value) { - let body: &'static str = Box::leak( - format!( - r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"{tool}","arguments":{args}}}}}"# - ) - .into_boxed_str(), - ); - let auth: &'static str = Box::leak(format!("Bearer {token}").into_boxed_str()); - let mut req = TestRequest::new() - .with_method(Method::Post) - .with_path("/mcp") - .with_header(tiny_http::Header::from_bytes("Authorization", auth).unwrap()) - .with_body(body) - .into(); - let resp = route(uteke, &ro_ctx(), &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - ( - status, - serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null), - ) - } - - fn store() -> Mutex { - Mutex::new(Uteke::open_with_backend(":memory:", None).unwrap()) - } - - #[test] - fn read_only_token_reaches_read_tools() { - let u = store(); - let (status, json) = mcp(&u, "ro-secret", "uteke_stats", "{}"); - assert_eq!(status, 200, "{json}"); - assert!(json.get("error").is_none(), "{json}"); - assert_ne!(json["result"]["isError"], true, "{json}"); - } - - #[test] - fn read_only_token_is_refused_write_tools() { - let u = store(); - let (status, json) = mcp(&u, "ro-secret", "uteke_remember", r#"{"content":"x"}"#); - assert_eq!(status, 200, "{json}"); - assert_eq!(json["result"]["isError"], true, "{json}"); - assert_eq!( - json["result"]["content"][0]["text"], - "Read-only token cannot use uteke_remember" - ); - assert_eq!(u.lock().unwrap().count(None).unwrap(), 0); - } - - #[test] - fn write_token_is_unchanged() { - let u = store(); - let (status, json) = mcp(&u, "rw-secret", "uteke_remember", r#"{"content":"x"}"#); - assert_eq!(status, 200, "{json}"); - assert!(json.get("error").is_none(), "{json}"); - assert_ne!(json["result"]["isError"], true, "{json}"); - assert_eq!(u.lock().unwrap().count(None).unwrap(), 1); - } -} - -#[cfg(test)] -mod namespace_deprecation_tests { - use super::*; - use tiny_http::TestRequest; - - fn call( - uteke: &Mutex, - method: Method, - url: &str, - body: serde_json::Value, - ) -> (u16, String) { - let leaked: &'static str = Box::leak(body.to_string().into_boxed_str()); - let mut req: Request = TestRequest::new() - .with_method(method) - .with_path(url) - .with_body(leaked) - .into(); - let ctx = ReqCtx { - auth_token_hash: None, - read_only_token_hash: None, - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - (status, String::from_utf8(bytes).unwrap()) - } - - fn app() -> Mutex { - Mutex::new(Uteke::open_with_backend(":memory:", None).expect("open without embedder")) - } - - fn warning(raw: &str) -> serde_json::Value { - serde_json::from_str::(raw).unwrap()["warning"].clone() - } - - #[test] - fn remember_non_default_namespace_carries_warning() { - let u = app(); - let (st, raw) = call( - &u, - Method::Post, - "/remember", - serde_json::json!({"content": "x", "namespace": "ns-http-remember"}), - ); - assert_eq!(st, 200, "{raw}"); - let w = warning(&raw); - let w = w.as_str().expect("warning is a string"); - assert!(w.contains(uteke_core::namespace_deprecation::NAMESPACE_DEPRECATION_WARNING)); - } - - #[test] - fn remember_default_or_omitted_namespace_has_no_deprecation() { - let u = app(); - for body in [ - serde_json::json!({"content": "a"}), - serde_json::json!({"content": "b", "namespace": "default"}), - ] { - let (st, raw) = call(&u, Method::Post, "/remember", body); - assert_eq!(st, 200, "{raw}"); - let w = warning(&raw); - assert!( - w.is_null() || !w.as_str().unwrap().contains("Namespaces are deprecated"), - "{raw}" - ); - // Same keys as before the change: warning stays a key, no new ones. - let v: serde_json::Value = serde_json::from_str(&raw).unwrap(); - let mut keys: Vec<_> = v.as_object().unwrap().keys().cloned().collect(); - keys.sort(); - assert_eq!(keys, ["author_type", "embedding_written", "id", "warning"]); - } - } - - #[test] - fn room_create_warning_only_for_non_default() { - let u = app(); - let (_, raw) = call( - &u, - Method::Post, - "/room/create", - serde_json::json!({"room_id": "r-default"}), - ); - let v: serde_json::Value = serde_json::from_str(&raw).unwrap(); - assert!(v.get("warning").is_none(), "{raw}"); - assert_eq!(v["namespace"], "default"); - - let (_, raw) = call( - &u, - Method::Post, - "/room/create", - serde_json::json!({"room_id": "r-ns", "namespace": "ns-http-room"}), - ); - let v: serde_json::Value = serde_json::from_str(&raw).unwrap(); - assert_eq!( - v["warning"], - uteke_core::namespace_deprecation::NAMESPACE_DEPRECATION_WARNING - ); - assert_eq!(v["created"], "r-ns"); - } - - #[test] - fn with_namespace_warning_is_identity_for_default() { - let body = serde_json::json!({"imported": 1, "skipped": 0}); - assert_eq!(with_namespace_warning(body.clone(), None), body); - assert_eq!(with_namespace_warning(body.clone(), Some("default")), body); - let w = with_namespace_warning(body, Some("other")); - assert!(w["warning"].is_string()); - assert_eq!(w["imported"], 1); - } -} - -// ── #1343: `DELETE /forget` goes through the typed forget request ────────── - -#[cfg(test)] -mod forget_route_tests { - use super::*; - use sha2::{Digest, Sha256}; - use uteke_core::memory::types::Memory; - - const ID_A: &str = "abcd0000-0000-4000-8000-00000000000a"; - const ID_B: &str = "abcd0000-0000-4000-8000-00000000000b"; - const ID_C: &str = "cafe0000-0000-4000-8000-00000000000c"; - - fn memory(id: &str, ns: &str, tag: &str) -> Memory { - let now = chrono::Utc::now(); - Memory { - id: id.to_string(), - content: format!("forget route probe {id}"), - embedding: vec![0.21; 768], - tags: vec![tag.to_string()], - metadata: serde_json::json!({}), - created_at: now, - updated_at: now, - namespace: ns.to_string(), - access_count: 0, - recall_count: 0, - last_accessed: None, - deprecated: false, - deprecated_at: None, - valid_from: None, - valid_until: None, - memory_type: "fact".to_string(), - importance: 0.5, - pinned: false, - content_type: "text".to_string(), - slug: None, - source: None, - source_type: "user".to_string(), - author_type: "agent".to_string(), - } - } - - fn app() -> Mutex { - let uteke = Uteke::open_with_backend(":memory:", None) - .expect("open in-memory uteke without embedder"); - uteke.store().insert(&memory(ID_A, "ns-a", "t1")).unwrap(); - uteke.store().insert(&memory(ID_B, "ns-a", "t1")).unwrap(); - uteke.store().insert(&memory(ID_C, "ns-b", "t2")).unwrap(); - Mutex::new(uteke) - } - - fn call( - uteke: &Mutex, - method: Method, - url: &str, - bearer: Option<&str>, - tokens: bool, - ) -> (u16, serde_json::Value) { - let mut builder = tiny_http::TestRequest::new() - .with_method(method) - .with_path(url); - if let Some(h) = bearer { - builder = - builder.with_header(tiny_http::Header::from_bytes("Authorization", h).unwrap()); - } - let mut req: tiny_http::Request = builder.into(); - let ctx = ReqCtx { - auth_token_hash: tokens.then(|| Sha256::digest("rw").into()), - read_only_token_hash: tokens.then(|| Sha256::digest("ro").into()), - cors_origins: Vec::new(), - recall_config: None, - extraction_config: None, - host_guard: Default::default(), - }; - let resp = route(uteke, &ctx, &mut req); - let status = resp.status_code().0; - let bytes = resp.into_reader().into_inner(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) - } - - fn del(uteke: &Mutex, url: &str) -> (u16, serde_json::Value) { - call(uteke, Method::Delete, url, None, false) - } - - #[test] - fn forget_by_full_id() { - let u = app(); - let (s, body) = del(&u, &format!("/forget?id={ID_A}")); - assert_eq!((s, &body), (200, &serde_json::json!({"forgotten": ID_A}))); - } - - #[test] - fn forget_by_unambiguous_short_prefix() { - let u = app(); - let (s, body) = del(&u, "/forget?id=cafe"); - assert_eq!((s, &body), (200, &serde_json::json!({"forgotten": ID_C}))); - } - - #[test] - fn error_statuses_and_texts_are_unchanged() { - let u = app(); - let (s, body) = del(&u, "/forget?id=ffff"); - assert_eq!(s, 404); - assert_eq!(body["error"], "Memory not found: ffff"); - let absent = "11111111-1111-4111-8111-111111111111"; - let (s, body) = del(&u, &format!("/forget?id={absent}")); - assert_eq!(s, 404); - assert_eq!(body["error"], format!("Memory not found: {absent}")); - let (s, body) = del(&u, "/forget?id=abcd"); - assert_eq!(s, 400); - assert_eq!( - body["error"], - "Validation error: Ambiguous ID prefix 'abcd' — matches 2 memories. Use a longer prefix." - ); - for url in ["/forget", "/forget?namespace=ns-a", "/forget?flag"] { - let (s, body) = del(&u, url); - assert_eq!(s, 400, "{url}"); - assert_eq!(body["error"], "Provide ?id= or ?tag= parameter", "{url}"); - } - } - - #[test] - fn id_wins_over_tag() { - let u = app(); - let (s, body) = del(&u, &format!("/forget?tag=t1&id={ID_C}")); - assert_eq!((s, &body), (200, &serde_json::json!({"forgotten": ID_C}))); - // The tag was not applied: the `t1` memories are untouched. - assert!( - !u.lock() - .unwrap() - .get_by_id(ID_A) - .unwrap() - .unwrap() - .deprecated - ); - } - - #[test] - fn tag_forget_honours_namespace_and_none_means_default() { - let u = app(); - // Wrong namespace: nothing matches. - let (s, body) = del(&u, "/forget?tag=t1&namespace=ns-b"); - assert_eq!(s, 200); - assert_eq!(body["deleted"], 0); - let (s, body) = del(&u, "/forget?tag=t1&namespace=ns-a"); - assert_eq!(s, 200); - assert_eq!(body["deleted"], 2); - // No namespace means the default namespace, NOT every namespace: - // `t2` lives in `ns-b`, so it is untouched until ns-b is named. - let (s, body) = del(&u, "/forget?tag=t2"); - assert_eq!(s, 200); - assert_eq!(body["deleted"], 0); - let (s, body) = del(&u, "/forget?tag=t2&namespace=ns-b"); - assert_eq!(s, 200); - assert_eq!(body["deleted"], 1); - } - - #[test] - fn read_only_token_cannot_forget() { - let u = app(); - for url in [format!("/forget?id={ID_C}"), "/forget?tag=t2".to_string()] { - let (s, body) = call(&u, Method::Delete, &url, Some("Bearer ro"), true); - assert_eq!(s, 403, "{url}"); - assert_eq!( - body["error"], - "Read-only token cannot perform write operations" - ); - } - // Nothing was deleted by the blocked requests. - assert!( - !u.lock() - .unwrap() - .get_by_id(ID_C) - .unwrap() - .unwrap() - .deprecated - ); - // The write token still can. - let (s, _) = call( - &u, - Method::Delete, - &format!("/forget?id={ID_C}"), - Some("Bearer rw"), - true, - ); - assert_eq!(s, 200); - // Recorded disagreement: the gate is HTTP-only. - let may = |p: uteke_core::ForgetPolicy| p.read_only_token_may_forget; - assert_eq!( - ( - may(uteke_core::ForgetPolicy::HTTP), - may(uteke_core::ForgetPolicy::MCP) - ), - (false, true) - ); - } - - #[test] - fn http_and_mcp_resolve_the_same_ids_to_the_same_memory() { - let u = app(); - let guard = u.lock().unwrap(); - let resolve = |input: &str, p| uteke_core::resolve_memory_id(&guard, input, p); - for input in [ID_A, "cafe", ID_B] { - let http = resolve(input, uteke_core::ForgetPolicy::HTTP); - let mcp = resolve(input, uteke_core::ForgetPolicy::MCP); - assert_eq!(http.unwrap(), mcp.unwrap(), "{input}"); - } - // Where they disagree, each keeps its own text (pinned). - let http = resolve("ffff", uteke_core::ForgetPolicy::HTTP); - let mcp = resolve("ffff", uteke_core::ForgetPolicy::MCP); - assert_eq!(http.unwrap_err().message(), "Memory not found: ffff"); - assert_eq!( - mcp.unwrap_err().message(), - "No memory matches id prefix 'ffff'" - ); - } -} diff --git a/crates/uteke-server/src/handlers/docs.rs b/crates/uteke-server/src/handlers/docs.rs new file mode 100644 index 00000000..3490bd91 --- /dev/null +++ b/crates/uteke-server/src/handlers/docs.rs @@ -0,0 +1,233 @@ +//! Document handlers: create, get, update, list, search, move, delete and memory references. + +use super::*; + +pub(super) fn post_doc_create(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect(); + let parent = req_data.parent.as_deref(); + match uteke.doc_upsert_with_parent( + &req_data.slug, + req_data.title.as_deref().unwrap_or(""), + &req_data.content, + &tag_refs, + None, + parent, + ) { + Ok(id) => { + ctx.ok_response_for(req, &serde_json::json!({"id": id, "slug": req_data.slug})) + } + Err(e) => { + if e.to_string().contains("already exists") { + ctx.error_response_for( + req, + 409, + format!("document slug '{}' already exists", req_data.slug), + ) + } else if e.to_string().contains("maximum") || e.to_string().contains("parent") + { + ctx.error_response_for(req, 400, e.to_string()) + } else { + error!("doc create error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_doc_get(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => match resolve_doc_id(&req_data) { + Ok(id_or_slug) => match uteke.doc_get(id_or_slug) { + Ok(Some(doc)) => ctx.ok_response_for(req, &doc), + Ok(None) => { + ctx.error_response_for(req, 404, format!("document not found: {id_or_slug}")) + } + Err(e) => { + error!("doc get error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_doc_update(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => match resolve_doc_id_update(&req_data) { + Ok(id_or_slug) => { + let title = req_data.title.as_deref(); + let content = req_data.content.as_deref(); + let tags = req_data.tags.as_deref(); + let metadata = req_data.metadata.as_ref(); + match uteke.doc_update(id_or_slug, title, content, tags, metadata) { + Ok(Some(doc)) => ctx.ok_response_for(req, &doc), + Ok(None) => ctx.error_response_for( + req, + 404, + format!("document not found: {id_or_slug}"), + ), + Err(e) => { + error!("doc update error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_doc_list(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(mut params) => { + params.limit = params.limit.min(MAX_LIST_LIMIT); + let result = if params.roots_only { + uteke.doc_list_roots(params.namespace.as_deref(), params.limit) + } else if let Some(ref parent) = params.parent { + uteke.doc_list_children(parent, params.limit) + } else { + uteke.doc_list(params.namespace.as_deref(), params.limit) + }; + match result { + Ok(docs) => ctx.ok_response_for(req, &docs), + Err(e) => { + error!("doc list error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_doc_search(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.doc_search(&req_data.query, req_data.limit, &req_data.mode) { + Ok(results) => ctx.ok_response_for(req, &results), + Err(e) => { + if e.to_string().contains("embed") { + ctx.error_response_for( + req, + 503, + "embedding model not available for semantic search", + ) + } else { + error!("doc search error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_doc_move(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => match resolve_doc_id_move(&req_data) { + Ok(id_or_slug) => { + let new_parent = req_data.new_parent.as_deref(); + let new_sort_order = req_data.new_sort_order; + match uteke.doc_move(id_or_slug, new_parent, new_sort_order) { + Ok(moved) => ctx.ok_response_for(req, &serde_json::json!({"moved": moved})), + Err(e) => { + if e.to_string().contains("not found") { + ctx.error_response_for(req, 404, e.to_string()) + } else { + error!("doc move error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn delete_doc_delete(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + // Extract query string only — req.url() returns full URL which + // parse_query_param() cannot handle (#776). + let query = req.url().split('?').nth(1).unwrap_or(""); + let id = parse_query_param(query, "id"); + let slug = parse_query_param(query, "slug"); + + let id_or_slug = match (&id, &slug) { + (Some(id), _) => id.as_str(), + (_, Some(slug)) => slug.as_str(), + _ => { + return ctx.error_response_for( + req, + 400, + "provide either 'id' or 'slug' query parameter", + ); + } + }; + + match uteke.doc_delete(id_or_slug) { + Ok((deleted, subtree)) => ctx.ok_response_for( + req, + &serde_json::json!({"deleted": deleted, "subtree_size": subtree}), + ), + Err(e) => { + error!("doc delete error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +// POST /doc/mem-refs — get memory IDs that reference a document +pub(super) fn post_doc_mem_refs(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct DocMemRefsReq { + doc_slug: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.recall_memories_for_document(&req_data.doc_slug) { + Ok(memory_ids) => ctx.ok_response_for( + req, + &serde_json::json!({ + "doc_slug": req_data.doc_slug, + "memory_ids": memory_ids, + }), + ), + Err(e) => { + error!("doc/mem-refs error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} diff --git a/crates/uteke-server/src/handlers/graph.rs b/crates/uteke-server/src/handlers/graph.rs new file mode 100644 index 00000000..ca215dc8 --- /dev/null +++ b/crates/uteke-server/src/handlers/graph.rs @@ -0,0 +1,285 @@ +//! Graph handlers: graph, edges, timeline, provenance and contradictions. + +use super::*; + +pub(super) fn get_graph(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let ns = parse_query_namespace(path); + let query = path.split('?').nth(1).unwrap_or(""); + // `node_id` = memory id whose neighbourhood to return (viewers); + // `limit` bounds the number of edges (default 500, max 5000). + let node_id = parse_query_param(query, "node_id").filter(|s| !s.is_empty()); + let limit = parse_query_param(query, "limit") + .and_then(|v| v.parse::().ok()) + .unwrap_or(uteke_core::graph_view::GRAPH_VIEW_DEFAULT_LIMIT); + match uteke.graph_view(ns.as_deref(), node_id.as_deref(), limit) { + Ok(data) => ctx.ok_response_for(req, &data), + Err(e) => { + error!("Graph data error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_graph_edge(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + // Reject self-loops + if req_data.source == req_data.target { + return ctx.error_response_for( + req, + 400, + "Self-loop edges are not allowed (source == target)", + ); + } + + let conn = uteke.graph_store(); + let gs = uteke_core::graph::GraphStore::new(conn); + + // Resolve an input ID to its graph node without creating + // anything: memory-linked nodes first, then explicit node IDs + // (clients may pass IDs from GET /graph) (#1180). + let resolve_node = |id: &str| -> Result, uteke_core::Error> { + if let Some(nid) = gs.node_id_for_memory(id)? { + return Ok(Some(nid)); + } + gs.get_node(id).map(|n| n.map(|node| node.id)) + }; + + // Validate both sides exist as memories or graph nodes — + // memory IDs are ensured into nodes below (#1180, relaxes the + // memory-only #542 check that made every valid call 500). + for (role, id) in [("Source", &req_data.source), ("Target", &req_data.target)] { + if matches!(uteke.get_by_id(id), Ok(Some(_))) { + continue; + } + match resolve_node(id) { + Ok(Some(_)) => {} + Ok(None) => { + return ctx.error_response_for( + req, + 404, + format!("{role} memory not found: {id}"), + ); + } + Err(e) => { + error!("Graph resolve error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + } + } + + let relation = req_data.edge_type.as_deref().unwrap_or("related"); + let weight = req_data.weight.unwrap_or(1.0); + + // Map memory IDs → graph node IDs before insertion (#1180). + // `graph_edges.source_id/target_id` carry FKs to + // `graph_nodes(id)`, so inserting raw memory IDs violates the + // FK and always 500s. Validation above guarantees each ID is + // a memory (node gets ensured) or an existing node. + let ensure = |id: &str| -> Result { + match resolve_node(id)? { + Some(nid) => Ok(nid), + None => gs.ensure_node_for_memory(id), + } + }; + let (src_node, tgt_node) = match (ensure(&req_data.source), ensure(&req_data.target)) { + (Ok(s), Ok(t)) => (s, t), + (Err(e), _) | (_, Err(e)) => { + error!("Graph ensure_node error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + }; + + match gs.add_edge(&src_node, &tgt_node, relation, weight) { + Ok(()) => ctx.ok_response_for( + req, + &serde_json::json!({ + "ok": true, + "source_node": src_node, + "target_node": tgt_node, + }), + ), + Err(e) => { + error!("Graph add_edge error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn delete_graph_edge(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let query = path.split('?').nth(1).unwrap_or(""); + let source = parse_query_param(query, "source"); + let target = parse_query_param(query, "target"); + + match (&source, &target) { + (Some(src), Some(tgt)) => { + let conn = uteke.graph_store(); + let gs = uteke_core::graph::GraphStore::new(conn); + // Accept either memory IDs or graph node IDs (#1180) — + // resolve both sides to node IDs before deleting. + let resolve = |id: &str| -> Result, uteke_core::Error> { + if let Some(nid) = gs.node_id_for_memory(id)? { + return Ok(Some(nid)); + } + gs.get_node(id).map(|n| n.map(|node| node.id)) + }; + let (src_node, tgt_node) = match (resolve(src), resolve(tgt)) { + (Ok(Some(s)), Ok(Some(t))) => (s, t), + (Ok(_), Ok(_)) => { + return ctx.error_response_for( + req, + 404, + format!("Edge not found: {src} -> {tgt}"), + ); + } + (Err(e), _) | (_, Err(e)) => { + error!("Graph node resolve error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + }; + match gs.remove_edge(&src_node, &tgt_node) { + Ok(true) => ctx.ok_response_for(req, &serde_json::json!({"ok": true})), + Ok(false) => { + ctx.error_response_for(req, 404, format!("Edge not found: {src} -> {tgt}")) + } + Err(e) => { + error!("Graph remove_edge error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + _ => ctx.error_response_for( + req, + 400, + "Provide both ?source=...&target=... query parameters", + ), + } +} + +pub(super) fn get_provenance(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let query = path.split('?').nth(1).unwrap_or(""); + let id = match parse_query_param(query, "id") { + Some(id) => id, + None => return ctx.error_response_for(req, 400, "Missing 'id' query parameter"), + }; + match uteke.provenance(&id) { + Ok(Some(report)) => ctx.ok_response_for(req, &report), + Ok(None) => ctx.error_response_for(req, 404, format!("Memory not found: {id}")), + Err(e) => { + error!("Provenance error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn get_contradictions(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let query = path.split('?').nth(1).unwrap_or(""); + let ns = parse_query_namespace(path); + let limit = parse_query_param(query, "limit") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50) + .min(MAX_LIST_LIMIT); + match uteke.contradiction_resolutions(ns.as_deref(), limit) { + Ok(resolutions) => ctx.ok_response_for(req, &resolutions), + Err(e) => { + error!("Contradiction resolutions error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_contradictions_undo(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct ContradictionUndoRequest { + /// ID of the retired (superseded) memory to restore. + id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.undo_supersession(&req_data.id) { + Ok(Some(winner)) => ctx.ok_response_for( + req, + &serde_json::json!({ + "undone": true, + "restored": req_data.id, + "was_superseded_by": winner, + }), + ), + Ok(None) => ctx.error_response_for( + req, + 404, + format!("No supersession found for memory: {}", req_data.id), + ), + Err(e) => { + error!("Contradiction undo error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn get_timeline(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let query = path.split('?').nth(1).unwrap_or(""); + let id = match parse_query_param(query, "id") { + Some(id) => id, + None => return ctx.error_response_for(req, 400, "Missing 'id' query parameter"), + }; + let limit = parse_query_param(query, "limit") + .and_then(|v| v.parse::().ok()) + .unwrap_or(50); + match uteke.timeline(&id, limit) { + Ok(events) => ctx.ok_response_for(req, &events), + Err(e) => { + error!("Timeline error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn get_edges(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let query = path.split('?').nth(1).unwrap_or(""); + let id = match parse_query_param(query, "id") { + Some(id) => id, + None => return ctx.error_response_for(req, 400, "Missing 'id' query parameter"), + }; + match uteke.edges_for(&id) { + Ok(edges) => ctx.ok_response_for(req, &edges), + Err(e) => { + error!("Edges error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} diff --git a/crates/uteke-server/src/handlers/health.rs b/crates/uteke-server/src/handlers/health.rs new file mode 100644 index 00000000..8dc6d94e --- /dev/null +++ b/crates/uteke-server/src/handlers/health.rs @@ -0,0 +1,64 @@ +//! Health, guide and route-introspection handlers. + +use super::*; + +pub(super) fn get_health(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let health_anonymous = c.health_anonymous; + // #1252: anonymous probe gets liveness only — no version, no + // counts, no update info. Authenticated (or auth-disabled) + // callers get the full payload, unchanged. + if health_anonymous { + #[derive(serde::Serialize)] + struct MinimalHealthResponse { + status: &'static str, + } + return ctx.ok_response_for(req, &MinimalHealthResponse { status: "ok" }); + } + let total = uteke.count(None).unwrap_or(0); + let namespaces = uteke.list_namespaces().unwrap_or_default().len(); + // Populate update_available from cache (non-blocking, no network). + let update_available = uteke_core::update_check::check_cached() + .filter(|i| i.is_update_available()) + .map(|i| i.latest); + ctx.ok_response_for( + req, + &HealthResponse { + status: "ok", + version: env!("CARGO_PKG_VERSION"), + memories: total, + namespaces, + api_versions: Some(API_VERSIONS.to_vec()), + api_latest: Some(API_LATEST), + update_available, + }, + ) +} + +pub(super) fn get_guide(c: &mut Call) -> Response>> { + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(serde::Serialize)] + struct GuideResponse<'a> { + guide: &'a str, + } + ctx.ok_response_for( + req, + &GuideResponse { + guide: &uteke_core::guide::default_guide(), + }, + ) +} + +// Machine-readable API spec: serialize the api_registry ENDPOINTS +// table — the same single source of truth docgen renders into +// docs/api-reference.md — so consumers can validate against the +// real contract instead of stale notes. GET is inherently +// read-only, so the ReadOnly token gate needs no extra entry. +pub(super) fn get_routes(c: &mut Call) -> Response>> { + let ctx = c.ctx; + let req = &mut *c.req; + ctx.ok_response_for(req, &api_registry::ENDPOINTS) +} diff --git a/crates/uteke-server/src/handlers/maintenance.rs b/crates/uteke-server/src/handlers/maintenance.rs new file mode 100644 index 00000000..e1866aba --- /dev/null +++ b/crates/uteke-server/src/handlers/maintenance.rs @@ -0,0 +1,552 @@ +//! Maintenance and lifecycle handlers: verify, repair, prune, consolidate, aging, importance, orphans, extract, import/export, dream, context, lifecycle and backlink rebuilds. + +use super::*; + +pub(super) fn get_lifecycle_status(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let query = req.url().split('?').nth(1).unwrap_or(""); + let params: std::collections::HashMap = query + .split('&') + .filter_map(|pair| { + let mut kv = pair.splitn(2, '='); + Some((kv.next()?.to_string(), kv.next()?.to_string())) + }) + .collect(); + let ns_param = params.get("namespace").map(|s| s.as_str()); + let active = match uteke.store().count_active(ns_param) { + Ok(v) => v, + Err(e) => { + error!("lifecycle status error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + }; + let deprecated = match uteke.store().count_deprecated(ns_param) { + Ok(v) => v, + Err(e) => { + error!("lifecycle status error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + }; + #[derive(serde::Serialize)] + struct LifecycleStatusResponse { + active: usize, + deprecated: usize, + } + ctx.ok_response_for(req, &LifecycleStatusResponse { active, deprecated }) +} + +pub(super) fn get_lifecycle_deprecated(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let query = req.url().split('?').nth(1).unwrap_or(""); + let params: std::collections::HashMap = query + .split('&') + .filter_map(|pair| { + let mut kv = pair.splitn(2, '='); + Some((kv.next()?.to_string(), kv.next()?.to_string())) + }) + .collect(); + let ns_param = params.get("namespace").map(|s| s.as_str()); + let limit: u32 = params + .get("limit") + .and_then(|s| s.parse().ok()) + .unwrap_or(100) + .min(MAX_LIST_LIMIT as u32); + match uteke.store().list_deprecated(ns_param, limit) { + Ok(items) => { + #[derive(serde::Serialize)] + struct DeprecatedListResponse { + deprecated: Vec, + count: usize, + } + let count = items.len(); + ctx.ok_response_for( + req, + &DeprecatedListResponse { + deprecated: items, + count, + }, + ) + } + Err(e) => { + error!("lifecycle deprecated list error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_lifecycle_cycle(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct CycleReq { + namespace: Option, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.lifecycle_cycle(ns(&req_data.namespace)) { + Ok(result) => ctx.ok_response_for(req, &result), + Err(e) => { + error!("lifecycle cycle error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_lifecycle_promote(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct PromoteReq { + id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.promote(&req_data.id) { + Ok(restored) => { + #[derive(serde::Serialize)] + struct PromoteResponse { + promoted: bool, + id: String, + } + ctx.ok_response_for( + req, + &PromoteResponse { + promoted: restored, + id: req_data.id, + }, + ) + } + Err(e) => { + error!("lifecycle promote error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_context(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(body) => { + let ns = body.get("namespace").and_then(|v| v.as_str()); + match uteke.build_context(ns) { + Ok(context) => { + let resp = serde_json::json!({ "context": context }); + ctx.ok_response_for(req, &resp) + } + Err(e) => { + error!("Context error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(_) => ctx.error_response_for(req, 400, "Invalid JSON body"), + } +} + +pub(super) fn post_dream(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(body) => { + let ns = body.get("namespace").and_then(|v| v.as_str()); + let dry_run = body + .get("dry_run") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + match uteke.dream(ns, dry_run, &[]) { + Ok(report) => ctx.ok_response_for(req, &report), + Err(e) => { + error!("Dream error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(_) => ctx.error_response_for(req, 400, "Invalid JSON body"), + } +} + +pub(super) fn post_extract(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + if validate_content_size(&req_data.content, 1_048_576).is_err() { + return ctx.error_response_for(req, 413, "Content too large (max 1MB)"); + } + if let Err(e) = uteke_core::validate_input(&req_data.content, &req_data.tags) { + return ctx.error_response_for(req, 400, e.to_string()); + } + + let ext_config = resolve_extraction_config( + ctx, + req_data.model.as_deref(), + req_data.max_facts, + None, // api_key from config only (not from request body) + ); + + let extractor = match uteke_core::extraction::Extractor::new(&ext_config) { + Ok(e) => e, + Err(e) => { + error!("Extractor init error: {e}"); + return ctx.error_response_for(req, 400, e.to_string()); + } + }; + + let facts = match extractor.extract(&req_data.content) { + Ok(f) => f, + Err(e) => { + error!("Extraction error: {e}"); + return ctx.error_response_for(req, 502, format!("Extraction failed: {e}")); + } + }; + + // Store each extracted fact as a memory + let mut stored_ids = Vec::new(); + let fact_ns = ns(&req_data.namespace); + + for fact in &facts { + // Build tags: caller-provided tags + scene tag if present (#1009). + let mut all_tags: Vec = + req_data.tags.iter().map(|s| s.to_string()).collect(); + if let Some(ref scene) = fact.scene { + let scene_tag = format!("scene:{}", scene); + if !all_tags.contains(&scene_tag) { + all_tags.push(scene_tag); + } + } + let tag_refs: Vec<&str> = all_tags.iter().map(|s| s.as_str()).collect(); + + let mut meta = serde_json::Map::new(); + meta.insert( + "source".into(), + serde_json::Value::String("extraction".into()), + ); + let metadata = if meta.is_empty() { + None + } else { + Some(serde_json::Value::Object(meta)) + }; + + // Use remember_typed when the LLM or request provided a type (#1009). + let effective_type = fact.fact_type.as_deref().or(req_data.r#type.as_deref()); + let result = if let Some(ft) = effective_type { + uteke.remember_typed(&fact.content, &tag_refs, metadata, fact_ns, ft) + } else { + uteke.remember(&fact.content, &tag_refs, metadata, fact_ns) + }; + + if let Ok(id) = result { + // Set importance if the LLM provided a priority score (#1009). + if let Some(priority) = fact.priority { + let _ = uteke.set_importance(&id, priority); + } + // Auto-populate source provenance (#1013). + let _ = uteke.set_source(&id, Some("api:extraction"), "extract"); + stored_ids.push(id); + } + } + + ctx.ok_response_for( + req, + &with_namespace_warning( + serde_json::json!({ + "facts": facts.iter().map(|f| &f.content).collect::>(), + "count": facts.len(), + "stored": stored_ids.len(), + "stored_ids": stored_ids, + }), + fact_ns, + ), + ) + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_import(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + // Same ceiling `read_body` enforces on the whole request, so the + // limit the caller is told about is the limit that applies (#1328). + let max_import = uteke_core::MAX_PAYLOAD_SIZE; + if validate_content_size(&req_data.content, max_import).is_err() { + return ctx.error_response_for( + req, + 413, + format!("Content too large (max {}MB)", max_import / 1_048_576), + ); + } + + // Merge request tags into the JSONL entries. + // The core import method handles re-embedding. + let import_ns = ns(&req_data.namespace); + match uteke.import(&req_data.content, import_ns) { + Ok(result) => ctx.ok_response_for( + req, + &with_namespace_warning( + serde_json::json!({ + "imported": result.imported, + "skipped": result.skipped, + }), + import_ns, + ), + ), + Err(e) => { + error!("Import error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn get_export(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let export_ns = parse_query_namespace(path); + match uteke.export(export_ns.as_deref()) { + Ok(jsonl) => { + let mut headers = ctx.cors_headers_for(req); + headers.push( + Header::from_bytes(&b"Content-Type"[..], &b"application/x-ndjson"[..]).unwrap(), + ); + Response::new( + StatusCode::from(200), + headers, + Cursor::new(jsonl.into_bytes()), + None, + None, + ) + } + Err(e) => { + error!("Export error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +// POST /verify → VerifyReport (read-only check, write token like +// other maintenance endpoints). POST /repair → rebuilds the vector +// index from SQLite embeddings so stores can recover from desync +// (e.g. version-lagged serve binaries, #1245 class) WITHOUT a +// restart — the previous runbook required stopping uteke-serve +// (pitfall 0f lock contention). +pub(super) fn post_verify(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match uteke.verify() { + Ok(r) => ctx.ok_response_for(req, &r), + Err(e) => { + error!("Request failed: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_repair(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match uteke.repair() { + Ok(r) => ctx.ok_response_for(req, &r), + Err(e) => { + error!("Request failed: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_prune(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + let result = uteke.prune(req_data.ttl_days, ns(&req_data.namespace), req_data.dry_run); + match result { + Ok(r) => ctx.ok_response_for(req, &r), + Err(e) => { + error!("Request failed: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_consolidate(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + if req_data.dry_run { + let pairs = uteke.find_duplicates(ns(&req_data.namespace), req_data.threshold); + match pairs { + Ok(p) => ctx.ok_response_for(req, &p), + Err(e) => { + error!("Request failed: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } else { + let result = uteke.consolidate(ns(&req_data.namespace), req_data.threshold, false); + match result { + Ok(r) => ctx.ok_response_for(req, &r), + Err(e) => { + error!("Request failed: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// Per-pair dedup control (#1076): caller picks the survivor. +pub(super) fn post_consolidate_pair(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + let result = + uteke.consolidate_pair(&req_data.id_keep, &req_data.id_remove, req_data.hard); + match result { + Ok(r) => ctx.ok_response_for(req, &r), + Err(e) => { + let status = if matches!(e, uteke_core::Error::Validation(_)) { + 400 + } else { + 500 + }; + ctx.error_response_for(req, status, e.to_string()) + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_aging(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + let result = match req_data.action.as_str() { + "status" => { + let status = uteke.aging_status(ns(&req_data.namespace)); + status.map(|s| serde_json::json!(s)) + } + "preview" => { + let older = req_data.older_than_days.unwrap_or(90); + let max_acc = req_data.max_access_count.unwrap_or(1); + let mems = uteke.aging_preview(older, max_acc, ns(&req_data.namespace)); + mems.map(|m| serde_json::json!(m)) + } + "cleanup" => { + if req_data.dry_run { + let older = req_data.older_than_days.unwrap_or(90); + let max_acc = req_data.max_access_count.unwrap_or(1); + let mems = uteke.aging_preview(older, max_acc, ns(&req_data.namespace)); + mems.map(|m| serde_json::json!({ "dry_run": true, "candidates": m.len(), "memories": m })) + } else { + let older = req_data.older_than_days.unwrap_or(90); + let max_acc = req_data.max_access_count.unwrap_or(1); + let r = uteke.aging_cleanup(older, max_acc, ns(&req_data.namespace)); + r.map(|c| serde_json::json!(c)) + } + } + other => { + return ctx.error_response_for( + req, + 400, + format!("Unknown action: {other}. Use: status, preview, cleanup"), + ); + } + }; + match result { + Ok(r) => ctx.ok_response_for(req, &r), + Err(e) => { + error!("Request failed: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_importance(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(_req_data) => match uteke.recompute_importance() { + Ok(count) => ctx.ok_response_for(req, &serde_json::json!({ "updated": count })), + Err(e) => { + error!("Request failed: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_orphans(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + match uteke.find_orphans(ns(&req_data.namespace), req_data.threshold, req_data.limit) { + Ok(orphans) => ctx.ok_response_for(req, &orphans), + Err(e) => { + error!("Request failed: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_rebuild_backlinks(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(_req_data) => match uteke.rebuild_backlinks() { + Ok(count) => { + ctx.ok_response_for(req, &serde_json::json!({ "backlinks_created": count })) + } + Err(e) => { + error!("Request failed: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} diff --git a/crates/uteke-server/src/handlers/mcp.rs b/crates/uteke-server/src/handlers/mcp.rs new file mode 100644 index 00000000..cbc8cdee --- /dev/null +++ b/crates/uteke-server/src/handlers/mcp.rs @@ -0,0 +1,66 @@ +//! MCP-over-HTTP handler. + +use super::*; + +pub(super) fn post_mcp(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let read_only = c.read_only; + // Enforce a body size limit to prevent memory exhaustion + // (CodeCora #397). 1 MiB is generous for JSON-RPC. + const MAX_MCP_BODY: u64 = 1024 * 1024; + // Check Content-Length and reject oversized requests. + let content_length = req + .headers() + .iter() + .find(|h| h.field.equiv("Content-Length")) + .and_then(|h| h.value.as_str().parse::().ok()) + .unwrap_or(0); + if content_length > MAX_MCP_BODY { + return ctx.error_response_for(req, 413, "Payload too large"); + } + // Read one byte past the cap so a chunked/unsized oversized body is + // a 413 instead of a silently truncated JSON parse error (#1328). + let mut body = String::new(); + if let Err(e) = req + .as_reader() + .take(MAX_MCP_BODY + 1) + .read_to_string(&mut body) + { + return ctx.error_response_for(req, 400, format!("Failed to read body: {e}")); + } + if body.len() as u64 > MAX_MCP_BODY { + return ctx.error_response_for(req, 413, "Payload too large"); + } + let cors = ctx.cors_headers_for(req); + // None = notification (no response per JSON-RPC 2.0 §4.1) → 204 No Content + // Read-only tokens reach /mcp (#1439); uteke-mcp refuses the + // tools outside READ_ONLY_TOOLS as tool errors. + let access = if read_only { + uteke_mcp::Access::ReadOnly + } else { + uteke_mcp::Access::Full + }; + let mut resp = match uteke_mcp::handle_jsonrpc_as(uteke, &body, access) { + Some(response) => tiny_http::Response::from_string(response) + .with_header( + tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .unwrap(), + ) + .with_header( + tiny_http::Header::from_bytes(&b"MCP-Protocol-Version"[..], &b"2025-06-18"[..]) + .unwrap(), + ), + None => tiny_http::Response::from_string("") + .with_status_code(204) + .with_header( + tiny_http::Header::from_bytes(&b"MCP-Protocol-Version"[..], &b"2025-06-18"[..]) + .unwrap(), + ), + }; + for h in cors { + resp.add_header(h); + } + resp +} diff --git a/crates/uteke-server/src/handlers/memory.rs b/crates/uteke-server/src/handlers/memory.rs new file mode 100644 index 00000000..1cf72443 --- /dev/null +++ b/crates/uteke-server/src/handlers/memory.rs @@ -0,0 +1,886 @@ +//! Memory handlers: remember, recall, search, list, forget, stats, recent, single-memory reads/updates, pin and feedback. + +use super::*; + +pub(super) fn post_remember(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(raw) => { + // Validation (content/tags, author_type, type) and the + // metadata map live in the typed request shared with MCP and + // the CLI (#1343). Order: content/tags, author_type, type; all + // before any write (#1083, #1302). + let req_data = match raw.decode() { + Ok(t) => t, + Err(e) => return ctx.error_response_for(req, 400, e.message().to_string()), + }; + let tag_refs = req_data.tag_refs(); + let metadata = req_data.metadata.clone(); + + let result: Result = + if req_data.detect_contradiction { + uteke + .remember_with_contradiction( + &req_data.content, + &tag_refs, + metadata, + ns(&req_data.namespace), + req_data.memory_type.as_deref(), + true, + 0.65, + ) + .map(|(outcome, _)| outcome) + } else if let Some(t) = req_data.memory_type.as_deref() { + uteke.remember_typed_detailed( + &req_data.content, + &tag_refs, + metadata, + ns(&req_data.namespace), + t, + ) + } else { + uteke.remember_detailed( + &req_data.content, + &tag_refs, + metadata, + ns(&req_data.namespace), + ) + }; + + match result { + Ok(outcome) => { + let id = outcome.id; + // Set source provenance after storage (#682) — matches CLI. + if req_data.source.is_some() || req_data.source_type.is_some() { + let st = req_data.source_type.as_deref().unwrap_or("user"); + if let Err(e) = uteke.set_source(&id, req_data.source.as_deref(), st) { + error!("Failed to set source for {id}: {e}"); + } + } + // Set author type after storage (#1083). Validate before + // writing so invalid values fail loudly (400, not silent). + if let Some(at) = req_data.author_type.as_deref() { + if let Err(e) = uteke.set_author_type(&id, at) { + return ctx.error_response_for(req, 400, e.to_string()); + } + } + // Echo author_type so clients can confirm what was + // recorded (#1106) — data was already stored correctly, + // the response just omitted the field. Default mirrors + // the schema default (author_type DEFAULT 'agent', #1083). + // #1273: honest embedding status — never a bare success + // when the vector write failed. + ctx.ok_response_for( + req, + &serde_json::json!({ + "id": id, + "author_type": req_data.author_type.clone().unwrap_or_else(|| "agent".to_string()), + "embedding_written": outcome.embedding_written, + "warning": uteke_core::namespace_deprecation::merge_warning( + outcome.warning, + uteke_core::namespace_deprecation::namespace_write_warning( + ns(&req_data.namespace), + ), + ), + }), + ) + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_recall(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let api_version = c.api_version; + match read_body::(req.as_reader()) { + Ok(req_data) => { + // Defaults, the limit cap (#903), the blank-query rejection (#907) + // and the RFC3339 parsing of at/after/before live in the typed + // request shared with MCP and the CLI (#1343). + let typed = match req_data.decode() { + Ok(t) => t, + Err(e) => return ctx.error_response_for(req, 400, e.to_string()), + }; + let limit = typed.limit; + let tag_vec = typed.tag_filter(); + let tags_filter = tag_vec.as_deref(); + // Threshold: min_score > [recall] min_score(_strict) > built-in + // (strict 0.5, otherwise 0.0). + let min_score = typed.resolve_min_score( + ctx.recall_config.as_ref().and_then(|r| r.min_score), + ctx.recall_config.as_ref().and_then(|r| r.min_score_strict), + ); + // Entity/category filters are now pushed into the core recall + // candidate loop (#663) — no 10x fetch amplification needed. + + // Time-travel mode: recall_at_time + let point_in_time = typed.at; + + let entity_filter = req_data.entity.as_deref(); + let category_filter = req_data.category.as_deref(); + + // #902: temporal range filters (before/after). + let after_ts = typed.after; + let before_ts = typed.before; + let has_temporal = after_ts.is_some() || before_ts.is_some(); + + // Resolve recall strategy ONCE for every path (#1034): + // request `strategy` > config `[recall] default_strategy` > + // Hybrid — matching the CLI default. Unknown values are a + // loud 400 on all paths, never a silent no-op. + let strategy = match typed.strategy( + ctx.recall_config + .as_ref() + .and_then(|r| r.default_strategy.as_deref()), + ) { + Ok(s) => s, + Err(e) => return ctx.error_response_for(req, 400, e.to_string()), + }; + + // Explain mode (#1160): memory-only recall with per-result + // ranking signals. Mutually exclusive with the unified / + // time-travel / temporal surfaces. + if req_data.explain { + if req_data.search_type.is_some() { + return ctx.error_response_for( + req, + 400, + "explain is memory-only: omit search_type", + ); + } + if point_in_time.is_some() || has_temporal { + return ctx.error_response_for( + req, + 400, + "explain cannot be combined with at/before/after", + ); + } + if entity_filter.is_some() || category_filter.is_some() || req_data.enrich { + return ctx.error_response_for( + req, + 400, + "explain cannot be combined with entity/category/enrich", + ); + } + return match uteke.recall_explained( + &req_data.query, + limit, + tags_filter, + ns(&req_data.namespace), + strategy, + min_score, + ) { + Ok(explained) => ctx.ok_response_for(req, &explained), + Err(e) => { + error!("Explain recall error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }; + } + + // Budgeted context pack (#1281 Phase 1): unified recall + + // greedy character-budget fill; deterministic, LLM-free, + // rank-order preserving. Honours the caller's search_type + // (validated with the same 400 as the plain path). + if req_data.pack { + // Loud reject like the CLI: pack is not composable with + // point-in-time / range filters (the plain unified path + // consumes them further down, after this early return). + if req_data.at.is_some() || req_data.after.is_some() || req_data.before.is_some() { + return ctx.error_response_for( + req, + 400, + "pack is not supported with at/after/before filters; rerun without time filters", + ); + } + let parsed_search_type = match typed.search_type() { + Ok(t) => t, + Err(e) => return ctx.error_response_for(req, 400, e.to_string()), + }; + let exclude_ids = typed.exclude_ids.clone(); + return match uteke.recall_unified_packed( + &req_data.query, + limit, + tags_filter, + ns(&req_data.namespace), + min_score, + parsed_search_type, + entity_filter, + category_filter, + req_data.enrich, + strategy, + typed.budget_chars_or_default(), + &exclude_ids, + ) { + Ok(pack) => ctx.ok_response_for(req, &pack), + Err(e) => { + error!("Pack recall error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }; + } + + // Unified search path (#531): when search_type is specified, + // use recall_unified. Entity/category filters are passed + // through to the core recall candidate loop (#663). + let unified_result = if req_data.search_type.is_some() && point_in_time.is_none() { + let search_type = match typed.search_type() { + Ok(t) => t, + Err(e) => return ctx.error_response_for(req, 400, e.to_string()), + }; + Some(uteke.recall_unified( + &req_data.query, + limit, + tags_filter, + ns(&req_data.namespace), + min_score, + search_type, + entity_filter, + category_filter, + req_data.enrich, + strategy, + )) + } else { + None + }; + + // Prefer unified results when available (#531) + match unified_result { + Some(Ok(mut results)) => { + // #902: Post-filter by temporal range (before/after). + if has_temporal { + results.retain(|r| { + let ts = r.created_at.as_ref(); + ts.is_some_and(|t| { + after_ts.is_none_or(|a| t >= &a) + && before_ts.is_none_or(|b| t <= &b) + }) + }); + } + if api_version == Some(ApiVersion::V1) { + // v1: flat format [{id, content, score, ...}] + let v1_results: Vec = + results.iter().map(to_v1_flat).collect(); + ctx.ok_response_for(req, &v1_results) + } else { + ctx.ok_response_for(req, &results) + } + } + Some(Err(e)) => { + error!("Unified search error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + None => { + // Memory-only recall path (#1034): route through + // recall_hybrid so the resolved strategy applies to + // the default path too — not just when search_type is + // present. recall_hybrid has no native entity/category + // support, so apply them as post-filters with 3× + // over-fetch (same pattern as recall_unified_memories). + let needs_post_filter = entity_filter.is_some() || category_filter.is_some(); + let fetch_limit = if needs_post_filter { + (limit.saturating_mul(3)).max(limit + 10) + } else { + limit + }; + let recall_result = if let Some(pit) = point_in_time { + // Time-travel: strategy doesn't apply to + // recall_at_time (point-in-time semantics). + uteke.recall_at_time( + &req_data.query, + limit, + tags_filter, + ns(&req_data.namespace), + pit, + min_score, + entity_filter, + category_filter, + ) + } else { + uteke.recall_hybrid( + &req_data.query, + fetch_limit, + tags_filter, + ns(&req_data.namespace), + strategy, + min_score, + ) + }; + // Entity/category post-filter (memory results only) — + // mirrors the CLI recall post-filter and core + // recall_unified_memories (#663, #900). + match recall_result { + Ok(mut results) => { + if needs_post_filter && point_in_time.is_none() { + results.retain(|sr| { + entity_filter.is_none_or(|ent| { + sr.memory + .metadata + .get("entity") + .and_then(|v| v.as_str()) + .is_some_and(|e| e == ent) + }) && category_filter.is_none_or(|cat| { + sr.memory + .metadata + .get("category") + .and_then(|v| v.as_str()) + .is_some_and(|c| c == cat) + }) + }); + results.truncate(limit); + } + // #902: Post-filter by temporal range (before/after). + if has_temporal { + results.retain(|r| { + let t = &r.memory.created_at; + after_ts.is_none_or(|a| t >= &a) + && before_ts.is_none_or(|b| t <= &b) + }); + } + if results.is_empty() && min_score > 0.0 { + ctx.ok_response_for( + req, + &serde_json::json!({ + "results": [], + "total": 0, + "threshold": min_score, + "message": "No memories above similarity threshold" + }), + ) + } else { + ctx.ok_response_for(req, &results) + } + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_search(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect(); + let tags_filter = if tag_refs.is_empty() { + None + } else { + Some(tag_refs.as_slice()) + }; + match uteke.search( + &req_data.query, + req_data.limit, + tags_filter, + ns(&req_data.namespace), + ) { + Ok(results) => ctx.ok_response_for(req, &results), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_list(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(params) => { + // #1343: limit cap and `at` parsing live in the typed request. + let list_req = match params.decode() { + Ok(r) => r, + Err(e) => return ctx.error_response_for(req, 400, e.to_string()), + }; + // Time-travel mode: use list_at_time + let list_result = match list_req.at { + Some(pit) => uteke.list_at_time( + list_req.tag.as_deref(), + list_req.limit, + list_req.offset, + ns(&list_req.namespace), + pit, + ), + None => uteke.list( + list_req.tag.as_deref(), + list_req.limit, + list_req.offset, + ns(&list_req.namespace), + ), + }; + match list_result { + Ok(memories) => { + // #1188: opt-in pagination envelope. The default + // response stays a bare array for existing clients. + if list_req.wants_envelope() { + // Total matching rows (same filters as the page). + let total = match &list_req.tag { + Some(tag) => uteke.count_by_tag(tag, ns(&list_req.namespace)), + None => uteke.count(ns(&list_req.namespace)), + } + .unwrap_or(0); + let meta = list_req.page_meta(total, memories.len()); + return ctx.ok_response_for( + req, + &serde_json::json!({ + "memories": memories, + "total": total, + "has_more": meta.has_more, + "next_offset": meta.next_offset, + }), + ); + } + ctx.ok_response_for(req, &memories) + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn delete_forget(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let query = path.split('?').nth(1).unwrap_or(""); + // Typed request shared with MCP and the CLI (#1343). + let request = match uteke_core::ForgetRequest::decode( + uteke_core::ForgetInput::from_http_query(query), + uteke_core::ForgetPolicy::HTTP, + ) { + Ok(r) => r, + Err(e) => return ctx.error_response_for(req, 400, e.message()), + }; + match request { + uteke_core::ForgetRequest::Id(_) => { + // Accept full UUID or short ID prefix (#794); the + // existence check (#762) is part of the HTTP policy + // because forget() silently returns Ok(()) for unknown ids. + let resolved_id = match request.resolve(uteke, uteke_core::ForgetPolicy::HTTP) { + Ok(Some(id)) => id, + Ok(None) => unreachable!("an id request resolves to an id"), + Err(e) => { + return match e.kind() { + uteke_core::ForgetErrorKind::NotFound => { + ctx.error_response_for(req, 404, e.message()) + } + _ => { + error!("Resolve error: {e}"); + ctx.error_response_for(req, 400, e.message()) + } + }; + } + }; + match uteke.forget(&resolved_id) { + Ok(()) => ctx.ok_response_for(req, &serde_json::json!({"forgotten": resolved_id})), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + uteke_core::ForgetRequest::Tag { tag, namespace } => { + match uteke.bulk_forget_by_tag(&tag, namespace.as_deref()) { + Ok(result) => ctx.ok_response_for(req, &result), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + } +} + +pub(super) fn get_stats(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + // Parse ?namespace= query parameter for scoped stats (#382). + let query = req.url().split('?').nth(1).unwrap_or(""); + let params: std::collections::HashMap = query + .split('&') + .filter_map(|pair| { + let mut kv = pair.splitn(2, '='); + Some((kv.next()?.to_string(), kv.next()?.to_string())) + }) + .collect(); + let ns_param = params.get("namespace").map(|s| s.as_str()); + match uteke.stats(ns_param) { + Ok(stats) => ctx.ok_response_for(req, &stats), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_stats(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct StatsReq { + namespace: Option, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.stats(ns(&req_data.namespace)) { + Ok(stats) => ctx.ok_response_for(req, &stats), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn get_recent(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let ns = parse_query_namespace(path); + let query = path.split('?').nth(1).unwrap_or(""); + let limit = parse_query_param(query, "limit") + .and_then(|v| v.parse::().ok()) + .unwrap_or(20) + .min(MAX_LIST_LIMIT); + let offset = parse_query_param(query, "offset") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + match uteke.list(None, limit, offset, ns.as_deref()) { + Ok(memories) => ctx.ok_response_for(req, &memories), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn get_memory(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let query = path.split('?').nth(1).unwrap_or(""); + let id = parse_query_param(query, "id").unwrap_or_default(); + // Validate UUID format + if uuid::Uuid::parse_str(&id).is_err() { + return ctx.error_response_for(req, 400, format!("Invalid UUID format: {id}")); + } + match uteke.get_by_id(&id) { + Ok(Some(memory)) => ctx.ok_response_for(req, &memory), + Ok(None) => ctx.error_response_for(req, 404, format!("Memory not found: {id}")), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_memory_pin(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + if uuid::Uuid::parse_str(&req_data.id).is_err() { + return ctx.error_response_for( + req, + 400, + format!("Invalid UUID format: {}", req_data.id), + ); + } + let result = if req_data.pinned { + uteke.pin(&req_data.id) + } else { + uteke.unpin(&req_data.id) + }; + match result { + Ok(true) => match uteke.get_by_id(&req_data.id) { + Ok(Some(memory)) => ctx.ok_response_for(req, &memory), + Ok(None) => ctx.error_response_for( + req, + 500, + "Memory updated but could not be retrieved", + ), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Ok(false) => { + ctx.error_response_for(req, 404, format!("Memory not found: {}", req_data.id)) + } + Err(e) => { + error!("Pin/unpin error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_memory_importance(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + if uuid::Uuid::parse_str(&req_data.id).is_err() { + return ctx.error_response_for( + req, + 400, + format!("Invalid UUID format: {}", req_data.id), + ); + } + match uteke.set_importance(&req_data.id, req_data.importance) { + Ok(true) => match uteke.get_by_id(&req_data.id) { + Ok(Some(memory)) => ctx.ok_response_for(req, &memory), + Ok(None) => ctx.error_response_for( + req, + 500, + "Memory updated but could not be retrieved", + ), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Ok(false) => { + ctx.error_response_for(req, 404, format!("Memory not found: {}", req_data.id)) + } + Err(e) => match e { + uteke_core::Error::Validation(_) => { + ctx.error_response_for(req, 400, e.to_string()) + } + _ => { + error!("Set importance error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_memory_feedback(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + if uuid::Uuid::parse_str(&req_data.id).is_err() { + return ctx.error_response_for( + req, + 400, + format!("Invalid UUID format: {}", req_data.id), + ); + } + let result = match req_data.feedback.as_str() { + "helpful" => uteke.feedback_helpful(&req_data.id).map(|imp| { + serde_json::json!({ + "id": req_data.id, + "feedback": "helpful", + "delta": 0.05, + "importance": imp, + }) + }), + "unhelpful" => uteke.feedback_unhelpful(&req_data.id).map(|imp| { + serde_json::json!({ + "id": req_data.id, + "feedback": "unhelpful", + "delta": -0.10, + "importance": imp, + }) + }), + _ => { + return ctx.error_response_for( + req, + 400, + "Invalid feedback value. Use 'helpful' or 'unhelpful'.".to_string(), + ); + } + }; + match result { + Ok(data) => ctx.ok_response_for(req, &data), + Err(e) => { + error!("Feedback error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn put_memory(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + // Validate UUID format + if uuid::Uuid::parse_str(&req_data.id).is_err() { + return ctx.error_response_for( + req, + 400, + format!("Invalid UUID format: {}", req_data.id), + ); + } + // Check that at least one field is provided + if req_data.content.is_none() + && req_data.tags.is_none() + && req_data.metadata.is_none() + && req_data.importance.is_none() + && req_data.pinned.is_none() + && req_data.memory_type.is_none() + && req_data.namespace.is_none() + { + return ctx.error_response_for( + req, + 400, + "No fields to update. Provide at least one of: content, tags, metadata, importance, pinned, memory_type, namespace", + ); + } + let tag_refs: Option> = req_data.tags; + let tag_slice: Option<&[String]> = tag_refs.as_deref(); + match uteke.update_memory( + &req_data.id, + req_data.content.as_deref(), + tag_slice, + req_data.metadata.as_ref(), + req_data.importance, + req_data.pinned, + req_data.memory_type.as_deref(), + ) { + Ok(true) => { + // Namespace move (#1181): separate op after the field + // update — plain column change, no re-embed needed. + if let Some(ns) = req_data.namespace.as_deref() { + if let Err(e) = uteke.move_memory(&req_data.id, ns) { + let status = match e { + uteke_core::Error::Validation(_) => 400, + _ => 500, + }; + return ctx.error_response_for(req, status, e.to_string()); + } + } + ctx.ok_response_for( + req, + &with_namespace_warning( + serde_json::json!({"updated": req_data.id}), + req_data.namespace.as_deref(), + ), + ) + } + Ok(false) => { + ctx.error_response_for(req, 404, format!("Memory not found: {}", req_data.id)) + } + Err(e) => { + error!("Update memory error: {e}"); + // Propagate validation errors with proper status codes + let status = match e { + uteke_core::Error::Validation(_) => 400, + _ => 500, + }; + ctx.error_response_for(req, status, e.to_string()) + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// POST /memory/doc-refs — get document slugs referenced by a memory +pub(super) fn post_memory_doc_refs(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct MemoryDocRefsReq { + memory_id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.recall_documents_for_memory(&req_data.memory_id) { + Ok(slugs) => ctx.ok_response_for( + req, + &serde_json::json!({ + "memory_id": req_data.memory_id, + "doc_slugs": slugs, + }), + ), + Err(e) => { + error!("memory/doc-refs error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_pin(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.pin(&req_data.id) { + Ok(true) => ctx.ok_response_for(req, &serde_json::json!({"pinned": req_data.id})), + Ok(false) => { + ctx.error_response_for(req, 404, format!("Memory not found: {}", req_data.id)) + } + Err(e) => { + error!("Pin error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_unpin(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.unpin(&req_data.id) { + Ok(true) => ctx.ok_response_for(req, &serde_json::json!({"unpinned": req_data.id})), + Ok(false) => { + ctx.error_response_for(req, 404, format!("Memory not found: {}", req_data.id)) + } + Err(e) => { + error!("Unpin error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} diff --git a/crates/uteke-server/src/handlers/mod.rs b/crates/uteke-server/src/handlers/mod.rs new file mode 100644 index 00000000..44f916de --- /dev/null +++ b/crates/uteke-server/src/handlers/mod.rs @@ -0,0 +1,2901 @@ +//! Route dispatcher — all HTTP endpoint handlers. +//! +//! [`route`] runs the shared preflight (API version prefix, Host guard, CORS +//! preflight, auth, read-only gate, registry 404), locks the store, packs the +//! per-request state into a [`Call`] and dispatches on method + path to one +//! named handler function per route. The handlers live in domain submodules +//! (`health`, `memory`, `namespaces`, `tags`, `rooms`, `docs`, `graph`, +//! `maintenance`, `mcp`); helpers shared by several handlers stay here. + +use std::io::{Cursor, Read as IoRead}; +use std::sync::Mutex; + +use serde::Deserialize; +use tiny_http::{Header, Method, Request, Response, StatusCode}; +use tracing::{error, warn}; + +use uteke_core::Uteke; + +use crate::api_registry; +use crate::context::{self, ApiRole, AuthResult, ReqCtx}; +use crate::types::*; + +/// Current API version constant — used by health and versioned routes. +const API_LATEST: &str = "v2"; +const API_VERSIONS: &[&str] = &["v1", "v2"]; + +mod docs; +mod graph; +mod health; +mod maintenance; +mod mcp; +mod memory; +mod namespaces; +mod rooms; +mod tags; + +/// Per-request state handed to a route handler: the locked store plus what +/// the preflight in [`route`] derived from the request. +pub(crate) struct Call<'a, 'r> { + /// The locked Uteke instance. + pub uteke: &'a Uteke, + pub ctx: &'a ReqCtx, + pub req: &'r mut Request, + /// Path with the `/api/vN` prefix stripped, query string intact. + pub path: &'a str, + pub api_version: Option, + /// A tokenless `GET /health` probe (#1252). + pub health_anonymous: bool, + /// The caller holds a read-only token (#409). + pub read_only: bool, +} + +pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response>> { + let method = req.method().clone(); + let raw_path = req.url().to_string(); + + // ── API Versioning (#737): parse /api/vN/ prefix ──────────────────── + let (api_version, path) = match ApiVersion::from_path(&raw_path) { + Some((ver, stripped)) => (Some(ver), stripped.to_string()), + None => (None, raw_path.clone()), + }; + + // Route matching uses path-only (without query string). + // Handler functions still receive the full `path` with query params intact. + let route_path = path.split('?').next().unwrap_or(&path); + + // DNS-rebinding guard (#1326): runs before everything else, preflight and + // health included, since a rebinding page can hit any route. + let host_header = req + .headers() + .iter() + .find(|h| h.field.equiv("Host")) + .map(|h| h.value.as_str().to_string()); + if !ctx.host_guard.allows(host_header.as_deref()) { + return ctx.error_response_for(req, 403, "Host not allowed"); + } + + // CORS preflight — no auth required + if method == Method::Options { + return Response::new( + StatusCode::from(204), + ctx.preflight_headers(req), + Cursor::new(Vec::new()), + None, + None, + ); + } + + // Health endpoint — reachable WITHOUT a token (load balancers, uptime + // probes), but anonymous callers get a minimal body: version, memory + // counts, and update info leak the instance fingerprint to scanners (#1252). + let is_health = matches!((&method, route_path), (Method::Get, "/health")); + + // Authenticate every request; a tokenless health probe degrades to the + // Anonymous role instead of a 401 so uptime checks keep working. + let mut health_anonymous = false; + let auth_role = if !is_health { + match context::check_auth(req, ctx) { + Ok(role) => role, + Err(resp) => return resp, + } + } else { + match context::check_auth(req, ctx) { + Ok(role) => role, + Err(_) => { + health_anonymous = true; + AuthResult::Anonymous + } + } + }; + + // Enforce read-only restriction (#409, #524): + // ReadOnly tokens can use GET endpoints + read-only POST endpoints. + // Write operations (POST /remember, POST /forget, etc.) are blocked. + let read_only = matches!(auth_role, AuthResult::Authenticated(ApiRole::ReadOnly)); + if read_only { + // The gate is derived from the registry's `writes` flag (#1344): GET + // plus the POST rows that do not write (semantic search, list, + // stats). Exact path match avoids prefix-based bypass (/recallfoo). + if !api_registry::read_only_allows(method.as_str(), &path) { + return ctx.error_response_for( + req, + 403, + "Read-only token cannot perform write operations", + ); + } + } + + // The registry is the route table (#1344): a (method, path) without a row + // is a 404 whatever arms the match below still carries, so a handler + // cannot be reachable while unregistered (or ungated). + if api_registry::find(method.as_str(), route_path).is_none() { + return ctx.error_response_for(req, 404, "Not found"); + } + + // Lock the Uteke instance for the duration of this request. + // This serializes requests but prevents data races on the SQLite connection. + // Future: use rwlock for read-heavy workloads. + // A handler panic poisons the mutex; recover the guard instead of failing + // every later request. SQLite transactions roll back on unwind, so the + // underlying state stays consistent. + let uteke = uteke.lock().unwrap_or_else(|e| e.into_inner()); + + let mut call = Call { + uteke: &uteke, + ctx, + req: &mut *req, + path: &path, + api_version, + health_anonymous, + read_only, + }; + + match (method, route_path) { + // ── Health ────────────────────────────────────────────────────── + (Method::Get, "/health") => health::get_health(&mut call), + + // ── Memory Tools Guide (#1010) ────────────────────────────────── + (Method::Get, "/guide") => health::get_guide(&mut call), + + // ── Route Introspection (#1289) ───────────────────────────────── + (Method::Get, "/routes") => health::get_routes(&mut call), + + // ── Remember ─────────────────────────────────────────────────── + (Method::Post, "/remember") => memory::post_remember(&mut call), + + // ── Recall (semantic search) ──────────────────────────────────── + (Method::Post, "/recall") => memory::post_recall(&mut call), + + // ── Search (keyword) ──────────────────────────────────────────── + (Method::Post, "/search") => memory::post_search(&mut call), + + // ── List ──────────────────────────────────────────────────────── + (Method::Post, "/list") => memory::post_list(&mut call), + + // ── Forget by ID or tag (DELETE /forget?id=xxx or ?tag=xxx) ──── + (Method::Delete, "/forget") => memory::delete_forget(&mut call), + + // ── Stats (GET = all or ?namespace=) ─────────────────── + (Method::Get, "/stats") => memory::get_stats(&mut call), + (Method::Post, "/stats") => memory::post_stats(&mut call), + + // ── Lifecycle (#936): status, cycle, promote ────────────────── + (Method::Get, "/lifecycle/status") => maintenance::get_lifecycle_status(&mut call), + + (Method::Get, "/lifecycle/deprecated") => maintenance::get_lifecycle_deprecated(&mut call), + + (Method::Post, "/lifecycle/cycle") => maintenance::post_lifecycle_cycle(&mut call), + + (Method::Post, "/lifecycle/promote") => maintenance::post_lifecycle_promote(&mut call), + + // ── Namespaces ────────────────────────────────────────────────── + (Method::Get, "/namespaces") => namespaces::get_namespaces(&mut call), + + // ── Namespace Rename/Merge (#1181) ────────────────────────────── + (Method::Post, "/namespaces/rename") => namespaces::post_namespaces_rename(&mut call), + + // ── Namespace Delete with explicit strategy (#1181) ───────────── + (Method::Post, "/namespaces/delete") => namespaces::post_namespaces_delete(&mut call), + + // ── Recent (#528) ────────────────────────────────────────────── + (Method::Get, "/recent") => memory::get_recent(&mut call), + + // ── Graph Visualization (#408) ─────────────────────────────────── + (Method::Get, "/graph") => graph::get_graph(&mut call), + + // ── Graph Mutation: Add Edge (#542) ────────────────────────────── + (Method::Post, "/graph/edge") => graph::post_graph_edge(&mut call), + + // ── Graph Mutation: Remove Edge (#542) ──────────────────────────── + (Method::Delete, "/graph/edge") => graph::delete_graph_edge(&mut call), + + // ── Room Summary ──────────────────────────────────────────────── + (Method::Post, "/room/summary") => rooms::post_room_summary(&mut call), + + // ── Room Summary Document ──────────────────────────────────────── + (Method::Post, "/room/summary-document") => rooms::post_room_summary_document(&mut call), + + // ── Room Consolidation (#1088) ─────────────────────────────────── + (Method::Post, "/room/consolidate") => rooms::post_room_consolidate(&mut call), + + // ── DEPRECATED: POST /room/document → /room/summary-document (#735) + (Method::Post, "/room/document") => rooms::post_room_document(&mut call), + + // ── Get memory by ID ────────────────────────────────────────── + (Method::Get, "/memory") => memory::get_memory(&mut call), + + // ── Memory Pin/Unpin (#660) ────────────────────────────────────── + (Method::Post, "/memory/pin") => memory::post_memory_pin(&mut call), + + // ── Memory Set Importance (#660) ────────────────────────────────── + (Method::Post, "/memory/importance") => memory::post_memory_importance(&mut call), + + // ── Memory Feedback (Trust Scoring) (#718) ──────────────────── + (Method::Post, "/memory/feedback") => memory::post_memory_feedback(&mut call), + + // ── Update memory by ID (PUT /memory, #659) ────────────────── + (Method::Put, "/memory") => memory::put_memory(&mut call), + + // ── Room Memories (chronological listing — GET /room/memories) ──── + (Method::Get, "/room/memories") => rooms::get_room_memories(&mut call), + + // ── Room Recall (semantic with optional fallback to chronological) ── + (Method::Post, "/room/recall") => rooms::post_room_recall(&mut call), + + // ── Room management endpoints (#395) ──────────────────────────── + (Method::Post, "/room/create") => rooms::post_room_create(&mut call), + + (Method::Post, "/room/rename") => rooms::post_room_rename(&mut call), + + (Method::Post, "/room/update") => rooms::post_room_update(&mut call), + + (Method::Post, "/room/memory/move") => rooms::post_room_memory_move(&mut call), + + (Method::Post, "/room/remember") => rooms::post_room_remember(&mut call), + + (Method::Get, "/room/list") => rooms::get_room_list(&mut call), + + (Method::Post, "/room/stats") => rooms::post_room_stats(&mut call), + + // ── Room ↔ Document junction (#689) ────────────────────────────── + (Method::Post, "/room/document/list") => rooms::post_room_document_list(&mut call), + + (Method::Put, "/room/document/add") => rooms::put_room_document_add(&mut call), + + (Method::Delete, "/room/document/remove") => rooms::delete_room_document_remove(&mut call), + + (Method::Post, "/doc/room/list") => rooms::post_doc_room_list(&mut call), + + (Method::Delete, "/room/delete") => rooms::delete_room_delete(&mut call), + // ── Context Summary (#442) ─────────────────────────────────────── + (Method::Post, "/context") => maintenance::post_context(&mut call), + + // ── Dream Cycle (#442) ───────────────────────────────────────────── + (Method::Post, "/dream") => maintenance::post_dream(&mut call), + + (Method::Post, "/mcp") => mcp::post_mcp(&mut call), + + // ── Document: Create / Upsert ──────────────────────────────────── + (Method::Post, "/doc/create") => docs::post_doc_create(&mut call), + + // ── Document: Get ─────────────────────────────────────────────── + (Method::Post, "/doc/get") => docs::post_doc_get(&mut call), + + // ── Document: Update (partial) ──────────────────────────────── + (Method::Post, "/doc/update") => docs::post_doc_update(&mut call), + + // ── Document: List ───────────────────────────────────────────── + (Method::Post, "/doc/list") => docs::post_doc_list(&mut call), + + // ── Document: Search ──────────────────────────────────────────── + (Method::Post, "/doc/search") => docs::post_doc_search(&mut call), + + // ── Document: Move ─────────────────────────────────────────────── + (Method::Post, "/doc/move") => docs::post_doc_move(&mut call), + + // ── Document: Delete ───────────────────────────────────────────── + (Method::Delete, "/doc/delete") => docs::delete_doc_delete(&mut call), + + // ── Cross-entity references (#689) ─────────────────────────────── + (Method::Post, "/memory/doc-refs") => memory::post_memory_doc_refs(&mut call), + + (Method::Post, "/doc/mem-refs") => docs::post_doc_mem_refs(&mut call), + + // ── Tags: List with counts ─────────────────────────────────────── + (Method::Get, "/tags") => tags::get_tags(&mut call), + + // ── Tags: Rename ───────────────────────────────────────────────── + (Method::Post, "/tags/rename") => tags::post_tags_rename(&mut call), + + // ── Tags: Delete ───────────────────────────────────────────────── + (Method::Post, "/tags/delete") => tags::post_tags_delete(&mut call), + + // ── Pin ─────────────────────────────────────────────────────────── + (Method::Post, "/pin") => memory::post_pin(&mut call), + + // ── Unpin ───────────────────────────────────────────────────────── + (Method::Post, "/unpin") => memory::post_unpin(&mut call), + + // ── Provenance chain (#1172) ───────────────────────────────────── + (Method::Get, "/provenance") => graph::get_provenance(&mut call), + + // ── Contradiction resolutions ledger (#1172) ──────────────────── + (Method::Get, "/contradictions") => graph::get_contradictions(&mut call), + + // ── Undo a contradiction resolution (#1172) ───────────────────── + (Method::Post, "/contradictions/undo") => graph::post_contradictions_undo(&mut call), + + // ── Timeline ───────────────────────────────────────────────────── + (Method::Get, "/timeline") => graph::get_timeline(&mut call), + + // ── Edges ──────────────────────────────────────────────────────── + (Method::Get, "/edges") => graph::get_edges(&mut call), + + // ── Extract (LLM fact extraction → store) ──────────────────────── + (Method::Post, "/extract") => maintenance::post_extract(&mut call), + + // ── Import (JSONL) ────────────────────────────────────────────── + (Method::Post, "/import") => maintenance::post_import(&mut call), + + // ── Export (JSONL) ────────────────────────────────────────────── + (Method::Get, "/export") => maintenance::get_export(&mut call), + + // ── Prune (maintenance) ─────────────────────────────────────────── + // ── Verify / Repair (index consistency, #1266) ─────────────────── + (Method::Post, "/verify") => maintenance::post_verify(&mut call), + + (Method::Post, "/repair") => maintenance::post_repair(&mut call), + + (Method::Post, "/prune") => maintenance::post_prune(&mut call), + + // ── Consolidate (maintenance) ──────────────────────────────────── + (Method::Post, "/consolidate") => maintenance::post_consolidate(&mut call), + + (Method::Post, "/consolidate/pair") => maintenance::post_consolidate_pair(&mut call), + + // ── Aging (maintenance) ───────────────────────────────────────── + (Method::Post, "/aging") => maintenance::post_aging(&mut call), + + // ── Importance (monitoring) ──────────────────────────────────────── + (Method::Post, "/importance") => maintenance::post_importance(&mut call), + + // ── Orphans (monitoring — read-only) ───────────────────────────── + (Method::Post, "/orphans") => maintenance::post_orphans(&mut call), + + // ── Rebuild Backlinks (monitoring) ──────────────────────────────── + (Method::Post, "/rebuild-backlinks") => maintenance::post_rebuild_backlinks(&mut call), + + // ── 404 ───────────────────────────────────────────────────────── + // Unreachable while every registry row has an arm (enforced by the + // `every_registry_row_has_a_handler_arm` test); kept for exhaustiveness. + _ => { + #[cfg(test)] + UNHANDLED_ROW.with(|c| c.set(true)); + ctx.error_response_for(req, 404, "Not found") + } + } +} + +// Test probe: set when a registry row reached the match fallback, i.e. it +// has no handler arm. +#[cfg(test)] +thread_local! { + static UNHANDLED_ROW: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Helper: validate content for extract/import to prevent abuse. +fn validate_content_size(content: &str, max_bytes: usize) -> Result<(), &'static str> { + if content.len() > max_bytes { + Err("Content too large") + } else { + Ok(()) + } +} + +/// Helper: resolve extraction config with per-request overrides. +fn resolve_extraction_config( + ctx: &ReqCtx, + req_model: Option<&str>, + req_max_facts: Option, + req_api_key: Option<&str>, +) -> uteke_core::extraction::ExtractionConfig { + let base = ctx.extraction_config.clone().unwrap_or_default(); + uteke_core::extraction::ExtractionConfig { + mode: base.mode, + model: req_model.map(String::from).unwrap_or(base.model), + api_key: req_api_key.map(String::from).unwrap_or(base.api_key), + base_url: base.base_url, + endpoint_path: base.endpoint_path, + max_facts: req_max_facts + .unwrap_or(base.max_facts) + .min(MAX_EXTRACT_FACTS), + } +} + +/// Point-in-time predicate for room time-travel (#1082). +/// Mirrors the core `recall_at_time` temporal rules: memory must have been +/// created at or before `pit`, not yet invalidated (valid_until), not +/// deprecated, and valid_from must not be in the future relative to `pit`. +/// +/// Known limitation (#1086): the schema stores no deprecation timestamp, +/// so a memory deprecated *after* `pit` is also excluded here — time-travel +/// treats `deprecated` as "never existed". Same rule as core `recall_at_time`. +fn memory_exists_at( + memory: &uteke_core::memory::types::Memory, + pit: chrono::DateTime, +) -> bool { + if memory.created_at > pit { + return false; + } + if let Some(valid_until) = memory.valid_until { + if valid_until <= pit { + return false; + } + } + if memory.deprecated { + // Deprecated before the point-in-time → did not exist then; + // deprecated after it → existed (#1086). Matches core semantics: + // NULL deprecated_at is treated as "deprecated after the pit" + // (the v17 migration backfills a timestamp onto every deprecated + // row, so NULL + deprecated should not occur in practice). + let gone_by_then = match memory.deprecated_at { + Some(dep_at) => dep_at <= pit, + None => false, + }; + if gone_by_then { + return false; + } + } + if let Some(valid_from) = memory.valid_from { + if valid_from > pit { + return false; + } + } + true +} + +/// Apply the time-travel predicate to chronological room recall results +/// (no-query path), preserving chronological order (#1082). +fn filter_room_memories_at_time( + memories: Vec, + pit: chrono::DateTime, +) -> Vec { + memories + .into_iter() + .filter(|m| memory_exists_at(m, pit)) + .collect() +} + +/// Add the namespace deprecation `warning` (#1427) to a write response that +/// has no warning field of its own. Nothing is added for the default +/// namespace, so those responses stay byte-identical. +fn with_namespace_warning( + mut body: serde_json::Value, + namespace: Option<&str>, +) -> serde_json::Value { + if let Some(w) = uteke_core::namespace_deprecation::namespace_write_warning(namespace) + && let Some(obj) = body.as_object_mut() + { + obj.insert("warning".into(), serde_json::Value::String(w.to_string())); + } + body +} + +#[cfg(test)] +mod room_memories_namespace_tests { + use super::*; + use tiny_http::TestRequest; + + /// Minimal harness mirroring ContradictionApp: no embedder (CI-safe), + /// route() called directly (#1288). + struct RoomMemoriesApp { + uteke: Mutex, + } + + impl RoomMemoriesApp { + fn new() -> Self { + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + } + } + + fn call( + &self, + method: Method, + url: &str, + body: Option, + ) -> (u16, serde_json::Value) { + let mut req = match body { + Some(b) => { + let leaked: &'static str = Box::leak(b.into_boxed_str()); + TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into() + } + None => TestRequest::new().with_method(method).with_path(url).into(), + }; + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + } + + #[test] + fn room_memories_namespace_filter_and_param_observable() { + let app = RoomMemoriesApp::new(); + + let body = serde_json::json!({ "room_id": "ns-room", "namespace": "default" }).to_string(); + let (status, resp) = app.call(Method::Post, "/room/create", Some(body)); + assert_eq!(status, 200, "{resp}"); + + let mut ids: Vec = Vec::new(); + for (ns, author) in [("cmo", "cmo-agent"), ("hermes", "hermes-agent")] { + let body = serde_json::json!({ + "room_id": "ns-room", + "content": format!("note from {ns}"), + "namespace": ns, + "author": author + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/room/remember", Some(body)); + assert_eq!(status, 200, "{resp}"); + ids.push(resp["id"].as_str().expect("memory id").to_string()); + } + + // URL constants for the query-string URLs used below. + const URL_ALL: &str = "/room/memories?room_id=ns-room"; + const URL_CMO: &str = "/room/memories?room_id=ns-room&namespace=cmo"; + const URL_CMO_OTHER_AUTHOR: &str = + "/room/memories?room_id=ns-room&namespace=cmo&author=hermes-agent"; + + // Unfiltered: cross-namespace default — both entries. + let (status, resp) = app.call(Method::Get, URL_ALL, None); + assert_eq!(status, 200, "{resp}"); + let arr = resp.as_array().expect("bare JSON array response"); + assert_eq!( + arr.len(), + 2, + "cross-namespace default must return both: {resp}" + ); + + // namespace=cmo → exactly the cmo entry (#1288 — was silently ignored). + let (status, resp) = app.call(Method::Get, URL_CMO, None); + assert_eq!(status, 200, "{resp}"); + let arr = resp.as_array().expect("bare JSON array response"); + assert_eq!(arr.len(), 1, "namespace filter must narrow results: {resp}"); + assert_eq!(arr[0]["namespace"], serde_json::json!("cmo")); + assert_eq!(arr[0]["content"], serde_json::json!("note from cmo")); + + // namespace + author combined. + let (status, resp) = app.call(Method::Get, URL_CMO_OTHER_AUTHOR, None); + assert_eq!(status, 200, "{resp}"); + let arr = resp.as_array().expect("bare JSON array response"); + assert!( + arr.is_empty(), + "conflicting filters must yield empty: {resp}" + ); + } +} + +#[cfg(test)] +mod room_recall_at_tests { + use super::*; + use chrono::Utc; + + fn mem(created_offset_secs: i64) -> uteke_core::memory::types::Memory { + uteke_core::memory::types::Memory { + id: uuid::Uuid::new_v4().to_string(), + content: format!("m{created_offset_secs}"), + embedding: Vec::new(), + tags: Vec::new(), + metadata: serde_json::Value::Object(serde_json::Map::new()), + created_at: Utc::now() + chrono::Duration::seconds(created_offset_secs), + updated_at: Utc::now(), + namespace: "default".into(), + access_count: 0, + recall_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: None, + valid_until: None, + memory_type: "fact".into(), + importance: 0.5, + pinned: false, + content_type: "text".into(), + slug: None, + source: None, + source_type: "user".into(), + author_type: "agent".into(), + } + } + + #[test] + fn at_time_excludes_future_and_invalidated() { + let now = Utc::now(); + let old = mem(-3600); // created an hour ago — existed at `now` + let future = mem(3600); // created an hour from now — must be excluded + let mut invalidated = mem(-7200); + invalidated.valid_until = Some(now - chrono::Duration::seconds(60)); // expired before `now` + let mut not_yet_valid = mem(-7200); + not_yet_valid.valid_from = Some(now + chrono::Duration::seconds(60)); + + assert!(memory_exists_at(&old, now)); + assert!(!memory_exists_at(&future, now)); + assert!(!memory_exists_at(&invalidated, now)); + assert!(!memory_exists_at(¬_yet_valid, now)); + } + + #[test] + fn at_time_excludes_deprecated() { + let now = Utc::now(); + let mut dep = mem(-60); + dep.deprecated = true; + dep.deprecated_at = Some(now - chrono::Duration::seconds(30)); // deprecated before pit + assert!(!memory_exists_at(&dep, now)); + } + + #[test] + fn at_time_includes_deprecated_after_pit() { + // #1086: deprecated AFTER the pit — the memory existed then. + let now = Utc::now(); + let mut dep = mem(-60); + dep.deprecated = true; + dep.deprecated_at = Some(now + chrono::Duration::seconds(30)); // deprecated after pit + assert!(memory_exists_at(&dep, now)); + } + + #[test] + fn at_time_boundary_inclusive_created_at() { + let exact = mem(0); + // created_at == pit: memory existed at that instant (inclusive, matches core recall_at_time rule `> pit → reject`) + assert!(memory_exists_at(&exact, exact.created_at)); + } + + #[test] + fn filter_room_memories_preserves_order() { + let now = Utc::now(); + let older = mem(-100); + let newer = mem(-50); + let future = mem(100); + let out = filter_room_memories_at_time(vec![older, newer, future], now); + assert_eq!(out.len(), 2); + assert_eq!(out[0].content, "m-100"); + assert_eq!(out[1].content, "m-50"); + } + + #[test] + fn room_recall_request_deserializes_at() { + // Regression for #1082: `at` must be a recognized field, not silently dropped. + let req: RoomRecallRequest = + serde_json::from_str(r#"{"room_id":"r1","limit":3,"at":"2020-01-01T00:00:00Z"}"#) + .expect("at field must deserialize"); + assert_eq!(req.at.as_deref(), Some("2020-01-01T00:00:00Z")); + assert_eq!(req.room_id, "r1"); + // Omitted → None (backwards compatible) + let req: RoomRecallRequest = + serde_json::from_str(r#"{"room_id":"r1"}"#).expect("no-at body must parse"); + assert!(req.at.is_none()); + } + + /// #1076: ConsolidatePairRequest deserialization — happy path + defaults. + #[test] + fn consolidate_pair_request_parses() { + let req: ConsolidatePairRequest = + serde_json::from_str(r#"{"id_keep":"abc","id_remove":"def","hard":true}"#) + .expect("pair body must parse"); + assert_eq!(req.id_keep, "abc"); + assert_eq!(req.id_remove, "def"); + assert!(req.hard); + + // hard defaults to false + let req: ConsolidatePairRequest = + serde_json::from_str(r#"{"id_keep":"abc","id_remove":"def"}"#).unwrap(); + assert!(!req.hard); + + // missing id_remove → 400 at deserialize + assert!(serde_json::from_str::(r#"{"id_keep":"abc"}"#).is_err()); + } + + // ── /graph/edge memory-ID contract (#1180) ───────────────────────── + + use tiny_http::TestRequest; + + struct GraphEdgeApp { + uteke: Mutex, + } + + impl GraphEdgeApp { + fn new() -> Self { + // No embedder: these endpoints are storage-only, and tests must + // run in CI builds without the ONNX runtime lib. + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + } + } + + fn call( + &self, + method: Method, + url: &str, + body: Option, + ) -> (u16, serde_json::Value) { + let mut req = match body { + Some(b) => { + let leaked: &'static str = Box::leak(b.into_boxed_str()); + TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into() + } + None => TestRequest::new().with_method(method).with_path(url).into(), + }; + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + fn remember(&self, content: &str) -> String { + let body = serde_json::json!({ "content": content }).to_string(); + let (status, resp) = self.call(Method::Post, "/remember", Some(body)); + assert_eq!(status, 200, "remember must succeed: {resp}"); + resp["id"] + .as_str() + .unwrap_or_else(|| panic!("remember response must carry id: {resp}")) + .to_string() + } + + fn add_edge(&self, source: &str, target: &str) -> (u16, serde_json::Value) { + let body = serde_json::json!({ "source": source, "target": target }).to_string(); + self.call(Method::Post, "/graph/edge", Some(body)) + } + + fn delete_edge(&self, source: &str, target: &str) -> (u16, serde_json::Value) { + let url = format!("/graph/edge?source={source}&target={target}"); + self.call(Method::Delete, &url, None) + } + } + + #[test] + fn graph_edge_post_with_memory_ids_returns_ok() { + let app = GraphEdgeApp::new(); + let id1 = app.remember("Uteke is a local-first memory engine"); + let id2 = app.remember("Corin renders the memory graph"); + + let (status, resp) = app.add_edge(&id1, &id2); + assert_eq!( + status, 200, + "valid memory IDs must not 500 (FK mismatch #1180): {resp}" + ); + assert_eq!(resp["ok"], serde_json::json!(true)); + } + + #[test] + fn graph_edge_post_resolves_memory_ids_to_nodes() { + let app = GraphEdgeApp::new(); + let id1 = app.remember("alpha memory"); + let id2 = app.remember("beta memory"); + + let (status, resp) = app.add_edge(&id1, &id2); + assert_eq!(status, 200, "{resp}"); + + // POST still reports the graph-node ids it created (#1180)... + assert!(resp["source_node"].as_str().is_some()); + assert!(resp["target_node"].as_str().is_some()); + + // ...but GET /graph keys nodes by MEMORY id (#1366) so a viewer can + // open them, and keeps `memory_id` for clients that map back. + let (_, graph) = app.call(Method::Get, "/graph", None); + let nodes = graph["nodes"].as_array().expect("nodes array"); + for (id, text) in [(&id1, "alpha memory"), (&id2, "beta memory")] { + assert!( + nodes.iter().any(|n| n["id"] == serde_json::json!(id) + && n["memory_id"] == serde_json::json!(id) + && n["label"].as_str().is_some_and(|l| l.starts_with(text))), + "node for memory {id} missing: {graph}" + ); + } + + // The edge is visible with both long and short endpoint names. + let edges = graph["edges"].as_array().expect("edges array"); + assert!( + edges + .iter() + .any(|e| e["source_id"] == serde_json::json!(id1) + && e["target_id"] == serde_json::json!(id2) + && e["source"] == serde_json::json!(id1) + && e["target"] == serde_json::json!(id2) + && e["relation"] == serde_json::json!("related")), + "edge must appear in GET /graph: {graph}" + ); + assert_eq!(graph["truncated"], serde_json::json!(false)); + } + + #[test] + fn graph_node_id_and_limit_query_params() { + let app = GraphEdgeApp::new(); + let a = app.remember("hub memory"); + let b = app.remember("spoke one"); + let c = app.remember("spoke two"); + let d = app.remember("elsewhere one"); + let e = app.remember("elsewhere two"); + for (s, t) in [(&a, &b), (&a, &c), (&d, &e)] { + let (status, resp) = app.add_edge(s, t); + assert_eq!(status, 200, "{resp}"); + } + + let hood_url = format!("/graph?node_id={a}"); + let (_, hood) = app.call(Method::Get, &hood_url, None); + let touching: Vec<&serde_json::Value> = hood["edges"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["relation"] == serde_json::json!("related")) + .collect(); + assert!( + touching + .iter() + .all(|e| e["source"] == serde_json::json!(a) || e["target"] == serde_json::json!(a)), + "node_id must restrict to that memory's edges: {hood}" + ); + assert!(touching.len() >= 2, "{hood}"); + + let capped_url = "/graph?limit=1"; + let (_, capped) = app.call(Method::Get, capped_url, None); + assert_eq!(capped["edges"].as_array().unwrap().len(), 1, "{capped}"); + assert_eq!(capped["truncated"], serde_json::json!(true)); + } + + #[test] + fn graph_edge_post_accepts_graph_node_ids_too() { + let app = GraphEdgeApp::new(); + let id1 = app.remember("source memory"); + let id2 = app.remember("target memory"); + + let (_, first) = app.add_edge(&id1, &id2); + let src_node = first["source_node"].as_str().unwrap().to_string(); + + // A client holding IDs from GET /graph must still be able to link a + // graph node directly (e.g. entity nodes without a memory link). + let (status, resp) = app.add_edge(&src_node, &id2); + assert_eq!(status, 200, "node IDs must stay accepted: {resp}"); + } + + #[test] + fn graph_edge_post_with_unknown_memory_returns_404() { + let app = GraphEdgeApp::new(); + let ghost1 = "00000000-0000-4000-8000-000000000000"; + let ghost2 = "11111111-1111-4111-8111-111111111111"; + + let (status, resp) = app.add_edge(ghost1, ghost2); + assert_eq!(status, 404, "unknown source must 404: {resp}"); + } + + #[test] + fn graph_edge_post_self_loop_rejected() { + let app = GraphEdgeApp::new(); + let id1 = app.remember("self loop probe"); + + let (status, _) = app.add_edge(&id1, &id1); + assert_eq!(status, 400, "self-loop must stay rejected"); + } + + #[test] + fn graph_edge_delete_accepts_memory_ids() { + let app = GraphEdgeApp::new(); + let id1 = app.remember("edge delete probe a"); + let id2 = app.remember("edge delete probe b"); + + let (status, resp) = app.add_edge(&id1, &id2); + assert_eq!(status, 200, "{resp}"); + + // DELETE with the same memory IDs must resolve back to the nodes. + let (status, resp) = app.delete_edge(&id1, &id2); + assert_eq!(status, 200, "delete by memory IDs: {resp}"); + assert_eq!(resp["ok"], serde_json::json!(true)); + + // Second delete: edge gone → 404, not 500. + let (status, resp) = app.delete_edge(&id1, &id2); + assert_eq!(status, 404, "deleting a removed edge must 404: {resp}"); + } + + #[test] + fn graph_edge_delete_accepts_node_ids_and_unknown_ids() { + let app = GraphEdgeApp::new(); + let id1 = app.remember("node id delete probe a"); + let id2 = app.remember("node id delete probe b"); + + let (_, resp) = app.add_edge(&id1, &id2); + let src_node = resp["source_node"].as_str().unwrap().to_string(); + let tgt_node = resp["target_node"].as_str().unwrap().to_string(); + + // Node IDs (from GET /graph payloads) must keep working. + let (status, resp) = app.delete_edge(&src_node, &tgt_node); + assert_eq!(status, 200, "delete by node IDs: {resp}"); + + // Fully unknown IDs must 404 — never 500. + let (status, resp) = app.delete_edge("ghost-a", "ghost-b"); + assert_eq!(status, 404, "unknown IDs must 404: {resp}"); + } + + #[test] + fn graph_edge_delete_requires_both_params() { + let app = GraphEdgeApp::new(); + let url = "/graph/edge?source=only-one"; + let (status, _) = app.call(Method::Delete, url, None); + assert_eq!(status, 400, "missing target param must 400"); + } + + // ── Namespace management API (#1181) ─────────────────────────────── + + struct NamespaceApp { + uteke: Mutex, + } + + impl NamespaceApp { + fn new() -> Self { + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + } + } + + fn call( + &self, + method: Method, + url: &str, + body: Option, + ) -> (u16, serde_json::Value) { + let mut req = match body { + Some(b) => { + let leaked: &'static str = Box::leak(b.into_boxed_str()); + TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into() + } + None => TestRequest::new().with_method(method).with_path(url).into(), + }; + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + fn remember_in(&self, content: &str, namespace: &str) -> String { + let body = + serde_json::json!({ "content": content, "namespace": namespace }).to_string(); + let (status, resp) = self.call(Method::Post, "/remember", Some(body)); + assert_eq!(status, 200, "remember must succeed: {resp}"); + resp["id"].as_str().expect("id").to_string() + } + } + + #[test] + fn namespace_put_memory_moves_namespace() { + let app = NamespaceApp::new(); + let id = app.remember_in("to be moved", "alpha"); + + let body = serde_json::json!({ "id": id, "namespace": "beta" }).to_string(); + let (status, resp) = app.call(Method::Put, "/memory", Some(body)); + assert_eq!(status, 200, "PUT with namespace must succeed: {resp}"); + + // The move is visible via GET /memory. + let get_url = format!("/memory?id={id}"); + let (status, resp) = app.call(Method::Get, &get_url, None); + assert_eq!(status, 200); + assert_eq!(resp["namespace"], serde_json::json!("beta")); + + // The old name vanishes from listings (derived view). + let (_, list) = app.call(Method::Get, "/namespaces", None); + let names: Vec<&str> = list + .as_array() + .expect("namespaces array") + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!(!names.contains(&"alpha"), "old namespace gone: {list:?}"); + assert!(names.contains(&"beta"), "{list:?}"); + } + + #[test] + fn namespace_rename_and_merge_endpoint() { + let app = NamespaceApp::new(); + app.remember_in("one", "old"); + app.remember_in("two", "old"); + app.remember_in("three", "existing"); + + let body = serde_json::json!({ "from": "old", "to": "existing" }).to_string(); + let (status, resp) = app.call(Method::Post, "/namespaces/rename", Some(body)); + assert_eq!(status, 200, "{resp}"); + assert_eq!(resp["moved"], serde_json::json!(2)); + assert_eq!(resp["target_existed"], serde_json::json!(true)); + assert_eq!(resp["from"], serde_json::json!("old")); + assert_eq!(resp["to"], serde_json::json!("existing")); + } + + #[test] + fn namespace_delete_refuse_returns_409() { + let app = NamespaceApp::new(); + app.remember_in("keep me", "busy"); + + let body = serde_json::json!({ "name": "busy" }).to_string(); + let (status, resp) = app.call(Method::Post, "/namespaces/delete", Some(body)); + assert_eq!( + status, 409, + "refuse on non-empty namespace must 409: {resp}" + ); + assert!( + resp["error"] + .as_str() + .unwrap_or_default() + .to_lowercase() + .contains("refus") + ); + } + + #[test] + fn room_rename_update_move_endpoints_roundtrip() { + let app = NamespaceApp::new(); + + // Create rooms + one room memory. + let body = serde_json::json!({ "room_id": "ws", "namespace": "default" }).to_string(); + let (status, resp) = app.call(Method::Post, "/room/create", Some(body)); + assert_eq!(status, 200, "{resp}"); + let body = serde_json::json!({ "room_id": "ws-3", "namespace": "default" }).to_string(); + let (status, resp) = app.call(Method::Post, "/room/create", Some(body)); + assert_eq!(status, 200, "{resp}"); + + let body = serde_json::json!({ + "content": "room note", + "tags": [], + "room_id": "ws", + "author": "tester" + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/room/remember", Some(body)); + assert_eq!(status, 200, "{resp}"); + let memory_id = resp["id"].as_str().expect("memory id").to_string(); + + // Rename ws → ws-2. + let body = serde_json::json!({ "from": "ws", "to": "ws-2" }).to_string(); + let (status, resp) = app.call(Method::Post, "/room/rename", Some(body)); + assert_eq!(status, 200, "{resp}"); + assert_eq!(resp["renamed"], serde_json::json!("ws")); + assert_eq!(resp["room"]["id"], serde_json::json!("ws-2")); + + // Update title/description on ws-2. + let body = serde_json::json!({ + "room_id": "ws-2", + "title": "Workspace", + "description": "Renamed workspace" + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/room/update", Some(body)); + assert_eq!(status, 200, "{resp}"); + assert_eq!( + resp["room"]["description"], + serde_json::json!("Renamed workspace") + ); + + // Move the memory ws-2 → ws-3. + let body = serde_json::json!({ + "memory_id": memory_id, + "from_room": "ws-2", + "to_room": "ws-3" + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/room/memory/move", Some(body.clone())); + assert_eq!(status, 200, "{resp}"); + assert_eq!(resp["moved"], serde_json::json!(memory_id)); + + // Moving again → 404 (no link left in source). + let (status, resp) = app.call(Method::Post, "/room/memory/move", Some(body)); + assert_eq!(status, 404, "{resp}"); + assert!( + resp["error"] + .as_str() + .unwrap_or_default() + .contains("no link") + ); + } + + #[test] + fn room_update_missing_room_returns_404() { + let app = NamespaceApp::new(); + let body = serde_json::json!({ "room_id": "ghost", "title": "x" }).to_string(); + let (status, resp) = app.call(Method::Post, "/room/update", Some(body)); + assert_eq!(status, 404, "{resp}"); + } + + #[test] + fn namespace_delete_merge_removes_namespace() { + let app = NamespaceApp::new(); + app.remember_in("m one", "doomed"); + app.remember_in("m two", "doomed"); + + let body = serde_json::json!({ "name": "doomed", "strategy": "merge", "target": "safe" }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/namespaces/delete", Some(body)); + assert_eq!(status, 200, "{resp}"); + assert_eq!(resp["affected"], serde_json::json!(2)); + assert_eq!(resp["empty"], serde_json::json!(true)); + + let (_, list) = app.call(Method::Get, "/namespaces", None); + let names: Vec<&str> = list + .as_array() + .expect("namespaces array") + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!(!names.contains(&"doomed"), "{list:?}"); + assert!(names.contains(&"safe"), "{list:?}"); + } + + #[test] + fn namespace_with_counts_reports_lifecycle_split() { + let app = NamespaceApp::new(); + let id = app.remember_in("ghost food", "ghosted"); + let _ = id; + let body = serde_json::json!({ "name": "ghosted", "strategy": "deprecate" }).to_string(); + let (status, resp) = app.call(Method::Post, "/namespaces/delete", Some(body)); + assert_eq!(status, 200, "{resp}"); + assert_eq!(resp["affected"], serde_json::json!(1)); + assert_eq!(resp["empty"], serde_json::json!(false)); + + let counts_url = "/namespaces?with_counts=true"; + let (_, counts) = app.call(Method::Get, counts_url, None); + let arr = counts.as_array().expect("counts array"); + let ghost = arr + .iter() + .find(|n| n["name"] == serde_json::json!("ghosted")) + .expect("ghost namespace stays listed"); + assert_eq!(ghost["active"], serde_json::json!(0)); + assert_eq!(ghost["deprecated"], serde_json::json!(1)); + assert_eq!(ghost["count"], serde_json::json!(1)); + } + + // ── Provenance API (#1172) ───────────────────────────────────────── + + #[test] + fn provenance_endpoint_reports_chain_and_hash() { + let app = NamespaceApp::new(); + let id = app.remember_in("provenance surface probe", "audit"); + + let get_url = format!("/provenance?id={id}"); + let (status, report) = app.call(Method::Get, &get_url, None); + assert_eq!(status, 200, "{report}"); + assert_eq!(report["id"], serde_json::json!(id)); + assert_eq!(report["namespace"], serde_json::json!("audit")); + assert_eq!(report["author_type"], serde_json::json!("agent")); + assert_eq!(report["trust_tier"], serde_json::json!("agent")); + assert_eq!(report["source_hash"], report["content_hash_now"]); + let events = report["events"].as_array().expect("events array"); + assert!( + events + .iter() + .any(|e| e["event_type"] == serde_json::json!("created")), + "created event must be in the chain: {report}" + ); + + // Unknown ID → 404. + let (status, _) = app.call( + Method::Get, + "/provenance?id=00000000-0000-4000-8000-000000000000", + None, + ); + assert_eq!(status, 404); + + // Missing param → 400. + let (status, _) = app.call(Method::Get, "/provenance", None); + assert_eq!(status, 400); + } +} + +// ── Contradiction ledger API (#1172 Fase 2) ───────────────────────── + +#[cfg(test)] +mod contradiction_api_tests { + use super::*; + use tiny_http::TestRequest; + + struct ContradictionApp { + uteke: Mutex, + } + + impl ContradictionApp { + fn new() -> Self { + // No embedder: storage-only endpoints, CI-safe without ONNX. + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + } + } + + fn call( + &self, + method: Method, + url: &str, + body: Option, + ) -> (u16, serde_json::Value) { + let mut req = match body { + Some(b) => { + let leaked: &'static str = Box::leak(b.into_boxed_str()); + TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into() + } + None => TestRequest::new().with_method(method).with_path(url).into(), + }; + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + fn remember_in(&self, content: &str, namespace: &str) -> String { + let body = + serde_json::json!({ "content": content, "namespace": namespace }).to_string(); + let (status, resp) = self.call(Method::Post, "/remember", Some(body)); + assert_eq!(status, 200, "remember must succeed: {resp}"); + resp["id"] + .as_str() + .unwrap_or_else(|| panic!("remember response must carry id: {resp}")) + .to_string() + } + + fn supersede(&self, old: &str, new: &str) { + // Setup via the core API — the server has no supersede route; + // the surfaces under test are the ledger + undo endpoints. + self.uteke + .lock() + .expect("poisoned mutex") + .supersede(old, new, None) + .expect("supersede must succeed"); + } + } + + #[test] + fn ledger_lists_superseded_and_undo_restores() { + let app = ContradictionApp::new(); + let old = app.remember_in("deploy target is VM-1", "ops"); + let new = app.remember_in("deploy target is VM-2", "ops"); + app.supersede(&old, &new); + + let list_url = "/contradictions?namespace=ops"; + let (status, ledger) = app.call(Method::Get, list_url, None); + assert_eq!(status, 200, "{ledger}"); + let entries = ledger.as_array().expect("ledger array"); + assert_eq!( + entries.len(), + 1, + "one supersession must be listed: {ledger}" + ); + assert_eq!(entries[0]["id"], serde_json::json!(old)); + let reason = entries[0]["deprecate_reason"].as_str().unwrap_or(""); + assert!( + reason.contains("superseded"), + "reason must mention superseded: {reason}" + ); + + // Undo via POST body. + let undo_body = serde_json::json!({ "id": old }).to_string(); + let (status, resp) = app.call(Method::Post, "/contradictions/undo", Some(undo_body)); + assert_eq!(status, 200, "{resp}"); + assert_eq!(resp["undone"], serde_json::json!(true)); + assert_eq!(resp["restored"], serde_json::json!(old)); + assert_eq!(resp["was_superseded_by"], serde_json::json!(new)); + + // Ledger is clean after the undo. + let (status, ledger) = app.call(Method::Get, list_url, None); + assert_eq!(status, 200); + assert_eq!( + ledger.as_array().expect("ledger array").len(), + 0, + "undo must clear the ledger: {ledger}" + ); + } + + #[test] + fn undo_unknown_supersession_is_404() { + let app = ContradictionApp::new(); + let id = app.remember_in("never superseded", "ops"); + + let body = serde_json::json!({ "id": id }).to_string(); + let (status, resp) = app.call(Method::Post, "/contradictions/undo", Some(body)); + assert_eq!(status, 404, "undo without a supersession must 404: {resp}"); + + // Malformed body → 400. + let (status, _) = app.call( + Method::Post, + "/contradictions/undo", + Some("not-json".into()), + ); + assert_eq!(status, 400); + } +} + +// ── Explain recall API (#1160) ────────────────────────────────────── + +#[cfg(test)] +mod pack_recall_api_tests { + //! #1281 Phase 1: `pack` recall returns a budgeted ContextPack envelope. + use super::*; + use tiny_http::TestRequest; + + struct PackApp { + uteke: Mutex, + } + + impl PackApp { + fn new() -> Self { + // No embedder: tests use the fts5 strategy, which needs no + // query embedding (CI-safe without ONNX). + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + } + } + + fn call( + &self, + method: Method, + url: &str, + body: Option, + ) -> (u16, serde_json::Value) { + let mut req = match body { + Some(b) => { + let leaked: &'static str = Box::leak(b.into_boxed_str()); + TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into() + } + None => TestRequest::new().with_method(method).with_path(url).into(), + }; + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + fn remember_in(&self, content: &str, namespace: &str) -> String { + let body = + serde_json::json!({ "content": content, "namespace": namespace }).to_string(); + let (status, resp) = self.call(Method::Post, "/remember", Some(body)); + assert_eq!(status, 200, "remember must succeed: {resp}"); + resp["id"] + .as_str() + .unwrap_or_else(|| panic!("remember response must carry id: {resp}")) + .to_string() + } + } + + #[test] + fn pack_returns_envelope_honours_budget_and_excludes() { + let app = PackApp::new(); + let fox_id = app.remember_in("The quick brown fox jumps over the lazy dog", "pack-ns"); + app.remember_in( + "Completely unrelated content about gardening tools", + "pack-ns", + ); + + // Normal pack: envelope shape with a generous budget. + let body = serde_json::json!({ + "query": "quick brown fox", + "limit": 5, + "namespace": "pack-ns", + "strategy": "fts5", + "pack": true, + "budget_chars": 10000 + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/recall", Some(body)); + assert_eq!(status, 200, "{resp}"); + assert!( + resp["selected"].is_array(), + "pack must carry selected: {resp}" + ); + assert!( + !resp["selected"].as_array().unwrap().is_empty(), + "fts5 must find the fox" + ); + assert_eq!(resp["budget_chars"], serde_json::json!(10000)); + assert!(resp["budget_used"].as_u64().unwrap() > 0, "{resp}"); + assert!(resp["skipped"].is_array(), "{resp}"); + + // exclude_ids: the fox is already injected → skipped, reason excluded. + let body2 = serde_json::json!({ + "query": "quick brown fox", + "limit": 5, + "namespace": "pack-ns", + "strategy": "fts5", + "pack": true, + "budget_chars": 10000, + "exclude_ids": [fox_id] + }) + .to_string(); + let (status, resp2) = app.call(Method::Post, "/recall", Some(body2)); + assert_eq!(status, 200, "{resp2}"); + let skipped = resp2["skipped"].as_array().unwrap(); + assert!( + skipped + .iter() + .any(|s| s["memory_id"] == serde_json::json!(fox_id) + && s["reason"] == serde_json::json!("excluded")), + "excluded fox must be reported: {resp2}" + ); + + // Impossible budget → nothing selected, everything skipped as budget. + let body3 = serde_json::json!({ + "query": "quick brown fox", + "limit": 5, + "namespace": "pack-ns", + "strategy": "fts5", + "pack": true, + "budget_chars": 1 + }) + .to_string(); + let (status, resp3) = app.call(Method::Post, "/recall", Some(body3)); + assert_eq!(status, 200, "{resp3}"); + assert!(resp3["selected"].as_array().unwrap().is_empty()); + assert_eq!(resp3["budget_used"], serde_json::json!(0)); + assert!( + !resp3["skipped"].as_array().unwrap().is_empty(), + "oversized items must be reported: {resp3}" + ); + + // pack + time filters → 400 (loud, same contract as the CLI). + let body4 = serde_json::json!({ + "query": "quick brown fox", + "namespace": "pack-ns", + "strategy": "fts5", + "pack": true, + "budget_chars": 10000, + "at": "2026-01-01T00:00:00Z" + }) + .to_string(); + let (status, resp4) = app.call(Method::Post, "/recall", Some(body4)); + assert_eq!(status, 400, "{resp4}"); + assert!( + resp4["error"] + .as_str() + .unwrap_or_default() + .contains("pack is not supported"), + "{resp4}" + ); + } +} + +#[cfg(test)] +mod explain_recall_api_tests { + use super::*; + use tiny_http::TestRequest; + + struct ExplainApp { + uteke: Mutex, + } + + impl ExplainApp { + fn new() -> Self { + // No embedder: tests use the fts5 strategy, which needs no + // query embedding (CI-safe without ONNX). + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + } + } + + fn call( + &self, + method: Method, + url: &str, + body: Option, + ) -> (u16, serde_json::Value) { + let mut req = match body { + Some(b) => { + let leaked: &'static str = Box::leak(b.into_boxed_str()); + TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into() + } + None => TestRequest::new().with_method(method).with_path(url).into(), + }; + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + fn remember_in(&self, content: &str, namespace: &str) -> String { + let body = + serde_json::json!({ "content": content, "namespace": namespace }).to_string(); + let (status, resp) = self.call(Method::Post, "/remember", Some(body)); + assert_eq!(status, 200, "remember must succeed: {resp}"); + resp["id"] + .as_str() + .unwrap_or_else(|| panic!("remember response must carry id: {resp}")) + .to_string() + } + } + + #[test] + fn explain_fts5_returns_signals_and_guards() { + let app = ExplainApp::new(); + let fox_id = app.remember_in("The quick brown fox jumps over the lazy dog", "explain-ns"); + app.remember_in( + "Completely unrelated content about gardening tools", + "explain-ns", + ); + + let body = serde_json::json!({ + "query": "quick brown fox", + "limit": 5, + "namespace": "explain-ns", + "strategy": "fts5", + "explain": true + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/recall", Some(body)); + assert_eq!(status, 200, "{resp}"); + let arr = resp.as_array().expect("explained results array"); + assert!(!arr.is_empty(), "fts5 must find the fox"); + let first = &arr[0]; + assert_eq!(first["memory"]["id"], serde_json::json!(fox_id)); + let ex = &first["explanation"]; + assert_eq!(ex["strategy"], serde_json::json!("fts5")); + assert_eq!(ex["fts_rank"], serde_json::json!(1)); + assert!(ex["final_score"].is_number(), "final_score must exist"); + + // explain + search_type → 400 (memory-only feature). + let bad = serde_json::json!({ + "query": "quick brown fox", + "search_type": "all", + "explain": true + }) + .to_string(); + let (status, _) = app.call(Method::Post, "/recall", Some(bad)); + assert_eq!(status, 400, "explain + search_type must 400"); + + // explain + at → 400. + let bad = serde_json::json!({ + "query": "quick brown fox", + "at": "2026-01-01T00:00:00Z", + "explain": true + }) + .to_string(); + let (status, _) = app.call(Method::Post, "/recall", Some(bad)); + assert_eq!(status, 400, "explain + at must 400"); + + // explain + entity → 400 (filter would be silently dropped). + let bad = serde_json::json!({ + "query": "quick brown fox", + "entity": "staging", + "explain": true + }) + .to_string(); + let (status, _) = app.call(Method::Post, "/recall", Some(bad)); + assert_eq!(status, 400, "explain + entity must 400"); + } +} + +// ── /list pagination metadata (#1188) ─────────────────────────────── + +#[cfg(test)] +mod list_pagination_tests { + use super::*; + use tiny_http::TestRequest; + + struct ListApp { + uteke: Mutex, + } + + impl ListApp { + fn new() -> Self { + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + } + } + + fn call( + &self, + method: Method, + url: &str, + body: Option, + ) -> (u16, serde_json::Value) { + let mut req = match body { + Some(b) => { + let leaked: &'static str = Box::leak(b.into_boxed_str()); + TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into() + } + None => TestRequest::new().with_method(method).with_path(url).into(), + }; + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + fn remember_in(&self, content: &str, namespace: &str) { + let body = + serde_json::json!({ "content": content, "namespace": namespace }).to_string(); + let (status, resp) = self.call(Method::Post, "/remember", Some(body)); + assert_eq!(status, 200, "seed remember must succeed: {resp}"); + } + } + + #[test] + fn list_meta_envelope_and_bare_default() { + let app = ListApp::new(); + for i in 0..7 { + app.remember_in(&format!("pagination probe {i}"), "pg"); + } + + // Default (no include_meta): bare array — unchanged shape. + let body = serde_json::json!({ "namespace": "pg", "limit": 100 }).to_string(); + let (status, resp) = app.call(Method::Post, "/list", Some(body)); + assert_eq!(status, 200); + assert!(resp.is_array(), "default must stay a bare array: {resp}"); + assert_eq!(resp.as_array().unwrap().len(), 7); + + // include_meta: envelope with total/has_more/next_offset. + let body = serde_json::json!({ + "namespace": "pg", + "limit": 3, + "offset": 0, + "include_meta": true + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/list", Some(body)); + assert_eq!(status, 200); + assert!(resp.is_object(), "envelope expected: {resp}"); + assert_eq!(resp["total"], serde_json::json!(7)); + assert_eq!(resp["has_more"], serde_json::json!(true)); + assert_eq!(resp["next_offset"], serde_json::json!(3)); + assert_eq!(resp["memories"].as_array().expect("page rows").len(), 3); + + // Last page: has_more false, next_offset null. + let body = serde_json::json!({ + "namespace": "pg", + "limit": 10, + "offset": 6, + "include_meta": true + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/list", Some(body)); + assert_eq!(status, 200); + assert_eq!(resp["has_more"], serde_json::json!(false)); + assert!(resp["next_offset"].is_null()); + assert_eq!( + resp["memories"].as_array().expect("page rows").len(), + 1, + "offset 6 of 7 rows returns the last row" + ); + + // include_meta + at: `at` wins — bare array (documented). + let body = serde_json::json!({ + "namespace": "pg", + "include_meta": true, + "at": "2100-01-01T00:00:00Z" + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/list", Some(body)); + assert_eq!(status, 200); + assert!(resp.is_array(), "at-mode stays a bare array: {resp}"); + } +} + +#[cfg(test)] +mod payload_conformance_tests { + use super::*; + use std::io::Read as IoRead; + + // ── #1233: raw-payload conformance — recall responses carry the FULL + // payload on every surface (HTTP route tested here; CLI --json prints + // the same serde SearchResult; MCP unified path reuses these types) ── + #[test] + fn recall_http_payload_conformance() { + use uteke_core::Uteke; + + // No embedder: these tests are payload-shape conformance and must run + // in CI builds without the ONNX runtime lib (same pattern as the + // graph-edge tests). Keyword (fts5) recall needs no vectors. + let uteke = Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"); + let shared = std::sync::Mutex::new(uteke); + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: vec![], + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + + let remember_body: &'static str = Box::leak(r#"{"content":"Payload conformance probe memory #1233 with distinctive tokens zebraquartz","namespace":"conf"}"#.to_string().into_boxed_str()); + let mut remember_req = tiny_http::TestRequest::new() + .with_method(tiny_http::Method::Post) + .with_path("/remember") + .with_body(remember_body) + .into(); + let remember_resp = route(&shared, &ctx, &mut remember_req); + let mut rbuf = String::new(); + remember_resp + .into_reader() + .read_to_string(&mut rbuf) + .unwrap(); + assert!(rbuf.contains("\"id\""), "remember must succeed: {rbuf}"); + + let mut req = tiny_http::TestRequest::new() + .with_method(tiny_http::Method::Post) + .with_path("/recall") + .with_body(r#"{"query":"zebraquartz","limit":5,"min_score":0.0,"namespace":"conf","strategy":"fts5"}"#) + .into(); + let resp = route(&shared, &ctx, &mut req); + let mut buf = String::new(); + resp.into_reader().read_to_string(&mut buf).unwrap(); + let v: serde_json::Value = serde_json::from_str(&buf).expect("valid JSON"); + + // Raw payload must be an array of FULL SearchResult objects. + let arr = v + .as_array() + .expect("recall response must be a top-level JSON array"); + assert!(!arr.is_empty(), "expected at least one hit"); + let first = &arr[0]; + // Full payload = the memory object itself, not a summary stub. + let mem = first + .get("memory") + .expect("each hit must carry the full memory object"); + assert!(mem.get("id").is_some(), "hit must include memory.id"); + let content = mem + .get("content") + .and_then(|c| c.as_str()) + .expect("hit must include memory.content"); + assert!( + content.contains("zebraquartz"), + "content must be the FULL memory text" + ); + assert!( + mem.get("created_at").is_some(), + "hit must include memory metadata" + ); + assert!( + first.get("score").and_then(|s| s.as_f64()).is_some(), + "hit must include score" + ); + // No stub markers anywhere. + let s = v.to_string(); + assert!( + !s.contains("raw_hits"), + "hit-count stub leaked into payload" + ); + } + + #[test] + fn recall_http_empty_result_has_no_stub() { + use uteke_core::Uteke; + + // No embedder: these tests are payload-shape conformance and must run + // in CI builds without the ONNX runtime lib (same pattern as the + // graph-edge tests). Keyword (fts5) recall needs no vectors. + let uteke = Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"); + let shared = std::sync::Mutex::new(uteke); + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: vec![], + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let mut req = tiny_http::TestRequest::new() + .with_method(tiny_http::Method::Post) + .with_path("/recall") + .with_body(r#"{"query":"totally-unique-missing-query-xyz","limit":5,"min_score":0.0,"namespace":"conf","strategy":"fts5"}"#) + .into(); + let resp = route(&shared, &ctx, &mut req); + let mut buf = String::new(); + resp.into_reader().read_to_string(&mut buf).unwrap(); + let v: serde_json::Value = serde_json::from_str(&buf).expect("valid JSON"); + // Contract: an empty result set is a bare EMPTY JSON ARRAY — the same + // full-payload shape as a populated response, just with zero hits. + // A "N hits" summary string or a {"raw_hits": N} stub would fail this. + let arr = v + .as_array() + .expect("recall response must be a top-level JSON array, never a summary string/stub"); + assert!(arr.is_empty(), "expected an empty array for a no-hit query"); + } + /// #1252: an unauthenticated GET /health gets liveness only — no version, + /// no counts — while an authenticated request (or auth-disabled server) + /// still receives the full payload. + #[test] + fn test_health_anonymous_gets_minimal_payload() { + struct HealthAuthApp { + uteke: Mutex, + admin_token: Option<&'static str>, + } + impl HealthAuthApp { + fn with_auth(token: Option<&'static str>) -> Self { + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + admin_token: token, + } + } + + fn call(&self, auth_header: Option<&str>) -> (u16, serde_json::Value) { + let mut builder = tiny_http::TestRequest::new() + .with_method(tiny_http::Method::Get) + .with_path("/health"); + if let Some(h) = auth_header { + builder = builder + .with_header(tiny_http::Header::from_bytes("Authorization", h).unwrap()); + } + let mut req: tiny_http::Request = builder.into(); + let ctx = ReqCtx { + auth_token_hash: self.admin_token.map(|t| { + use sha2::{Digest, Sha256}; + Sha256::digest(t.as_bytes()).into() + }), + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + } + + // Auth ENABLED, no token -> minimal body only. + let app = HealthAuthApp::with_auth(Some("secret-token")); + let (status, json) = app.call(None); + assert_eq!(status, 200); + assert_eq!(json["status"], "ok", "liveness must stay available"); + assert!( + json.get("version").is_none() + && json.get("memories").is_none() + && json.get("namespaces").is_none() + && json.get("update_available").is_none(), + "anonymous health must not leak instance details: {json}" + ); + + // Auth ENABLED, valid token -> full payload. + let (status, json) = app.call(Some("Bearer secret-token")); + assert_eq!(status, 200); + assert_eq!(json["status"], "ok"); + assert!( + json.get("version").is_some(), + "authenticated health carries version: {json}" + ); + assert!( + json.get("memories").is_some(), + "authenticated health carries counts: {json}" + ); + + // Auth ENABLED, wrong token -> same minimal body as anonymous: + // an invalid credential must not see more than a scanner would, + // and uptime probes keep a 200 while tokens are being rotated. + let (status, json) = app.call(Some("Bearer wrong-token")); + assert_eq!(status, 200); + assert!( + json.get("version").is_none() && json.get("memories").is_none(), + "invalid credentials must get the minimal body: {json}" + ); + + // Auth DISABLED, no token -> full payload (single-operator local mode). + let app = HealthAuthApp::with_auth(None); + let (status, json) = app.call(None); + assert_eq!(status, 200); + assert!( + json.get("memories").is_some(), + "auth-disabled health stays full: {json}" + ); + } +} + +// ── /routes machine-readable registry (#1289) ────────────────────────── + +#[cfg(test)] +mod routes_introspection_tests { + use super::*; + use sha2::{Digest, Sha256}; + + struct RoutesApp { + uteke: Mutex, + read_only_token_hash: Option<[u8; 32]>, + } + + impl RoutesApp { + fn new() -> Self { + // No embedder: storage-only server tests must run in CI builds + // without the ONNX runtime lib. + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + read_only_token_hash: None, + } + } + + fn with_read_only_token(token: &str) -> Self { + let mut app = Self::new(); + app.read_only_token_hash = Some(Sha256::digest(token.as_bytes()).into()); + app + } + + fn call( + &self, + method: Method, + url: &str, + auth_header: Option<&str>, + ) -> (u16, serde_json::Value) { + let mut builder = tiny_http::TestRequest::new() + .with_method(method) + .with_path(url); + if let Some(h) = auth_header { + builder = + builder.with_header(tiny_http::Header::from_bytes("Authorization", h).unwrap()); + } + let mut req: tiny_http::Request = builder.into(); + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: self.read_only_token_hash, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + } + + #[test] + fn routes_returns_full_registry() { + let app = RoutesApp::new(); + let (status, json) = app.call(Method::Get, "/routes", None); + assert_eq!(status, 200, "{json}"); + + let entries = json.as_array().expect("routes payload is a JSON array"); + assert_eq!( + entries.len(), + crate::api_registry::ENDPOINTS.len(), + "one entry per registered endpoint" + ); + + // Self-describing: /routes lists itself. + let self_entry = entries + .iter() + .find(|e| e["path"] == "/routes" && e["method"] == "GET") + .expect("/routes must list itself"); + assert!(self_entry["tier"].is_string(), "{self_entry}"); + assert!(self_entry["description"].is_string(), "{self_entry}"); + + // Entry shape matches the registry struct fields. + let first = &entries[0]; + for field in ["method", "path", "tier", "description", "issues"] { + assert!( + first.get(field).is_some(), + "registry entry missing field '{field}': {first}" + ); + } + } + + #[test] + fn routes_respects_auth_roles() { + // RO-only server: no token -> 401. + let app = RoutesApp::with_read_only_token("ro-secret"); + let (status, _) = app.call(Method::Get, "/routes", None); + assert_eq!(status, 401, "anonymous must not read the registry"); + + // Valid RO token -> allowed (GET is inherently read-only). + let (status, json) = app.call(Method::Get, "/routes", Some("Bearer ro-secret")); + assert_eq!(status, 200, "{json}"); + assert!(json.as_array().is_some(), "RO token gets the registry"); + } + + fn method_of(name: &str) -> Method { + match name { + "GET" => Method::Get, + "POST" => Method::Post, + "PUT" => Method::Put, + "DELETE" => Method::Delete, + other => panic!("unexpected registry method {other}"), + } + } + + /// Every registry row must have a handler arm: the router consults the + /// table first, so a row whose arm is missing would silently 404 through + /// the fallback. Built from the table, not from source text (#1344). + #[test] + fn every_registry_row_has_a_handler_arm() { + let app = RoutesApp::new(); + for e in crate::api_registry::ENDPOINTS { + UNHANDLED_ROW.with(|c| c.set(false)); + let _ = app.call(method_of(e.method), e.path, None); + assert!( + !UNHANDLED_ROW.with(|c| c.get()), + "{} {} is registered but has no handler arm", + e.method, + e.path + ); + } + } + + /// The dispatch match and the registry name exactly the same + /// (method, path) pairs, with no duplicates (#1344). The match has one + /// `(Method::X, "/path") => module::handler(&mut call),` line per route, + /// so it is read from this file's source; the test above proves the + /// rows reach a handler at run time, this one also catches an arm with + /// no row (dead, since the table 404s it before the match). + #[test] + fn dispatch_match_routes_equal_the_registry_rows() { + let src = include_str!("mod.rs"); + let body = src + .split_once(" match (method, route_path) {") + .expect("route's dispatch match") + .1 + .split_once(" _ => {") + .expect("route's fallback arm") + .0; + let mut arms: Vec<(String, String)> = body + .lines() + .filter_map(|l| { + let rest = l.trim_start().strip_prefix("(Method::")?; + let (method, rest) = rest.split_once(", \"")?; + let (path, rest) = rest.split_once("\")")?; + rest.trim_start().strip_prefix("=>")?; + Some((method.to_ascii_uppercase(), path.to_string())) + }) + .collect(); + let mut rows: Vec<(String, String)> = crate::api_registry::ENDPOINTS + .iter() + .map(|e| (e.method.to_string(), e.path.to_string())) + .collect(); + let (arm_count, row_count) = (arms.len(), rows.len()); + arms.sort(); + arms.dedup(); + rows.sort(); + rows.dedup(); + assert_eq!(arms.len(), arm_count, "duplicate (method, path) arm"); + assert_eq!(rows.len(), row_count, "duplicate (method, path) row"); + assert_eq!( + arms, rows, + "dispatch match and api_registry::ENDPOINTS differ" + ); + } + + /// A route that is not in the table is a 404 that never reaches a + /// handler, even when the path exists under another verb. + #[test] + fn unregistered_method_path_pairs_are_404_before_dispatch() { + let app = RoutesApp::new(); + for (method, path) in [ + (Method::Put, "/list"), + (Method::Delete, "/recall"), + (Method::Get, "/remember"), + (Method::Post, "/health"), + (Method::Get, "/no-such-route"), + ] { + UNHANDLED_ROW.with(|c| c.set(false)); + let (status, json) = app.call(method.clone(), path, None); + assert_eq!(status, 404, "{method} {path}: {json}"); + assert!( + !UNHANDLED_ROW.with(|c| c.get()), + "{method} {path} must be refused by the table, not the match fallback" + ); + } + } + + /// The read-only token is refused (403, gate message) on exactly the + /// rows the table marks as writes, and never on the others. + #[test] + fn read_only_token_gets_403_on_exactly_the_writing_rows() { + const GATE_MSG: &str = "Read-only token cannot perform write operations"; + let app = RoutesApp::with_read_only_token("ro-secret"); + let mut gated = 0; + for e in crate::api_registry::ENDPOINTS { + let (status, json) = app.call(method_of(e.method), e.path, Some("Bearer ro-secret")); + let refused = status == 403 && json["error"] == GATE_MSG; + let expect_refused = e.method != "GET" && !(e.method == "POST" && !e.writes); + assert_eq!( + refused, expect_refused, + "{} {}: {status} {json}", + e.method, e.path + ); + if e.writes { + assert!(refused, "{} {} writes but was not gated", e.method, e.path); + gated += 1; + } + } + assert!(gated > 0, "table must contain writing rows"); + } + + #[test] + fn routes_post_is_not_routed() { + let app = RoutesApp::new(); + let (status, _) = app.call(Method::Post, "/routes", None); + assert_eq!(status, 404, "only GET /routes exists (catch-all 404)"); + } +} + +// ── #1302: plain /remember must honor (not silently normalize) `type` ────── + +#[cfg(test)] +mod plain_remember_type_tests { + use super::*; + use tiny_http::TestRequest; + + /// Minimal harness mirroring the other in-memory route tests: + /// no embedder (CI-safe), route() called directly (#1288). + struct PlainRememberApp { + uteke: Mutex, + } + + impl PlainRememberApp { + fn new() -> Self { + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ), + } + } + + fn call( + &self, + method: Method, + url: &str, + body: Option, + ) -> (u16, serde_json::Value) { + let mut req = match body { + Some(b) => { + let leaked: &'static str = Box::leak(b.into_boxed_str()); + TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into() + } + None => TestRequest::new().with_method(method).with_path(url).into(), + }; + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + } + + #[test] + fn plain_remember_honors_explicit_type() { + let app = PlainRememberApp::new(); + + // Issue repro: explicit `decision` on the plain path must survive — + // silently downgrading it to `fact` broke typed writers (tole #143). + let body = serde_json::json!({ + "content": "ISOLATION-TEST decision typing contract", + "namespace": "repo-mem-e2e", + "type": "decision", + "tags": ["test", "isolation"], + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/remember", Some(body)); + assert_eq!(status, 200, "{resp}"); + let id = resp["id"].as_str().expect("memory id").to_string(); + + let (status, mem) = app.call(Method::Get, &format!("/memory?id={id}"), None); + assert_eq!(status, 200, "{mem}"); + assert_eq!( + mem["memory_type"].as_str(), + Some("decision"), + "explicit type must be stored verbatim, not normalized to fact: {mem}" + ); + // Metadata copy retained (back-compat for metadata-based readers). + assert_eq!(mem["metadata"]["type"].as_str(), Some("decision")); + } + + #[test] + fn plain_remember_rejects_unknown_type_loudly() { + let app = PlainRememberApp::new(); + + let body = serde_json::json!({ + "content": "ISOLATION-TEST unknown type must 400", + "type": "journal", + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/remember", Some(body)); + assert_eq!(status, 400, "unknown type must fail loudly: {resp}"); + let err = resp["error"].as_str().unwrap_or_default(); + assert!(err.contains("Unknown memory type"), "{err}"); + assert!( + err.contains("decision"), + "error must list the supported vocabulary: {err}" + ); + } + + #[test] + fn plain_remember_rejects_unknown_type_even_with_contradiction() { + let app = PlainRememberApp::new(); + + // #1302 review follow-up: type validation runs BEFORE the branch, so + // the contradiction arm is equally covered — no path may store an + // invalid type. (Honoring a valid type on the contradiction arm is + // verified by live E2E: it forwards the type to the core, which has + // no embedder here — CI-safe validation-only assertion.) + let body = serde_json::json!({ + "content": "ISOLATION-TEST unknown type contradiction arm", + "type": "journal", + "detect_contradiction": true, + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/remember", Some(body)); + assert_eq!( + status, 400, + "type validation must precede the contradiction arm: {resp}" + ); + assert!( + resp["error"] + .as_str() + .unwrap_or_default() + .contains("Unknown memory type") + ); + } + + #[test] + fn plain_remember_without_type_still_auto_infers() { + let app = PlainRememberApp::new(); + + // No explicit type → legacy auto-inference path unchanged. + let body = serde_json::json!({ + "content": "ISOLATION-TEST auto infer: we decided to keep the contract", + }) + .to_string(); + let (status, resp) = app.call(Method::Post, "/remember", Some(body)); + assert_eq!(status, 200, "{resp}"); + let id = resp["id"].as_str().expect("memory id").to_string(); + + let (status, mem) = app.call(Method::Get, &format!("/memory?id={id}"), None); + assert_eq!(status, 200, "{mem}"); + assert_eq!( + mem["memory_type"].as_str(), + Some("decision"), + "content contains 'we decided' → auto-inference must still run: {mem}" + ); + } +} + +#[cfg(test)] +mod host_guard_route_tests { + use super::*; + use crate::context::HostGuard; + use tiny_http::{Header, TestRequest}; + + fn status(guard: HostGuard, host: Option<&str>, path: &str) -> u16 { + let uteke = Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ); + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: guard, + }; + let mut t = TestRequest::new().with_method(Method::Get).with_path(path); + if let Some(h) = host { + t = t.with_header(Header::from_bytes(&b"Host"[..], h.as_bytes()).unwrap()); + } + let mut req = t.into(); + route(&uteke, &ctx, &mut req).status_code().0 + } + + // #1326 acceptance: a loopback bind rejects `Host: evil.example`. + #[test] + fn loopback_bind_rejects_foreign_host() { + let g = HostGuard::new("127.0.0.1", &[]); + assert_eq!(status(g.clone(), Some("evil.example:8767"), "/health"), 403); + assert_eq!(status(g.clone(), Some("evil.example"), "/stats"), 403); + assert_eq!(status(g.clone(), Some("127.0.0.1:8767"), "/health"), 200); + assert_eq!(status(g, Some("localhost:8767"), "/health"), 200); + } + + // #1326 acceptance: a Docker-style bind works, and allowed_hosts is honoured. + #[test] + fn docker_style_bind_and_allowed_hosts() { + let open = HostGuard::new("0.0.0.0", &[]); + assert_eq!(status(open, Some("uteke:8767"), "/health"), 200); + + let listed = HostGuard::new("0.0.0.0", &["uteke".to_string()]); + assert_eq!(status(listed.clone(), Some("uteke:8767"), "/health"), 200); + assert_eq!(status(listed, Some("evil.example"), "/health"), 403); + } +} + +#[cfg(test)] +mod mcp_http_hardening_tests { + use super::*; + use tiny_http::{Header, TestRequest}; + + fn app() -> Mutex { + Mutex::new( + Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"), + ) + } + + fn ctx(origins: Vec) -> ReqCtx { + ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: origins, + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + } + } + + // #1328: an oversized /mcp body is a 413, not a truncated JSON parse error. + #[test] + fn mcp_oversized_body_is_413() { + let big: &'static str = Box::leak("x".repeat(1024 * 1024 + 8).into_boxed_str()); + let mut req = TestRequest::new() + .with_method(Method::Post) + .with_path("/mcp") + .with_body(big) + .into(); + let resp = route(&app(), &ctx(Vec::new()), &mut req); + assert_eq!(resp.status_code().0, 413); + } + + // #1328: /mcp responses carry CORS headers like every other endpoint. + #[test] + fn mcp_response_has_cors_headers() { + let origin = "https://app.example"; + let body = r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#; + let mut req = TestRequest::new() + .with_method(Method::Post) + .with_path("/mcp") + .with_header(Header::from_bytes(&b"Origin"[..], origin.as_bytes()).unwrap()) + .with_body(body) + .into(); + let resp = route(&app(), &ctx(vec![origin.to_string()]), &mut req); + assert!( + resp.headers() + .iter() + .any(|h| h.field.equiv("Access-Control-Allow-Origin") + && h.value.as_str() == origin), + "missing ACAO on /mcp: {:?}", + resp.headers() + ); + } +} + +// #1439: /mcp is open to read-only tokens; the per-tool gate inside uteke-mcp +// refuses the write tools, write tokens are unchanged. +#[cfg(test)] +mod mcp_read_only_tests { + use super::*; + use sha2::{Digest, Sha256}; + use tiny_http::TestRequest; + + fn ro_ctx() -> ReqCtx { + ReqCtx { + auth_token_hash: Some(Sha256::digest(b"rw-secret").into()), + read_only_token_hash: Some(Sha256::digest(b"ro-secret").into()), + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + } + } + + fn mcp(uteke: &Mutex, token: &str, tool: &str, args: &str) -> (u16, serde_json::Value) { + let body: &'static str = Box::leak( + format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"{tool}","arguments":{args}}}}}"# + ) + .into_boxed_str(), + ); + let auth: &'static str = Box::leak(format!("Bearer {token}").into_boxed_str()); + let mut req = TestRequest::new() + .with_method(Method::Post) + .with_path("/mcp") + .with_header(tiny_http::Header::from_bytes("Authorization", auth).unwrap()) + .with_body(body) + .into(); + let resp = route(uteke, &ro_ctx(), &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null), + ) + } + + fn store() -> Mutex { + Mutex::new(Uteke::open_with_backend(":memory:", None).unwrap()) + } + + #[test] + fn read_only_token_reaches_read_tools() { + let u = store(); + let (status, json) = mcp(&u, "ro-secret", "uteke_stats", "{}"); + assert_eq!(status, 200, "{json}"); + assert!(json.get("error").is_none(), "{json}"); + assert_ne!(json["result"]["isError"], true, "{json}"); + } + + #[test] + fn read_only_token_is_refused_write_tools() { + let u = store(); + let (status, json) = mcp(&u, "ro-secret", "uteke_remember", r#"{"content":"x"}"#); + assert_eq!(status, 200, "{json}"); + assert_eq!(json["result"]["isError"], true, "{json}"); + assert_eq!( + json["result"]["content"][0]["text"], + "Read-only token cannot use uteke_remember" + ); + assert_eq!(u.lock().unwrap().count(None).unwrap(), 0); + } + + #[test] + fn write_token_is_unchanged() { + let u = store(); + let (status, json) = mcp(&u, "rw-secret", "uteke_remember", r#"{"content":"x"}"#); + assert_eq!(status, 200, "{json}"); + assert!(json.get("error").is_none(), "{json}"); + assert_ne!(json["result"]["isError"], true, "{json}"); + assert_eq!(u.lock().unwrap().count(None).unwrap(), 1); + } +} + +#[cfg(test)] +mod namespace_deprecation_tests { + use super::*; + use tiny_http::TestRequest; + + fn call( + uteke: &Mutex, + method: Method, + url: &str, + body: serde_json::Value, + ) -> (u16, String) { + let leaked: &'static str = Box::leak(body.to_string().into_boxed_str()); + let mut req: Request = TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into(); + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + (status, String::from_utf8(bytes).unwrap()) + } + + fn app() -> Mutex { + Mutex::new(Uteke::open_with_backend(":memory:", None).expect("open without embedder")) + } + + fn warning(raw: &str) -> serde_json::Value { + serde_json::from_str::(raw).unwrap()["warning"].clone() + } + + #[test] + fn remember_non_default_namespace_carries_warning() { + let u = app(); + let (st, raw) = call( + &u, + Method::Post, + "/remember", + serde_json::json!({"content": "x", "namespace": "ns-http-remember"}), + ); + assert_eq!(st, 200, "{raw}"); + let w = warning(&raw); + let w = w.as_str().expect("warning is a string"); + assert!(w.contains(uteke_core::namespace_deprecation::NAMESPACE_DEPRECATION_WARNING)); + } + + #[test] + fn remember_default_or_omitted_namespace_has_no_deprecation() { + let u = app(); + for body in [ + serde_json::json!({"content": "a"}), + serde_json::json!({"content": "b", "namespace": "default"}), + ] { + let (st, raw) = call(&u, Method::Post, "/remember", body); + assert_eq!(st, 200, "{raw}"); + let w = warning(&raw); + assert!( + w.is_null() || !w.as_str().unwrap().contains("Namespaces are deprecated"), + "{raw}" + ); + // Same keys as before the change: warning stays a key, no new ones. + let v: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let mut keys: Vec<_> = v.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + assert_eq!(keys, ["author_type", "embedding_written", "id", "warning"]); + } + } + + #[test] + fn room_create_warning_only_for_non_default() { + let u = app(); + let (_, raw) = call( + &u, + Method::Post, + "/room/create", + serde_json::json!({"room_id": "r-default"}), + ); + let v: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!(v.get("warning").is_none(), "{raw}"); + assert_eq!(v["namespace"], "default"); + + let (_, raw) = call( + &u, + Method::Post, + "/room/create", + serde_json::json!({"room_id": "r-ns", "namespace": "ns-http-room"}), + ); + let v: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!( + v["warning"], + uteke_core::namespace_deprecation::NAMESPACE_DEPRECATION_WARNING + ); + assert_eq!(v["created"], "r-ns"); + } + + #[test] + fn with_namespace_warning_is_identity_for_default() { + let body = serde_json::json!({"imported": 1, "skipped": 0}); + assert_eq!(with_namespace_warning(body.clone(), None), body); + assert_eq!(with_namespace_warning(body.clone(), Some("default")), body); + let w = with_namespace_warning(body, Some("other")); + assert!(w["warning"].is_string()); + assert_eq!(w["imported"], 1); + } +} + +// ── #1343: `DELETE /forget` goes through the typed forget request ────────── + +#[cfg(test)] +mod forget_route_tests { + use super::*; + use sha2::{Digest, Sha256}; + use uteke_core::memory::types::Memory; + + const ID_A: &str = "abcd0000-0000-4000-8000-00000000000a"; + const ID_B: &str = "abcd0000-0000-4000-8000-00000000000b"; + const ID_C: &str = "cafe0000-0000-4000-8000-00000000000c"; + + fn memory(id: &str, ns: &str, tag: &str) -> Memory { + let now = chrono::Utc::now(); + Memory { + id: id.to_string(), + content: format!("forget route probe {id}"), + embedding: vec![0.21; 768], + tags: vec![tag.to_string()], + metadata: serde_json::json!({}), + created_at: now, + updated_at: now, + namespace: ns.to_string(), + access_count: 0, + recall_count: 0, + last_accessed: None, + deprecated: false, + deprecated_at: None, + valid_from: None, + valid_until: None, + memory_type: "fact".to_string(), + importance: 0.5, + pinned: false, + content_type: "text".to_string(), + slug: None, + source: None, + source_type: "user".to_string(), + author_type: "agent".to_string(), + } + } + + fn app() -> Mutex { + let uteke = Uteke::open_with_backend(":memory:", None) + .expect("open in-memory uteke without embedder"); + uteke.store().insert(&memory(ID_A, "ns-a", "t1")).unwrap(); + uteke.store().insert(&memory(ID_B, "ns-a", "t1")).unwrap(); + uteke.store().insert(&memory(ID_C, "ns-b", "t2")).unwrap(); + Mutex::new(uteke) + } + + fn call( + uteke: &Mutex, + method: Method, + url: &str, + bearer: Option<&str>, + tokens: bool, + ) -> (u16, serde_json::Value) { + let mut builder = tiny_http::TestRequest::new() + .with_method(method) + .with_path(url); + if let Some(h) = bearer { + builder = + builder.with_header(tiny_http::Header::from_bytes("Authorization", h).unwrap()); + } + let mut req: tiny_http::Request = builder.into(); + let ctx = ReqCtx { + auth_token_hash: tokens.then(|| Sha256::digest("rw").into()), + read_only_token_hash: tokens.then(|| Sha256::digest("ro").into()), + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + }; + let resp = route(uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) + } + + fn del(uteke: &Mutex, url: &str) -> (u16, serde_json::Value) { + call(uteke, Method::Delete, url, None, false) + } + + #[test] + fn forget_by_full_id() { + let u = app(); + let (s, body) = del(&u, &format!("/forget?id={ID_A}")); + assert_eq!((s, &body), (200, &serde_json::json!({"forgotten": ID_A}))); + } + + #[test] + fn forget_by_unambiguous_short_prefix() { + let u = app(); + let (s, body) = del(&u, "/forget?id=cafe"); + assert_eq!((s, &body), (200, &serde_json::json!({"forgotten": ID_C}))); + } + + #[test] + fn error_statuses_and_texts_are_unchanged() { + let u = app(); + let (s, body) = del(&u, "/forget?id=ffff"); + assert_eq!(s, 404); + assert_eq!(body["error"], "Memory not found: ffff"); + let absent = "11111111-1111-4111-8111-111111111111"; + let (s, body) = del(&u, &format!("/forget?id={absent}")); + assert_eq!(s, 404); + assert_eq!(body["error"], format!("Memory not found: {absent}")); + let (s, body) = del(&u, "/forget?id=abcd"); + assert_eq!(s, 400); + assert_eq!( + body["error"], + "Validation error: Ambiguous ID prefix 'abcd' — matches 2 memories. Use a longer prefix." + ); + for url in ["/forget", "/forget?namespace=ns-a", "/forget?flag"] { + let (s, body) = del(&u, url); + assert_eq!(s, 400, "{url}"); + assert_eq!(body["error"], "Provide ?id= or ?tag= parameter", "{url}"); + } + } + + #[test] + fn id_wins_over_tag() { + let u = app(); + let (s, body) = del(&u, &format!("/forget?tag=t1&id={ID_C}")); + assert_eq!((s, &body), (200, &serde_json::json!({"forgotten": ID_C}))); + // The tag was not applied: the `t1` memories are untouched. + assert!( + !u.lock() + .unwrap() + .get_by_id(ID_A) + .unwrap() + .unwrap() + .deprecated + ); + } + + #[test] + fn tag_forget_honours_namespace_and_none_means_default() { + let u = app(); + // Wrong namespace: nothing matches. + let (s, body) = del(&u, "/forget?tag=t1&namespace=ns-b"); + assert_eq!(s, 200); + assert_eq!(body["deleted"], 0); + let (s, body) = del(&u, "/forget?tag=t1&namespace=ns-a"); + assert_eq!(s, 200); + assert_eq!(body["deleted"], 2); + // No namespace means the default namespace, NOT every namespace: + // `t2` lives in `ns-b`, so it is untouched until ns-b is named. + let (s, body) = del(&u, "/forget?tag=t2"); + assert_eq!(s, 200); + assert_eq!(body["deleted"], 0); + let (s, body) = del(&u, "/forget?tag=t2&namespace=ns-b"); + assert_eq!(s, 200); + assert_eq!(body["deleted"], 1); + } + + #[test] + fn read_only_token_cannot_forget() { + let u = app(); + for url in [format!("/forget?id={ID_C}"), "/forget?tag=t2".to_string()] { + let (s, body) = call(&u, Method::Delete, &url, Some("Bearer ro"), true); + assert_eq!(s, 403, "{url}"); + assert_eq!( + body["error"], + "Read-only token cannot perform write operations" + ); + } + // Nothing was deleted by the blocked requests. + assert!( + !u.lock() + .unwrap() + .get_by_id(ID_C) + .unwrap() + .unwrap() + .deprecated + ); + // The write token still can. + let (s, _) = call( + &u, + Method::Delete, + &format!("/forget?id={ID_C}"), + Some("Bearer rw"), + true, + ); + assert_eq!(s, 200); + // Recorded disagreement: the gate is HTTP-only. + let may = |p: uteke_core::ForgetPolicy| p.read_only_token_may_forget; + assert_eq!( + ( + may(uteke_core::ForgetPolicy::HTTP), + may(uteke_core::ForgetPolicy::MCP) + ), + (false, true) + ); + } + + #[test] + fn http_and_mcp_resolve_the_same_ids_to_the_same_memory() { + let u = app(); + let guard = u.lock().unwrap(); + let resolve = |input: &str, p| uteke_core::resolve_memory_id(&guard, input, p); + for input in [ID_A, "cafe", ID_B] { + let http = resolve(input, uteke_core::ForgetPolicy::HTTP); + let mcp = resolve(input, uteke_core::ForgetPolicy::MCP); + assert_eq!(http.unwrap(), mcp.unwrap(), "{input}"); + } + // Where they disagree, each keeps its own text (pinned). + let http = resolve("ffff", uteke_core::ForgetPolicy::HTTP); + let mcp = resolve("ffff", uteke_core::ForgetPolicy::MCP); + assert_eq!(http.unwrap_err().message(), "Memory not found: ffff"); + assert_eq!( + mcp.unwrap_err().message(), + "No memory matches id prefix 'ffff'" + ); + } +} diff --git a/crates/uteke-server/src/handlers/namespaces.rs b/crates/uteke-server/src/handlers/namespaces.rs new file mode 100644 index 00000000..56a33105 --- /dev/null +++ b/crates/uteke-server/src/handlers/namespaces.rs @@ -0,0 +1,98 @@ +//! Namespace handlers: list, rename and delete. + +use super::*; + +pub(super) fn get_namespaces(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let with_counts = path.contains("with_counts=true"); + if with_counts { + match uteke.list_namespaces_with_lifecycle_counts() { + Ok(counts) => { + #[derive(serde::Serialize)] + struct NamespaceCount { + name: String, + count: usize, + /// Memories not deprecated (#1181). + active: usize, + /// Deprecated memories — additive fields, `count` keeps + /// the total so existing clients stay correct (#1181). + deprecated: usize, + } + let result: Vec = counts + .into_iter() + .map(|(name, active, deprecated)| NamespaceCount { + count: active + deprecated, + name, + active, + deprecated, + }) + .collect(); + ctx.ok_response_for(req, &result) + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } else { + match uteke.list_namespaces() { + Ok(namespaces) => ctx.ok_response_for(req, &namespaces), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } +} + +pub(super) fn post_namespaces_rename(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.rename_namespace(&req_data.from, &req_data.to) { + Ok(result) => ctx.ok_response_for(req, &result), + Err(uteke_core::Error::Validation(msg)) => ctx.error_response_for(req, 400, msg), + Err(e) => { + error!("Namespace rename error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_namespaces_delete(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + match uteke.delete_namespace( + &req_data.name, + &req_data.strategy, + req_data.target.as_deref(), + ) { + Ok(result) => ctx.ok_response_for(req, &result), + Err(uteke_core::Error::Validation(msg)) => { + // `refuse` on a non-empty namespace → 409 Conflict; + // other validation failures → 400. + let status = if req_data.strategy == "refuse" { + 409 + } else { + 400 + }; + ctx.error_response_for(req, status, msg) + } + Err(e) => { + error!("Namespace delete error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} diff --git a/crates/uteke-server/src/handlers/rooms.rs b/crates/uteke-server/src/handlers/rooms.rs new file mode 100644 index 00000000..b330fe18 --- /dev/null +++ b/crates/uteke-server/src/handlers/rooms.rs @@ -0,0 +1,655 @@ +//! Room handlers: room CRUD, room memories/recall, room summaries and room-document links. + +use super::*; + +pub(super) fn post_room_summary(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomSummaryRequest { + room_id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_summary(&req_data.room_id) { + Ok(Some(summary)) => ctx.ok_response_for(req, &summary), + Ok(None) => { + ctx.error_response_for(req, 404, format!("Room not found: {}", req_data.room_id)) + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_room_summary_document(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomSummaryDocumentRequest { + room_id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_summary_document(&req_data.room_id) { + Ok(Some(doc)) => ctx.ok_response_for(req, &doc), + Ok(None) => { + ctx.error_response_for(req, 404, format!("Room not found: {}", req_data.room_id)) + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// Dry-run by default; `apply: true` executes with a hard budget cap. +// Blocked for read-only tokens (not in read_only_post_paths). +pub(super) fn post_room_consolidate(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomConsolidateRequest { + room_id: String, + /// Execute the plan (LLM calls + writes). Default false = dry-run. + #[serde(default)] + apply: bool, + /// Max LLM requests for this run. Default 10, hard cap 100. + #[serde(default = "default_max_calls")] + max_calls: usize, + } + fn default_max_calls() -> usize { + 10 + } + match read_body::(req.as_reader()) { + Ok(req_data) => { + let max_calls = req_data.max_calls.min(100); + let result = if req_data.apply { + let ext = resolve_extraction_config(ctx, None, None, None); + if ext.api_key.is_empty() { + return ctx.error_response_for( + req, + 503, + "Consolidation apply requires server-side extraction LLM config", + ); + } + match uteke_core::consolidation_api::consolidate_room( + uteke, + &req_data.room_id, + &ext, + max_calls, + ) { + Ok(exec) => serde_json::to_value(&exec).unwrap_or_default(), + Err(e) => { + error!("Internal error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + } + } else { + match uteke_core::consolidation_api::plan_room(uteke, &req_data.room_id) { + Ok(dry) => serde_json::json!({ + "room_id": req_data.room_id, + "dry_run": true, + "total_memories": dry.plan.total_memories, + "batches": dry.plan.batches.len(), + "estimated_llm_calls": dry.plan.batches.len().min(max_calls), + "plan": dry.plan, + }), + Err(e) => { + error!("Internal error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + } + }; + ctx.ok_response_for(req, &result) + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_room_document(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + warn!("DEPRECATED: POST /room/document is renamed to POST /room/summary-document (see #735)"); + #[derive(Deserialize)] + struct RoomDocumentRequest { + room_id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_summary_document(&req_data.room_id) { + Ok(Some(doc)) => ctx.ok_response_for(req, &doc), + Ok(None) => { + ctx.error_response_for(req, 404, format!("Room not found: {}", req_data.room_id)) + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn get_room_memories(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let query_str = path.split('?').nth(1); + let room_id = query_str.and_then(|q| parse_query_param(q, "room_id")); + let room_id = match room_id { + Some(id) => id, + None => { + return ctx.error_response_for( + req, + 400, + "Missing required parameter: room_id. Usage: GET /room/memories?room_id=[&author=&namespace=&limit=]", + ); + } + }; + let limit = query_str + .and_then(|q| parse_query_param(q, "limit")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(100); + let author = query_str.and_then(|q| parse_query_param(q, "author")); + let namespace = query_str.and_then(|q| parse_query_param(q, "namespace")); + match uteke.recall_room_scoped(&room_id, author.as_deref(), namespace.as_deref(), limit) { + Ok(memories) => ctx.ok_response_for(req, &memories), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_room_recall(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + // Time-travel: parse & validate `at` before any query so an + // invalid timestamp fails loudly (400) instead of being + // silently ignored (#1082). + let point_in_time = match req_data.at.as_deref() { + Some(at_str) => match chrono::DateTime::parse_from_rfc3339(at_str) { + Ok(dt) => Some(dt.with_timezone(&chrono::Utc)), + Err(_) => { + return ctx.error_response_for( + req, + 400, + format!( + "Invalid 'at' timestamp: {at_str}. Use RFC3339 format (e.g. 2026-06-01T12:00:00Z)" + ), + ); + } + }, + None => None, + }; + let query = req_data.query.as_deref().unwrap_or("").trim(); + if query.is_empty() { + // No query provided — fall back to chronological recall (#785) + // Over-fetch when time-traveling: the SQL LIMIT applies + // before the temporal post-filter, so fetch extra rows + // and truncate after filtering (cora MAJOR on #1085). + let fetch_limit = if point_in_time.is_some() && req_data.limit > 0 { + req_data.limit.saturating_mul(3).max(req_data.limit + 10) + } else { + req_data.limit + }; + match uteke.recall_room(&req_data.room_id, req_data.author.as_deref(), fetch_limit) + { + Ok(memories) => { + let mut memories = match point_in_time { + Some(pit) => filter_room_memories_at_time(memories, pit), + None => memories, + }; + if point_in_time.is_some() && req_data.limit > 0 { + memories.truncate(req_data.limit); + } + ctx.ok_response_for(req, &memories) + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } else { + // Semantic recall with query + let min_score = req_data.min_score.unwrap_or( + ctx.recall_config + .as_ref() + .and_then(|r| r.min_score) + .unwrap_or(DEFAULT_MIN_SCORE as f64) as f32, + ); + match uteke.recall_room_semantic( + &req_data.room_id, + query, + req_data.limit, + req_data.author.as_deref(), + min_score, + ) { + Ok(results) => { + let results = match point_in_time { + Some(pit) => { + let mut kept: Vec<_> = results + .into_iter() + .filter(|sr| memory_exists_at(&sr.memory, pit)) + .collect(); + kept.truncate(req_data.limit.max(1)); + kept + } + None => results, + }; + ctx.ok_response_for(req, &results) + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_room_create(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomCreateRequest { + room_id: String, + #[serde(default)] + title: Option, + #[serde(default = "default_namespace")] + namespace: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => { + match uteke.create_room( + &req_data.room_id, + req_data.title.as_deref(), + &req_data.namespace, + ) { + Ok(()) => ctx.ok_response_for( + req, + &with_namespace_warning( + serde_json::json!({ + "created": req_data.room_id, + "namespace": req_data.namespace + }), + Some(req_data.namespace.as_str()), + ), + ), + Err(e) => { + let msg = format!("Failed to create room: {e}"); + ctx.error_response_for(req, 400, &msg) + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// POST /room/rename — rename a room, moving all member/document +// links in one transaction (#1202) +pub(super) fn post_room_rename(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomRenameRequest { + from: String, + to: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.rename_room(&req_data.from, &req_data.to) { + Ok(room) => ctx.ok_response_for( + req, + &serde_json::json!({ + "renamed": req_data.from, + "to": req_data.to, + "room": room, + }), + ), + Err(e) => { + let status = match e { + uteke_core::Error::Validation(_) => 400, + _ => 500, + }; + ctx.error_response_for(req, status, e.to_string()) + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// POST /room/update — update room title/description (#1202) +pub(super) fn post_room_update(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomUpdateRequest { + room_id: String, + #[serde(default)] + title: Option, + #[serde(default)] + description: Option, + } + match read_body::(req.as_reader()) { + Ok(req_data) => { + match uteke.update_room( + &req_data.room_id, + req_data.title.as_deref(), + req_data.description.as_deref(), + ) { + Ok(Some(room)) => ctx.ok_response_for(req, &serde_json::json!({ "room": room })), + Ok(None) => ctx.error_response_for( + req, + 404, + format!("Room not found: {}", req_data.room_id), + ), + Err(e) => { + let status = match e { + uteke_core::Error::Validation(_) => 400, + _ => 500, + }; + ctx.error_response_for(req, status, e.to_string()) + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// POST /room/memory/move — move a memory from one room to another, +// preserving link author/role/joined_at (#1202) +pub(super) fn post_room_memory_move(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomMemoryMoveRequest { + memory_id: String, + from_room: String, + to_room: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => { + match uteke.move_memory_to_room( + &req_data.memory_id, + &req_data.from_room, + &req_data.to_room, + ) { + Ok(1) => ctx.ok_response_for( + req, + &serde_json::json!({ + "moved": req_data.memory_id, + "from_room": req_data.from_room, + "to_room": req_data.to_room, + }), + ), + Ok(0) => ctx.error_response_for( + req, + 404, + format!( + "Memory {} has no link in room {}", + req_data.memory_id, req_data.from_room + ), + ), + Ok(_) => unreachable!("move returns 0 or 1"), + Err(e) => { + let status = match e { + uteke_core::Error::Validation(_) => 400, + _ => 500, + }; + ctx.error_response_for(req, status, e.to_string()) + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// POST /room/remember — store memory and link to room (#762) +pub(super) fn post_room_remember(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + if let Err(e) = uteke_core::validate_input(&req_data.content, &req_data.tags) { + return ctx.error_response_for(req, 400, e.to_string()); + } + let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect(); + let memory_type = req_data.r#type.as_deref().unwrap_or("fact"); + let author = req_data.author.as_deref().unwrap_or("user"); + match uteke.remember_in_room( + &req_data.content, + &tag_refs, + req_data.metadata.clone(), + ns(&req_data.namespace), + memory_type, + &req_data.room_id, + author, + ) { + Ok(outcome) => ctx.ok_response_for( + req, + &serde_json::json!({ + "id": outcome.id, + "room_id": req_data.room_id, + "embedding_written": outcome.embedding_written, + "warning": uteke_core::namespace_deprecation::merge_warning( + outcome.warning, + uteke_core::namespace_deprecation::namespace_write_warning( + ns(&req_data.namespace), + ), + ), + }), + ), + Err(e) => { + error!("room/remember error: {e}"); + let status = match e { + uteke_core::Error::Validation(_) => 400, + _ => 500, + }; + ctx.error_response_for(req, status, e.to_string()) + } + } + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn get_room_list(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let ns_param = parse_query_namespace(path); + match uteke.list_rooms(ns_param.as_deref()) { + Ok(rooms) => ctx.ok_response_for(req, &rooms), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_room_stats(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomStatsRequest { + room_id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_stats(&req_data.room_id) { + Ok(Some(stats)) => ctx.ok_response_for(req, &stats), + Ok(None) => { + ctx.error_response_for(req, 404, format!("Room not found: {}", req_data.room_id)) + } + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// POST /room/document/list — list documents linked to a room +pub(super) fn post_room_document_list(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomDocListReq { + room_id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_list_documents(&req_data.room_id) { + Ok(slugs) => ctx.ok_response_for( + req, + &serde_json::json!({ "room_id": req_data.room_id, "doc_slugs": slugs }), + ), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// PUT /room/document/add — link a document to a room +pub(super) fn put_room_document_add(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomDocAddReq { + room_id: String, + doc_slug: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_add_document(&req_data.room_id, &req_data.doc_slug) { + Ok(()) => ctx.ok_response_for(req, &serde_json::json!({ "status": "linked", "room_id": req_data.room_id, "doc_slug": req_data.doc_slug })), + Err(e) => match e { + uteke_core::Error::Validation(_) => { + ctx.error_response_for(req, 400, e.to_string()) + } + _ => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// DELETE /room/document/remove — unlink a document from a room +pub(super) fn delete_room_document_remove(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct RoomDocRemoveReq { + room_id: String, + doc_slug: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_remove_document(&req_data.room_id, &req_data.doc_slug) { + Ok(()) => ctx.ok_response_for(req, &serde_json::json!({ "status": "unlinked", "room_id": req_data.room_id, "doc_slug": req_data.doc_slug })), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +// POST /doc/room/list — list rooms linked to a document +pub(super) fn post_doc_room_list(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + #[derive(Deserialize)] + struct DocRoomListReq { + doc_slug: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.document_list_rooms(&req_data.doc_slug) { + Ok(room_ids) => ctx.ok_response_for( + req, + &serde_json::json!({ "doc_slug": req_data.doc_slug, "room_ids": room_ids }), + ), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn delete_room_delete(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let room_id = if let Some(q) = path.split('?').nth(1) { + parse_query_param(q, "room_id") + } else { + // Try reading from query params in headers or body + None + }; + let room_id = match room_id { + Some(id) => id, + None => { + // Try body as JSON + #[derive(Deserialize)] + struct RoomDeleteRequest { + room_id: String, + } + match read_body::(req.as_reader()) { + Ok(data) => data.room_id, + Err(_) => { + return ctx.error_response_for(req, 400, "Missing 'room_id' parameter"); + } + } + } + }; + match uteke.delete_room(&room_id) { + Ok(unlinked) => ctx.ok_response_for( + req, + &serde_json::json!({ + "deleted": room_id, + "unlinked_memories": unlinked, + "note": "memories and documents are preserved in their namespaces, no longer linked to any room" + }), + ), + Err(e) => { + let msg = format!("{e}"); + if msg.contains("not found") { + ctx.error_response_for(req, 404, &msg) + } else { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } +} diff --git a/crates/uteke-server/src/handlers/tags.rs b/crates/uteke-server/src/handlers/tags.rs new file mode 100644 index 00000000..04316b88 --- /dev/null +++ b/crates/uteke-server/src/handlers/tags.rs @@ -0,0 +1,72 @@ +//! Tag handlers: list, rename and delete. + +use super::*; + +pub(super) fn get_tags(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + let path = c.path; + let ns = parse_query_namespace(path); + match uteke.tags_with_counts(ns.as_deref()) { + Ok(tags) => ctx.ok_response_for(req, &tags), + Err(e) => { + error!("Tags list error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } +} + +pub(super) fn post_tags_rename(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + let count = + match uteke.rename_tag(&req_data.old, &req_data.new, ns(&req_data.namespace)) { + Ok(count) => count, + Err(e) => { + error!("Tag rename error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + }; + ctx.ok_response_for( + req, + &serde_json::json!({ + "renamed": count > 0, + "count": count, + "old": req_data.old, + "new": req_data.new, + }), + ) + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} + +pub(super) fn post_tags_delete(c: &mut Call) -> Response>> { + let uteke = c.uteke; + let ctx = c.ctx; + let req = &mut *c.req; + match read_body::(req.as_reader()) { + Ok(req_data) => { + let count = match uteke.delete_tag(&req_data.tag, ns(&req_data.namespace)) { + Ok(count) => count, + Err(e) => { + error!("Tag delete error: {e}"); + return ctx.error_response_for(req, 500, "Internal server error"); + } + }; + ctx.ok_response_for( + req, + &serde_json::json!({ + "deleted": count > 0, + "count": count, + "tag": req_data.tag, + }), + ) + } + Err(e) => ctx.error_response_for(req, 400, e), + } +} diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 65831933..c8f1cfcb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -62,7 +62,7 @@ uteke upgrade --yes |------|-------------| | `--yes` | Skip confirmation prompt | -> **Note:** Renamed from `uteke update` in v0.7.0 to avoid conflict with `uteke doc update`. Verifies SHA-256 checksums by default. Set `UTEKE_UPGRADE_SKIP_CHECKSUM=1` to skip verification (not recommended). +> **Note:** Renamed from `uteke update` in v0.7.0 to avoid conflict with `uteke doc update`. Verifies the minisign signature of `checksums-sha256.txt` and then the archive's SHA-256 by default; a missing or invalid signature aborts the upgrade, as does a release older than the installed version or a non-stable (pre-release) one; `uteke upgrade` installs only stable releases (see SECURITY.md). Only `uteke upgrade` checks the signature; `install.sh`, `install.ps1` and Homebrew do not. Set `UTEKE_UPGRADE_SKIP_CHECKSUM=1` to skip both checks (not recommended). ## uteke remember diff --git a/docs/core-contract.json b/docs/core-contract.json index 4cdd0b6a..3183bdd8 100644 --- a/docs/core-contract.json +++ b/docs/core-contract.json @@ -319,5 +319,5 @@ } ], "source_of_truth": "uteke PROD store, document tree kontrak-core-cloud", - "version": "0.21.0" + "version": "0.21.1" } diff --git a/docs/install.md b/docs/install.md index 76921902..8f35661f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -133,11 +133,17 @@ uteke upgrade --yes # Skip confirmation `upgrade` keeps every installed artifact in sync with the release bundle: the CLI, `uteke-serve`, `uteke-mcp`, and the bundled ONNX Runtime shared libraries -are all replaced from the same checksum-verified archive (binaries are +are all replaced from the same signature- and checksum-verified archive (binaries are run-verified and swapped atomically). The version comparison normalizes the release tag's `v` prefix, so an up-to-date install reports "Already up to date" instead of re-offering the same release. +`upgrade` installs only stable releases (`vX.Y.Z`, never a pre-release), verifies +the release's minisign signature and refuses to downgrade; +this check exists only in `uteke upgrade`. The install script, `install.ps1` and +Homebrew do not verify it (Homebrew relies on the formula SHA256s); verify +manually as described in [SECURITY.md](../SECURITY.md#verifying-a-release). + ## What Gets Installed The install script deploys three binaries plus the bundled ONNX Runtime shared library (required for local embeddings, no API keys needed): diff --git a/docs/plans/2026-10-13-unified-surfaces.md b/docs/plans/2026-10-13-unified-surfaces.md new file mode 100644 index 00000000..8797c8f0 --- /dev/null +++ b/docs/plans/2026-10-13-unified-surfaces.md @@ -0,0 +1,560 @@ +# Unified Surfaces (#1439) and Namespace Deprecation (#1427) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Every operation has one behaviour, owned by uteke-core. HTTP, MCP and CLI differ only in transport: auth, argument parsing and output format. This matches how uteke-cloud already serves MCP, by running each tool through its REST router. + +**Architecture:** The typed request modules from #1343 (`recall_request.rs`, `list_request.rs`, `remember_request.rs`, `forget_request.rs`) become the single deep module per operation. Their `Policy::HTTP` / `Policy::MCP` constants are collapsed into one behaviour, and then the `*Policy` structs are deleted. The CLI runs the same `decode` before it picks a transport (server or local store). Namespace-related disagreements are not unified here: #1427 phase 4 (single-namespace mode) removes them. + +**Tech Stack:** Rust workspace (uteke-core, uteke-server on tiny_http, uteke-mcp JSON-RPC, uteke-cli on clap). Tests use `Uteke::open_with_backend(":memory:", None)` (no ONNX in CI). Benchmarks use the Python harness in `benchmarks/longmemeval/scripts/`. + +Decisions come from the grilling session of 2026-10-13 (Q1–Q20) and are recorded on [codecoradev/uteke#1439](https://github.com/codecoradev/uteke/issues/1439#issuecomment-6136926825). + +--- + +## 0. Ground rules (read before any task) + +### Repo rules (AGENTS.md) +- Branch from `origin/develop`. Allowed prefixes: `feat/ fix/ security/ refactor/ test/ docs/ chore/`. `release/` and `merge/` are rejected. +- The PR body needs `## What`, `## Why` and `## Testing` (CI enforces this). The title is a Conventional Commit. +- Git author email: `ajianaz@users.noreply.github.com`. +- **Merge gate:** `gh pr checks` all green, a real Cora verdict, every bot and human comment triaged, each code-scanning alert validated. Squash, delete the branch, sync develop. +- Local checks before push: + - `cargo fmt --all -- --check` + - `cargo clippy --workspace --all-targets -- -D warnings` + - `cargo test --workspace` +- `docs/api-reference.md` and `docs/core-contract.json` are generated by `cargo run -p docgen`. Never hand-edit them. **`core-contract.json` must stay byte-identical** for every PR in this plan; the `/mcp` `writes` flag is `#[serde(skip)]`, so flipping it does not change the file. + +### Project room (AGENTS.md "Project room protocol", PR #1440) +- **Before each PR:** `uteke_room_recall` on room `uteke` with the PR's goal (limit 3–5), and check the result against the code. +- **After each PR merges** (and after each release step): `uteke_remember` with `room: "uteke"`, `namespace: "macmini"`, tags `project:uteke`, `tool:claude-code`. + - A state update starts with `[STATE YYYY-MM-DD]` and lists what is done, what is next and what waits on the owner. + - Recall afterwards to confirm the note is found. + +### CORE contract +- MCP and memory ops are CORE. Every semantic change in Phase 2 needs the uteke-cloud contract issue (Appendix A) filed **before the first Phase 2 PR merges**. +- Phase 1 is additive or security-only. Note each Phase 1 PR in a comment on uteke-cloud#117. The owner posts it. + +### Benchmark invariants (do not change) +The LongMemEval/LoCoMo harness (`benchmarks/longmemeval/scripts/run_eval.py`) runs the **CLI local path**: `uteke import`, then `uteke recall "" --limit 50 --tags longmemeval --min 0.0 --namespace lmeval --json`. These must stay identical: +1. `import` hardcodes `type=fact`, importance 0.5, dedup at 0.95 and cosine auto-link. +2. A plain `recall` takes `recall_unified` (cap 50), then `recall_hybrid(2*limit, 0.0)`, then the doc RRF merge, then the post-merge `min_score`. +3. Default strategy `fusion`, weights 1.7/1.0, k=60, `boost_window = 4*limit+16`. +4. Salience and recency default to 0.1/0.1 when no flags are passed. +5. The JSON output keeps `metadata.session_id`. + +**Rule:** `decode` only validates and normalises input. It never changes which core retrieval function runs. If a task seems to need that, stop and report. + +**Gate:** any PR that touches `crates/uteke-cli/src/commands/recall.rs`, `import`, `main.rs` routing or `operations.rs` recall code needs LongMemEval fast50 with **0 regressed, strict 47/50, any@5 50/50** (compare with `compare_fast50.py`). `modal_fast50.py:48` pins `UTEKE_VERSION = "v0.15.0"`, so pin it to the PR build first (Task B4). + +--- + +## Roadmap + +| Phase | PR | Branch | When | Issue | +|---|---|---|---|---| +| 1 | A. Read-only gate for MCP tools over HTTP | `security/mcp-read-only-gate` | before 0.21.0 | #1439 | +| 1 | B. Hardening: no panics, no retry of permanent errors | `fix/embed-panics-and-retry` | before 0.21.0 | #1439 | +| 1 | B4. Benchmark harness: never route via a server; pinnable fast50 | `test/bench-harness-local-only` | before 0.21.0 | — | +| — | Release 0.21.0 (AGENTS.md "Release procedure") | | | | +| 2 | C. recall: one behaviour on HTTP, MCP and CLI | `refactor/unify-recall-1439` | after 0.21.0 | #1439 | +| 2 | D. list | `refactor/unify-list-1439` | after C | #1439 | +| 2 | E. remember | `refactor/unify-remember-1439` | after D | #1439 | +| 2 | F. forget | `refactor/unify-forget-1439` | after E | #1439 | +| 2 | G. Delete the `*Policy` structs; move inline tests out | `refactor/drop-surface-policies` | after F | #1439 | +| 3 | H. #1427 phase 2: `author` without a room (schema v22) | `feat/author-without-room-1427` | after 0.21.0 | #1427 | +| 3 | I. #1427 phase 3: `uteke namespace migrate` | `feat/namespace-migrate-1427` | after H | #1427 | +| 3 | J. #1427 phase 4: single-namespace mode | `feat/single-namespace-1427` | after I and the hook update | #1427 | +| 3 | K. #1427 phase 5: remove namespace CRUD | | one minor after J | #1427 | + +Phase 1 PRs are fully specified below. Phase 2 and 3 PRs are specified as tasks with acceptance tests. Expand each one into bite-sized steps (the same format as Phase 1) **when it starts**, because 0.21.0 and the earlier PRs move the code. + +--- + +## Phase 1, PR A: read-only gate for MCP tools over HTTP + +**Problem:** `POST /mcp` is registered `writes: true` (`crates/uteke-server/src/api_registry.rs:870-877`). +- A read-only token cannot call any MCP tool, not even recall. +- Once `/mcp` is opened, nothing inside MCP is gated: `handle_jsonrpc` (`crates/uteke-mcp/src/lib.rs:68`) has no notion of the caller. + +uteke-cloud refuses destructive tools for read-only keys. This PR does the same. + +**Design:** +- Add `Access { Full, ReadOnly }` and `handle_jsonrpc_as(uteke, raw, access)` in uteke-mcp. `handle_jsonrpc` keeps its signature and means `Full`, so stdio is unchanged. +- The allowlist is the MCP counterpart of the GET rows and non-writing POST rows in the registry. +- `/mcp` becomes `writes: false` and the per-tool gate does the work. +- Write tokens are unchanged. Read-only tokens gain read tools only. + +### Task A1: allowlist and access-aware dispatch in uteke-mcp + +**Files:** +- Modify: `crates/uteke-mcp/src/lib.rs:60-130` (`handle_jsonrpc`), `:132` (`handle_request`), `:195` (`tools/call` match) +- Test: a new `#[cfg(test)] mod read_only_gate_tests` at the end of `crates/uteke-mcp/src/lib.rs` + +**Step 1: Write the failing tests** + +```rust +#[cfg(test)] +mod read_only_gate_tests { + use super::*; + + fn store() -> Uteke { + Uteke::open_with_backend(":memory:", None).expect("open without embedder") + } + + fn call(u: &Uteke, access: Access, tool: &str, args: serde_json::Value) -> serde_json::Value { + let raw = serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": tool, "arguments": args} + }) + .to_string(); + let out = handle_jsonrpc_as(u, &raw, access).expect("request, not notification"); + serde_json::from_str(&out).unwrap() + } + + fn listed_tools(u: &Uteke) -> Vec { + let raw = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#; + let out: serde_json::Value = + serde_json::from_str(&handle_jsonrpc(u, raw).unwrap()).unwrap(); + out["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| t["name"].as_str().unwrap().to_string()) + .collect() + } + + #[test] + fn every_read_only_tool_exists() { + let u = store(); + let listed = listed_tools(&u); + for t in READ_ONLY_TOOLS { + // uteke_room_document is a dispatch alias of uteke_room_summary_document. + if *t == "uteke_room_document" { + continue; + } + assert!(listed.iter().any(|l| l == t), "{t} is not a listed tool"); + } + } + + #[test] + fn read_only_refuses_a_write_tool_and_writes_nothing() { + let u = store(); + let resp = call(&u, Access::ReadOnly, "uteke_remember", serde_json::json!({"content": "x"})); + assert_eq!(resp["result"]["isError"], true); + assert_eq!(resp["result"]["content"][0]["text"], "Read-only token cannot use uteke_remember"); + assert_eq!(u.count(None).unwrap(), 0); + } + + #[test] + fn read_only_allows_a_read_tool() { + let u = store(); + let resp = call(&u, Access::ReadOnly, "uteke_stats", serde_json::json!({})); + assert!(resp.get("error").is_none(), "{resp}"); + } + + #[test] + fn full_access_is_unchanged() { + let u = store(); + let resp = call(&u, Access::Full, "uteke_remember", serde_json::json!({"content": "x"})); + assert!(resp.get("error").is_none(), "{resp}"); + assert_eq!(u.count(None).unwrap(), 1); + } +} +``` + +**Step 2: Run the tests and watch them fail** + +Run: `cargo test -p uteke-mcp read_only_gate_tests` +Expected: compile error, `Access` / `handle_jsonrpc_as` / `READ_ONLY_TOOLS` not found. + +**Step 3: Implement** + +Add this above `handle_jsonrpc`: + +```rust +/// Who is calling (#1439). The HTTP bridge passes the token role; stdio is +/// always `Full` (the process belongs to the local caller). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + Full, + ReadOnly, +} + +/// Tools a read-only caller may use: the MCP counterparts of the GET rows and +/// the non-writing POST rows in uteke-server's `api_registry`. A new tool is +/// write-gated until it is added here on purpose. +pub const READ_ONLY_TOOLS: &[&str] = &[ + "uteke_recall", + "uteke_search", + "uteke_list", + "uteke_get", + "uteke_provenance", + "uteke_timeline", + "uteke_contradictions", + "uteke_stats", + "uteke_doc_get", + "uteke_doc_list", + "uteke_doc_search", + "uteke_doc_list_rooms", + "uteke_graph", + "uteke_room_list", + "uteke_room_recall", + "uteke_room_memories", + "uteke_room_stats", + "uteke_room_summary", + "uteke_room_summary_document", + "uteke_room_document", + "uteke_room_list_documents", + "uteke_tags_list", +]; +``` + +- Rename the body of `handle_jsonrpc` to `pub fn handle_jsonrpc_as(uteke: &Uteke, raw: &str, access: Access) -> Option`. +- Make `handle_jsonrpc` a one-line wrapper: `handle_jsonrpc_as(uteke, raw, Access::Full)`. Keep its doc comment. +- Thread `access` into `handle_request(uteke, &req.method, req.params, access)`. +- In `"tools/call"`, right after `tool_name` is read and before `let result = match tool_name`, add: + +```rust + if access == Access::ReadOnly && !READ_ONLY_TOOLS.contains(&tool_name) { + return Err(format!("Read-only token cannot use {tool_name}")); + } +``` + +Each tool in the list was checked against the registry. Two are flagged **write** in HTTP and are deliberately left off the list: `uteke_context` (`POST /context`) and `uteke_verify` (`POST /verify`). `uteke_room_document` was verified read-only: `exec_room_document` only calls `room_summary_document`. + +**Step 4: Run the tests and watch them pass** + +Run: `cargo test -p uteke-mcp` +Expected: PASS, including the existing tests. + +**Step 5: Commit** + +```bash +git add crates/uteke-mcp/src/lib.rs +git commit -m "security(mcp): per-tool read-only gate for callers without write access (#1439)" +``` + +### Task A2: the HTTP bridge passes the token role; `/mcp` opens to read-only tokens + +**Files:** +- Modify: `crates/uteke-server/src/handlers.rs:84-96` (gate), `:2016` (`handle_jsonrpc` call) +- Modify: `crates/uteke-server/src/api_registry.rs:870-877` (`/mcp` row), `:996-1025` (reviewed set) +- Test: `crates/uteke-server/src/handlers.rs`, a new `mod mcp_read_only_tests` next to the `/mcp` tests near `:4840` + +**Step 1: Write the failing test** + +```rust +#[cfg(test)] +mod mcp_read_only_tests { + use super::*; + use sha2::{Digest, Sha256}; + use tiny_http::TestRequest; + + fn ro_ctx() -> ReqCtx { + ReqCtx { + auth_token_hash: Some(Sha256::digest(b"rw-secret").into()), + read_only_token_hash: Some(Sha256::digest(b"ro-secret").into()), + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + } + } + + fn mcp(uteke: &Mutex, token: &str, tool: &str, args: &str) -> (u16, serde_json::Value) { + let body: &'static str = Box::leak( + format!( + r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"{tool}","arguments":{args}}}}}"# + ) + .into_boxed_str(), + ); + let auth: &'static str = Box::leak(format!("Bearer {token}").into_boxed_str()); + let mut req = TestRequest::new() + .with_method(Method::Post) + .with_path("/mcp") + .with_header(tiny_http::Header::from_bytes("Authorization", auth).unwrap()) + .with_body(body) + .into(); + let resp = route(uteke, &ro_ctx(), &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + (status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null)) + } + + fn store() -> Mutex { + Mutex::new(Uteke::open_with_backend(":memory:", None).unwrap()) + } + + #[test] + fn read_only_token_reaches_read_tools() { + let u = store(); + let (status, json) = mcp(&u, "ro-secret", "uteke_stats", "{}"); + assert_eq!(status, 200, "{json}"); + assert!(json.get("error").is_none(), "{json}"); + } + + #[test] + fn read_only_token_is_refused_write_tools() { + let u = store(); + let (status, json) = mcp(&u, "ro-secret", "uteke_remember", r#"{"content":"x"}"#); + assert_eq!(status, 200, "{json}"); + // As implemented: a tool error (isError), like uteke-cloud, not a JSON-RPC error. + assert_eq!(json["result"]["isError"], true, "{json}"); + assert_eq!(json["result"]["content"][0]["text"], "Read-only token cannot use uteke_remember"); + assert_eq!(u.lock().unwrap().count(None).unwrap(), 0); + } + + #[test] + fn write_token_is_unchanged() { + let u = store(); + let (status, json) = mcp(&u, "rw-secret", "uteke_remember", r#"{"content":"x"}"#); + assert_eq!(status, 200, "{json}"); + assert!(json.get("error").is_none(), "{json}"); + } +} +``` + +**Step 2: Run the test and watch it fail** + +Run: `cargo test -p uteke-server mcp_read_only_tests` +Expected: `read_only_token_reaches_read_tools` FAILS with 403 "Read-only token cannot perform write operations". + +**Step 3: Implement** +1. In `api_registry.rs`, set the `/mcp` row to `writes: false`. Add a comment: `// Per-tool gate inside uteke-mcp (READ_ONLY_TOOLS), #1439.` +2. In `read_only_post_set_is_the_reviewed_one`, add `"/mcp"` to `want`. +3. In `handlers.rs`, just before the read-only gate (line 86), add: + `let read_only = matches!(auth_role, AuthResult::Authenticated(ApiRole::ReadOnly));` + and change the gate to `if read_only {`. +4. In the `/mcp` arm, replace `uteke_mcp::handle_jsonrpc(&uteke, &body)` with: + +```rust + let access = if read_only { + uteke_mcp::Access::ReadOnly + } else { + uteke_mcp::Access::Full + }; + let mut resp = match uteke_mcp::handle_jsonrpc_as(&uteke, &body, access) { +``` + +**Step 4: Run the tests and watch them pass** + +Run: `cargo test -p uteke-server` +Expected: PASS. Check that `read_only_token_gets_403_on_exactly_the_writing_rows` still passes; it reads the same flag. + +**Step 5: Check the generated contract is unchanged** + +Run: `cargo run -p docgen && git diff --exit-code docs/` +Expected: no diff (`writes` is `#[serde(skip)]`). + +**Step 6: Commit** + +```bash +git add crates/uteke-server/src/handlers.rs crates/uteke-server/src/api_registry.rs +git commit -m "security(server): read-only tokens reach MCP read tools; writes stay refused (#1439)" +``` + +### Task A3: docs, changelog, PR +- `docs/mcp.md`: add a short "Read-only tokens" note listing what `READ_ONLY_TOOLS` covers. +- `CHANGELOG.md` `[Unreleased]` → `### Security`: one line. +- Open the PR into `develop`: `## What` / `## Why` (#1439, parity with uteke-cloud) / `## Testing` (commands above). Apply the merge gate. +- Draft a comment for uteke-cloud#117. It is additive: read-only keys gain read tools over `/mcp`, and write keys are unchanged. Show it to the owner and let them post it. + +--- + +## Phase 1, PR B: hardening (no behaviour change on success) + +### Task B1: `retry_embed` returns an error instead of panicking when there is no embedder + +**Files:** +- Modify: `crates/uteke-core/src/operations.rs:18-56` +- Test: `crates/uteke-core/src/operations.rs`, a new `#[cfg(test)] mod retry_embed_tests` + +**Step 1: Write the failing test** + +```rust +#[cfg(test)] +mod retry_embed_tests { + use super::*; + + #[test] + fn no_embedder_is_an_error_not_a_panic() { + let embedder: Mutex>> = Mutex::new(None); + let err = retry_embed(&embedder, "hello").unwrap_err(); + assert!(err.to_string().contains("No embedder"), "{err}"); + } +} +``` + +**Step 2: Run the test and watch it fail** + +Run: `cargo test -p uteke-core retry_embed_tests` +Expected: FAIL, panicked at "embedder ensured above". + +**Step 3: Implement.** Replace `let embedder = lock.as_ref().expect("embedder ensured above");` with: + +```rust + let Some(embedder) = lock.as_ref() else { + return Err(Error::embed_msg( + "No embedder configured; the memory was not embedded", + )); + }; +``` + +**Step 4: Run the test and watch it pass.** `cargo test -p uteke-core retry_embed_tests`, PASS. + +**Step 5: Commit** + +```bash +git commit -am "fix(core): retry_embed reports a missing embedder instead of panicking" +``` + +### Task B2: do not retry validation errors + +**Step 1: Write the failing test.** Add a test embedder whose `embed` counts calls and returns `Error::Validation(..)`. Assert that `retry_embed` returns after **1** call. Use `KeywordEmbedder` in `crates/uteke-core/src/test_support.rs` as the model for implementing the `Embedder` trait. Copy its trait methods and change only `embed`. + +**Step 2:** Run it and watch it fail (3 calls). + +**Step 3: Implement.** In the `Err(e)` arm, before the retry branch: + +```rust + Err(e @ Error::Validation(_)) => return Err(e), +``` + +**Step 4:** Run it and watch it pass. Then run `cargo test -p uteke-core` (all of it). + +**Step 5:** Commit: `fix(core): do not retry embedding validation errors`. + +**Scope note:** Embedding errors carry only a message (`Error::Embedding { message }`), so there is no safe way to tell a 4xx or dimension mismatch from a timeout. Do not parse message text. That classification belongs to the "embedder lifecycle" candidate (architecture review #5), which is out of scope here. + +### Task B3: the two remaining panics on recall paths +- `operations.rs:612`: `.expect("embedder ensured above")` in recall. Map `None` to `Error::embed_msg("No embedder configured; semantic recall is unavailable")`. The test opens with `open_with_backend(":memory:", None)`, calls the recall entry that reaches line 612, and asserts `Err`, not a panic. If `ensure_embedder` already errors first on that path, write the test anyway to pin the behaviour, then make the change. +- `operations.rs:1184`: `.expect("RRF score references memory that should exist")`. Change `.map` to `.filter_map`, skip ids missing from `memories`, and log `tracing::warn!`. This changes nothing when every id is present, which is the only case today, so it is benchmark-neutral by construction. It needs no new test beyond the existing recall tests passing. Note in the PR that it guards a delete-between-phases race. +- Commit each separately. The PR title is `fix(core): no panics on embedder-less or racing recall paths`. + +### Task B4 (separate PR): benchmark harness hygiene +- `benchmarks/longmemeval/scripts/run_eval.py:42`: next to `UTEKE_NO_UPDATE_CHECK=1`, make every subprocess run against a config with `[server] enabled = false`. The simplest way is a temp `UTEKE_HOME` with a `uteke.toml` that contains `[server]\nenabled = false`. Check the key name in `crates/uteke-cli/src/config.rs` first. This keeps a running server from silently moving the benchmark onto the HTTP path. +- `benchmarks/longmemeval/scripts/modal_fast50.py:48`: replace the hardcoded `UTEKE_VERSION = "v0.15.0"` with the same `UTEKE_RELEASE` / `UTEKE_GIT_REF` env override `modal_fanout.py` uses, so a PR build can be gated. +- Verify: run fast50 on current develop and record the baseline directory for Phase 2 comparisons. Expect any@5 50/50, strict 47/50. +- PR title: `test(bench): keep the harness on the local CLI path and make fast50 pinnable`. + +### Investigated and dropped +"MCP error text leaks internals" (`format!("Failed: {e}")`) does not hold. `Error`'s `Display` is already sanitized: `Error::db` and `Error::embed` strip rusqlite/ONNX detail and log it (`crates/uteke-core/src/error.rs:1-60`). There is no change, only the wording question, which belongs to Phase 2 (one error kind per failure). + +--- + +## Release 0.21.0 (between Phase 1 and Phase 2) +Follow AGENTS.md "Release procedure": +- The owner approves the version. +- Run the mutation gate on `git diff v0.20.1..develop -- crates/uteke-core`. +- Rehearse cosign, then check that `checksums-sha256.txt.bundle` exists after the tag. +- **Back up prod before the schema v21 migration.** +- Cora on a large diff may hit the 10-minute job limit. If so, raise `timeout-minutes` or split the PR. + +--- + +## Phase 2: one behaviour per operation (after 0.21.0) + +Each PR covers **one operation on all three surfaces**: HTTP, MCP, CLI via-server and CLI local. A surface is never left half-unified. Expand each into bite-sized TDD steps when it starts. + +**Pre-condition:** the Appendix A contract issue has been posted on uteke-cloud. + +**Common shape for C–F:** +1. Change the operation's `Policy::MCP` (and any HTTP field being changed) to the decided value. The existing side-by-side tests in `*_request.rs` flip from "pins the divergence" to "pins the one behaviour". Make those test diffs first (red), then the constants (green). +2. MCP: the `decode_*_args` function rejects unknown arguments with a message that lists the supported ones (cloud uses `-32602`; OSS returns its tool error). Build the known-key list from the tool's `inputSchema` properties, so the two cannot drift. +3. CLI local: `crates/uteke-cli/src/commands/.rs` builds the core `*Input`, calls `decode`, and passes the decoded request to the same core call it makes today. The via-server path (`crates/uteke-cli/src/commands/server.rs`) sends the same decoded request. **Decode never selects a different core function.** In particular `recall` keeps `recall_unified` and its flags. +4. CHANGELOG `### Behavior Change` per surface. + +### PR C: recall +Decided values: +- `limit` cap **100** everywhere; a CLI value above 100 is clamped with a stderr warning. +- A blank query is rejected. +- `tags: []` means no filter. +- MCP reads the `[recall]` config (`min_score`, strategy, `strict`). +- MCP honours entity/category/enrich/at/after/before. +- `explain` + `search_type` follow the HTTP rule. +- An invalid `limit` is an error. +- `full_ids` stays MCP-only and moves to MCP output formatting. + +Acceptance: +- The tests in `recall_request.rs:326-513` assert one behaviour. +- A new cross-surface test feeds the same JSON through the server's `RecallRequest::decode` path and `uteke_mcp::decode_recall_args` and gets equal `RecallRequest` values. +- **fast50: 0 regressed** (the harness passes `--limit 50`, below the cap). + +### PR D: list +- Default limit **20** everywhere (HTTP changes from 5); cap 100. +- Wrong-typed fields are an error. +- `at` and `include_meta` become available on MCP; entity/category are forwarded on the CLI server path. +- List is not on the benchmark path, so no fast50 is needed. + +### PR E: remember +- `type` omitted is **inferred** on every surface. The CLI stops defaulting `--type fact`, and its help text is fixed. +- Content and tags are validated at decode everywhere. +- Wrong-shaped tags are an error. +- The CLI stops sending keys `/remember` rejects (#1251). +- One metadata parser: delete `parse_meta_pairs` and keep `metadata_from_cli_flags` with `rel:` support. Check which one understands `rel:` first; the surviving parser must. +- `import` is **not touched** (benchmark invariant 1). + +### PR F: forget +- `--confirm` is honoured via-server. +- `--cold` / `--all` via-server return "unsupported" and fall back to local, instead of the misleading "Provide an ID…". +- The missing-target and unknown-prefix texts are unified. +- The existence check (#762) applies on every surface. +- The read-only gate is already done in PR A. + +### PR G: delete the `*Policy` structs +- Remove `RecallPolicy`, `ListPolicy`, `RememberPolicy` and `ForgetPolicy`. `decode` takes only the input. +- Move the inline test modules out of `handlers.rs`, `uteke-mcp/src/lib.rs`, `operations.rs` and `uteke-core/src/lib.rs` into sibling `tests_*.rs` modules. This is mechanical, with no production change. +- **Done when:** grep for `Policy::HTTP` / `Policy::MCP` returns nothing, and #1439 can close. + +--- + +## Phase 3: #1427 namespace deprecation (after 0.21.0) + +Contract: Amendment 2 (`kontrak-core-cloud/02-daftar-core`, "Namespace deprecated"). Cloud already implements it (uteke-cloud#152, contract 0.12.0). OSS converges on it. + +- **H, phase 2: attribution.** + - A `memories.author` column (nullable) in **schema v22**, a forward-only migration. + - `author` is accepted on remember with or without a room, and `source` is optional. + - Export and import carry `author`. + - Write a migration test from a v21 fixture. +- **I, phase 3: `uteke namespace migrate --map `.** + - Targets: `room:`, `tag:`, `author:`, `source:`, `drop`. + - Dry run by default, with a report. The real run exports first. +- **J, phase 4: single-namespace mode.** + - Every write goes to `default`; a non-default value becomes the tag `ns:`. + - The param is **accepted, not rejected**. Reads ignore it and carry a `warning`. + - `GET /namespaces` returns `["default"]`, and `rename`/`delete` return `namespace_deprecated`. + - Import maps namespace to an `ns:` tag; export reports `"default"`. + - This removes the namespace items of #1439. + - **Pre-condition:** the owner's own recall setup moves to tags: `~/.claude/CLAUDE.md` (recall `macmini` with tags), `~/.claude/hooks/uteke-recall.sh`, and the `uteke-conventions` doc. That change needs an explicit owner order. + - Prod has 42 namespaces, so run phase 3's migration on a copy first. +- **K, phase 5:** remove the namespace CRUD commands, at least one minor after J. + +--- + +## Appendix A: draft contract issue for uteke-cloud (owner posts, before PR C merges) + +> **Title:** contract notice: OSS menyatukan perilaku HTTP/MCP/CLI per operasi (uteke#1439), mengikuti semantik REST +> +> Merujuk `kontrak-core-cloud/01-aturan-perubahan` §5 dan uteke-cloud#117 (yang menahan uteke#1343 sampai ada jawaban). +> +> **Prinsip:** satu perilaku per operasi di uteke-core; HTTP, MCP dan CLI hanya berbeda transport. Sama dengan desain cloud (`docs/mcp-tools.md`: tool MCP dijalankan lewat router REST). +> +> **Perubahan (OSS, setelah v0.21.0, satu PR per operasi):** +> +> | Operasi | Perubahan | Surface yang berubah | +> |---|---|---| +> | recall | cap `limit` 100 (cloud: clamp 1..100); query kosong ditolak; `tags: []` = tanpa filter; `limit` invalid = error; MCP membaca config `[recall]` dan menghormati entity/category/at/after/before | MCP, CLI | +> | list | default `limit` 20 (cloud sama), cap 100; tipe field salah = error | HTTP (default 5→20, cap 1000→100), MCP | +> | remember | `type` kosong = inferensi di semua surface; validasi konten/tag di decode; tag salah bentuk = error | MCP, CLI | +> | forget | teks error disatukan; cek keberadaan id di semua surface | MCP, CLI | +> | MCP | argumen tak dikenal ditolak (cloud: `-32602`) | MCP | +> +> **Tidak termasuk:** semua butir namespace, karena diselesaikan oleh mode satu-namespace (uteke#1427 fase 4, sesuai Amandemen 2). +> +> **Tidak berubah:** route, method, skema, tier, `docs/core-contract.json`. +> +> **Sudah dikirim terpisah (aditif):** gate read-only per tool untuk `/mcp` (uteke PR A). +> +> **Yang diminta:** konfirmasi tidak ada butir yang bertentangan dengan cloud; keputusan apakah cap 100 menjadi bagian CORE (menjawab #117 butir 2). diff --git a/scripts/release-signature-reminder.sh b/scripts/release-signature-reminder.sh new file mode 100755 index 00000000..c39ff16c --- /dev/null +++ b/scripts/release-signature-reminder.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# Open (and re-ping) a "Sign release (minisign)" issue while the latest +# stable release has no checksums-sha256.txt.minisig, and close it once the +# signature is on the release. Signing itself stays offline +# (scripts/sign-release.sh); this script only watches and nags, holds no key +# and reads no secret beyond the GitHub token gh already uses. +# +# scripts/release-signature-reminder.sh [--dry-run] +# +# env: GH_REPO or GITHUB_REPOSITORY (owner/name), RELEASE_SIGNER (default +# ajianaz), REMINDER_AFTER_HOURS (default 12), REMINDER_NOW_EPOCH (tests). +# bash 3.2 compatible. +set -euo pipefail + +DRY=0 +for arg in "$@"; do + case "$arg" in + --dry-run) DRY=1 ;; + *) echo "usage: $0 [--dry-run]" >&2; exit 2 ;; + esac +done + +REPO="${GH_REPO:-${GITHUB_REPOSITORY:-}}" +[ -n "$REPO" ] || { echo "error: set GH_REPO or GITHUB_REPOSITORY" >&2; exit 2; } +SIGNER="${RELEASE_SIGNER:-ajianaz}" +HOURS="${REMINDER_AFTER_HOURS:-12}" +case "$HOURS" in ''|*[!0-9]*) echo "error: REMINDER_AFTER_HOURS must be an integer" >&2; exit 2 ;; esac +HOURS=$((10#$HOURS)) # "08" is a valid number here, not a bad octal +NOW="${REMINDER_NOW_EPOCH:-$(date +%s)}" +command -v gh >/dev/null 2>&1 || { echo "error: gh is required" >&2; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "error: jq is required" >&2; exit 2; } + +# Run a mutating gh command, or only print it under --dry-run. +mutate() { + # [^ ]+) \\(minisign\\)$").t)} + | select(.tag != $latest)] +| sort_by(.number) | .[0:20][] +| "\(.number) \(.tag)" +JQ +)" +STALE="$(printf '%s' "$ISSUES" | jq -r --arg latest "$TAG" "$STALE_FILTER")" +if [ -n "$STALE" ]; then + while read -r SNUM STAG; do + printf '%s' "$STAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || continue + ERR="$(mktemp)" + if OLD="$(gh api "repos/$REPO/releases/tags/$STAG" 2>"$ERR" /dev/null; then + MSG="Signature found on the release, closing." + else + MSG="Superseded: $TAG is now the latest release, and \`uteke upgrade\` only installs the latest release, so this release no longer needs signing. Closing." + fi + elif grep -q 'HTTP 404' "$ERR"; then + MSG="Superseded: $TAG is now the latest release, and \`uteke upgrade\` only installs the latest release, so this release no longer needs signing. Closing." + else + echo "Could not look up $STAG for reminder #$SNUM; leaving it open." + rm -f "$ERR"; continue + fi + rm -f "$ERR" + echo "Reminder #$SNUM is for $STAG, not the latest ($TAG); closing." + mutate issue close "$SNUM" --repo "$REPO" --comment "$MSG" + done </dev/null +} +if ! has_asset "checksums-sha256.txt.bundle"; then + echo "$TAG has no checksums-sha256.txt.bundle (not signable by sign-release.sh); nothing to do." + exit 0 +fi +SIGNED=0 +if has_asset "checksums-sha256.txt.minisig"; then SIGNED=1; fi + +TITLE="Sign release $TAG (minisign)" +# Exact title match in jq, lowest number first; empty when none. +EXISTING="$(printf '%s' "$ISSUES" | jq -c --arg t "$TITLE" \ + '[.[] | select(.title == $t)] | sort_by(.number) | .[0] // empty')" +NUM="" +if [ -n "$EXISTING" ]; then NUM="$(printf '%s' "$EXISTING" | jq -r '.number')"; fi + +if [ "$SIGNED" -eq 1 ]; then + if [ -n "$NUM" ]; then + echo "$TAG is signed; closing reminder #$NUM." + mutate issue close "$NUM" --repo "$REPO" --comment "Signature found on the release, closing." + else + echo "$TAG is signed and no reminder is open; nothing to do." + fi + exit 0 +fi + +if [ -z "$NUM" ]; then + BODY="Release $TAG has been published but its checksums are not signed yet. + +**Why:** \`uteke upgrade\` fails closed and refuses to install $TAG until \`checksums-sha256.txt.minisig\` is on the release (#1327). + +**What to do** (on the maintainer's machine; needs \`gh\`, \`cosign\` and \`minisign\`): + +\`\`\`bash +git pull +scripts/sign-release.sh $TAG +\`\`\` + +The script verifies the release's cosign bundle, shows the checksums, then asks for the minisign key password itself and for a confirmation before uploading. The key lives in the password manager with an offline backup (AGENTS.md, Release procedure, step 6); it is never in CI or GitHub secrets. + +This issue closes itself once \`checksums-sha256.txt.minisig\` is on the release. It is re-pinged at most every $HOURS hours until then." + echo "$TAG is unsigned and no reminder is open; creating \"$TITLE\" (assignee $SIGNER)." + if [ "$DRY" -eq 1 ]; then + echo "[dry-run] gh issue create --repo $REPO --title \"$TITLE\" --assignee $SIGNER" + printf '%s\n' "$BODY" | sed 's/^/[dry-run] /' + elif ! gh issue create --repo "$REPO" --title "$TITLE" --body "$BODY" --assignee "$SIGNER"; then + echo "Creating with assignee $SIGNER failed; checking whether the issue exists before retrying." + # A lost response may still have created it. + AGAIN="$(list_reminders | jq -c --arg t "$TITLE" '[.[] | select(.title == $t)] | length')" + if [ "${AGAIN:-0}" -gt 0 ]; then + echo "Reminder for $TAG already exists; not creating another." + exit 0 + fi + echo "Retrying without an assignee." + gh issue create --repo "$REPO" --title "$TITLE" --body "$BODY" + fi + exit 0 +fi + +# Last activity = issue creation or the newest TRUSTED comment (a stranger's +# comment must not silence the reminder). +LAST="$(printf '%s' "$EXISTING" | jq -r --arg s "$SIGNER" "$TRUST"' + ([.createdAt] + [.comments[]? | select(trusted) | .createdAt]) | map(fromdateiso8601) | max')" +AGE=$(( NOW - LAST )) +if [ "$AGE" -lt $(( HOURS * 3600 )) ]; then + echo "Reminder #$NUM is recent (last activity $(( AGE / 3600 ))h ago, interval ${HOURS}h); not commenting." + exit 0 +fi +PUB="$(printf '%s' "$REL" | jq -r '.published_at // .created_at // ""')" +UNSIGNED_H="?" +if [ -n "$PUB" ]; then + PUB_EPOCH="$(printf '%s' "$PUB" | jq -R 'fromdateiso8601' 2>/dev/null || true)" + [ -n "$PUB_EPOCH" ] && UNSIGNED_H=$(( (NOW - PUB_EPOCH) / 3600 )) +fi +echo "Reminder #$NUM is stale; pinging @$SIGNER." +mutate issue comment "$NUM" --repo "$REPO" --body "@$SIGNER release $TAG has been unsigned for about ${UNSIGNED_H} hours, so \`uteke upgrade\` still refuses it. Run \`git pull\` then \`scripts/sign-release.sh $TAG\`. This issue closes itself once the \`.minisig\` is on the release." diff --git a/scripts/sign-release.sh b/scripts/sign-release.sh new file mode 100755 index 00000000..f8cc982f --- /dev/null +++ b/scripts/sign-release.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Sign the checksums of a published uteke release with the offline minisign key +# and upload the signature (#1327). Run on the maintainer's machine, never in CI. +# +# scripts/sign-release.sh [--dry-run] [--force] vX.Y.Z +# +# Requires gh, minisign and cosign (the cosign bundle is verified before signing). +# +# Environment: +# UTEKE_MINISIGN_KEY secret key path (default: $HOME/.minisign/uteke-release.key) +# +# minisign prompts for the key password itself; this script never reads, +# prints or stores it. +set -euo pipefail + +usage() { + echo "usage: $0 [--dry-run] [--force] (e.g. v0.22.0)" >&2 + exit 2 +} + +DRY_RUN=0 +FORCE=0 +TAG="" +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --force) FORCE=1 ;; + -h | --help) usage ;; + -*) echo "unknown option: $arg" >&2; usage ;; + *) [ -z "$TAG" ] || usage; TAG="$arg" ;; + esac +done +[ -n "$TAG" ] || usage +if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then + echo "error: '$TAG' is not a release tag: it must start with 'v' and look like vX.Y.Z (e.g. v0.22.0)" >&2 + exit 2 +fi + +for tool in gh minisign cosign; do + command -v "$tool" >/dev/null 2>&1 || { echo "error: '$tool' is required" >&2; exit 1; } +done + +REPO="codecoradev/uteke" +KEY="${UTEKE_MINISIGN_KEY:-$HOME/.minisign/uteke-release.key}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PUB="$SCRIPT_DIR/uteke-release.pub" +SIG_NAME="checksums-sha256.txt.minisig" +BUNDLE_NAME="checksums-sha256.txt.bundle" + +[ -f "$KEY" ] || { echo "error: secret key not found: $KEY (set UTEKE_MINISIGN_KEY)" >&2; exit 1; } +[ -f "$PUB" ] || { echo "error: public key not found: $PUB" >&2; exit 1; } + +ASSETS="$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name')" +if grep -qx "$SIG_NAME" <<<"$ASSETS"; then + if [ "$FORCE" -ne 1 ]; then + echo "error: $TAG already has $SIG_NAME; use --force to replace it" >&2 + exit 1 + fi + echo "warning: $TAG already has $SIG_NAME; it will be replaced (--force)" >&2 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +echo "==> Downloading checksums-sha256.txt for $TAG" +gh release download "$TAG" --repo "$REPO" --pattern checksums-sha256.txt --dir "$WORK" + +# Never sign blind: prove the file is what the Release workflow produced. +echo "==> Verifying the cosign bundle (the file must come from the Release workflow)" +if ! grep -qx "$BUNDLE_NAME" <<<"$ASSETS"; then + echo "error: $TAG has no $BUNDLE_NAME; refusing to sign a file that cannot be traced to the Release workflow" >&2 + exit 1 +fi +gh release download "$TAG" --repo "$REPO" --pattern "$BUNDLE_NAME" --dir "$WORK" +IDENTITY="https://github.com/$REPO/.github/workflows/release.yml@refs/tags/$TAG" +if ! cosign verify-blob \ + --bundle "$WORK/$BUNDLE_NAME" \ + --certificate-identity "$IDENTITY" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "$WORK/checksums-sha256.txt"; then + echo "error: cosign verification failed. Signing is refused: the minisign signature must only cover a checksums file proven (via the cosign bundle) to be what the Release workflow produced." >&2 + exit 1 +fi + +echo +echo "---- checksums-sha256.txt ----" +cat "$WORK/checksums-sha256.txt" +echo "------------------------------" +echo "Verified cosign identity: $IDENTITY" +printf 'Sign exactly this file as "uteke %s"? [y/N] ' "$TAG" +read -r answer +case "$answer" in + y | Y | yes | YES) ;; + *) echo "aborted"; exit 1 ;; +esac + +echo "==> Signing (minisign will ask for the key password)" +minisign -S -s "$KEY" -m "$WORK/checksums-sha256.txt" -t "uteke $TAG" -x "$WORK/$SIG_NAME" + +echo "==> Verifying the fresh signature with $PUB" +minisign -V -p "$PUB" -m "$WORK/checksums-sha256.txt" -x "$WORK/$SIG_NAME" +echo "Trusted comment: $(sed -n 3p "$WORK/$SIG_NAME" | sed 's/^trusted comment: //')" + +if [ "$DRY_RUN" -eq 1 ]; then + echo "==> --dry-run: stopping before upload" + exit 0 +fi + +printf 'Upload %s to release %s of %s? [y/N] ' "$SIG_NAME" "$TAG" "$REPO" +read -r answer +case "$answer" in + y | Y | yes | YES) ;; + *) echo "aborted"; exit 1 ;; +esac + +UPLOAD_ARGS=() +if [ "$FORCE" -eq 1 ]; then UPLOAD_ARGS+=(--clobber); fi +gh release upload "$TAG" "$WORK/$SIG_NAME" --repo "$REPO" ${UPLOAD_ARGS[@]+"${UPLOAD_ARGS[@]}"} +echo "==> Uploaded. Check: gh release view $TAG --repo $REPO --json assets --jq '.assets[].name'" diff --git a/scripts/tests/release-signature-reminder.test.sh b/scripts/tests/release-signature-reminder.test.sh new file mode 100644 index 00000000..c143b27a --- /dev/null +++ b/scripts/tests/release-signature-reminder.test.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# Tests for scripts/release-signature-reminder.sh with a stub gh on PATH. +# Never touches the network. +# bash scripts/tests/release-signature-reminder.test.sh +# bash 3.2 compatible. +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$HERE/../release-signature-reminder.sh" +command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not installed"; exit 0; } + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/tags" + +# Stub: api -> $STUB_RELEASE, issue list -> $STUB_ISSUES, everything else is +# a mutation and is logged one line per call. +cat > "$T/bin/gh" <<'STUB' +#!/usr/bin/env bash +case "$1" in + api) + case "$2" in + */releases/latest) printf '%s' "$STUB_RELEASE" ;; + */releases/tags/*) + f="$STUB_TAGDIR/${2##*/}.json" + if [ -n "${STUB_API_ERR-}" ]; then echo "$STUB_API_ERR" >&2; exit 1; fi + if [ -f "$f" ]; then cat "$f"; else echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi ;; + esac ;; + issue) + case "$2" in + list) + case "$*" in *--search*) echo "SEARCH_USED $*" >> "$STUB_LOG"; exit 1 ;; esac + n=$(( $(cat "$STUB_LOG.n" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$STUB_LOG.n" + if [ "$n" -gt 1 ] && [ -n "${STUB_ISSUES2-}" ]; then printf '%s' "$STUB_ISSUES2"; else printf '%s' "$STUB_ISSUES"; fi ;; + create) + echo "CREATE $*" >> "$STUB_LOG" + if [ -n "${STUB_FAIL_ASSIGN-}" ]; then + case "$*" in *--assignee*) exit 1 ;; esac + fi ;; + *) echo "$(echo "$2" | tr a-z A-Z) $*" >> "$STUB_LOG" ;; + esac ;; + *) echo "OTHER $*" >> "$STUB_LOG" ;; +esac +STUB +chmod +x "$T/bin/gh" + +NOW=1800000000 # fixed clock +iso() { date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } +H=3600 + +rel() { # tag assets-json [prerelease] [draft] + printf '{"tag_name":"%s","prerelease":%s,"draft":%s,"published_at":"%s","assets":%s}' \ + "$1" "${3:-false}" "${4:-false}" "$(iso $((NOW - 30*H)))" "$2" +} +A_BUNDLE='[{"name":"checksums-sha256.txt"},{"name":"checksums-sha256.txt.bundle"}]' +A_SIGNED='[{"name":"checksums-sha256.txt"},{"name":"checksums-sha256.txt.bundle"},{"name":"checksums-sha256.txt.minisig"}]' +A_NOBUNDLE='[{"name":"checksums-sha256.txt"}]' +TITLE="Sign release v1.2.3 (minisign)" +# author JSON in gh's real shape; "login[:false]" forces is_bot=false +author() { + local l="${1%%:*}" b=false + case "$1" in *:false) ;; app/github-actions|github-actions\[bot\]) b=true ;; esac + printf '{"id":"X","is_bot":%s,"login":"%s","name":"n"}' "$b" "$l" +} +AUTH="\"author\":$(author app/github-actions)" +issue() { # age-hours-of-creation [comment-age-hours] + local c='[]' + [ -n "${2-}" ] && c="[{\"author\":$(author "${3:-app/github-actions}"),\"body\":\"b\",\"createdAt\":\"$(iso $((NOW - $2*H)))\"}]" + printf '[{"number":7,"title":"%s","createdAt":"%s","comments":%s,%s},{"number":8,"title":"%s extra","createdAt":"%s","comments":[],%s}]' \ + "$TITLE" "$(iso $((NOW - $1*H)))" "$c" "$AUTH" "$TITLE" "$(iso $((NOW - 99*H)))" "$AUTH" +} + +FAILS=0 +oldissue() { # number tag [title-override] [author-login] [age-hours] + printf '{"number":%s,"title":"%s","createdAt":"%s","comments":[],"author":%s}' "$1" "${3:-Sign release $2 (minisign)}" "$(iso $((NOW - ${5:-50}*H)))" "$(author "${4:-app/github-actions}")" +} +# run args... ; sets RC, OUT, and $T/log +run() { + local r="$1" i="$2"; shift 2 + : > "$T/log"; rm -f "$T/log.n" + OUT="$(env PATH="$T/bin:$PATH" STUB_LOG="$T/log" STUB_TAGDIR="$T/tags" STUB_RELEASE="$r" STUB_ISSUES="$i" \ + GH_REPO=o/r RELEASE_SIGNER=ajianaz REMINDER_NOW_EPOCH=$NOW \ + STUB_ISSUES2="${STUB_ISSUES2-}" STUB_API_ERR="${STUB_API_ERR-}" \ + STUB_FAIL_ASSIGN="${STUB_FAIL_ASSIGN-}" bash "$SCRIPT" "$@" 2>&1)" + RC=$? +} +check() { # name 0|1 + if [ "$2" = 0 ]; then echo "ok - $1"; else echo "FAIL - $1 (rc=$RC)"; echo "$OUT" | sed 's/^/ /'; echo " log:"; sed 's/^/ /' "$T/log"; FAILS=$((FAILS+1)); fi +} +rc_is() { [ "$RC" -eq "$1" ]; } +nlog() { grep -c -e "$1" "$T/log" || true; } +no_mutation() { [ ! -s "$T/log" ]; } +t() { local n="$1"; shift; if "$@"; then check "$n" 0; else check "$n" 1; fi; } + +run "$(rel v1.2.3 "$A_SIGNED")" "$(issue 5)" +t "signed + open reminder -> exit 0" rc_is 0 +grep -q '^CLOSE issue close 7 .*--comment Signature found on the release, closing' "$T/log"; check "signed -> single close call carries the comment" $? +t "signed + open reminder -> no separate comment call" test "$(nlog '^COMMENT ')" = 0 +t "signed + open reminder -> closes #7 only" test "$(nlog '^CLOSE ')" = 1 +grep -q '^CLOSE issue close 7 ' "$T/log"; check "signed -> closed the exact-title issue, not #8" $? + +run "$(rel v1.2.3 "$A_SIGNED")" '[]' +t "signed + no reminder -> nothing mutating" no_mutation + +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' +t "unsigned + no reminder -> exit 0" rc_is 0 +t "unsigned + no reminder -> one create" test "$(nlog '^CREATE ')" = 1 +grep -qF -- "--title $TITLE " "$T/log"; check "create has the exact title" $? +grep -qF 'scripts/sign-release.sh v1.2.3' "$T/log"; check "create body has the sign command" $? +grep -qF -- '--assignee ajianaz' "$T/log"; check "create assigns the signer" $? + +STUB_FAIL_ASSIGN=1 run "$(rel v1.2.3 "$A_BUNDLE")" '[]' +t "assign fails -> still exit 0" rc_is 0 +t "assign fails -> retried without assignee" test "$(nlog '^CREATE ')" = 2 +t "assign fails -> second create has no --assignee" test "$(nlog "--assignee")" = 1 + +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 30 3)" +t "unsigned + reminder younger than interval (recent comment) -> no mutation" no_mutation +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 11)" +t "unsigned + reminder 11h old -> no mutation" no_mutation + +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 13)" +t "unsigned + reminder 13h old -> exit 0" rc_is 0 +t "unsigned + older reminder -> exactly one comment, nothing else" test "$(nlog '')" = 1 +grep -q '^COMMENT issue comment 7 .*@ajianaz' "$T/log"; check "comment mentions the signer" $? +grep -q 'about 30 hours' "$T/log"; check "comment says how long it was unsigned" $? + +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 40 13)" +t "old issue but comment 13h ago -> comments again" test "$(nlog '^COMMENT ')" = 1 +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 40 2)" +t "old issue but comment 2h ago -> no spam" no_mutation + +run "$(rel v1.2.3 "$A_NOBUNDLE")" '[]' +t "no bundle -> exit 0 and nothing mutating" no_mutation +t "no bundle -> exit code 0" rc_is 0 + +for bad in 'v1.2.3; rm -rf' 'v1.2.3-rc.1' '1.2.3' 'v1.2' 'v1.2.3+b1'; do + run "$(rel "$bad" "$A_BUNDLE")" '[]' + t "bad tag '$bad' -> exit 0, nothing mutating" sh -c "[ $RC -eq 0 ] && [ ! -s '$T/log' ]" +done + +run "$(rel v1.2.3 "$A_BUNDLE" true)" '[]' +t "prerelease latest -> exit 0, nothing mutating" sh -c "[ $RC -eq 0 ] && [ ! -s '$T/log' ]" +run "$(rel v1.2.3 "$A_BUNDLE" false true)" '[]' +t "draft latest -> exit 0, nothing mutating" sh -c "[ $RC -eq 0 ] && [ ! -s '$T/log' ]" + +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --dry-run +t "--dry-run unsigned + no reminder -> no mutating call" no_mutation +echo "$OUT" | grep -q 'dry-run'; check "--dry-run prints the intended action" $? +run "$(rel v1.2.3 "$A_BUNDLE")" "$(issue 13)" --dry-run +t "--dry-run stale reminder -> no mutating call" no_mutation +run "$(rel v1.2.3 "$A_SIGNED")" "$(issue 5)" --dry-run +t "--dry-run signed + reminder -> no mutating call" no_mutation +echo "$OUT" | grep -q 'dry-run\] gh issue close 7'; check "--dry-run prints the close" $? + +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --bogus +t "unknown flag -> exit 2" rc_is 2 +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --dry-run --bogus +t "extra unknown flag after --dry-run -> exit 2, nothing mutating" sh -c "[ $RC -eq 2 ] && [ ! -s '$T/log' ]" +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --bogus --dry-run +t "unknown flag before --dry-run -> exit 2" rc_is 2 +run "$(rel v1.2.3 "$A_BUNDLE")" '[]' --dry-run --dry-run +t "repeated --dry-run is harmless" no_mutation + +# --- stale pass: reminders for superseded releases --- +rm -f "$T/tags/"* +echo "{\"assets\":$A_SIGNED}" > "$T/tags/v1.0.0.json" +echo "{\"assets\":$A_BUNDLE}" > "$T/tags/v1.1.0.json" +LATEST="$(rel v1.2.3 "$A_SIGNED")" + +run "$LATEST" "[$(oldissue 3 v1.0.0)]" +t "old reminder, signed old release -> exit 0" rc_is 0 +grep -q '^CLOSE issue close 3 .*Signature found on the release, closing' "$T/log"; check "old signed -> Signature found comment" $? +grep -q '^CLOSE issue close 3 ' "$T/log"; check "old signed -> closed" $? + +run "$LATEST" "[$(oldissue 4 v1.1.0)]" +grep -q '^CLOSE issue close 4 .*Superseded: v1.2.3 is now the latest release' "$T/log"; check "old unsigned -> superseded comment" $? +grep -q '^CLOSE issue close 4 ' "$T/log"; check "old unsigned -> closed" $? + +run "$LATEST" "[$(oldissue 7 v1.2.3 "")]" +t "latest-tag reminder (signed latest) handled only by main logic: one close" test "$(nlog '^CLOSE ')" = 1 +grep -q 'Signature found' "$T/log"; check "latest-tag reminder gets the normal close, not superseded" $? +run "$(rel v1.2.3 "$A_BUNDLE")" "[$(oldissue 7 v1.2.3 "" app/github-actions 2)]" +t "latest-tag unsigned young reminder untouched by stale pass" no_mutation + +run "$LATEST" "[$(oldissue 5 v1.0.0 'Sign release v1.0.0 (minisign) please'),$(oldissue 6 v1.0.0 'Sign release v1.0.0-rc.1 (minisign)'),$(oldissue 9 x 'Unrelated issue')]" +t "non-matching titles never touched" no_mutation + +run "$LATEST" "[$(oldissue 3 v1.0.0),$(oldissue 4 v1.1.0)]" +t "two stale issues -> two closes" test "$(nlog '^CLOSE ')" = 2 +t "two stale issues -> comments ride on the closes" test "$(nlog '^COMMENT ')" = 0 + +run "$LATEST" "[$(oldissue 8 v0.9.0)]" +grep -q '^CLOSE issue close 8 .*Superseded' "$T/log"; check "404 on old tag -> superseded" $? +grep -q '^CLOSE issue close 8 ' "$T/log"; check "404 on old tag -> closed" $? + +run "$LATEST" "[$(oldissue 3 v1.0.0)]" --dry-run +t "--dry-run stale pass -> no mutating call" no_mutation + +# --- trust boundary: issue authors --- +BUN="$(rel v1.2.3 "$A_BUNDLE")" +run "$BUN" "[$(oldissue 20 v1.2.3 "" stranger 2)]" +t "stranger look-alike ignored, genuine issue still created" test "$(nlog '^CREATE ')" = 1 +t "stranger look-alike never commented or closed" test "$(nlog '^COMMENT \|^CLOSE ')" = 0 +run "$LATEST" "[$(oldissue 21 v1.2.3 "" stranger 2),$(oldissue 22 v1.0.0 "" stranger)]" +t "stranger look-alikes (latest and stale) never touched when signed" no_mutation +run "$BUN" "[$(oldissue 23 v1.2.3 "" 'github-actions[bot]' 13)]" +grep -q '^COMMENT issue comment 23 ' "$T/log"; check "github-actions[bot] issue is acted on" $? +run "$BUN" "[$(oldissue 24 v1.2.3 "" ajianaz 13)]" +grep -q '^COMMENT issue comment 24 ' "$T/log"; check "RELEASE_SIGNER issue is acted on" $? +run "$LATEST" "[$(oldissue 25 v1.0.0 "" ajianaz)]" +grep -q '^CLOSE issue close 25 ' "$T/log"; check "signer-authored stale issue closed" $? +run "$LATEST" "[$(oldissue 26 v1.0.0 "" stranger)]" +t "stranger stale look-alike not closed" no_mutation + +# --- review hardening --- +# listing never uses --search (stub fails the run if it does) +run "$BUN" '[]' +t "listing does not use --search" test "$(nlog SEARCH_USED)" = 0 + +# stranger comment does not reset activity; trusted comment does +run "$BUN" "[$(issue 40 1 stranger | sed 's/^\[//; s/,{"number":8.*$//')]" +t "stranger comment 1h ago + old issue -> still re-pings" test "$(nlog '^COMMENT issue comment 7 ')" = 1 +run "$BUN" "[$(issue 40 1 | sed 's/^\[//; s/,{"number":8.*$//')]" +t "trusted comment 1h ago -> no re-ping" no_mutation +run "$BUN" "[$(issue 40 1 'github-actions:false' | sed 's/^\[//; s/,{"number":8.*$//')]" +t "human named github-actions (is_bot false) comment not trusted -> re-pings" test "$(nlog '^COMMENT issue comment 7 ')" = 1 +run "$BUN" "[$(oldissue 30 v1.2.3 "" 'github-actions:false' 2)]" +t "human 'github-actions' (is_bot false) issue not trusted -> genuine created" test "$(nlog '^CREATE ')" = 1 + +# HOURS=08 +: > "$T/log" +OUT="$(env PATH="$T/bin:$PATH" STUB_LOG="$T/log" STUB_TAGDIR="$T/tags" STUB_RELEASE="$BUN" STUB_ISSUES="$(issue 10 | sed 's/^\[//; s/,{"number":8.*$//; s/^/[/; s/$/]/')" GH_REPO=o/r REMINDER_NOW_EPOCH=$NOW REMINDER_AFTER_HOURS=08 bash "$SCRIPT" 2>&1)"; RC=$? +t "REMINDER_AFTER_HOURS=08 accepted, 10h old -> re-pings" test "$RC" = 0 -a "$(nlog '^COMMENT ')" = 1 +OUT="$(env PATH="$T/bin:$PATH" REMINDER_AFTER_HOURS=abc GH_REPO=o/r bash "$SCRIPT" 2>&1)"; RC=$? +t "non-numeric hours -> exit 2" rc_is 2 + +# 404 parsing +STUB_API_ERR="gh: Internal error mentions 404 (HTTP 502)" run "$LATEST" "[$(oldissue 3 v1.0.0)]" +t "5xx whose text contains 404 -> issue left open" no_mutation +STUB_API_ERR="gh: Server Error (HTTP 500)" run "$LATEST" "[$(oldissue 3 v1.0.0)]" +t "HTTP 500 on old tag -> issue left open" no_mutation +STUB_API_ERR="gh: API rate limit exceeded (HTTP 403)" run "$LATEST" "[$(oldissue 3 v1.0.0)]" +t "rate limit on old tag -> issue left open" no_mutation + +# assign failure: re-list before retrying +STUB_FAIL_ASSIGN=1 STUB_ISSUES2="[$(oldissue 30 v1.2.3 "" app/github-actions 1)]" run "$BUN" '[]' +t "assign fails but issue now exists -> no second create" test "$(nlog '^CREATE ')" = 1 + +if [ "$FAILS" -ne 0 ]; then echo "$FAILS failure(s)"; exit 1; fi +echo "all passed" diff --git a/scripts/tests/sign-release.test.sh b/scripts/tests/sign-release.test.sh new file mode 100644 index 00000000..598e5878 --- /dev/null +++ b/scripts/tests/sign-release.test.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Tests for scripts/sign-release.sh using stub gh/cosign on PATH and a +# throwaway minisign key. Never touches a real key or the network. +# bash scripts/tests/sign-release.test.sh +# bash 3.2 compatible. +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_SRC="$HERE/../sign-release.sh" +command -v minisign >/dev/null 2>&1 || { echo "SKIP: minisign not installed"; exit 0; } + +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/bin" "$T/nocosign" "$T/sc" +cp "$SCRIPT_SRC" "$T/sc/sign-release.sh" +minisign -G -W -p "$T/sc/uteke-release.pub" -s "$T/test.key" -f >/dev/null + +cat > "$T/bin/gh" <<'STUB' +#!/usr/bin/env bash +case "$2" in + view) for a in ${STUB_ASSETS-}; do echo "$a"; done ;; + download) + while [ $# -gt 0 ]; do + case "$1" in --pattern) p="$2" ;; --dir) d="$2" ;; esac + shift + done + echo "abc file.tar.gz" > "$d/checksums-sha256.txt" + [ "$p" = "checksums-sha256.txt" ] || echo bundle > "$d/$p" + ;; + upload) echo "UPLOAD $*" >> "$STUB_LOG" ;; +esac +STUB +cat > "$T/bin/cosign" <<'STUB' +#!/usr/bin/env bash +exit "${STUB_COSIGN_RC:-0}" +STUB +# minisign wrapper that records every signing call +REAL_MINISIGN="$(command -v minisign)" +cat > "$T/bin/minisign" <> "\$STUB_LOG" ;; esac +exec "$REAL_MINISIGN" "\$@" +STUB +chmod +x "$T/bin/"* + +# Minimal PATH without cosign: symlink the tools the script needs. +for tool in bash env cat grep sed mktemp rm dirname cd; do + p="$(command -v "$tool" 2>/dev/null || true)" + [ -n "$p" ] && [ -x "$p" ] && ln -sf "$p" "$T/nocosign/$tool" +done +cp "$T/bin/gh" "$T/bin/minisign" "$T/nocosign/" + +FAILS=0 +ASSETS_OK="checksums-sha256.txt checksums-sha256.txt.bundle" + +# run [cosign_rc] -- args... ; sets RC, OUT +run() { + local input="$1" pathdir="$2" assets="$3" crc="$4" + shift 4 + : > "$T/log" + OUT="$(printf '%s\n' "$input" | env PATH="$pathdir:/usr/bin:/bin" STUB_LOG="$T/log" \ + STUB_ASSETS="$assets" STUB_COSIGN_RC="$crc" UTEKE_MINISIGN_KEY="$T/test.key" \ + bash "$T/sc/sign-release.sh" "$@" 2>&1)" + RC=$? +} +expect() { # name expected_rc + if [ "$RC" -eq "$2" ]; then echo "ok - $1"; else echo "FAIL - $1 (rc=$RC, want $2)"; echo "$OUT" | sed 's/^/ /'; FAILS=$((FAILS+1)); fi +} +log_has() { grep -q "$1" "$T/log"; } +check() { # name condition-result + if [ "$2" = 0 ]; then echo "ok - $1"; else echo "FAIL - $1"; echo "$OUT" | sed 's/^/ /'; FAILS=$((FAILS+1)); fi +} + +run y "$T/bin" "$ASSETS_OK" 1 --dry-run v1.2.3 +expect "cosign fails -> exit 1" 1 +log_has SIGN; check "cosign fails -> minisign never invoked" $((log_has_rc=$? == 0 ? 1 : 0)) + +run y "$T/bin" "checksums-sha256.txt" 0 --dry-run v1.2.3 +expect "no bundle asset -> exit 1" 1 + +run y "$T/nocosign" "$ASSETS_OK" 0 --dry-run v1.2.3 +expect "cosign missing -> exit 1" 1 + +run n "$T/bin" "$ASSETS_OK" 0 --dry-run v1.2.3 +expect "answer n -> aborts" 1 +log_has SIGN; check "answer n -> minisign never invoked" $((log_has_rc=$? == 0 ? 1 : 0)) + +run y "$T/bin" "$ASSETS_OK checksums-sha256.txt.minisig" 0 --dry-run v1.2.3 +expect "existing .minisig without --force -> refuses" 1 + +run y "$T/bin" "$ASSETS_OK" 0 --dry-run 1.2.3 +expect "bad tag (no v) -> exit 2" 2 + +run y "$T/bin" "$ASSETS_OK" 0 --no-cosign --dry-run v1.2.3 +expect "unknown flag -> exit 2" 2 + +run y "$T/bin" "$ASSETS_OK" 0 --dry-run v1.2.3 +expect "happy path --dry-run -> exit 0" 0 +log_has SIGN; check "happy path signs" $? +echo "$OUT" | grep -q "Trusted comment: uteke v1.2.3"; check "happy path verifies trusted comment" $? +log_has UPLOAD; check "happy path does not upload" $((log_has_rc=$? == 0 ? 1 : 0)) + +if [ "$FAILS" -ne 0 ]; then echo "$FAILS failure(s)"; exit 1; fi +echo "all passed" diff --git a/scripts/uteke-release.pub b/scripts/uteke-release.pub new file mode 100644 index 00000000..86613386 --- /dev/null +++ b/scripts/uteke-release.pub @@ -0,0 +1,2 @@ +untrusted comment: minisign public key 96924B76E31B048B +RWSLBBvjdkuSlvaQ/yoyoMlpQAOLTt5jACZGTgSOCKb+/1JoqhjOmT8G