diff --git a/.github/workflows/marreq-ci.yml b/.github/workflows/marreq-ci.yml index ffe282f4..00e27265 100644 --- a/.github/workflows/marreq-ci.yml +++ b/.github/workflows/marreq-ci.yml @@ -14,6 +14,36 @@ concurrency: cancel-in-progress: true jobs: + api-contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: actions/setup-node@v7 + with: + node-version: '22' + cache: npm + - name: Install pinned standards tooling + run: scripts/install-oasdiff.sh + - name: Test native compatibility semantics and CLI failures + run: cargo test --locked -p marreq-contracts + - name: Install TypeScript tooling and consumers + run: | + npm ci + npm ci --prefix frontend + npm ci --prefix mcp-server + - name: Check generated consumer schemas + run: node scripts/generate-api-types.mjs --check + - name: Verify compiled Server/Cloud operations + run: cargo test --locked -p marreq-cloud --test api_contract_inventory + - name: Exercise isolated real Server/Cloud and HTTP consumers + run: scripts/test-api-contracts.sh + - name: Require verified contracts for every current consumer operation + run: cargo run --locked -p marreq-contracts -- validate + lint: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 8f9af2bc..c9bff04c 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -12,7 +12,63 @@ permissions: packages: write jobs: + compatibility: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Install pinned OpenAPI standards engine + run: scripts/install-oasdiff.sh + - name: Test contract semantics and validate producer document + run: | + cargo test --locked -p marreq-contracts + cargo run --locked -p marreq-contracts -- validate --document-only + cargo test --locked -p marreq-cloud --test api_contract_inventory + - name: Require immutable deployment-plan revision + id: plan + env: + PLAN_SHA: ${{ vars.MARREQ_RELEASE_CONTRACTS_SHA }} + run: | + if [[ ! "$PLAN_SHA" =~ ^[a-f0-9]{40}$ ]]; then + echo "Set MARREQ_RELEASE_CONTRACTS_SHA to an immutable source revision containing deployment.json and exact retained contract bundles." >&2 + exit 1 + fi + echo "sha=$PLAN_SHA" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v7 + with: + ref: ${{ steps.plan.outputs.sha }} + path: .release-contracts + - name: Verify exact candidate and unchanged deployment artifacts + env: + CANDIDATE_TAG: ${{ github.ref_name }} + CANDIDATE_SHA: ${{ github.sha }} + run: | + case "$CANDIDATE_TAG" in + marreq-server-v*) component=backend; version=${CANDIDATE_TAG#marreq-server-v} ;; + marreq-frontend-v*) component=frontend; version=${CANDIDATE_TAG#marreq-frontend-v} ;; + marreq-mcp-v*) component=mcp; version=${CANDIDATE_TAG#marreq-mcp-v} ;; + *) echo "unsupported component tag" >&2; exit 1 ;; + esac + if [[ "$component" == backend ]]; then + cargo run --locked -p marreq-contracts -- bundle --component backend --variant server --version "$version" --output /tmp/candidate-contract.json + else + cargo run --locked -p marreq-contracts -- bundle --component "$component" --version "$version" --output /tmp/candidate-contract.json + fi + scripts/check-api-compatibility.sh \ + --manifest .release-contracts/deployment.json \ + --candidate-component "$component" --candidate-version "$version" \ + --candidate-source-sha "$CANDIDATE_SHA" \ + --candidate-bundle /tmp/candidate-contract.json + - uses: actions/upload-artifact@v7 + with: + name: candidate-api-contract + path: /tmp/candidate-contract.json + backend: + needs: compatibility if: startsWith(github.ref_name, 'marreq-server-v') runs-on: ubuntu-latest steps: @@ -51,6 +107,7 @@ jobs: MARREQ_GIT_SHA=${{ github.sha }} frontend: + needs: compatibility if: startsWith(github.ref_name, 'marreq-frontend-v') runs-on: ubuntu-latest steps: @@ -88,6 +145,7 @@ jobs: MARREQ_GIT_SHA=${{ github.sha }} mcp: + needs: compatibility if: startsWith(github.ref_name, 'marreq-mcp-v') runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index a07a0881..b6141e14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -84,6 +98,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "allsorts-azul" version = "0.17.2" @@ -116,6 +136,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -356,7 +426,7 @@ checksum = "591eba69b7c085632e22ca03606f0bbea68d53bbe26c8962e2d549af20c6a561" dependencies = [ "paste", "roman-numerals-rs", - "strum", + "strum 0.27.2", "unic-langid", "unicode-normalization", "unscanny", @@ -443,6 +513,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli-decompressor" version = "5.0.3" @@ -484,6 +560,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.2" @@ -673,6 +755,46 @@ dependencies = [ "serde_path_to_error", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "cmake" version = "0.1.58" @@ -730,6 +852,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.8" @@ -962,9 +1090,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", ] @@ -1065,6 +1193,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" @@ -1371,6 +1505,9 @@ name = "email_address" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] [[package]] name = "encoding_rs" @@ -1439,7 +1576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1462,6 +1599,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-float2" version = "0.2.4" @@ -1555,6 +1703,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1623,6 +1782,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1736,6 +1905,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -1745,7 +1928,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -1879,6 +2062,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2619,9 +2804,15 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -2761,6 +2952,60 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42a11b47492e6a2cc76e9c7bcc8077776cb31dac444e0795d7f61bcc6441c08" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex 0.19.2", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum 0.28.0", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "107aeb6ff14b8deb5610096a8ce5c554ec89806b521147eaeb3cae05b07edb7a" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e281d6b177bb4834f2ffd6f92aa0443cffe29c996b251c8d94eb6f456186feba" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "memchr", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "kamadak-exif" version = "0.6.1" @@ -2992,6 +3237,7 @@ dependencies = [ "chrono", "diesel", "lettre", + "marreq-contracts", "marreq-core", "rand 0.10.3", "rocket", @@ -3003,6 +3249,24 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "marreq-contracts" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "jsonschema", + "semver", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2 0.11.1", + "tempfile", + "tree-sitter", + "tree-sitter-typescript", + "walkdir", +] + [[package]] name = "marreq-core" version = "0.1.0" @@ -3091,6 +3355,12 @@ dependencies = [ "libc", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -3226,6 +3496,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3252,6 +3536,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3277,6 +3576,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3332,6 +3642,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -3445,6 +3761,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -3969,7 +4291,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.5", "thiserror 2.0.21", "tokio", "tracing", @@ -4008,9 +4330,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.5", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4028,6 +4350,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -4166,6 +4494,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "referencing" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42818036d3b37cda6bd4c494543158a36e431cb717015ef7c93acc6ba214d3b" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -4535,7 +4880,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4593,7 +4938,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4749,6 +5094,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -4796,6 +5145,8 @@ version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash", + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -4890,6 +5241,19 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.2", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5106,6 +5470,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strict-num" version = "0.1.1" @@ -5138,7 +5508,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -5153,6 +5532,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -5230,7 +5621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", + "fancy-regex 0.16.2", "flate2", "fnv", "once_cell", @@ -5298,7 +5689,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5431,9 +5822,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.54.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -5648,6 +6039,36 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6246,6 +6667,22 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -6311,6 +6748,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -6336,6 +6779,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -6510,7 +6962,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6746,6 +7198,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "write-fonts" version = "0.48.1" diff --git a/Cargo.toml b/Cargo.toml index ee7c0dd6..82233590 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # Virtual workspace: three-crate split — marreq-core (lib), marreq-server (bin), marreq-cloud (bin). [workspace] resolver = "2" -members = ["marreq-core", "marreq-server", "marreq-cloud"] +members = ["marreq-core", "marreq-server", "marreq-cloud", "tools/marreq-contracts"] [workspace.package] edition = "2024" diff --git a/docs/developer/http-api-contract.md b/docs/developer/http-api-contract.md index 79cdc045..9a62f0bc 100644 --- a/docs/developer/http-api-contract.md +++ b/docs/developer/http-api-contract.md @@ -212,3 +212,102 @@ A minimal **OpenAPI 3** sketch for auth and session project listing lives in [`o - **Adminer:** host port **8081** (to avoid clashing with the frontend). See [`../../docker/README.md`](../../docker/README.md). + +## Consumer-aware contract verification (#423 / #424) + +`docs/developer/openapi.yaml` is the single wire contract. Only operations used by +frontend or MCP belong in release compatibility checks. A route inventory is not +serialization evidence: `x-marreq-unverified` means the operation still needs an +audit and cannot be used in a releasable consumer bundle. Unconsumed backend +endpoints are outside this contract; adding/changing them does not itself block +an existing consumer. + +**The refactor remains draft.** The earlier 245-operation global completeness gate +was removed, but most remaining gaps are genuinely consumed APIs. Completing their +wire schemas, binding more producer DTO serialization to those schemas, and +retaining actual historical artifact evidence are still release blockers. A +passing document validator alone is not a compatibility guarantee. + +### Architecture and rationale + +- `tools/marreq-contracts` is a workspace Rust CLI/library. Serde/serde_yaml_ng parse + data, `semver` validates versions, `sha2` pins bundle content, and `jsonschema` + validates actual request/response bodies offline. +- OpenAPI validation and breaking-change analysis use checksum-pinned **oasdiff + 1.34.0**, not a custom JSON Schema subset algorithm. See the upstream + [breaking-change semantics](https://github.com/oasdiff/oasdiff/blob/main/docs/BREAKING-CHANGES.md). + Request acceptance and response guarantees are compared in their respective + directions. Unknown advanced schema semantics fail for the affected selected + schema; they never silently certify a combination. +- Consumer calls are parsed with the established TypeScript/TSX **tree-sitter** + grammar. The analyzer follows finite URL helpers, local constants, templates, + branch alternatives and explicit HTTP methods. It includes awaited generic + calls, direct fetches, shared transports, multipart/download wrappers, and API + browser navigation. Unknown paths/methods fail; there is no source regex parser, + source-review checksum or separately maintained full route list. +- The small protocol dependency list covers browser/OAuth callbacks and discovery + which are not ordinary `fetch` expressions. Its IDs must exist in OpenAPI. All + other dependencies are discovered from consumer source. The only dynamic href + exclusion is `StatementText`'s sanitized user-content link: user-authored + navigation is not an application HTTP client operation. It does not exclude + fetches or fixed API links in that component. +- Compiled Rocket mount tests check every declared consumer operation against + Server/Cloud registration. Extra backend routes are permitted. Four public + account routes are Cloud-only; shared deployment capabilities remain runtime + behavior rather than invented Server/Cloud route differences. +- Standard `openapi-typescript` emits only schemas reachable by each consumer. + Committed declarations keep Rust/global OpenAPI out of frontend/MCP Docker + builds. Each consumer owns its minimal build input; the duplicate global type + snapshots were replaced by independently selected declarations. + +The old Python comparator, regex inventory parser, source-review hashes, +generated route JSON and separate reusable workflow were removed. The unrelated +existing requirements-compliance Python script is retained. + +### Developer commands + +Install the pinned upstream engine once (`scripts/install-oasdiff.sh`; CI does +this automatically), add its destination to PATH, or set `OASDIFF` to that binary. +The installer currently handles Linux x86_64; other platforms may install the same +upstream version themselves. No contract command downloads refs or schemas. + +```sh +cargo test --locked -p marreq-contracts +cargo run --locked -p marreq-contracts -- validate +cargo run --locked -p marreq-contracts -- coverage --component frontend +cargo run --locked -p marreq-contracts -- coverage --component mcp +node scripts/generate-api-types.mjs +node scripts/generate-api-types.mjs --check +cargo test --locked -p marreq-cloud --test api_contract_inventory +scripts/test-api-contracts.sh +``` + +`validate` checks document/policy validity and requires a verified schema for +**every discovered consumer dependency**. `--document-only` checks the document +without certifying consumer coverage; it cannot create a consumer release bundle. +`select --allow-unverified` is used only for development type generation, never +for bundle export or compatibility verification. + +When adding/changing an HTTP call, update the actual Rocket implementation and +OpenAPI, then regenerate declarations and extend existing consumer/producer tests. +A new unknown call, unresolved dynamic call, or newly used unverified operation +fails validation. Remove `x-marreq-unverified` only after auditing request defaults, +response nullability/required fields, errors/statuses, media, guards and scopes. +Do not infer a schema from one response or from a TypeScript assertion. + +The isolated smoke test runs migrated tmpfs PostgreSQL plus real Server and Cloud +binaries, exercising the actual frontend transport/session flow and MCP HTTP +client. Test processes use a clean directory/environment, loopback addresses and +synthetic credentials; existing `.env`/database/SMTP configuration is excluded. +Transcripts omit headers/cookies and redact login credentials. Default workspace +unit tests can unify `test-helpers`; the separate production-path run is essential. +It remains representative evidence, not certification of all unmodeled APIs. + +### Scope limits + +The CLI reports exact operation and call-site counts. Keep the PR draft while any +required operation is unverified, a producer behavior lacks adequate contract +protection, or a required check fails. Hand-maintained schemas still need broader +Rust DTO/serialization verification; compiled route presence alone is insufficient. +The remaining upstream Rust advisory is tracked in +[#425](https://github.com/MarreqSW/Marreq/issues/425); the security gate is unchanged. diff --git a/docs/developer/mcp-setup.md b/docs/developer/mcp-setup.md index 7210b7c5..7b85eae6 100644 --- a/docs/developer/mcp-setup.md +++ b/docs/developer/mcp-setup.md @@ -359,3 +359,14 @@ Reference: shared route list in `marreq-core/src/api/mod.rs` (plus deployment-sp - **Forbidden (403)**: The token’s project scope (if set) must match `MARREQ_PROJECT_ID`; or the user must be a member of the project with permission for the action. - **Connection refused**: Ensure Marreq is running and `MARREQ_BASE_URL` is correct (e.g. `http://localhost:8000`). - **Tool not found**: Confirm `MARREQ_MODE` and `MARREQ_TRACE_WRITE` — extended and trace tools are only registered when those settings enable them. + +## Artifact compatibility + +`mcp-server/compatibility.json` declares its backend range independently of MCP's +version and remains in the MCP image. The complete global OpenAPI is not copied +into that image or the public nginx directory. Consumer-specific immutable bundles +are retained alongside release artifacts and verified by the Rust +`marreq-contracts` CLI; see [release verification](../releases.md#exact-artifact-api-compatibility-423). +A missing historical bundle or an unknown operation fails safely. Backend ranges +alone are not compatibility evidence. The existing mocked tool tests remain, +with actual HTTP/session/OAuth smoke checks in `scripts/test-api-contracts.sh`. diff --git a/docs/developer/openapi.yaml b/docs/developer/openapi.yaml index a01ccfde..6f23216b 100644 --- a/docs/developer/openapi.yaml +++ b/docs/developer/openapi.yaml @@ -1,216 +1,4856 @@ -openapi: 3.0.3 -info: - title: Marreq API (partial contract) - description: | - Subset of the Marreq JSON API for interchangeable frontends. - All application routes are under `/api`. Many more paths exist; see `marreq-core/src/api/mod.rs` and README. - version: 0.1.0 +openapi: 3.1.0 +info: {title: Marreq HTTP API, version: 0.1.0, description: Consumer-facing HTTP contracts. Unconsumed backend routes + are outside compatibility enforcement. Required operations marked x-marreq-unverified fail verification; no + missing schemas are inferred from inventory entries.} servers: - - url: /api - description: Same-origin (nginx proxy or Vite dev proxy) - - url: http://localhost:8000/api - description: Direct Rocket (local) - +- {url: /} paths: - /auth/providers: + /api/projects/{project_id}/requirements/{id}/activity: get: - summary: Discover enabled browser authentication methods - operationId: authProviders + operationId: activity_requirement_activity_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/verifications/{id}/activity: + get: + operationId: activity_verification_activity_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/applicability: + get: + operationId: applicability_list + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + post: + operationId: applicability_create + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/applicability/{id}: + put: + operationId: applicability_update + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + delete: + operationId: applicability_delete + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/attachments: + get: + operationId: attachments_list + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: attachments_upload + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/attachments/{attachment_id}/download: + get: + operationId: attachments_download + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: attachment_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/storage: + get: + operationId: attachments_get_storage + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/storage/quota: + put: + operationId: attachments_set_quota + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/baselines/{baseline_id}/attachments: + get: + operationId: attachments_list_for_baseline + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: baseline_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/baselines/{baseline_id}/attachments/{attachment_id}/download: + get: + operationId: attachments_download_for_baseline + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: baseline_id + required: true + schema: {type: integer} + - in: path + name: attachment_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/auth/providers: + get: + operationId: auth_auth_providers + security: [] + parameters: [] + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/external_ProviderDiscovery_response'} + /api/auth/external/{provider}/start: + get: + operationId: auth_auth_external_start + parameters: + - in: path + name: provider + required: true + schema: {type: string} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: [] + /api/auth/external/{provider}/callback: + get: + operationId: auth_auth_external_callback + parameters: + - in: path + name: provider + required: true + schema: {type: string} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: [] + /api/auth/identities: + get: + operationId: auth_auth_identities + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/auth/me: + get: + operationId: auth_auth_me + security: + - cookieAuth: [] + parameters: [] + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/entities_User_response'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': + description: API error + content: + application/json: + schema: {$ref: '#/components/schemas/ApiError'} + put: + operationId: auth_auth_update_me + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/admin/backup/info: + get: + operationId: backup_info + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/admin/backup: + post: + operationId: backup_create + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/baselines: + get: + operationId: baselines_list + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: baselines_create + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/baselines/{baseline_id}: + get: + operationId: baselines_get + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: baseline_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/baselines/{baseline_id}/requirements: + get: + operationId: baselines_get_requirements + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: baseline_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/baselines/{baseline_id}/traceability: + get: + operationId: baselines_get_traceability + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: baseline_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/baselines/{baseline_id}/verifications: + get: + operationId: baselines_get_verifications + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: baseline_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/baselines/diff: + get: + operationId: baselines_diff_baselines + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/catalog: + get: + operationId: catalog_get + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/categories: + get: + operationId: categories_list + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + post: + operationId: categories_create + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + /api/categories/{id}: + put: + operationId: categories_update + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + delete: + operationId: categories_delete + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/requirements/{requirement_id}/comments: + get: + operationId: comments_list_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: requirement_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: comments_create_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: requirement_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/requirements/{requirement_id}/comments: + get: + operationId: comments_list + parameters: + - in: path + name: requirement_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + post: + operationId: comments_create + parameters: + - in: path + name: requirement_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/custom_fields: + get: + operationId: custom_fields_list_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: custom_fields_create + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/custom_fields/{field_id}: + put: + operationId: custom_fields_update + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: field_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + delete: + operationId: custom_fields_delete + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: field_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/dsm: + get: + operationId: dsm_get_dsm + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/requirements.xlsx: + get: + operationId: exports_export_requirements_xlsx + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/verifications.xlsx: + get: + operationId: exports_export_verifications_xlsx + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/matrix.xlsx: + get: + operationId: exports_export_matrix_xlsx + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/dsm.xlsx: + get: + operationId: exports_export_dsm_xlsx + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/matrix-links.xlsx: + get: + operationId: exports_export_matrix_links_xlsx + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/requirements.pdf: + get: + operationId: exports_export_requirements_pdf + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/report.pdf: + get: + operationId: exports_export_report_pdf + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/requirements.reqif: + get: + operationId: exports_export_requirements_reqif + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/requirements.reqifz: + get: + operationId: exports_export_requirements_reqifz + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/baselines/{filename}: + get: + operationId: exports_export_baseline_reqif + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: filename + required: true + schema: {type: string} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/bundle.json: + get: + operationId: exports_export_project_bundle + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/exports/bundle.zip: + get: + operationId: exports_export_project_bundle_zip + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/groups: + get: + operationId: groups_list + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: groups_create + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/groups/creatable: + get: + operationId: groups_list_creatable + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/groups/{group_id}: + get: + operationId: groups_get + parameters: + - in: path + name: group_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + patch: + operationId: groups_update + parameters: + - in: path + name: group_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + delete: + operationId: groups_delete + parameters: + - in: path + name: group_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/groups/{group_id}/projects: + get: + operationId: groups_list_projects + parameters: + - in: path + name: group_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/groups/{group_id}/members: + get: + operationId: groups_list_members + parameters: + - in: path + name: group_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/groups/{group_id}/members/{user_id}: + put: + operationId: groups_set_member_role + parameters: + - in: path + name: group_id + required: true + schema: {type: integer} + - in: path + name: user_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + delete: + operationId: groups_remove_member + parameters: + - in: path + name: group_id + required: true + schema: {type: integer} + - in: path + name: user_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/home/attention: + get: + operationId: home_attention + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/search: + get: + operationId: home_search + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/imports/excel/preview: + post: + operationId: imports_preview_excel + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/imports/excel: + post: + operationId: imports_commit_excel + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/imports/reqif: + post: + operationId: imports_commit_reqif + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/imports/bundle: + post: + operationId: imports_import_project_bundle + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/admin/logs: + get: + operationId: logs_list + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/admin/logs/stats: + get: + operationId: logs_stats + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/admin/logs/export.json: + get: + operationId: logs_export_json + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/admin/logs/cleanup: + post: + operationId: logs_cleanup + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/matrix: + get: + operationId: matrix_list_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/verifications/{verification_id}/matrix: + get: + operationId: matrix_get_verification_matrix + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: verification_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + put: + operationId: matrix_put_verification_matrix + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: verification_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/mcp/principal: + get: + operationId: mcp_principal + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: [] + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/McpPrincipalResponse'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + /api/mcp/audit: + post: + operationId: mcp_audit + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - bearerAuth: [] + x-marreq-authentication-note: Personal API tokens only; delegated OAuth and sessions rejected. + /api/mcp/internal/audit: + post: + operationId: mcp_internal_audit + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - mcpServiceAuth: [] + /api/projects/{project_id}/me/permissions: + get: + operationId: members_get_my_permissions + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/members: + get: + operationId: members_list_members + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/members/{user_id}: + put: + operationId: members_set_member_role + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: user_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + delete: + operationId: members_remove_member + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: user_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/reviewers: + get: + operationId: members_list_project_reviewers + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + put: + operationId: members_put_project_reviewers + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/meta/deployment: + get: + operationId: meta_deployment_info + security: [] + parameters: [] + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/DeploymentInfo'} + /api/meta/build: + get: + operationId: meta_build_info + security: [] + parameters: [] + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/BuildInfo'} + /api/notifications: + get: + operationId: notifications_list + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/notifications/{id}/read: + patch: + operationId: notifications_mark_read + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/notifications/read-all: + post: + operationId: notifications_mark_all_read + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/notifications/preferences: + get: + operationId: notifications_get_preferences + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/notifications/preferences/{project_id}: + put: + operationId: notifications_set_preference + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + delete: + operationId: notifications_delete_preference + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /.well-known/oauth-authorization-server: + get: + operationId: oauth_authorization_server_metadata + parameters: [] + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + issuer: &id001 {type: string} + authorization_endpoint: *id001 + token_endpoint: *id001 + registration_endpoint: *id001 + response_types_supported: + type: array + items: *id001 + grant_types_supported: + type: array + items: *id001 + code_challenge_methods_supported: + type: array + items: *id001 + scopes_supported: + type: array + items: *id001 + token_endpoint_auth_methods_supported: + type: array + items: *id001 + authorization_response_iss_parameter_supported: {type: boolean} + required: [issuer, authorization_endpoint, token_endpoint, registration_endpoint, response_types_supported, + grant_types_supported, code_challenge_methods_supported, scopes_supported, token_endpoint_auth_methods_supported, + authorization_response_iss_parameter_supported] + additionalProperties: true + description: See implementation and http-api-contract.md for validation rules. + security: [] + /.well-known/oauth-protected-resource/mcp: + get: + operationId: oauth_protected_resource_metadata_mcp + parameters: [] + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + resource: *id001 + authorization_servers: + type: array + items: *id001 + scopes_supported: + type: array + items: *id001 + bearer_methods_supported: + type: array + items: *id001 + required: [resource, authorization_servers, scopes_supported, bearer_methods_supported] + additionalProperties: true + description: See implementation and http-api-contract.md for validation rules. + security: [] + /oauth/register: + post: + operationId: oauth_register + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: [] + /oauth/authorize: + get: + operationId: oauth_authorize_page + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: [] + post: + operationId: oauth_authorize_decision + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /oauth/token: + post: + operationId: oauth_token + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: [] + /api/projects: + post: + operationId: projects_create + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + get: + operationId: projects_session_list_for_session + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}: + patch: + operationId: projects_update + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + delete: + operationId: projects_delete + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/archive: + post: + operationId: projects_archive + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/unarchive: + post: + operationId: projects_unarchive + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/project-from-path/{slug}: + get: + operationId: projects_session_project_from_path + parameters: + - in: path + name: slug + required: true + schema: {type: string} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/reports/types: + get: + operationId: reports_list_types + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/report_templates: + get: + operationId: reports_list_templates + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: reports_create_template + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/report_templates/{template_id}: + patch: + operationId: reports_update_template + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: template_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + delete: + operationId: reports_delete_template + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: template_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/reports/{file}: + post: + operationId: reports_generate + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: file + required: true + schema: {type: string} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/{req_id}/versions/{v1}/diff/{v2}: + get: + operationId: requirement_diff_diff_versions_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: req_id + required: true + schema: {type: integer} + - in: path + name: v1 + required: true + schema: {type: integer} + - in: path + name: v2 + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/baselines/{baseline_id}/requirements/{req_id}/diff/current: + get: + operationId: requirement_diff_diff_baseline_vs_current + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: baseline_id + required: true + schema: {type: integer} + - in: path + name: req_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirement-version-links: + post: + operationId: requirement_version_links_create + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + get: + operationId: requirement_version_links_list + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirement-version-links/{link_id}: + delete: + operationId: requirement_version_links_delete + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: link_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirement-version-links/link-types: + get: + operationId: requirement_version_links_link_types + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements: + get: + operationId: requirements_list_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: requirements_create_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/requirements/{id}: + delete: + operationId: requirements_delete + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/requirements/{id}: + get: + operationId: requirements_get_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + patch: + operationId: requirements_patch_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/{id}/versions: + get: + operationId: requirements_list_versions_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/{req_id}/versions/{version_id}: + get: + operationId: requirements_get_version_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: req_id + required: true + schema: {type: integer} + - in: path + name: version_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/{id}/impacted_tests: + get: + operationId: requirements_get_impacted_tests_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/{req_id}/versions/{version_id}/approval: + put: + operationId: requirements_set_version_approval_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: req_id + required: true + schema: {type: integer} + - in: path + name: version_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/saved_views: + get: + operationId: saved_views_list + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: saved_views_create + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/saved_views/{view_id}: + get: + operationId: saved_views_get + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: view_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + patch: + operationId: saved_views_update + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: view_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + delete: + operationId: saved_views_delete + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: view_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/semantic_search: + get: + operationId: semantic_search_semantic_search + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/ask: + post: + operationId: semantic_search_ask + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/index_status: + get: + operationId: semantic_search_index_status + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/status: + get: + operationId: status_list_requirement_statuses + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + post: + operationId: status_create_requirement_status + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/status/{id}: + put: + operationId: status_update_requirement_status + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + delete: + operationId: status_delete_requirement_status + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/requirements/{id}/trace_up: + get: + operationId: traceability_trace_up + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/{id}/trace_down: + get: + operationId: traceability_trace_down + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/coverage_report: + get: + operationId: traceability_coverage_report + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/traceability/clear_suspect: + post: + operationId: traceability_clear_suspect + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/traceability/clear_suspect: + post: + operationId: traceability_clear_suspect_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/use-cases: + get: + operationId: use_cases_list + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: query + name: q + required: false + schema: + anyOf: + - {type: string} + - {type: 'null'} + - in: query + name: approval_state + required: false + schema: + anyOf: + - {type: string} + - {type: 'null'} + - in: query + name: actor + required: false + schema: + anyOf: + - {type: string} + - {type: 'null'} + - in: query + name: has_suspect + required: false + schema: + anyOf: + - {type: boolean} + - {type: 'null'} + - in: query + name: requirement_id + required: false + schema: + anyOf: + - {type: integer} + - {type: 'null'} + - in: query + name: verification_id + required: false + schema: + anyOf: + - {type: integer} + - {type: 'null'} + - in: query + name: sort_column + required: false + schema: + anyOf: + - {type: string} + - {type: 'null'} + - in: query + name: sort_dir + required: false + schema: + anyOf: + - {type: string} + - {type: 'null'} + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/use_cases_UseCaseRow_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + post: + operationId: use_cases_create + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: header + name: Idempotency-Key + required: false + schema: &id002 {type: string} + description: Safe replay for the same principal, target, operation and payload; changed payload returns + 409. + responses: + '201': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_UseCaseDetail_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + description: Create the shared draft; optional reference_code, title, primary_actor and document. A missing + code is generated. Supports Idempotency-Key. + security: + - cookieAuth: [] + - bearerAuth: [] + x-marreq-bearer-scopes: ['use_cases:write'] + x-marreq-csrf: session-csrf + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + reference_code: + type: [string, 'null'] + title: + type: [string, 'null'] + primary_actor: + type: [string, 'null'] + document: + anyOf: + - {$ref: '#/components/schemas/UseCaseDocument'} + - {type: 'null'} + additionalProperties: true + /api/projects/{project_id}/use-cases/next-code: + get: + operationId: use_cases_next_code + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_NextCode_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/projects/{project_id}/use-cases/{id}: + get: + operationId: use_cases_get + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: query + name: version_id + required: false + schema: + anyOf: + - {type: integer} + - {type: 'null'} + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_UseCaseDetail_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + delete: + operationId: use_cases_delete + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: header + name: X-CSRF-Token + required: false + schema: {type: string} + description: Required for session mutations; Bearer requests use scoped authentication instead. + responses: + '204': {description: Deleted} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-csrf: session-csrf + x-marreq-bearer-scopes: ['use_cases:write'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/projects/{project_id}/use-cases/{id}/draft: + put: + operationId: use_cases_put_draft + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: header + name: X-CSRF-Token + required: false + schema: {type: string} + description: Required for session mutations; Bearer requests use scoped authentication instead. + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_DraftChangeView_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-csrf: session-csrf + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_PutDraftRequest_request'} + x-marreq-bearer-scopes: ['use_cases:write'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + delete: + operationId: use_cases_discard_draft + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: header + name: X-CSRF-Token + required: false + schema: {type: string} + description: Required for session mutations; Bearer requests use scoped authentication instead. + responses: + '204': {description: Deleted} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-csrf: session-csrf + x-marreq-bearer-scopes: ['use_cases:write'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + get: + operationId: use_cases_get_draft + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_DraftView_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/projects/{project_id}/use-cases/{id}/draft/operations: + post: + operationId: use_cases_draft_operations + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_DraftChangeView_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '403': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '500': {$ref: '#/components/responses/ApiError'} + description: Use-case service contract; see use-cases-api.md for revision conflicts, document validation, + idempotency, publishing, links and review permissions. + security: + - cookieAuth: [] + - bearerAuth: [] + x-marreq-bearer-scopes: ['use_cases:write'] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + base_revision: + anyOf: + - &id003 {type: integer} + - {type: 'null'} + operations: + type: array + items: {$ref: '#/components/schemas/UseCaseOperation'} + additionalProperties: true + required: [operations] + x-marreq-csrf: session-csrf + /api/projects/{project_id}/use-cases/{id}/draft/publish: + post: + operationId: use_cases_publish + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: header + name: Idempotency-Key + required: false + schema: *id002 + description: Safe replay for the same principal, target, operation and payload; changed payload returns + 409. + responses: + '201': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/UseCasePublishResponse'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '403': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '500': {$ref: '#/components/responses/ApiError'} + description: Use-case service contract; see use-cases-api.md for revision conflicts, document validation, + idempotency, publishing, links and review permissions. + security: + - cookieAuth: [] + - bearerAuth: [] + x-marreq-bearer-scopes: ['use_cases:write'] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + base_revision: *id003 + change_note: + anyOf: + - *id002 + - {type: 'null'} + additionalProperties: true + required: [base_revision] + x-marreq-csrf: session-csrf + /api/projects/{project_id}/use-cases/{id}/versions: + get: + operationId: use_cases_list_versions + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/use_cases_VersionSummary_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/projects/{project_id}/use-cases/{id}/versions/{version_id}/approval: + put: + operationId: use_cases_set_version_approval + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: path + name: version_id + required: true + schema: {type: integer} + - in: header + name: X-CSRF-Token + required: false + schema: {type: string} + description: Required for session mutations; Bearer requests use scoped authentication instead. + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_VersionView_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-csrf: session-csrf + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_SetApprovalRequest_request'} + x-marreq-bearer-scopes: ['use_cases:approve'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/projects/{project_id}/use-cases/{id}/links: + get: + operationId: use_cases_list_links + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/use_cases_LinkView_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + post: + operationId: use_cases_create_link + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + '201': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_LinkView_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '403': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '500': {$ref: '#/components/responses/ApiError'} + description: Use-case service contract; see use-cases-api.md for revision conflicts, document validation, + idempotency, publishing, links and review permissions. + security: + - cookieAuth: [] + - bearerAuth: [] + x-marreq-bearer-scopes: ['use_cases:write'] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + element_id: + anyOf: + - *id002 + - {type: 'null'} + requirement_id: + anyOf: + - *id003 + - {type: 'null'} + verification_id: + anyOf: + - *id003 + - {type: 'null'} + rationale: + anyOf: + - *id002 + - {type: 'null'} + additionalProperties: true + x-marreq-csrf: session-csrf + /api/projects/{project_id}/use-cases/{id}/links/{link_id}: + delete: + operationId: use_cases_delete_link + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: path + name: link_id + required: true + schema: {type: integer} + - in: header + name: X-CSRF-Token + required: false + schema: {type: string} + description: Required for session mutations; Bearer requests use scoped authentication instead. + responses: + '204': {description: Deleted} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-csrf: session-csrf + x-marreq-bearer-scopes: ['use_cases:write'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/projects/{project_id}/use-cases/{id}/links/{link_id}/clear_suspect: + post: + operationId: use_cases_clear_link_suspect + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: path + name: link_id + required: true + schema: {type: integer} + - in: header + name: X-CSRF-Token + required: false + schema: {type: string} + description: Required for session mutations; Bearer requests use scoped authentication instead. + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_ClearSuspectResponse_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-csrf: session-csrf + x-marreq-bearer-scopes: ['use_cases:write'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/projects/{project_id}/requirements/{requirement_id}/use-cases: + get: + operationId: use_cases_requirement_use_cases + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: requirement_id + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/use_cases_UseCaseRef_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/projects/{project_id}/verifications/{verification_id}/use-cases: + get: + operationId: use_cases_verification_use_cases + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: verification_id + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/use_cases_UseCaseRef_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/projects/{project_id}/use-cases/{id}/draft/diff: + get: + operationId: use_cases_draft_diff + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: query + name: include_unchanged + required: false + schema: &id004 {type: boolean} + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/UseCaseDiff'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '403': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '500': {$ref: '#/components/responses/ApiError'} + description: Use-case service contract; see use-cases-api.md for revision conflicts, document validation, + idempotency, publishing, links and review permissions. + security: + - cookieAuth: [] + - bearerAuth: [] + x-marreq-bearer-scopes: ['use_cases:read'] + /api/projects/{project_id}/use-cases/{id}/versions/{a}/diff/{b}: + get: + operationId: use_cases_version_diff + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: path + name: a + required: true + schema: {type: integer} + - in: path + name: b + required: true + schema: {type: integer} + - in: query + name: include_unchanged + required: false + schema: *id004 + responses: + '200': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/UseCaseDiff'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '403': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '500': {$ref: '#/components/responses/ApiError'} + description: Use-case service contract; see use-cases-api.md for revision conflicts, document validation, + idempotency, publishing, links and review permissions. + security: + - cookieAuth: [] + - bearerAuth: [] + x-marreq-bearer-scopes: ['use_cases:read'] + /api/projects/{project_id}/use-cases/{id}/comments: + get: + operationId: use_cases_list_comments + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: query + name: version_id + required: false + schema: + anyOf: + - {type: integer} + - {type: 'null'} + - in: query + name: element_id + required: false + schema: + anyOf: + - {type: string} + - {type: 'null'} + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/use_cases_UseCaseCommentView_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + post: + operationId: use_cases_create_comment + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: header + name: Idempotency-Key + required: false + schema: *id002 + description: Safe replay for the same principal, target, operation and payload; changed payload returns + 409. + responses: + '201': + description: Success + content: + application/json: + schema: {$ref: '#/components/schemas/use_cases_UseCaseCommentView_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '403': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: Guard/fairing/validation failure; body may be empty or framework generated} + '500': {$ref: '#/components/responses/ApiError'} + description: Use-case service contract; see use-cases-api.md for revision conflicts, document validation, + idempotency, publishing, links and review permissions. + security: + - cookieAuth: [] + - bearerAuth: [] + x-marreq-bearer-scopes: ['use_cases:write'] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + body: *id002 + use_case_version_id: + anyOf: + - *id003 + - {type: 'null'} + element_id: + anyOf: + - *id002 + - {type: 'null'} + additionalProperties: true + required: [body] + x-marreq-csrf: session-csrf + /api/projects/{project_id}/baselines/{baseline_id}/use-cases: + get: + operationId: use_cases_baseline_use_cases + security: + - cookieAuth: [] + - bearerAuth: [] + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: baseline_id + required: true + schema: {type: integer} responses: '200': - description: Public provider names only; no credentials or protocol secrets + description: Success content: application/json: schema: - type: object - required: [password_enabled, external] - properties: - password_enabled: - type: boolean - external: - type: array - items: - type: object - required: [id, display_name] - properties: - id: { type: string } - display_name: { type: string } - /auth/external/{provider}/start: + type: array + items: {$ref: '#/components/schemas/use_cases_BaselineUseCaseView_response'} + '400': {$ref: '#/components/responses/ApiError'} + '401': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '403': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '404': {$ref: '#/components/responses/ApiError'} + '409': {$ref: '#/components/responses/ApiError'} + '422': {description: 'Authentication, authorization, CSRF or Rocket validation failure. Body may be empty + or framework generated; do not require an ApiError body.'} + '500': {$ref: '#/components/responses/ApiError'} + x-marreq-bearer-scopes: ['use_cases:read'] + description: Session or scoped Bearer authentication; project membership and ViewRequirements/EditRequirements/review + permissions apply. Archived projects reject mutations. See use-cases-api.md for validation and revision-conflict + semantics. + /api/users/{id}: + put: + operationId: users_update + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + delete: + operationId: users_delete + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/users/{id}/password: + put: + operationId: users_set_password + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/verifications/{verification_id}/control: + get: + operationId: verification_control_get_verification_control + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: verification_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + put: + operationId: verification_control_put_verification_control + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: verification_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/{requirement_id}/close_out: + get: + operationId: verification_control_get_requirement_close_out + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: requirement_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/close_out: + get: + operationId: verification_control_get_project_close_out + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/requirements/{requirement_id}/compliance: + put: + operationId: verification_control_put_requirement_compliance + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: requirement_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/verifications/{id}/snapshots: + get: + operationId: verification_diff_list_snapshots_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/verifications/{id}/snapshots/{v1}/diff/{v2}: + get: + operationId: verification_diff_diff_snapshots_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + - in: path + name: v1 + required: true + schema: {type: integer} + - in: path + name: v2 + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/baselines/{baseline_id}/verifications/{id}/diff/current: + get: + operationId: verification_diff_diff_baseline_vs_current + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: baseline_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/verification-methods: + get: + operationId: verification_methods_list_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: verification_methods_create_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/verification-methods/{method_id}: + put: + operationId: verification_methods_update_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: method_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + delete: + operationId: verification_methods_delete_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: method_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/verification-status: + get: + operationId: verification_status_list_verification_statuses + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + post: + operationId: verification_status_create_verification_status + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/verification-status/{id}: + put: + operationId: verification_status_update_verification_status + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + delete: + operationId: verification_status_delete_verification_status + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/verifications: + get: + operationId: verifications_list + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + post: + operationId: verifications_create + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + /api/projects/{project_id}/verifications: + get: + operationId: verifications_list_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + post: + operationId: verifications_create_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/projects/{project_id}/verifications/{id}: get: - summary: Begin an external Authorization Code + PKCE login - operationId: authExternalStart + operationId: verifications_get_by_project + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + put: + operationId: verifications_update_by_project parameters: - - in: path - name: provider - required: true - schema: { type: string } - - in: query - name: return_to - schema: { type: string, example: /projects } + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} responses: - '303': { description: Redirect to the provider } - '400': { $ref: '#/components/responses/ApiError' } - '404': { $ref: '#/components/responses/ApiError' } - /auth/external/{provider}/callback: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/verifications/{id}: get: - summary: Consume a single-use external authentication callback - operationId: authExternalCallback + operationId: verifications_get + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + delete: + operationId: verifications_delete + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + /api/projects/{project_id}/verifications/{id}/field: + post: + operationId: verifications_update_field_by_project parameters: - - in: path - name: provider - required: true - schema: { type: string } - - in: query - name: code - schema: { type: string } - - in: query - name: state - schema: { type: string } + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: id + required: true + schema: {type: integer} responses: - '303': { description: Login or link completed; redirect to the local return path } - '400': { $ref: '#/components/responses/ApiError' } - /auth/csrf: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Wire contract pending audit; required consumers fail verification. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/auth/csrf: get: - summary: Get or mint CSRF token - operationId: authCsrf + operationId: auth_auth_csrf + security: [] + parameters: [] responses: '200': - description: CSRF token for X-CSRF-Token header + description: Success content: application/json: schema: type: object - required: [csrf_token] properties: - csrf_token: - type: string - /auth/login: - post: - summary: Session login (JSON) - operationId: authLogin + csrf_token: {type: string} + required: [csrf_token] + additionalProperties: true + /api/auth/identities/{identity_id}: + delete: + operationId: auth_auth_identity_delete parameters: - - in: header - name: X-CSRF-Token - required: true - schema: - type: string + - in: path + name: identity_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + /api/auth/register: + post: + operationId: auth_public_auth_register + x-marreq-variants: [cloud] + parameters: [] + responses: + '201': + description: Success + content: + application/json: + schema: + type: object + properties: + status: {type: string, const: ok} + required: [status] + additionalProperties: true + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + status: {type: string, const: ok} + required: [status] + additionalProperties: true + '400': {$ref: '#/components/responses/ApiError'} + '500': {$ref: '#/components/responses/ApiError'} + description: Cloud-only public account flow. Registration returns 201 for creation or 200 for an existing + email, preventing enumeration. Forgot-password always reports success. CSRF fairing rules still apply. + security: [] requestBody: required: true content: application/json: schema: type: object - required: [username, password] properties: - username: - type: string - password: - type: string + username: &id005 {type: string} + name: *id005 + email: *id005 + password: *id005 + required: [username, name, email, password] + additionalProperties: true + /api/auth/login: + post: + operationId: auth_auth_login + parameters: [] responses: '200': - description: Logged in; Set-Cookie session + csrf + description: Authenticated session established content: application/json: schema: type: object properties: - status: - type: string - example: ok - user: - type: object - '400': - $ref: '#/components/responses/ApiError' - /auth/logout: - post: - summary: End session - operationId: authLogout - parameters: - - in: header - name: X-CSRF-Token - required: true - schema: - type: string + status: {type: string, const: ok} + user: {$ref: '#/components/schemas/entities_User_response'} + required: [status, user] + '400': {$ref: '#/components/responses/ApiError'} + '403': {description: CSRF failure; body may be framework generated} + '410': {$ref: '#/components/responses/ApiError'} + '422': {description: Rocket JSON validation failure} + '500': {$ref: '#/components/responses/ApiError'} + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: [] + x-marreq-csrf: auth-origin-fallback + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: {type: string} + password: {type: string} + /api/users: + post: + operationId: users_create + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + get: + operationId: users_list + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + /api/projects/{project_id}/attachments/{attachment_id}: + delete: + operationId: attachments_delete + parameters: + - in: path + name: project_id + required: true + schema: {type: integer} + - in: path + name: attachment_id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + - bearerAuth: [] + /api/auth/external/{provider}/link: + post: + operationId: auth_auth_external_link_start + parameters: + - in: path + name: provider + required: true + schema: {type: string} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + /api/auth/verify-email: + get: + operationId: auth_public_auth_verify_email + x-marreq-variants: [cloud] + parameters: + - in: query + name: token + required: true + schema: *id005 responses: '200': - description: Logged out + description: Success content: application/json: schema: type: object properties: - status: - type: string - example: ok - /auth/me: - get: - summary: Current user (session) - operationId: authMe - security: - - cookieAuth: [] + status: {type: string, const: ok} + required: [status] + additionalProperties: true + '400': {$ref: '#/components/responses/ApiError'} + '500': {$ref: '#/components/responses/ApiError'} + description: Cloud-only public account flow. Registration returns 201 for creation or 200 for an existing + email, preventing enumeration. Forgot-password always reports success. CSRF fairing rules still apply. + security: [] + /api/auth/logout: + post: + operationId: auth_auth_logout + parameters: [] responses: '200': - description: Current user + description: Session and CSRF cookies cleared content: application/json: schema: type: object - '401': - $ref: '#/components/responses/ApiError' - /dashboard: - get: - summary: SPA dashboard payload (user + decorated projects + CSRF) - operationId: dashboardJson - security: - - cookieAuth: [] + properties: + status: {type: string, const: ok} + required: [status] + '403': {description: CSRF failure; body may be framework generated} + '500': {$ref: '#/components/responses/ApiError'} + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: [] + x-marreq-csrf: auth-origin-fallback + requestBody: + required: false + description: Optional ignored body; the SPA sends an empty JSON object. + content: + application/json: + schema: {} + /api/auth/forgot-password: + post: + operationId: auth_public_auth_forgot_password + x-marreq-variants: [cloud] + parameters: [] responses: '200': - description: Dashboard context for the home view + description: Success content: application/json: schema: type: object - '401': - $ref: '#/components/responses/ApiError' - /projects: + properties: + status: {type: string, const: ok} + required: [status] + additionalProperties: true + '400': {$ref: '#/components/responses/ApiError'} + '500': {$ref: '#/components/responses/ApiError'} + description: Cloud-only public account flow. Registration returns 201 for creation or 200 for an existing + email, preventing enumeration. Forgot-password always reports success. CSRF fairing rules still apply. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: *id005 + required: [email] + additionalProperties: true + /api/notifications/unread-count: + get: + operationId: notifications_unread_count + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + /api/auth/change-password: + post: + operationId: auth_auth_change_password + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + /api/oauth/grants: + get: + operationId: oauth_grants + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + /api/oauth/grants/{id}: + delete: + operationId: oauth_revoke_grant + parameters: + - in: path + name: id + required: true + schema: {type: integer} + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. + security: + - cookieAuth: [] + /api/dashboard: get: - summary: List projects for session user - operationId: listProjectsSession + operationId: dashboard_dashboard_json + parameters: [] + responses: + default: {description: Wire behavior has not yet been verified.} + x-marreq-unverified: true + description: Route inventory only. Request/response serialization, status codes and authorization require + audit before compatibility can be established. security: - - cookieAuth: [] + - cookieAuth: [] + /api/auth/reset-password: + post: + operationId: auth_public_auth_reset_password + x-marreq-variants: [cloud] + parameters: [] responses: '200': - description: Project list + description: Success content: application/json: schema: - type: array - items: - type: object - '401': - $ref: '#/components/responses/ApiError' - + type: object + properties: + status: {type: string, const: ok} + required: [status] + additionalProperties: true + '400': {$ref: '#/components/responses/ApiError'} + '500': {$ref: '#/components/responses/ApiError'} + description: Cloud-only public account flow. Registration returns 201 for creation or 200 for an existing + email, preventing enumeration. Forgot-password always reports success. CSRF fairing rules still apply. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + token: *id005 + new_password: *id005 + required: [token, new_password] + additionalProperties: true components: securitySchemes: - cookieAuth: - type: apiKey - in: cookie - name: session - description: | - Rocket private session cookie: typically `session` on HTTP (localhost) or - `__Host-session` when `MARREQ_SECURE_SESSION_COOKIE=1` over HTTPS. - Use `fetch(..., { credentials: 'include' })`. - responses: + cookieAuth: {type: apiKey, in: cookie, name: session, description: Rocket private session; __Host-session on + secure deployments. Mutations require X-CSRF-Token.} + bearerAuth: {type: http, scheme: bearer, description: 'Personal API token or delegated OAuth access token; project + membership, domain scopes and project permissions apply.'} + mcpServiceAuth: {type: apiKey, in: header, name: X-Marreq-Mcp-Audit-Secret, description: Private service-to-service + audit secret; never browser or delegated caller configuration.} + schemas: + external_ProviderDiscovery_response: + type: object + properties: + password_enabled: {type: boolean} + external: + type: array + items: {$ref: '#/components/schemas/external_PublicAuthProvider_response'} + additionalProperties: true + required: [password_enabled, external] + external_PublicAuthProvider_response: + type: object + properties: + id: {type: string} + display_name: {type: string} + additionalProperties: true + required: [id, display_name] + entities_User_response: + type: object + properties: + id: {type: integer} + username: {type: string} + name: {type: string} + email: {type: string} + creation_date: {type: string} + last_login: {type: string} + is_admin: {type: boolean} + email_verified: {type: boolean} + additionalProperties: true + required: [id, username, name, email, creation_date, last_login, is_admin, email_verified] + use_cases_UseCaseRow_response: + type: object + properties: + id: {type: integer} + reference_code: {type: string} + title: {type: string} + primary_actor: + anyOf: + - {type: string} + - {type: 'null'} + step_count: {type: integer} + alternative_count: {type: integer} + exception_count: {type: integer} + approval_state: + anyOf: + - {type: string} + - {type: 'null'} + current_version_number: + anyOf: + - {type: integer} + - {type: 'null'} + has_draft: {type: boolean} + link_count: {type: integer} + suspect_count: {type: integer} + updated_at: {type: string} + additionalProperties: true + required: [id, reference_code, title, primary_actor, step_count, alternative_count, exception_count, approval_state, + current_version_number, has_draft, link_count, suspect_count, updated_at] + use_cases_NextCode_response: + type: object + properties: + reference_code: {type: string} + additionalProperties: true + required: [reference_code] + use_cases_UseCaseDetail_response: + type: object + properties: + id: {type: integer} + project_id: {type: integer} + reference_code: {type: string} + title: {type: string} + created_by: + anyOf: + - {type: integer} + - {type: 'null'} + created_at: {type: string} + updated_at: {type: string} + current_version: + anyOf: + - {$ref: '#/components/schemas/use_cases_VersionView_response'} + - {type: 'null'} + draft: + anyOf: + - {$ref: '#/components/schemas/use_cases_DraftSummary_response'} + - {type: 'null'} + additionalProperties: true + required: [id, project_id, reference_code, title, created_by, created_at, updated_at, current_version, draft] + use_cases_VersionView_response: + type: object + properties: + id: {type: integer} + version_number: {type: integer} + approval_state: {type: string} + title: {type: string} + primary_actor: + anyOf: + - {type: string} + - {type: 'null'} + change_note: + anyOf: + - {type: string} + - {type: 'null'} + created_by: + anyOf: + - {type: integer} + - {type: 'null'} + created_by_name: + anyOf: + - {type: string} + - {type: 'null'} + created_at: {type: string} + reviewed_by: + anyOf: + - {type: integer} + - {type: 'null'} + reviewed_at: + anyOf: + - {type: string} + - {type: 'null'} + approved_by: + anyOf: + - {type: integer} + - {type: 'null'} + approved_at: + anyOf: + - {type: string} + - {type: 'null'} + document: {$ref: '#/components/schemas/UseCaseDocumentResponse'} + labels: + type: object + additionalProperties: {type: string} + additionalProperties: true + required: [id, version_number, approval_state, title, primary_actor, change_note, created_by, created_by_name, + created_at, reviewed_by, reviewed_at, approved_by, approved_at, document, labels] + use_cases_VersionSummary_response: + type: object + properties: + id: {type: integer} + version_number: {type: integer} + approval_state: {type: string} + title: {type: string} + primary_actor: + anyOf: + - {type: string} + - {type: 'null'} + change_note: + anyOf: + - {type: string} + - {type: 'null'} + created_by: + anyOf: + - {type: integer} + - {type: 'null'} + created_by_name: + anyOf: + - {type: string} + - {type: 'null'} + created_at: {type: string} + reviewed_by: + anyOf: + - {type: integer} + - {type: 'null'} + reviewed_at: + anyOf: + - {type: string} + - {type: 'null'} + approved_by: + anyOf: + - {type: integer} + - {type: 'null'} + approved_at: + anyOf: + - {type: string} + - {type: 'null'} + additionalProperties: true + required: [id, version_number, approval_state, title, primary_actor, change_note, created_by, created_by_name, + created_at, reviewed_by, reviewed_at, approved_by, approved_at] + use_cases_DraftSummary_response: + type: object + properties: + revision: {type: integer} + base_version_id: + anyOf: + - {type: integer} + - {type: 'null'} + updated_by: + anyOf: + - {type: integer} + - {type: 'null'} + updated_by_name: + anyOf: + - {type: string} + - {type: 'null'} + updated_at: {type: string} + additionalProperties: true + required: [revision, base_version_id, updated_by, updated_by_name, updated_at] + use_cases_DraftView_response: + type: object + properties: + use_case_id: {type: integer} + revision: {type: integer} + base_version_id: + anyOf: + - {type: integer} + - {type: 'null'} + updated_by: + anyOf: + - {type: integer} + - {type: 'null'} + updated_by_name: + anyOf: + - {type: string} + - {type: 'null'} + updated_at: {type: string} + document: {$ref: '#/components/schemas/UseCaseDocumentResponse'} + labels: + type: object + additionalProperties: {type: string} + additionalProperties: true + required: [use_case_id, revision, base_version_id, updated_by, updated_by_name, updated_at, document, labels] + use_cases_PutDraftRequest_request: + type: object + properties: + base_revision: + anyOf: + - {type: integer} + - {type: 'null'} + document: {$ref: '#/components/schemas/UseCaseDocument'} + additionalProperties: true + required: [document] + use_cases_DraftChangeView_response: + type: object + properties: + use_case_id: {type: integer} + revision: {type: integer} + base_version_id: + anyOf: + - {type: integer} + - {type: 'null'} + updated_by: + anyOf: + - {type: integer} + - {type: 'null'} + updated_by_name: + anyOf: + - {type: string} + - {type: 'null'} + updated_at: {type: string} + document: {$ref: '#/components/schemas/UseCaseDocumentResponse'} + labels: + type: object + additionalProperties: {type: string} + effects: + type: array + items: {$ref: '#/components/schemas/UseCaseEffect'} + additionalProperties: true + required: [use_case_id, revision, base_version_id, updated_by, updated_by_name, updated_at, document, labels, + effects] + use_cases_SetApprovalRequest_request: + type: object + properties: + state: {type: string} + additionalProperties: true + required: [state] + use_cases_LinkView_response: + type: object + properties: + id: {type: integer} + element_id: + anyOf: + - {type: string} + - {type: 'null'} + element_label: + anyOf: + - {type: string} + - {type: 'null'} + target_type: + type: string + enum: [requirement, verification] + requirement_id: + anyOf: + - {type: integer} + - {type: 'null'} + verification_id: + anyOf: + - {type: integer} + - {type: 'null'} + target_reference_code: {type: string} + target_title: {type: string} + rationale: + anyOf: + - {type: string} + - {type: 'null'} + suspect: {type: boolean} + suspect_at: + anyOf: + - {type: string} + - {type: 'null'} + suspect_reason: + anyOf: + - {type: string} + - {type: 'null'} + linked_requirement_version_id: + anyOf: + - {type: integer} + - {type: 'null'} + current_requirement_version_id: + anyOf: + - {type: integer} + - {type: 'null'} + created_by: + anyOf: + - {type: integer} + - {type: 'null'} + created_at: {type: string} + additionalProperties: true + required: [id, element_id, element_label, target_type, requirement_id, verification_id, target_reference_code, + target_title, rationale, suspect, suspect_at, suspect_reason, linked_requirement_version_id, current_requirement_version_id, + created_by, created_at] + use_cases_ClearSuspectResponse_response: + type: object + properties: + status: {type: string} + cleared: {type: boolean} + additionalProperties: true + required: [status, cleared] + use_cases_UseCaseRef_response: + type: object + properties: + use_case_id: {type: integer} + reference_code: {type: string} + title: {type: string} + link_id: {type: integer} + element_id: + anyOf: + - {type: string} + - {type: 'null'} + element_label: + anyOf: + - {type: string} + - {type: 'null'} + suspect: {type: boolean} + additionalProperties: true + required: [use_case_id, reference_code, title, link_id, element_id, element_label, suspect] + use_cases_UseCaseCommentView_response: + type: object + properties: + id: {type: integer} + use_case_id: {type: integer} + use_case_version_id: + anyOf: + - {type: integer} + - {type: 'null'} + element_id: + anyOf: + - {type: string} + - {type: 'null'} + element_label: + anyOf: + - {type: string} + - {type: 'null'} + author_id: {type: integer} + author_name: {type: string} + body: {type: string} + created_at: {type: string} + additionalProperties: true + required: [id, use_case_id, use_case_version_id, element_id, element_label, author_id, author_name, body, + created_at] + use_cases_BaselineUseCaseView_response: + type: object + properties: + use_case_id: {type: integer} + reference_code: {type: string} + title: {type: string} + primary_actor: + anyOf: + - {type: string} + - {type: 'null'} + version_id: {type: integer} + version_number: {type: integer} + approval_state: {type: string} + current_version_id: + anyOf: + - {type: integer} + - {type: 'null'} + current_version_number: + anyOf: + - {type: integer} + - {type: 'null'} + link_count: {type: integer} + additionalProperties: true + required: [use_case_id, reference_code, title, primary_actor, version_id, version_number, approval_state, + current_version_id, current_version_number, link_count] ApiError: - description: Structured error - content: - application/json: - schema: + type: object + required: [status, error, message] + properties: + status: {type: integer} + error: {type: string} + message: {type: string} + additionalProperties: true + BuildInfo: + type: object + properties: + backend_version: &id006 {type: string} + backend_git_sha: *id006 + deployment_mode: + type: string + enum: [server, cloud] + frontend_compatibility: + type: object + properties: + min_version: *id006 + max_version: *id006 + required: [min_version, max_version] + additionalProperties: true + required: [backend_version, backend_git_sha, deployment_mode, frontend_compatibility] + additionalProperties: true + UseCaseElement: + type: object + properties: + id: &id007 {type: string} + text: *id007 + additionalProperties: false + required: [id, text] + UseCaseBranchEnd: + oneOf: + - type: object + properties: + type: {const: rejoin, type: string} + step: *id007 + additionalProperties: false + required: [type, step] + - type: object + properties: + type: {const: end, type: string} + outcome: + type: string + enum: [success, failure] + additionalProperties: false + required: [type, outcome] + UseCaseDocument: + type: object + properties: + schema: {type: string, const: marreq.use-case.v1} + title: *id007 + primary_actor: + anyOf: + - *id007 + - {type: 'null'} + secondary_actors: + type: array + items: *id007 + summary: + anyOf: + - *id007 + - {type: 'null'} + preconditions: + type: array + items: {$ref: '#/components/schemas/UseCaseElement'} + main: + type: array + items: {$ref: '#/components/schemas/UseCaseElement'} + branches: + type: array + items: {$ref: '#/components/schemas/UseCaseBranch'} + postconditions: + type: array + items: {$ref: '#/components/schemas/UseCaseElement'} + additionalProperties: false + required: [title] + UseCaseBranch: + type: object + properties: + id: *id007 + kind: + type: string + enum: [alternative, exception] + title: *id007 + from_step: *id007 + end: {$ref: '#/components/schemas/UseCaseBranchEnd'} + steps: + type: array + items: {$ref: '#/components/schemas/UseCaseElement'} + additionalProperties: false + required: [id, kind, title, from_step, end] + UseCaseEffect: + oneOf: + - type: object + properties: + type: {type: string, const: relabelled} + id: *id007 + from: *id007 + to: *id007 + additionalProperties: true + required: [type, id, from, to] + - type: object + properties: + type: {type: string, const: removed} + id: *id007 + additionalProperties: true + required: [type, id] + - type: object + properties: + type: {type: string, const: rebranched} + id: *id007 + from_step: *id007 + additionalProperties: true + required: [type, id, from_step] + - type: object + properties: + type: {type: string, const: created} + id: *id007 + additionalProperties: true + required: [type, id] + DeploymentInfo: + type: object + properties: + mode: + type: string + enum: [server, cloud] + allows_self_registration: &id008 {type: boolean} + requires_email_verification: *id008 + allows_admin_promotion: *id008 + assigns_personal_workspace: *id008 + allows_self_administered_user_creation: *id008 + allows_database_backup: *id008 + required: [mode, allows_self_registration, requires_email_verification, allows_admin_promotion, assigns_personal_workspace, + allows_self_administered_user_creation, allows_database_backup] + additionalProperties: true + McpPrincipalResponse: + type: object + properties: + user_id: {type: integer} + authentication_type: {type: string} + principal_id: {type: string} + client_id: + anyOf: + - {type: string} + - {type: 'null'} + grant_id: + anyOf: + - {type: integer} + - {type: 'null'} + additionalProperties: true + required: [user_id, authentication_type, principal_id, client_id, grant_id] + UseCaseDocumentResponse: + type: object + properties: + schema: {type: string, const: marreq.use-case.v1} + title: &id009 {type: string} + primary_actor: + anyOf: + - *id009 + - {type: 'null'} + secondary_actors: + type: array + items: *id009 + summary: + anyOf: + - *id009 + - {type: 'null'} + preconditions: + type: array + items: {$ref: '#/components/schemas/UseCaseElement'} + main: + type: array + items: {$ref: '#/components/schemas/UseCaseElement'} + branches: + type: array + items: {$ref: '#/components/schemas/UseCaseBranchResponse'} + postconditions: + type: array + items: {$ref: '#/components/schemas/UseCaseElement'} + additionalProperties: false + required: [schema, title, primary_actor, secondary_actors, summary, preconditions, main, branches, postconditions] + UseCaseBranchResponse: + type: object + properties: + id: &id010 {type: string} + kind: + type: string + enum: [alternative, exception] + title: *id010 + from_step: *id010 + end: {$ref: '#/components/schemas/UseCaseBranchEnd'} + steps: + type: array + items: {$ref: '#/components/schemas/UseCaseElement'} + additionalProperties: false + required: [id, kind, title, from_step, end, steps] + UseCaseListRef: + oneOf: + - type: string + enum: [preconditions, main, postconditions] + - type: object + properties: + branch: *id002 + additionalProperties: true + required: [branch] + UseCaseNewElement: + type: object + properties: + id: + anyOf: + - *id002 + - {type: 'null'} + text: *id002 + additionalProperties: false + required: [text] + UseCaseNewBranch: + type: object + properties: + id: + anyOf: + - *id002 + - {type: 'null'} + kind: + type: string + enum: [alternative, exception] + title: *id002 + from_step: *id002 + end: {$ref: '#/components/schemas/UseCaseBranchEnd'} + steps: + type: array + items: {$ref: '#/components/schemas/UseCaseNewElement'} + additionalProperties: false + required: [kind, title, from_step, end] + UseCaseDependents: + oneOf: + - type: string + enum: [cascade] + - type: object + properties: + rebranch_to: *id002 + additionalProperties: true + required: [rebranch_to] + UseCaseOperation: + oneOf: + - type: object + properties: + op: {type: string, const: set_header} + title: + anyOf: + - *id002 + - {type: 'null'} + primary_actor: + anyOf: + - *id002 + - {type: 'null'} + secondary_actors: + anyOf: + - type: array + items: *id002 + - {type: 'null'} + summary: + anyOf: + - *id002 + - {type: 'null'} + additionalProperties: false + required: [op] + - type: object + properties: + op: {type: string, const: add_element} + list: {$ref: '#/components/schemas/UseCaseListRef'} + after: + anyOf: + - *id002 + - {type: 'null'} + element: {$ref: '#/components/schemas/UseCaseNewElement'} + additionalProperties: false + required: [op, list, element] + - type: object + properties: + op: {type: string, const: update_text} + id: *id002 + text: *id002 + additionalProperties: false + required: [op, id, text] + - type: object + properties: + op: {type: string, const: move_element} + id: *id002 + list: + anyOf: + - {$ref: '#/components/schemas/UseCaseListRef'} + - {type: 'null'} + after: + anyOf: + - *id002 + - {type: 'null'} + additionalProperties: false + required: [op, id] + - type: object + properties: + op: {type: string, const: remove_element} + id: *id002 + dependents: + anyOf: + - {$ref: '#/components/schemas/UseCaseDependents'} + - {type: 'null'} + additionalProperties: false + required: [op, id] + - type: object + properties: + op: {type: string, const: add_branch} + branch: {$ref: '#/components/schemas/UseCaseNewBranch'} + additionalProperties: false + required: [op, branch] + - type: object + properties: + op: {type: string, const: update_branch} + id: *id002 + title: + anyOf: + - *id002 + - {type: 'null'} + kind: + anyOf: + - type: string + enum: [alternative, exception] + - {type: 'null'} + from_step: + anyOf: + - *id002 + - {type: 'null'} + end: + anyOf: + - {$ref: '#/components/schemas/UseCaseBranchEnd'} + - {type: 'null'} + additionalProperties: false + required: [op, id] + - type: object + properties: + op: {type: string, const: remove_branch} + id: *id002 + additionalProperties: false + required: [op, id] + UseCaseDiffSide: + type: object + properties: + list: + type: string + enum: [preconditions, main, postconditions, branch, branches] + branch_id: *id002 + label: + anyOf: + - *id002 + - {type: 'null'} + text: *id002 + from_step: *id002 + end: {$ref: '#/components/schemas/UseCaseBranchEnd'} + kind: + type: string + enum: [alternative, exception] + additionalProperties: true + required: [list, label, text] + UseCaseElementDiff: + type: object + properties: + id: *id002 + kind: + type: string + enum: [precondition, postcondition, step, branch] + changes: + type: array + items: + type: string + enum: [added, removed, edited, moved, rebranched] + old: + anyOf: + - {$ref: '#/components/schemas/UseCaseDiffSide'} + - {type: 'null'} + new: + anyOf: + - {$ref: '#/components/schemas/UseCaseDiffSide'} + - {type: 'null'} + text_diff: + type: array + items: type: object properties: - status: - type: integer - error: - type: string - message: + op: type: string + enum: [equal, delete, insert] + text: *id002 + additionalProperties: true + required: [op, text] + additionalProperties: true + required: [id, kind, changes, old, new] + UseCaseDiffEnd: + type: object + properties: + version_id: + anyOf: + - *id003 + - {type: 'null'} + version_number: + anyOf: + - *id003 + - {type: 'null'} + draft_revision: + anyOf: + - *id003 + - {type: 'null'} + additionalProperties: true + required: [version_id, version_number, draft_revision] + UseCaseDiff: + type: object + properties: + from: + anyOf: + - {$ref: '#/components/schemas/UseCaseDiffEnd'} + - {type: 'null'} + to: {$ref: '#/components/schemas/UseCaseDiffEnd'} + summary: + type: object + properties: + added: *id003 + removed: *id003 + edited: *id003 + moved: *id003 + rebranched: *id003 + additionalProperties: true + required: [added, removed, edited, moved, rebranched] + header: + type: object + additionalProperties: + type: object + properties: + old: &id011 + oneOf: + - *id002 + - {type: 'null'} + - type: array + items: *id002 + new: *id011 + additionalProperties: true + required: [old, new] + elements: + type: array + items: {$ref: '#/components/schemas/UseCaseElementDiff'} + additionalProperties: true + required: [from, to, summary, header, elements] + UseCasePublishResponse: + type: object + properties: + version: {$ref: '#/components/schemas/use_cases_VersionView_response'} + removed_links: + type: array + items: {$ref: '#/components/schemas/use_cases_LinkView_response'} + additionalProperties: true + required: [version, removed_links] + responses: + ApiError: + description: API error; use cases may include code and structured details. Guard/fairing errors can have an + empty body. + content: + application/json: + schema: {$ref: '#/components/schemas/ApiError'} +security: +- cookieAuth: [] diff --git a/docs/developer/use-cases-api.md b/docs/developer/use-cases-api.md index 227257de..3920ac80 100644 --- a/docs/developer/use-cases-api.md +++ b/docs/developer/use-cases-api.md @@ -166,3 +166,13 @@ Comments are immutable. `@username` mentions of project members send a `mentione - **Search.** `GET /api/search` hits have `kind: "requirement" | "use_case"` and `use_case_id`; use cases match on code, title, actors and step text. - **Project bundle.** `bundle.json` has an optional `use_cases` section (see the HTTP API contract). - **MCP.** `list_use_cases`, `get_use_case`, `get_use_case_diff` and `list_use_case_links` (`use_cases:read`); `create_use_case`, `edit_use_case_draft`, `publish_use_case_draft` and `link_use_case_element` (`use_cases:write`, `MARREQ_MODE=draft_write`). Approval is not exposed to agents. See [mcp-setup.md](mcp-setup.md). + +## Executable contract checks + +Use-case request/response definitions are in `openapi.yaml`; consumer declarations +are generated with standard `openapi-typescript` through +`node scripts/generate-api-types.mjs`. Native Rust tests and +`scripts/test-api-contracts.sh` check representative real read/write, session, +Bearer-scope and project-boundary behavior. See the +[contract workflow](http-api-contract.md#consumer-aware-contract-verification-423--424) +for scope limits and the required schema-update procedure. diff --git a/docs/releases.md b/docs/releases.md index 9f194078..e749832b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -81,3 +81,76 @@ Images carry OCI label `org.opencontainers.image.version`. - Frontend: Vite `define` (`__FRONTEND_VERSION__`, `__FRONTEND_GIT_SHA__`) in `frontend/vite.config.ts`. When `MARREQ_VERSION` is unset or empty, each artifact falls back to its manifest version. A leading `v` is stripped so the value stays plain `X.Y.Z` for the compatibility check. Local images: `make docker-server MARREQ_VERSION=0.1.3`. + +## Exact artifact API compatibility (#423) + +The Rust CLI checks each consumer's own retained contract, never current-main +substitutes. A bundle contains only its selected operations and reachable schemas, +component/version/source SHA and compatibility policy. Backend bundles contain +verified producer operations for one variant; unknown unconsumed routes are not +invented. Consumer export fails on any required unknown/unverified dependency. + +Export from the artifact's clean source commit: + +```sh +cargo run --locked -p marreq-contracts -- bundle --component backend --variant server --version 0.2.4 --output backend.json +cargo run --locked -p marreq-contracts -- bundle --component frontend --version 0.2.1 --output frontend.json +cargo run --locked -p marreq-contracts -- bundle --component mcp --version 0.3.0 --output mcp.json +``` + +Each command must run on **that component version's actual source**, not all on +main. Retain the output beside its release artifact. `marreq.contract.v2` requires +consumer-specific evidence; the old whole-backend bootstrap bundle is not +retrospectively certified. Never fabricate contracts for already published images. + +A `marreq.deployment-contract.v2` plan names `variant` (`server`/`cloud`) and +`backend`, `frontend`, `mcp`. Each descriptor contains exactly `file`, `sha256`, +`version`, `source_sha`. Files must remain inside the plan directory. SHA-256 pins +**contract bytes, not Docker image provenance**; a trusted orchestrator must bind +the bundle/source to the actual image digest. This prerequisite does not implement +that future #423 release/provenance/CD architecture. + +```json +{ + "format": "marreq.deployment-contract.v2", + "variant": "server", + "backend": {"file": "backend.json", "sha256": "<64 hex>", "version": "0.2.4", "source_sha": "<40 hex>"}, + "frontend": {"file": "frontend.json", "sha256": "<64 hex>", "version": "0.2.1", "source_sha": "<40 hex>"}, + "mcp": {"file": "mcp.json", "sha256": "<64 hex>", "version": "0.3.0", "source_sha": "<40 hex>"} +} +``` + +```sh +cargo run --locked -p marreq-contracts -- verify --manifest deployment.json +# Equivalent thin wrapper: +scripts/check-api-compatibility.sh --manifest deployment.json +``` + +For this backend-only update, retain the actual frontend 0.2.1 and MCP 0.3.0 +bundles. The candidate 0.2.4 must satisfy their request/response contracts and each +consumer's backend range. Equal component versions are not required. The backend's +existing frontend acceptance range is also enforced. An unrelated backend route +outside both consumer bundles can change without failing their compatibility. + +Optional request fields and additional response fields are compatible where the +old contract accepts them. Removed guaranteed response properties, new required +request properties, narrowed request enums, widened closed response enums, +incompatible types/authentication, or missing required operations fail. Per-operation +Bearer scopes and stable CSRF policy IDs are checked separately; unused global +security schemes and exact global status/header set equality do not couple APIs. +The pinned standards engine decides ordinary OpenAPI changes, not homegrown rules. +Missing evidence, altered hashes, mismatched source/version, invalid policy, +unsupported semantics and uncertain required operations exit nonzero with JSON +errors. Version ranges supplement executable contracts and never prove them alone. +During rolling upgrades, check every old/new combination that may coexist. + +Current component-tag publishing is retained. A publication job checks out an +immutable plan revision from `MARREQ_RELEASE_CONTRACTS_SHA`, exports the exact tagged +candidate, and verifies its version, source and bundle hash against the plan before +any image publisher runs. The release gate uses the **actual artifact combination**; +it does not require the latest checkout's unchanged consumers. The PR gate instead +checks all calls in current source. No gate requires coverage of unconsumed routes. +No global release migration or production deployment is performed here. + +The PR remains draft until required consumer schema gaps and security failures are +resolved; a document-only check is not a merge/release-readiness assertion. diff --git a/frontend/src/api/__tests__/catalog.test.ts b/frontend/src/api/__tests__/catalog.test.ts index 13efb309..bcac0bec 100644 --- a/frontend/src/api/__tests__/catalog.test.ts +++ b/frontend/src/api/__tests__/catalog.test.ts @@ -157,9 +157,9 @@ describe('catalog API', () => { { name: 'listVerificationMethods', - call: () => listVerificationMethods(), + call: () => listVerificationMethods(5), method: 'GET', - url: '/api/verification-methods', + url: '/api/projects/5/verification-methods', }, { name: 'createVerificationMethod scopes the body to the project', diff --git a/frontend/src/api/catalog.ts b/frontend/src/api/catalog.ts index 8fb00c17..d84a3a45 100644 --- a/frontend/src/api/catalog.ts +++ b/frontend/src/api/catalog.ts @@ -248,8 +248,8 @@ export async function deleteCustomField( }); } -export async function listVerificationMethods(): Promise { - return fetchJson('/api/verification-methods'); +export async function listVerificationMethods(projectId: number): Promise { + return fetchJson(`/api/projects/${projectId}/verification-methods`); } export async function createVerificationMethod( diff --git a/frontend/src/api/generated-contract.ts b/frontend/src/api/generated-contract.ts new file mode 100644 index 00000000..13b8e14f --- /dev/null +++ b/frontend/src/api/generated-contract.ts @@ -0,0 +1,484 @@ +// Generated by scripts/generate-api-types.mjs; do not edit. +export interface components { + schemas: { + ApiError: { + error: string; + message: string; + status: number; + } & { + [key: string]: unknown; + }; + BuildInfo: { + backend_git_sha: string; + backend_version: string; + /** @enum {string} */ + deployment_mode: "server" | "cloud"; + frontend_compatibility: { + max_version: string; + min_version: string; + } & { + [key: string]: unknown; + }; + } & { + [key: string]: unknown; + }; + DeploymentInfo: { + allows_admin_promotion: boolean; + allows_database_backup: boolean; + allows_self_administered_user_creation: boolean; + allows_self_registration: boolean; + assigns_personal_workspace: boolean; + /** @enum {string} */ + mode: "server" | "cloud"; + requires_email_verification: boolean; + } & { + [key: string]: unknown; + }; + UseCaseBranch: { + end: components["schemas"]["UseCaseBranchEnd"]; + from_step: string; + id: string; + /** @enum {string} */ + kind: "alternative" | "exception"; + steps?: components["schemas"]["UseCaseElement"][]; + title: string; + }; + UseCaseBranchEnd: { + step: string; + /** @constant */ + type: "rejoin"; + } | { + /** @enum {string} */ + outcome: "success" | "failure"; + /** @constant */ + type: "end"; + }; + UseCaseBranchResponse: { + end: components["schemas"]["UseCaseBranchEnd"]; + from_step: string; + id: string; + /** @enum {string} */ + kind: "alternative" | "exception"; + steps: components["schemas"]["UseCaseElement"][]; + title: string; + }; + UseCaseDependents: "cascade" | ({ + rebranch_to: string; + } & { + [key: string]: unknown; + }); + UseCaseDiff: { + elements: components["schemas"]["UseCaseElementDiff"][]; + from: components["schemas"]["UseCaseDiffEnd"] | null; + header: { + [key: string]: { + new: string | null | string[]; + old: string | null | string[]; + } & { + [key: string]: unknown; + }; + }; + summary: { + added: number; + edited: number; + moved: number; + rebranched: number; + removed: number; + } & { + [key: string]: unknown; + }; + to: components["schemas"]["UseCaseDiffEnd"]; + } & { + [key: string]: unknown; + }; + UseCaseDiffEnd: { + draft_revision: number | null; + version_id: number | null; + version_number: number | null; + } & { + [key: string]: unknown; + }; + UseCaseDiffSide: { + branch_id?: string; + end?: components["schemas"]["UseCaseBranchEnd"]; + from_step?: string; + /** @enum {string} */ + kind?: "alternative" | "exception"; + label: string | null; + /** @enum {string} */ + list: "preconditions" | "main" | "postconditions" | "branch" | "branches"; + text: string; + } & { + [key: string]: unknown; + }; + UseCaseDocument: { + branches?: components["schemas"]["UseCaseBranch"][]; + main?: components["schemas"]["UseCaseElement"][]; + postconditions?: components["schemas"]["UseCaseElement"][]; + preconditions?: components["schemas"]["UseCaseElement"][]; + primary_actor?: string | null; + /** @constant */ + schema?: "marreq.use-case.v1"; + secondary_actors?: string[]; + summary?: string | null; + title: string; + }; + UseCaseDocumentResponse: { + branches: components["schemas"]["UseCaseBranchResponse"][]; + main: components["schemas"]["UseCaseElement"][]; + postconditions: components["schemas"]["UseCaseElement"][]; + preconditions: components["schemas"]["UseCaseElement"][]; + primary_actor: string | null; + /** @constant */ + schema: "marreq.use-case.v1"; + secondary_actors: string[]; + summary: string | null; + title: string; + }; + UseCaseEffect: ({ + from: string; + id: string; + to: string; + /** @constant */ + type: "relabelled"; + } & { + [key: string]: unknown; + }) | ({ + id: string; + /** @constant */ + type: "removed"; + } & { + [key: string]: unknown; + }) | ({ + from_step: string; + id: string; + /** @constant */ + type: "rebranched"; + } & { + [key: string]: unknown; + }) | ({ + id: string; + /** @constant */ + type: "created"; + } & { + [key: string]: unknown; + }); + UseCaseElement: { + id: string; + text: string; + }; + UseCaseElementDiff: { + changes: ("added" | "removed" | "edited" | "moved" | "rebranched")[]; + id: string; + /** @enum {string} */ + kind: "precondition" | "postcondition" | "step" | "branch"; + new: components["schemas"]["UseCaseDiffSide"] | null; + old: components["schemas"]["UseCaseDiffSide"] | null; + text_diff?: ({ + /** @enum {string} */ + op: "equal" | "delete" | "insert"; + text: string; + } & { + [key: string]: unknown; + })[]; + } & { + [key: string]: unknown; + }; + UseCaseListRef: ("preconditions" | "main" | "postconditions") | ({ + branch: string; + } & { + [key: string]: unknown; + }); + UseCaseNewBranch: { + end: components["schemas"]["UseCaseBranchEnd"]; + from_step: string; + id?: string | null; + /** @enum {string} */ + kind: "alternative" | "exception"; + steps?: components["schemas"]["UseCaseNewElement"][]; + title: string; + }; + UseCaseNewElement: { + id?: string | null; + text: string; + }; + UseCaseOperation: { + /** @constant */ + op: "set_header"; + primary_actor?: string | null; + secondary_actors?: string[] | null; + summary?: string | null; + title?: string | null; + } | { + after?: string | null; + element: components["schemas"]["UseCaseNewElement"]; + list: components["schemas"]["UseCaseListRef"]; + /** @constant */ + op: "add_element"; + } | { + id: string; + /** @constant */ + op: "update_text"; + text: string; + } | { + after?: string | null; + id: string; + list?: components["schemas"]["UseCaseListRef"] | null; + /** @constant */ + op: "move_element"; + } | { + dependents?: components["schemas"]["UseCaseDependents"] | null; + id: string; + /** @constant */ + op: "remove_element"; + } | { + branch: components["schemas"]["UseCaseNewBranch"]; + /** @constant */ + op: "add_branch"; + } | { + end?: components["schemas"]["UseCaseBranchEnd"] | null; + from_step?: string | null; + id: string; + kind?: ("alternative" | "exception") | null; + /** @constant */ + op: "update_branch"; + title?: string | null; + } | { + id: string; + /** @constant */ + op: "remove_branch"; + }; + UseCasePublishResponse: { + removed_links: components["schemas"]["use_cases_LinkView_response"][]; + version: components["schemas"]["use_cases_VersionView_response"]; + } & { + [key: string]: unknown; + }; + entities_User_response: { + creation_date: string; + email: string; + email_verified: boolean; + id: number; + is_admin: boolean; + last_login: string; + name: string; + username: string; + } & { + [key: string]: unknown; + }; + external_ProviderDiscovery_response: { + external: components["schemas"]["external_PublicAuthProvider_response"][]; + password_enabled: boolean; + } & { + [key: string]: unknown; + }; + external_PublicAuthProvider_response: { + display_name: string; + id: string; + } & { + [key: string]: unknown; + }; + use_cases_BaselineUseCaseView_response: { + approval_state: string; + current_version_id: number | null; + current_version_number: number | null; + link_count: number; + primary_actor: string | null; + reference_code: string; + title: string; + use_case_id: number; + version_id: number; + version_number: number; + } & { + [key: string]: unknown; + }; + use_cases_ClearSuspectResponse_response: { + cleared: boolean; + status: string; + } & { + [key: string]: unknown; + }; + use_cases_DraftChangeView_response: { + base_version_id: number | null; + document: components["schemas"]["UseCaseDocumentResponse"]; + effects: components["schemas"]["UseCaseEffect"][]; + labels: { + [key: string]: string; + }; + revision: number; + updated_at: string; + updated_by: number | null; + updated_by_name: string | null; + use_case_id: number; + } & { + [key: string]: unknown; + }; + use_cases_DraftSummary_response: { + base_version_id: number | null; + revision: number; + updated_at: string; + updated_by: number | null; + updated_by_name: string | null; + } & { + [key: string]: unknown; + }; + use_cases_DraftView_response: { + base_version_id: number | null; + document: components["schemas"]["UseCaseDocumentResponse"]; + labels: { + [key: string]: string; + }; + revision: number; + updated_at: string; + updated_by: number | null; + updated_by_name: string | null; + use_case_id: number; + } & { + [key: string]: unknown; + }; + use_cases_LinkView_response: { + created_at: string; + created_by: number | null; + current_requirement_version_id: number | null; + element_id: string | null; + element_label: string | null; + id: number; + linked_requirement_version_id: number | null; + rationale: string | null; + requirement_id: number | null; + suspect: boolean; + suspect_at: string | null; + suspect_reason: string | null; + target_reference_code: string; + target_title: string; + /** @enum {string} */ + target_type: "requirement" | "verification"; + verification_id: number | null; + } & { + [key: string]: unknown; + }; + use_cases_NextCode_response: { + reference_code: string; + } & { + [key: string]: unknown; + }; + use_cases_PutDraftRequest_request: { + base_revision?: number | null; + document: components["schemas"]["UseCaseDocument"]; + } & { + [key: string]: unknown; + }; + use_cases_SetApprovalRequest_request: { + state: string; + } & { + [key: string]: unknown; + }; + use_cases_UseCaseCommentView_response: { + author_id: number; + author_name: string; + body: string; + created_at: string; + element_id: string | null; + element_label: string | null; + id: number; + use_case_id: number; + use_case_version_id: number | null; + } & { + [key: string]: unknown; + }; + use_cases_UseCaseDetail_response: { + created_at: string; + created_by: number | null; + current_version: components["schemas"]["use_cases_VersionView_response"] | null; + draft: components["schemas"]["use_cases_DraftSummary_response"] | null; + id: number; + project_id: number; + reference_code: string; + title: string; + updated_at: string; + } & { + [key: string]: unknown; + }; + use_cases_UseCaseRef_response: { + element_id: string | null; + element_label: string | null; + link_id: number; + reference_code: string; + suspect: boolean; + title: string; + use_case_id: number; + } & { + [key: string]: unknown; + }; + use_cases_UseCaseRow_response: { + alternative_count: number; + approval_state: string | null; + current_version_number: number | null; + exception_count: number; + has_draft: boolean; + id: number; + link_count: number; + primary_actor: string | null; + reference_code: string; + step_count: number; + suspect_count: number; + title: string; + updated_at: string; + } & { + [key: string]: unknown; + }; + use_cases_VersionSummary_response: { + approval_state: string; + approved_at: string | null; + approved_by: number | null; + change_note: string | null; + created_at: string; + created_by: number | null; + created_by_name: string | null; + id: number; + primary_actor: string | null; + reviewed_at: string | null; + reviewed_by: number | null; + title: string; + version_number: number; + } & { + [key: string]: unknown; + }; + use_cases_VersionView_response: { + approval_state: string; + approved_at: string | null; + approved_by: number | null; + change_note: string | null; + created_at: string; + created_by: number | null; + created_by_name: string | null; + document: components["schemas"]["UseCaseDocumentResponse"]; + id: number; + labels: { + [key: string]: string; + }; + primary_actor: string | null; + reviewed_at: string | null; + reviewed_by: number | null; + title: string; + version_number: number; + } & { + [key: string]: unknown; + }; + }; + responses: { + /** @description API error; use cases may include code and structured details. Guard/fairing errors can have an empty body. */ + ApiError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiError"]; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d4251a25..32c8be4f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,3 +1,4 @@ +import type { components as ApiComponents } from './generated-contract'; /** API shapes aligned with marreq-core JSON (snake_case). */ export interface RequirementStatus { @@ -187,21 +188,9 @@ export interface DashboardPayload { csrf_token: string; } -export interface DeploymentInfo { - mode: 'server' | 'cloud' | string; - allows_self_registration: boolean; - requires_email_verification: boolean; - allows_admin_promotion: boolean; - assigns_personal_workspace: boolean; - allows_self_administered_user_creation: boolean; - /** False in cloud mode, where a whole-database dump would contain every tenant. */ - allows_database_backup?: boolean; -} +export type DeploymentInfo = ApiComponents['schemas']['DeploymentInfo']; -export interface AuthProviderDiscovery { - password_enabled: boolean; - external: Array<{ id: string; display_name: string }>; -} +export type AuthProviderDiscovery = ApiComponents['schemas']['external_ProviderDiscovery_response']; export interface ConnectedIdentity { id: number; @@ -229,12 +218,7 @@ export interface FrontendCompatibility { } /** Response from `GET /api/meta/build` (issue #213). */ -export interface BuildInfo { - backend_version: string; - backend_git_sha: string; - deployment_mode: string; - frontend_compatibility: FrontendCompatibility; -} +export type BuildInfo = ApiComponents['schemas']['BuildInfo']; export type RegistrationBody = { username: string; @@ -989,52 +973,16 @@ export interface UseCaseDocument { /** Display labels by element id: `3`, `A3`, `A3.1`, `E4`, `P1`, `Q1`. */ export type UseCaseLabels = Record; -export interface UseCaseRow { - id: number; - reference_code: string; - title: string; - primary_actor: string | null; - step_count: number; - alternative_count: number; - exception_count: number; - /** Of the current version; `null` before the first publish. */ - approval_state: string | null; - current_version_number: number | null; - has_draft: boolean; - /** Links to requirements and verifications, and how many are suspect (issue #400). */ - link_count: number; - suspect_count: number; - updated_at: string; -} +export type UseCaseRow = ApiComponents['schemas']['use_cases_UseCaseRow_response']; -export interface UseCaseVersionSummary { - id: number; - version_number: number; - approval_state: string; - title: string; - primary_actor: string | null; - change_note: string | null; - created_by: number | null; - created_by_name: string | null; - created_at: string; - reviewed_by: number | null; - reviewed_at: string | null; - approved_by: number | null; - approved_at: string | null; -} +export type UseCaseVersionSummary = ApiComponents['schemas']['use_cases_VersionSummary_response']; export interface UseCaseVersion extends UseCaseVersionSummary { document: UseCaseDocument; labels: UseCaseLabels; } -export interface UseCaseDraftSummary { - revision: number; - base_version_id: number | null; - updated_by: number | null; - updated_by_name: string | null; - updated_at: string; -} +export type UseCaseDraftSummary = ApiComponents['schemas']['use_cases_DraftSummary_response']; export interface UseCaseDraft extends UseCaseDraftSummary { use_case_id: number; @@ -1096,36 +1044,10 @@ export type UseCaseOperation = | { op: 'remove_branch'; id: string }; /** A link from a use case, or one of its elements, to a requirement or verification (issue #400). */ -export interface UseCaseLink { - id: number; - /** `null` for the whole use case. */ - element_id: string | null; - element_label: string | null; - target_type: 'requirement' | 'verification'; - requirement_id: number | null; - verification_id: number | null; - target_reference_code: string; - target_title: string; - rationale: string | null; - suspect: boolean; - suspect_at: string | null; - suspect_reason: string | null; - linked_requirement_version_id: number | null; - current_requirement_version_id: number | null; - created_by: number | null; - created_at: string; -} +export type UseCaseLink = ApiComponents['schemas']['use_cases_LinkView_response']; /** A use case that links to a requirement or verification. */ -export interface UseCaseRef { - use_case_id: number; - reference_code: string; - title: string; - link_id: number; - element_id: string | null; - element_label: string | null; - suspect: boolean; -} +export type UseCaseRef = ApiComponents['schemas']['use_cases_UseCaseRef_response']; /** Structural diff of two use case documents (issue #401). */ export type UseCaseChange = 'added' | 'removed' | 'edited' | 'moved' | 'rebranched'; @@ -1163,28 +1085,7 @@ export interface UseCaseDiff { elements: UseCaseElementDiff[]; } -export interface UseCaseComment { - id: number; - use_case_id: number; - use_case_version_id: number | null; - element_id: string | null; - element_label: string | null; - author_id: number; - author_name: string; - body: string; - created_at: string; -} +export type UseCaseComment = ApiComponents['schemas']['use_cases_UseCaseCommentView_response']; /** A use case version a baseline recorded (issue #402). */ -export interface BaselineUseCase { - use_case_id: number; - reference_code: string; - title: string; - primary_actor: string | null; - version_id: number; - version_number: number; - approval_state: string; - current_version_id: number | null; - current_version_number: number | null; - link_count: number; -} +export type BaselineUseCase = ApiComponents['schemas']['use_cases_BaselineUseCaseView_response']; diff --git a/frontend/src/hooks/__tests__/useBuildInfo.test.ts b/frontend/src/hooks/__tests__/useBuildInfo.test.ts index c35c5d11..1cedf393 100644 --- a/frontend/src/hooks/__tests__/useBuildInfo.test.ts +++ b/frontend/src/hooks/__tests__/useBuildInfo.test.ts @@ -7,7 +7,7 @@ vi.mock('@/api/client'); const build = { backend_version: '0.1.3', backend_git_sha: 'abc', - deployment_mode: 'server', + deployment_mode: 'server' as const, frontend_compatibility: { min_version: '0.1.0', max_version: '0.1.99' }, }; diff --git a/frontend/src/pages/SystemLogsPage.tsx b/frontend/src/pages/SystemLogsPage.tsx index 82f3f7c7..16a8ee12 100644 --- a/frontend/src/pages/SystemLogsPage.tsx +++ b/frontend/src/pages/SystemLogsPage.tsx @@ -114,7 +114,7 @@ export default function SystemLogsPage() { listVerificationStatuses().catch(() => []), listCategories().catch(() => []), listApplicability().catch(() => []), - listVerificationMethods().catch(() => []), + Promise.all((dashboard?.projects ?? []).map((p) => listVerificationMethods(p.id).catch(() => []))).then((rows) => rows.flat()), ]); setItems(res.items); setTotal(res.total); @@ -131,7 +131,7 @@ export default function SystemLogsPage() { setLoading(false); } }, - [filterParams], + [filterParams, dashboard?.projects], ); useEffect(() => { diff --git a/frontend/src/pages/__tests__/AccountPage.test.tsx b/frontend/src/pages/__tests__/AccountPage.test.tsx index 5138986b..79cd9fa6 100644 --- a/frontend/src/pages/__tests__/AccountPage.test.tsx +++ b/frontend/src/pages/__tests__/AccountPage.test.tsx @@ -31,6 +31,7 @@ const serverDeployment = { allows_admin_promotion: true, assigns_personal_workspace: false, allows_self_administered_user_creation: true, + allows_database_backup: true, }; function renderPage() { diff --git a/frontend/src/pages/__tests__/AdminPage.test.tsx b/frontend/src/pages/__tests__/AdminPage.test.tsx index 9fc82660..b935722e 100644 --- a/frontend/src/pages/__tests__/AdminPage.test.tsx +++ b/frontend/src/pages/__tests__/AdminPage.test.tsx @@ -47,6 +47,7 @@ const serverDeployment = { allows_admin_promotion: true, assigns_personal_workspace: false, allows_self_administered_user_creation: true, + allows_database_backup: true, }; const cloudDeployment = { @@ -55,6 +56,7 @@ const cloudDeployment = { allows_self_registration: true, allows_admin_promotion: false, allows_self_administered_user_creation: false, + allows_database_backup: false, }; function renderPage() { diff --git a/frontend/src/pages/__tests__/HelpPage.test.tsx b/frontend/src/pages/__tests__/HelpPage.test.tsx index 601f467b..1eae3b01 100644 --- a/frontend/src/pages/__tests__/HelpPage.test.tsx +++ b/frontend/src/pages/__tests__/HelpPage.test.tsx @@ -28,7 +28,7 @@ vi.mock('react-router-dom', async () => { const compatibleBuild = { backend_version: '0.1.0', backend_git_sha: '56c09392abcdef', - deployment_mode: 'server', + deployment_mode: 'server' as const, frontend_compatibility: { min_version: '0.1.0', max_version: '0.1.99' }, }; diff --git a/frontend/src/pages/__tests__/LoginPage.test.tsx b/frontend/src/pages/__tests__/LoginPage.test.tsx index 273c5aa5..59db6743 100644 --- a/frontend/src/pages/__tests__/LoginPage.test.tsx +++ b/frontend/src/pages/__tests__/LoginPage.test.tsx @@ -26,6 +26,7 @@ const serverDeployment = { allows_admin_promotion: true, assigns_personal_workspace: false, allows_self_administered_user_creation: true, + allows_database_backup: true, }; const cloudDeployment = { @@ -35,6 +36,7 @@ const cloudDeployment = { allows_admin_promotion: false, assigns_personal_workspace: true, allows_self_administered_user_creation: false, + allows_database_backup: false, }; describe('LoginPage', () => { diff --git a/frontend/src/pages/__tests__/ReportBuilderPage.test.tsx b/frontend/src/pages/__tests__/ReportBuilderPage.test.tsx index 9cc91639..191dde0a 100644 --- a/frontend/src/pages/__tests__/ReportBuilderPage.test.tsx +++ b/frontend/src/pages/__tests__/ReportBuilderPage.test.tsx @@ -159,6 +159,7 @@ describe('ReportBuilderPage', () => { await waitFor(() => expect(screen.getByTestId('where')).toHaveTextContent('?type=vcd')); await userEvent.click(screen.getByRole('checkbox', { name: 'Include Contents' })); + await screen.findByText('Unsaved changes.'); vi.mocked(window.confirm).mockReturnValue(false); await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Report' }), 'coverage'); expect(screen.getByTestId('where')).toHaveTextContent('?type=vcd'); diff --git a/marreq-cloud/Cargo.toml b/marreq-cloud/Cargo.toml index 52bd6a40..861acbb7 100644 --- a/marreq-cloud/Cargo.toml +++ b/marreq-cloud/Cargo.toml @@ -31,6 +31,7 @@ anyhow = { workspace = true } sha2 = { workspace = true } [dev-dependencies] +marreq-contracts = { path = "../tools/marreq-contracts" } marreq-core = { path = "../marreq-core", features = ["test-helpers"] } tokio = { workspace = true, features = ["rt"] } serde_json = { workspace = true } diff --git a/marreq-cloud/tests/api_contract_inventory.rs b/marreq-cloud/tests/api_contract_inventory.rs new file mode 100644 index 00000000..d1dbac2a --- /dev/null +++ b/marreq-cloud/tests/api_contract_inventory.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +//! Consumer contracts must correspond to compiled production Rocket mounts. +//! Additional, unconsumed backend routes deliberately do not fail this check. +use std::collections::BTreeSet; + +#[test] +fn consumed_contract_routes_exist_in_server_and_cloud() { + let spec = marreq_contracts::contract::load( + &std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/developer/openapi.yaml"), + ) + .unwrap(); + for (variant, extra) in [ + ("server", Vec::new()), + ("cloud", marreq_cloud::routes::routes()), + ] { + let mounts = marreq_core::app::declared_mounts(extra); + let actual: BTreeSet<_> = [("/api", mounts.api), ("", mounts.root)] + .into_iter() + .flat_map(|(base, routes)| { + routes.into_iter().map(move |(_, route)| { + ( + route.method.to_string().to_lowercase(), + format!( + "{base}{}", + route.uri.path().replace('<', "{").replace('>', "}") + ), + ) + }) + }) + .collect(); + let documented = marreq_contracts::contract::operations(&spec, Some(variant)).unwrap(); + for key in documented.keys() { + assert!( + actual.contains(&(key.method.clone(), key.path.clone())), + "{variant}: missing compiled {key}" + ); + } + for (key, op) in marreq_contracts::contract::operations(&spec, None).unwrap() { + if op + .get("x-marreq-variants") + .and_then(serde_json::Value::as_array) + .is_some_and(|a| !a.iter().any(|x| x == variant)) + { + assert!( + !actual.contains(&(key.method, key.path)), + "deployment-specific endpoint leaked into {variant}" + ); + } + } + } +} diff --git a/marreq-core/tests/delegated_oauth_mcp_tools_postgres_integration.rs b/marreq-core/tests/delegated_oauth_mcp_tools_postgres_integration.rs index f76697e3..6c6d6bb7 100644 --- a/marreq-core/tests/delegated_oauth_mcp_tools_postgres_integration.rs +++ b/marreq-core/tests/delegated_oauth_mcp_tools_postgres_integration.rs @@ -85,10 +85,12 @@ async fn status_of( body: Option, key: Option<&str>, ) -> (Status, String) { + let recorded_body = body.clone(); let mut request = match method { "GET" => client.get(path.to_owned()), "POST" => client.post(path.to_owned()), "DELETE" => client.delete(path.to_owned()), + "PUT" => client.put(path.to_owned()), other => panic!("unsupported method {other}"), } .header(bearer(token)); @@ -100,7 +102,22 @@ async fn status_of( } let response = request.dispatch().await; let status = response.status(); - (status, response.into_string().await.unwrap_or_default()) + let text = response.into_string().await.unwrap_or_default(); + if let Ok(output) = std::env::var("MARREQ_CONTRACT_TRANSCRIPT") { + use std::io::Write; + let record = serde_json::json!({ + "method": method, "path": path, "status": status.code, + "request": recorded_body, + "response": serde_json::from_str::(&text).unwrap_or(serde_json::Value::Null), + }); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(output) + .expect("contract transcript"); + writeln!(file, "{record}").expect("record actual Rocket exchange"); + } + (status, text) } #[rocket::async_test] @@ -152,9 +169,18 @@ async fn delegated_scopes_gate_the_routes_wrapped_by_mcp_tools() { SELECT setval(pg_get_serial_sequence('requirement_version_links','id'), 500);", ) .expect("seed fixtures"); + // Isolated browser-session fixture, also used by the real HTTP smoke test. + let password_hash = + marreq_core::auth::password::hash_password("Contract-only-fixture-58!z").unwrap(); + diesel::sql_query("UPDATE users SET password_hash=$1, email_verified=true WHERE id=1") + .bind::(password_hash) + .execute(&mut conn) + .unwrap(); for (raw, scopes) in [ ("projects", vec!["projects:read"]), + ("uc-read", vec!["use_cases:read"]), + ("uc-write", vec!["use_cases:read", "use_cases:write"]), ("req-read", vec!["requirements:read"]), ("trace-read", vec!["traceability:read"]), ("trace-write", vec!["traceability:write"]), @@ -174,6 +200,48 @@ async fn delegated_scopes_gate_the_routes_wrapped_by_mcp_tools() { ); let client = Client::tracked(rocket).await.expect("Rocket client"); + // Contract verification uses the real Rocket handlers and migrated PostgreSQL. + // Credentials never enter the transcript; only synthetic payloads are recorded. + for path in ["/api/meta/build", "/api/meta/deployment"] { + let (status, _) = status_of(&client, "GET", path, "uc-read", None, None).await; + assert_eq!(status, Status::Ok); + } + let use_cases = format!("{RES_BASE}/use-cases"); + let (status, _) = status_of(&client, "GET", &use_cases, "req-read", None, None).await; + assert_eq!(status, Status::Forbidden); + let (status, created) = status_of( + &client, + "POST", + &use_cases, + "uc-write", + Some(serde_json::json!({"title": "Contract use case", "primary_actor": "Tester"})), + Some("contract-create-use-case"), + ) + .await; + assert_eq!(status, Status::Created, "{created}"); + let created: serde_json::Value = serde_json::from_str(&created).unwrap(); + let uc_id = created["id"].as_i64().unwrap(); + let revision = created["draft"]["revision"].as_i64().unwrap(); + let draft_path = format!("{use_cases}/{uc_id}/draft"); + let document = serde_json::json!({ + "schema": "marreq.use-case.v1", "title": "Contract use case", + "main": [{"id": "s_test1", "text": "Verify the API contract"}] + }); + let (status, body) = status_of( + &client, + "PUT", + &draft_path, + "uc-write", + Some(serde_json::json!({"base_revision": revision, "document": document})), + None, + ) + .await; + assert_eq!(status, Status::Ok, "{body}"); + let (status, _) = status_of(&client, "GET", &use_cases, "uc-read", None, None).await; + assert_eq!(status, Status::Ok); + let (status, _) = status_of(&client, "GET", &draft_path, "uc-read", None, None).await; + assert_eq!(status, Status::Ok); + // (method, path, token lacking the scope, token holding it, expected status with scope) let links = format!("{RES_BASE}/requirement-version-links"); let reads: Vec<(String, &str, &str, Status)> = vec![ diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile index 08e905f7..3ff8b9bf 100644 --- a/mcp-server/Dockerfile +++ b/mcp-server/Dockerfile @@ -13,6 +13,7 @@ COPY --from=build --chown=node:node /app/package.json ./ COPY --from=build --chown=node:node /app/node_modules ./node_modules COPY --from=build --chown=node:node /app/dist ./dist COPY --chown=node:node mcp-server/MARREQ_SKILL.md ./MARREQ_SKILL.md +COPY --chown=node:node mcp-server/compatibility.json ./compatibility.json USER node EXPOSE 3000 HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \ diff --git a/mcp-server/compatibility.json b/mcp-server/compatibility.json new file mode 100644 index 00000000..776ee06e --- /dev/null +++ b/mcp-server/compatibility.json @@ -0,0 +1,4 @@ +{ + "requires_backend_min": "0.1.0", + "requires_backend_max": "0.2.99" +} diff --git a/mcp-server/dist/client.js b/mcp-server/dist/client.js index 8ff7f956..35f319ba 100644 --- a/mcp-server/dist/client.js +++ b/mcp-server/dist/client.js @@ -47,12 +47,16 @@ export class MarreqClient { async getRequirement(id) { return this.request(`/api/projects/${this.ctx.projectId}/requirements/${id}`); } - async listRequirements(approvalState, hasTests) { + async listRequirements(approvalState, hasTests, extra = {}) { const params = new URLSearchParams(); if (approvalState != null) params.set("approval_state", approvalState); if (hasTests != null) params.set("has_tests", String(hasTests)); + for (const [key, value] of Object.entries(extra)) { + if (value != null && value !== "") + params.set(key, String(value)); + } const q = params.toString(); return this.request(`/api/projects/${this.ctx.projectId}/requirements${q ? `?${q}` : ""}`); } @@ -273,6 +277,104 @@ export class MarreqClient { body: JSON.stringify(payload), }); } + // ----- Tool gaps (issue #416) ----- + project(path) { + return `/api/projects/${this.ctx.projectId}${path}`; + } + withQuery(path, query) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value != null && value !== "") + params.set(key, String(value)); + } + const q = params.toString(); + return q ? `${path}?${q}` : path; + } + async listRequirementLinkTypes() { + return this.request(this.project("/requirement-version-links/link-types")); + } + async listRequirementLinks(query = {}) { + return this.request(this.withQuery(this.project("/requirement-version-links"), query)); + } + async createRequirementLink(payload, idempotencyKey) { + return this.request(this.project("/requirement-version-links"), { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + body: JSON.stringify(payload), + }); + } + async deleteRequirementLink(linkId) { + return this.request(this.project(`/requirement-version-links/${linkId}`), { method: "DELETE" }); + } + async deleteUseCaseLink(useCaseId, linkId) { + return this.request(this.useCases(`/${useCaseId}/links/${linkId}`), { method: "DELETE" }); + } + async clearUseCaseLinkSuspect(useCaseId, linkId) { + return this.request(this.useCases(`/${useCaseId}/links/${linkId}/clear_suspect`), { method: "POST" }); + } + async listLinkedUseCases(kind, id) { + return this.request(this.project(`/${kind}s/${id}/use-cases`)); + } + async askRequirements(payload) { + return this.request(this.project("/requirements/ask"), { method: "POST", body: JSON.stringify(payload) }); + } + async listReviewers() { + return this.request(this.project("/reviewers")); + } + async getMyPermissions() { + return this.request(this.project("/me/permissions")); + } + async listSavedViews() { + return this.request(this.project("/saved_views")); + } + async listAttachments(entityType, entityId) { + return this.request(this.withQuery(this.project("/attachments"), { entity_type: entityType, entity_id: entityId })); + } + /** What needs the user's attention across projects (not project-scoped). */ + async getAttention() { + return this.request("/api/home/attention"); + } + async listUseCaseVersions(useCaseId) { + return this.request(this.useCases(`/${useCaseId}/versions`)); + } + async listUseCaseComments(useCaseId, query = {}) { + return this.request(this.withQuery(this.useCases(`/${useCaseId}/comments`), query)); + } + async createUseCaseComment(useCaseId, payload, idempotencyKey) { + return this.request(this.useCases(`/${useCaseId}/comments`), { + method: "POST", + headers: { "Idempotency-Key": idempotencyKey }, + body: JSON.stringify(payload), + }); + } + async discardUseCaseDraft(useCaseId) { + return this.request(this.useCases(`/${useCaseId}/draft`), { method: "DELETE" }); + } + /** Project close-out, or one requirement's when requirementId is given. */ + async getCloseOut(requirementId) { + return this.request(this.project(requirementId != null ? `/requirements/${requirementId}/close_out` : "/close_out")); + } + async getImpactedTests(requirementId) { + return this.request(this.project(`/requirements/${requirementId}/impacted_tests`)); + } + async listVerificationSnapshots(verificationId) { + return this.request(this.project(`/verifications/${verificationId}/snapshots`)); + } + async compareVerificationSnapshots(verificationId, v1, v2) { + return this.request(this.project(`/verifications/${verificationId}/snapshots/${v1}/diff/${v2}`)); + } + async setVerificationField(verificationId, field, value) { + return this.request(this.project(`/verifications/${verificationId}/field`), { + method: "POST", + body: JSON.stringify({ field, value }), + }); + } + async getBaselineContents(baselineId, kind) { + return this.request(this.project(`/baselines/${baselineId}/${kind === "use_cases" ? "use-cases" : "verifications"}`)); + } + async diffBaselineVerificationVsCurrent(baselineId, verificationId) { + return this.request(this.project(`/baselines/${baselineId}/verifications/${verificationId}/diff/current`)); + } get projectId() { return this.ctx.projectId; } diff --git a/mcp-server/dist/gap-tools.js b/mcp-server/dist/gap-tools.js new file mode 100644 index 00000000..7ca6712a --- /dev/null +++ b/mcp-server/dist/gap-tools.js @@ -0,0 +1,276 @@ +/** + * MCP tools added for issue #416 (tool gaps). Each one wraps one project-scoped + * backend route; none is a generic REST passthrough. Modes follow the PDR: + * core = every mode, RX = read_extended and up, DW = draft_write, TW = the + * MARREQ_TRACE_WRITE flag (same gate as put_verification_matrix). + */ +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { contextAllowsReadExtended } from "./context.js"; +import { paginate, paginationFields } from "./pagination.js"; +const id = (what) => z.number().int().positive().describe(what); +export function registerGapTools(deps) { + const { server, ctx, client, forProject, projectField, operationKey, withAudit, jsonContent } = deps; + const add = (spec) => { + const shape = { + ...(spec.global ? {} : projectField), + ...spec.shape, + ...(spec.paged ? paginationFields : {}), + }; + server.registerTool(spec.name, { + description: spec.description, + inputSchema: z.object(shape), + ...(spec.destructive ? { annotations: { destructiveHint: true } } : {}), + }, async (args) => { + const toolClient = spec.global ? client : forProject(args?.project_id); + const { idempotency_key: _key, ...audited } = args ?? {}; + const summary = spec.summary ? spec.summary(args ?? {}) : audited; + const out = await withAudit(toolClient, spec.name, JSON.stringify(summary), Boolean(spec.write), () => spec.run(toolClient, args ?? {})); + return { content: [jsonContent(spec.paged ? paginate(out, args ?? {}, 50) : out)] }; + }); + }; + // ----- Core: every mode ----- + add({ + name: "list_linked_use_cases", + description: "Use cases linked to a requirement or to a verification, with the link ids and suspect flags. Give exactly one of requirement_id or verification_id.", + shape: { + requirement_id: id("Requirement id").optional(), + verification_id: id("Verification id").optional(), + }, + paged: true, + run: (c, a) => { + if ((a.requirement_id == null) === (a.verification_id == null)) { + throw new Error("Give exactly one of requirement_id or verification_id"); + } + return a.requirement_id != null + ? c.listLinkedUseCases("requirement", a.requirement_id) + : c.listLinkedUseCases("verification", a.verification_id); + }, + }); + add({ + name: "ask_requirements", + description: "Answer a question from the project's requirements (retrieval plus an LLM call, so it costs time and money; prefer semantic_search_requirements when you only need matches). Fails with a clear error when answer generation is disabled on the server.", + shape: { + query: z.string().min(1), + k: z.number().int().min(1).max(20).optional().describe("Requirements used as context (default 10, max 20)."), + status_filter: id("Only requirements with this status id").optional(), + category_filter: id("Only requirements in this category id").optional(), + applicability_filter: id("Only requirements with this applicability id").optional(), + verification_filter: id("Only requirements with this verification method id").optional(), + }, + summary: (a) => ({ query_length: String(a.query ?? "").length, k: a.k }), + run: (c, a) => { + const { project_id: _p, ...payload } = a; + return c.askRequirements(payload); + }, + }); + add({ + name: "list_use_case_versions", + description: "Version history of a use case: version ids and numbers, approval states and change notes. Read a version with get_use_case(version_id).", + shape: { use_case_id: id("Use case id") }, + paged: true, + run: (c, a) => c.listUseCaseVersions(a.use_case_id), + }); + add({ + name: "get_baseline_contents", + description: "What a baseline recorded besides requirements: its use cases (kind=use_cases) or its verifications (kind=verifications).", + shape: { baseline_id: id("Baseline id"), kind: z.enum(["use_cases", "verifications"]) }, + paged: true, + run: (c, a) => c.getBaselineContents(a.baseline_id, a.kind), + }); + // ----- RX: read_extended and up ----- + if (contextAllowsReadExtended(ctx)) { + add({ + name: "list_requirement_link_types", + description: "Link types allowed between requirement versions (for link_requirements).", + shape: {}, + run: (c) => c.listRequirementLinkTypes(), + }); + add({ + name: "list_requirement_links", + description: "Requirement version links of the project, optionally filtered by source version, target version or link type. Use the link id with unlink_requirements.", + shape: { + source_version_id: id("Source requirement version id").optional(), + target_version_id: id("Target requirement version id").optional(), + link_type: z.string().min(1).optional(), + }, + paged: true, + run: (c, a) => c.listRequirementLinks({ + source_version_id: a.source_version_id, + target_version_id: a.target_version_id, + link_type: a.link_type, + }), + }); + add({ + name: "list_reviewers", + description: "User ids designated as reviewers of the project.", + shape: {}, + run: (c) => c.listReviewers(), + }); + add({ + name: "get_my_permissions", + description: "The authenticated user's effective permissions in the project. Check it before set_approval or other writes that may be refused.", + shape: {}, + run: (c) => c.getMyPermissions(), + }); + add({ + name: "list_saved_views", + description: "Saved requirement views visible to the user. Pass a view id as view_id to list_requirements.", + shape: {}, + paged: true, + run: (c) => c.listSavedViews(), + }); + add({ + name: "list_attachments", + description: "Attachments of one requirement or verification (metadata only; files are not downloaded). Over OAuth it needs both requirements:read and verifications:read.", + shape: { entity_type: z.enum(["requirement", "verification"]), entity_id: id("Requirement or verification id") }, + paged: true, + run: (c, a) => c.listAttachments(a.entity_type, a.entity_id), + }); + add({ + name: "get_attention", + description: "What needs the user's attention across their projects: approvals waiting, drafts to review, suspect links, notifications. Not tied to one project.", + shape: {}, + global: true, + run: (c) => c.getAttention(), + }); + add({ + name: "list_use_case_comments", + description: "Comments on a use case, optionally for one version or one step/condition (element_id).", + shape: { + use_case_id: id("Use case id"), + version_id: id("Only comments on this version").optional(), + element_id: z.string().min(1).optional(), + }, + paged: true, + run: (c, a) => c.listUseCaseComments(a.use_case_id, { version_id: a.version_id, element_id: a.element_id }), + }); + add({ + name: "get_close_out", + description: "Close-out status of the project, or of one requirement when requirement_id is given: what lacks a passing verification.", + shape: { requirement_id: id("Requirement id").optional() }, + run: (c, a) => c.getCloseOut(a.requirement_id), + }); + add({ + name: "impacted_tests", + description: "Verifications linked to a requirement that are marked suspect because the requirement changed. Run it after patch_requirement.", + shape: { requirement_id: id("Requirement id") }, + paged: true, + run: (c, a) => c.getImpactedTests(a.requirement_id), + }); + add({ + name: "list_verification_snapshots", + description: "Chronological snapshots of a verification, rebuilt from the audit log. Compare two with compare_verification_snapshots.", + shape: { verification_id: id("Verification id") }, + paged: true, + run: (c, a) => c.listVerificationSnapshots(a.verification_id), + }); + add({ + name: "compare_verification_snapshots", + description: "Structured diff between two snapshots of a verification (v1 = older, v2 = newer snapshot id).", + shape: { verification_id: id("Verification id"), v1: id("Older snapshot id"), v2: id("Newer snapshot id") }, + run: (c, a) => c.compareVerificationSnapshots(a.verification_id, a.v1, a.v2), + }); + add({ + name: "diff_baseline_verification_vs_current", + description: "Diff of a verification as a baseline recorded it against the verification now.", + shape: { baseline_id: id("Baseline id"), verification_id: id("Verification id") }, + run: (c, a) => c.diffBaselineVerificationVsCurrent(a.baseline_id, a.verification_id), + }); + } + // ----- DW: draft_write ----- + if (ctx.mode === "draft_write") { + add({ + name: "unlink_use_case_element", + description: "Delete one use case link (a requirement or verification linked to the use case or one of its steps). Get link ids from list_use_case_links. Deletes only the link, never the use case, requirement or verification.", + shape: { use_case_id: id("Use case id"), link_id: id("Link id") }, + write: true, + destructive: true, + run: (c, a) => c.deleteUseCaseLink(a.use_case_id, a.link_id), + }); + add({ + name: "clear_use_case_link_suspect", + description: "Clear the suspect flag of a use case link after checking the linked requirement still fits the use case.", + shape: { use_case_id: id("Use case id"), link_id: id("Link id") }, + write: true, + run: (c, a) => c.clearUseCaseLinkSuspect(a.use_case_id, a.link_id), + }); + add({ + name: "create_use_case_comment", + description: "Comment on a use case, on one version (use_case_version_id) or one step/condition (element_id). @username mentions of project members notify them. Retrying with the same idempotency_key does not post twice.", + shape: { + idempotency_key: operationKey, + use_case_id: id("Use case id"), + body: z.string().min(1), + use_case_version_id: id("Version the comment is about").optional(), + element_id: z.string().min(1).optional(), + }, + write: true, + summary: (a) => ({ + use_case_id: a.use_case_id, + body_len: String(a.body ?? "").length, + use_case_version_id: a.use_case_version_id, + element_id: a.element_id, + }), + run: (c, a) => c.createUseCaseComment(a.use_case_id, { body: a.body, use_case_version_id: a.use_case_version_id ?? null, element_id: a.element_id ?? null }, a.idempotency_key ?? randomUUID()), + }); + add({ + name: "discard_use_case_draft", + description: "Discard the shared draft of a use case (unpublished edits are lost; published versions stay). Read the draft first with get_use_case_diff to be sure it is stale.", + shape: { use_case_id: id("Use case id") }, + write: true, + destructive: true, + run: (c, a) => c.discardUseCaseDraft(a.use_case_id), + }); + add({ + name: "set_verification_field", + description: "Change one field of a verification without resending the rest. Fields: name, description, source, status_id, reference_code, parent_id, verification_method_id, author_id, reviewer_id. Status changes keep reviewer enforcement.", + shape: { + verification_id: id("Verification id"), + field: z.enum([ + "name", + "description", + "source", + "status_id", + "reference_code", + "parent_id", + "verification_method_id", + "author_id", + "reviewer_id", + ]), + value: z.string().describe("New value; ids as digits. parent_id: empty or 0 clears it."), + }, + write: true, + run: (c, a) => c.setVerificationField(a.verification_id, a.field, a.value), + }); + } + // ----- TW: MARREQ_TRACE_WRITE ----- + if (ctx.traceWrite) { + add({ + name: "link_requirements", + description: "Link two requirement versions (source derives from / refines / satisfies / depends on / relates to target; see list_requirement_link_types). Both versions must be in the project; cycles are refused. Retrying with the same idempotency_key returns the same link.", + shape: { + idempotency_key: operationKey, + source_version_id: id("Source requirement version id"), + target_version_id: id("Target requirement version id"), + link_type: z.string().min(1), + rationale: z.string().optional(), + }, + write: true, + run: (c, a) => c.createRequirementLink({ + source_version_id: a.source_version_id, + target_version_id: a.target_version_id, + link_type: a.link_type, + rationale: a.rationale ?? null, + }, a.idempotency_key ?? randomUUID()), + }); + add({ + name: "unlink_requirements", + description: "Delete one requirement version link by id (from list_requirement_links). Deletes only the link; baselines taken earlier keep what they recorded.", + shape: { link_id: id("Link id") }, + write: true, + destructive: true, + run: (c, a) => c.deleteRequirementLink(a.link_id), + }); + } +} diff --git a/mcp-server/dist/generated-contract.js b/mcp-server/dist/generated-contract.js new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/mcp-server/dist/generated-contract.js @@ -0,0 +1 @@ +export {}; diff --git a/mcp-server/dist/index.js b/mcp-server/dist/index.js index f19cf540..4e1efafb 100644 --- a/mcp-server/dist/index.js +++ b/mcp-server/dist/index.js @@ -7,6 +7,10 @@ import { z } from "zod"; import { contextAllowsReadExtended, loadContext } from "./context.js"; import { MarreqAuthenticationError, MarreqClient } from "./client.js"; import { loadTransportConfig, startRemoteServer } from "./remote.js"; +import { registerGapTools } from "./gap-tools.js"; +import { paginate, paginationFields } from "./pagination.js"; +import { registerResources } from "./resources.js"; +import { registerPrompts } from "./prompts.js"; /** Requirement statements and verification descriptions use "Marreq statement Markdown" (marreq-core/src/rich_text.rs). */ const DESCRIPTION_FORMAT = "Verification description in Marreq statement Markdown: paragraphs separated by a blank line, '- ' bulleted and '1. ' numbered lists, **bold**, *italic*, `code`, [label](https://...) links (http/https/mailto only). Max 2000 bytes including markup."; const STATEMENT_FORMAT = "Requirement statement in Marreq statement Markdown: paragraphs separated by a blank line, '- ' bulleted and '1. ' numbered lists, **bold**, *italic*, `code`, [label](https://...) links (http/https/mailto only). Max 2000 bytes including markup."; @@ -129,6 +133,31 @@ export function createMarreqServer(ctx) { edit_use_case_draft: ["use_cases:write"], publish_use_case_draft: ["use_cases:write"], link_use_case_element: ["use_cases:write"], + // Tool gaps (issue #416) + list_requirement_link_types: ["traceability:read"], + list_requirement_links: ["traceability:read"], + link_requirements: ["traceability:write"], + unlink_requirements: ["traceability:write"], + unlink_use_case_element: ["use_cases:write"], + clear_use_case_link_suspect: ["use_cases:write"], + list_linked_use_cases: ["use_cases:read"], + ask_requirements: ["requirements:read"], + list_reviewers: ["projects:read"], + get_my_permissions: ["projects:read"], + list_saved_views: ["requirements:read"], + list_attachments: ["requirements:read", "verifications:read"], + get_attention: ["projects:read"], + list_use_case_versions: ["use_cases:read"], + list_use_case_comments: ["use_cases:read"], + create_use_case_comment: ["use_cases:write"], + discard_use_case_draft: ["use_cases:write"], + get_close_out: ["requirements:read"], + impacted_tests: ["requirements:read", "verifications:read"], + list_verification_snapshots: ["verifications:read"], + compare_verification_snapshots: ["verifications:read"], + set_verification_field: ["verifications:write"], + get_baseline_contents: ["baselines:read"], + diff_baseline_verification_vs_current: ["verifications:read", "baselines:read"], }; const originalRegisterTool = server.registerTool.bind(server); const unsafeRegisterTool = originalRegisterTool; @@ -142,6 +171,8 @@ export function createMarreqServer(ctx) { "create_verification", "create_use_case", "publish_use_case_draft", + "link_requirements", + "create_use_case_comment", ].includes(name); const annotations = { readOnlyHint: !mutating, @@ -172,11 +203,11 @@ export function createMarreqServer(ctx) { }); server.registerTool("list_projects", { description: "List only projects accessible to the authenticated Marreq user.", - inputSchema: z.object({}), + inputSchema: z.object({ ...paginationFields }), annotations: { readOnlyHint: true }, - }, async () => { + }, async (args) => { const out = await withAudit(client, "list_projects", "{}", false, () => client.listProjects()); - return { content: [jsonContent(out)] }; + return { content: [jsonContent(paginate(out, args ?? {}))] }; }); server.registerTool("get_requirement", { description: "Get a requirement by id (project-scoped, with trace summary)", @@ -188,21 +219,35 @@ export function createMarreqServer(ctx) { return { content: [jsonContent(out)] }; }); server.registerTool("list_requirements", { - description: "List requirements in the project with optional filters (approval_state, has_tests)", + description: "List requirements in the project with optional filters (approval_state, has_tests, status_id, category_id, free-text q, a saved view_id from list_saved_views) and sorting (sort_column, sort_dir). Pass limit (and cursor from next_cursor) to page; without them the whole list is returned.", inputSchema: z.object({ ...projectField, filter: z .object({ approval_state: z.enum(["draft", "reviewed", "approved"]).optional(), has_tests: z.boolean().optional(), + status_id: z.number().int().positive().optional(), + category_id: z.number().int().positive().optional(), + q: z.string().optional().describe("Text to find in code, title or statement"), + view_id: z.number().int().positive().optional().describe("Saved view id (list_saved_views)"), }) .optional(), + sort_column: z.string().optional(), + sort_dir: z.enum(["asc", "desc"]).optional(), + ...paginationFields, }), }, async (args) => { const f = args?.filter; const toolClient = forProject(args?.project_id); - const out = await withAudit(toolClient, "list_requirements", JSON.stringify(args ?? {}), false, () => toolClient.listRequirements(f?.approval_state, f?.has_tests)); - return { content: [jsonContent(out)] }; + const out = await withAudit(toolClient, "list_requirements", JSON.stringify(args ?? {}), false, () => toolClient.listRequirements(f?.approval_state, f?.has_tests, { + status_id: f?.status_id, + category_id: f?.category_id, + q: f?.q, + view_id: f?.view_id, + sort_column: args?.sort_column, + sort_dir: args?.sort_dir, + })); + return { content: [jsonContent(paginate(out, args ?? {}))] }; }); server.registerTool("get_versions", { description: "Get version history for a requirement", @@ -311,11 +356,12 @@ export function createMarreqServer(ctx) { q: z.string().optional().describe("Filter on code, title or actor"), approval_state: z.enum(["draft", "reviewed", "approved", "unpublished"]).optional(), has_suspect: z.boolean().optional().describe("Only use cases with suspect links"), + ...paginationFields, }), - }, async ({ project_id, q, approval_state, has_suspect }) => { + }, async ({ project_id, q, approval_state, has_suspect, limit, cursor }) => { const toolClient = forProject(project_id); const out = await withAudit(toolClient, "list_use_cases", JSON.stringify({ q, approval_state, has_suspect }), false, () => toolClient.listUseCases({ q, approval_state, has_suspect })); - return { content: [jsonContent(out)] }; + return { content: [jsonContent(paginate(out, { limit, cursor }))] }; }); server.registerTool("get_use_case", { description: "Get a use case: its current version (or version_id) as a marreq.use-case.v1 document with computed labels (1, 2, A3, A3.1, E4), and the shared draft (document, labels, revision) when one exists. Element ids such as s_… and b_… are stable; labels are not.", @@ -347,21 +393,21 @@ export function createMarreqServer(ctx) { }); server.registerTool("list_use_case_links", { description: "Requirements and verifications linked to a use case or to one of its steps, with suspect flags (a linked requirement changed since the link was made or cleared).", - inputSchema: z.object({ ...projectField, use_case_id: z.number().int().positive() }), - }, async ({ project_id, use_case_id }) => { + inputSchema: z.object({ ...projectField, use_case_id: z.number().int().positive(), ...paginationFields }), + }, async ({ project_id, use_case_id, limit, cursor }) => { const toolClient = forProject(project_id); const out = await withAudit(toolClient, "list_use_case_links", JSON.stringify({ use_case_id }), false, () => toolClient.listUseCaseLinks(use_case_id)); - return { content: [jsonContent(out)] }; + return { content: [jsonContent(paginate(out, { limit, cursor }))] }; }); // read_extended / draft_write: extra read tools (catalog, verifications, audit, matrix read, …) if (contextAllowsReadExtended(ctx)) { server.registerTool("list_verifications", { description: "List verifications (tests) in the project. Requires MARREQ_MODE=read_extended or draft_write.", - inputSchema: z.object({ ...projectField }), - }, async ({ project_id }) => { + inputSchema: z.object({ ...projectField, ...paginationFields }), + }, async ({ project_id, limit, cursor }) => { const toolClient = forProject(project_id); const out = await withAudit(toolClient, "list_verifications", "{}", false, () => toolClient.listVerificationsByProject()); - return { content: [jsonContent(out)] }; + return { content: [jsonContent(paginate(out, { limit, cursor }))] }; }); server.registerTool("get_verification", { description: "Get one verification by id. The record must belong to MARREQ_PROJECT_ID. Requires read_extended or draft_write mode.", @@ -381,11 +427,11 @@ export function createMarreqServer(ctx) { }); server.registerTool("list_baselines", { description: "List baselines for the project (metadata only). Use get_baseline for full snapshot.", - inputSchema: z.object({ ...projectField }), - }, async ({ project_id }) => { + inputSchema: z.object({ ...projectField, ...paginationFields }), + }, async ({ project_id, limit, cursor }) => { const toolClient = forProject(project_id); const out = await withAudit(toolClient, "list_baselines", "{}", false, () => toolClient.listBaselinesByProject()); - return { content: [jsonContent(out)] }; + return { content: [jsonContent(paginate(out, { limit, cursor }))] }; }); server.registerTool("get_requirement_activity", { description: "Audit log entries for a requirement (create/update history with field summaries).", @@ -411,12 +457,13 @@ export function createMarreqServer(ctx) { ...projectField, requirement_id: z.string(), requirement_version_id: z.number().optional(), + ...paginationFields, }), - }, async ({ project_id, requirement_id, requirement_version_id }) => { + }, async ({ project_id, requirement_id, requirement_version_id, limit, cursor }) => { const id = parseInt(requirement_id, 10); const toolClient = forProject(project_id); const out = await withAudit(toolClient, "list_requirement_comments", JSON.stringify({ requirement_id, requirement_version_id }), false, () => toolClient.listRequirementComments(id, requirement_version_id ?? null)); - return { content: [jsonContent(out)] }; + return { content: [jsonContent(paginate(out, { limit, cursor }))] }; }); server.registerTool("get_verification_matrix", { description: "Requirement ids linked to a verification in the traceability matrix (read).", @@ -531,6 +578,7 @@ export function createMarreqServer(ctx) { reviewer_id: z.number().optional(), category_id: z.number().optional(), applicability_id: z.number().optional(), + justification: z.string().optional().describe("Why the requirement exists; an empty string clears it."), custom_fields: z .array(z.object({ field_id: z.number(), value: z.string() })) .optional(), @@ -708,6 +756,18 @@ export function createMarreqServer(ctx) { return { content: [jsonContent(out)] }; }); } + registerGapTools({ + server: server, + ctx, + client, + forProject, + projectField, + operationKey, + withAudit, + jsonContent, + }); + registerResources(server, { ctx, client, withAudit }); + registerPrompts(server); return server; } async function main() { diff --git a/mcp-server/dist/pagination.js b/mcp-server/dist/pagination.js new file mode 100644 index 00000000..3cf3ef59 --- /dev/null +++ b/mcp-server/dist/pagination.js @@ -0,0 +1,49 @@ +import { z } from "zod"; +export const MAX_PAGE_SIZE = 200; +export const DEFAULT_PAGE_SIZE = 50; +/** Optional `limit` and opaque `cursor` inputs shared by list tools. */ +export const paginationFields = { + limit: z + .number() + .int() + .min(1) + .max(MAX_PAGE_SIZE) + .optional() + .describe(`Page size (max ${MAX_PAGE_SIZE}). Pass next_cursor from the previous page as cursor to continue.`), + cursor: z.string().optional().describe("Opaque cursor from a previous page's next_cursor."), +}; +function encodeCursor(offset) { + return Buffer.from(JSON.stringify({ o: offset }), "utf8").toString("base64url"); +} +function decodeCursor(cursor) { + try { + const { o } = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + if (typeof o === "number" && Number.isInteger(o) && o >= 0) + return o; + } + catch { + // fall through to the error below + } + throw new Error("Invalid cursor: pass the next_cursor value of a previous page unchanged"); +} +/** + * Slices a list response in the MCP server (the backend still returns the whole + * list). Tools that predate paging keep their full output until the caller sends + * `limit` or `cursor`; new list tools pass `defaultLimit` to page from the start. + * Anything that is not an array is returned as is. + */ +export function paginate(data, args, defaultLimit) { + if (!Array.isArray(data)) + return data; + if (args.limit == null && args.cursor == null && defaultLimit == null) + return data; + const limit = Math.min(args.limit ?? defaultLimit ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); + const offset = args.cursor != null ? decodeCursor(args.cursor) : 0; + const end = offset + limit; + const page = { + items: data.slice(offset, end), + total: data.length, + next_cursor: end < data.length ? encodeCursor(end) : null, + }; + return page; +} diff --git a/mcp-server/dist/prompts.js b/mcp-server/dist/prompts.js new file mode 100644 index 00000000..8b6752a0 --- /dev/null +++ b/mcp-server/dist/prompts.js @@ -0,0 +1,52 @@ +/** + * MCP prompts (issue #416). Each prompt names the tools it expects, so a client + * can plan the calls. They only describe work; no prompt writes anything. + */ +import { z } from "zod"; +const projectArg = z.string().regex(/^\d+$/).describe("Project id from list_projects"); +const requirementArg = z.string().regex(/^\d+$/).describe("Requirement id"); +function user(text) { + return { messages: [{ role: "user", content: { type: "text", text } }] }; +} +export const PROMPT_NAMES = [ + "review_requirement", + "derive_children", + "coverage_gap_analysis", + "use_case_from_requirement", +]; +export function registerPrompts(server) { + server.registerPrompt("review_requirement", { + title: "Review a requirement", + description: "Check one requirement for ambiguity, testability, duplicates, traceability and suspect links.", + argsSchema: { project_id: projectArg, requirement_id: requirementArg }, + }, ({ project_id, requirement_id }) => user(`Review requirement ${requirement_id} in project ${project_id}; do not change anything.\n` + + `Tools: get_requirement, get_versions, trace_up, trace_down, get_close_out (with requirement_id), ` + + `list_linked_use_cases, semantic_search_requirements.\n` + + `Report: is it atomic, unambiguous and testable; does a similar requirement already exist; does it have a parent ` + + `and a passing verification; are any links suspect. Give findings first, then proposed edits for me to approve.`)); + server.registerPrompt("derive_children", { + title: "Derive child requirements", + description: "Propose atomic child requirements for a parent, without creating them.", + argsSchema: { project_id: projectArg, requirement_id: requirementArg }, + }, ({ project_id, requirement_id }) => user(`Propose child requirements for requirement ${requirement_id} in project ${project_id}.\n` + + `Tools: get_requirement, list_project_catalog, semantic_search_requirements, list_requirement_link_types.\n` + + `Write each child as one atomic, testable statement, check it against existing requirements for overlap, ` + + `and say which link type ties it to the parent. List them for my approval; create them with ` + + `create_requirement (parent_links) only after I say so.`)); + server.registerPrompt("coverage_gap_analysis", { + title: "Coverage gap analysis", + description: "Find requirements without verification and tests made stale by changes.", + argsSchema: { project_id: projectArg }, + }, ({ project_id }) => user(`Analyse verification coverage in project ${project_id}.\n` + + `Tools: coverage_report, get_close_out, list_requirements (has_tests=false), impacted_tests, get_verification_matrix.\n` + + `Rank the gaps by approval state and importance, list suspect tests, and suggest which verification would close ` + + `each gap. Do not link or create anything until I choose.`)); + server.registerPrompt("use_case_from_requirement", { + title: "Use case from a requirement", + description: "Draft a use case that exercises a requirement, and link it.", + argsSchema: { project_id: projectArg, requirement_id: requirementArg }, + }, ({ project_id, requirement_id }) => user(`Draft a use case for requirement ${requirement_id} in project ${project_id}.\n` + + `Tools: get_requirement, list_use_cases, create_use_case, edit_use_case_draft, link_use_case_element.\n` + + `Check list_use_cases for an existing one first. Outline the actor, preconditions, main steps and alternatives, ` + + `show me the outline, then create the draft and link it to the requirement. Publish only if I ask.`)); +} diff --git a/mcp-server/dist/resources.js b/mcp-server/dist/resources.js new file mode 100644 index 00000000..7b41c7fd --- /dev/null +++ b/mcp-server/dist/resources.js @@ -0,0 +1,49 @@ +/** + * MCP resources (issue #416): requirements, use cases and the catalog as + * addressable documents, and the workflow guide. Each read reuses the client + * method and backend scope of the matching tool. + */ +import { readFile } from "node:fs/promises"; +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +const JSON_TYPE = "application/json"; +function one(value) { + return Array.isArray(value) ? value[0] ?? "" : value ?? ""; +} +function positiveInt(raw, what) { + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) + throw new Error(`Invalid ${what} in resource URI: ${raw}`); + return n; +} +async function guideText() { + try { + return await readFile(new URL("../MARREQ_SKILL.md", import.meta.url), "utf8"); + } + catch { + return "The Marreq workflow guide (MARREQ_SKILL.md) is not available in this deployment."; + } +} +export function registerResources(server, { client, withAudit }) { + const json = (uri, data) => ({ + contents: [{ uri: uri.href, mimeType: JSON_TYPE, text: JSON.stringify(data, null, 2) }], + }); + // The project in the URI is passed through; the backend still checks that the + // caller has the scope and project permission. + const read = async (uri, vars, name, fn) => { + const projectClient = client.withProject(positiveInt(one(vars.project_id), "project id")); + const out = await withAudit(projectClient, `resource:${name}`, JSON.stringify({ uri: uri.href }), false, () => fn(projectClient)); + return json(uri, out); + }; + server.registerResource("requirement", new ResourceTemplate("marreq://projects/{project_id}/requirements/{id}", { list: undefined }), { title: "Requirement", description: "One requirement with its trace summary.", mimeType: JSON_TYPE }, (uri, vars) => read(uri, vars, "requirement", (c) => c.getRequirement(positiveInt(one(vars.id), "requirement id")))); + server.registerResource("use-case", new ResourceTemplate("marreq://projects/{project_id}/use-cases/{id}", { list: undefined }), { title: "Use case", description: "The current version of one use case.", mimeType: JSON_TYPE }, (uri, vars) => read(uri, vars, "use-case", (c) => c.getUseCase(positiveInt(one(vars.id), "use case id")))); + server.registerResource("catalog", new ResourceTemplate("marreq://projects/{project_id}/catalog", { list: undefined }), { + title: "Project catalog", + description: "Statuses, categories, applicability, verification methods and users of the project.", + mimeType: JSON_TYPE, + }, (uri, vars) => read(uri, vars, "catalog", (c) => c.listProjectCatalog())); + server.registerResource("guide", "marreq://guide", { + title: "Marreq workflow guide", + description: "How to review, create and modify requirements and use cases with the Marreq tools.", + mimeType: "text/markdown", + }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: await guideText() }] })); +} diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 28094b84..3c59f108 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -228,9 +228,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -249,9 +246,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -270,9 +264,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -291,9 +282,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -312,9 +300,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -333,9 +318,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -954,17 +936,17 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -1326,9 +1308,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -1515,9 +1497,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -1535,9 +1517,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ip-address": { - "version": "10.7.2", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", "engines": { "node": ">= 12" } @@ -1727,9 +1709,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1752,9 +1731,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1777,9 +1753,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1802,9 +1775,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2110,9 +2080,9 @@ } }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz", + "integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==", "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" diff --git a/mcp-server/src/client.ts b/mcp-server/src/client.ts index 77797c95..cb3b831d 100644 --- a/mcp-server/src/client.ts +++ b/mcp-server/src/client.ts @@ -1,3 +1,9 @@ +import type { components } from "./generated-contract.js"; +type McpPrincipalResponse = components["schemas"]["McpPrincipalResponse"]; +type UseCaseRow = components["schemas"]["use_cases_UseCaseRow_response"]; +type UseCaseDetail = components["schemas"]["use_cases_UseCaseDetail_response"]; +type UseCaseDraft = components["schemas"]["use_cases_DraftView_response"]; +type UseCaseDraftChange = components["schemas"]["use_cases_DraftChangeView_response"]; import type { SessionContext } from "./context.js"; import { currentBearer } from "./credentials.js"; @@ -18,13 +24,7 @@ export class MarreqClient { return this.request("/api/projects"); } - async getPrincipal(): Promise<{ - user_id: number; - authentication_type: string; - principal_id: string; - client_id?: string; - grant_id?: number; - }> { + async getPrincipal(): Promise { return this.request("/api/mcp/principal"); } @@ -360,7 +360,7 @@ export class MarreqClient { return `/api/projects/${this.ctx.projectId}/use-cases${path}`; } - async listUseCases(query: { q?: string; approval_state?: string; has_suspect?: boolean } = {}) { + async listUseCases(query: { q?: string; approval_state?: string; has_suspect?: boolean } = {}): Promise { const params = new URLSearchParams(); if (query.q) params.set("q", query.q); if (query.approval_state) params.set("approval_state", query.approval_state); @@ -369,14 +369,14 @@ export class MarreqClient { return this.request(this.useCases(q ? `?${q}` : "")); } - async getUseCase(id: number, versionId?: number) { + async getUseCase(id: number, versionId?: number): Promise { return this.request(this.useCases(`/${id}${versionId != null ? `?version_id=${versionId}` : ""}`)); } /** The shared draft, or null when there is none. */ - async getUseCaseDraft(id: number) { + async getUseCaseDraft(id: number): Promise { try { - return await this.request(this.useCases(`/${id}/draft`)); + return await this.request(this.useCases(`/${id}/draft`)); } catch (e) { if (e instanceof Error && e.message.startsWith("Marreq API 404")) return null; throw e; @@ -399,7 +399,7 @@ export class MarreqClient { payload: { reference_code?: string; title?: string; primary_actor?: string; document?: unknown }, idempotencyKey: string ) { - return this.request(this.useCases(), { + return this.request(this.useCases(), { method: "POST", headers: { "Idempotency-Key": idempotencyKey }, body: JSON.stringify(payload), @@ -413,7 +413,7 @@ export class MarreqClient { }); } - async putUseCaseDraft(id: number, baseRevision: number | null, document: unknown) { + async putUseCaseDraft(id: number, baseRevision: number | null, document: unknown): Promise { return this.request(this.useCases(`/${id}/draft`), { method: "PUT", body: JSON.stringify({ base_revision: baseRevision, document }), diff --git a/mcp-server/src/generated-contract.ts b/mcp-server/src/generated-contract.ts new file mode 100644 index 00000000..eb559813 --- /dev/null +++ b/mcp-server/src/generated-contract.ts @@ -0,0 +1,438 @@ +// Generated by scripts/generate-api-types.mjs; do not edit. +export interface components { + schemas: { + ApiError: { + error: string; + message: string; + status: number; + } & { + [key: string]: unknown; + }; + McpPrincipalResponse: { + authentication_type: string; + client_id: string | null; + grant_id: number | null; + principal_id: string; + user_id: number; + } & { + [key: string]: unknown; + }; + UseCaseBranch: { + end: components["schemas"]["UseCaseBranchEnd"]; + from_step: string; + id: string; + /** @enum {string} */ + kind: "alternative" | "exception"; + steps?: components["schemas"]["UseCaseElement"][]; + title: string; + }; + UseCaseBranchEnd: { + step: string; + /** @constant */ + type: "rejoin"; + } | { + /** @enum {string} */ + outcome: "success" | "failure"; + /** @constant */ + type: "end"; + }; + UseCaseBranchResponse: { + end: components["schemas"]["UseCaseBranchEnd"]; + from_step: string; + id: string; + /** @enum {string} */ + kind: "alternative" | "exception"; + steps: components["schemas"]["UseCaseElement"][]; + title: string; + }; + UseCaseDependents: "cascade" | ({ + rebranch_to: string; + } & { + [key: string]: unknown; + }); + UseCaseDiff: { + elements: components["schemas"]["UseCaseElementDiff"][]; + from: components["schemas"]["UseCaseDiffEnd"] | null; + header: { + [key: string]: { + new: string | null | string[]; + old: string | null | string[]; + } & { + [key: string]: unknown; + }; + }; + summary: { + added: number; + edited: number; + moved: number; + rebranched: number; + removed: number; + } & { + [key: string]: unknown; + }; + to: components["schemas"]["UseCaseDiffEnd"]; + } & { + [key: string]: unknown; + }; + UseCaseDiffEnd: { + draft_revision: number | null; + version_id: number | null; + version_number: number | null; + } & { + [key: string]: unknown; + }; + UseCaseDiffSide: { + branch_id?: string; + end?: components["schemas"]["UseCaseBranchEnd"]; + from_step?: string; + /** @enum {string} */ + kind?: "alternative" | "exception"; + label: string | null; + /** @enum {string} */ + list: "preconditions" | "main" | "postconditions" | "branch" | "branches"; + text: string; + } & { + [key: string]: unknown; + }; + UseCaseDocument: { + branches?: components["schemas"]["UseCaseBranch"][]; + main?: components["schemas"]["UseCaseElement"][]; + postconditions?: components["schemas"]["UseCaseElement"][]; + preconditions?: components["schemas"]["UseCaseElement"][]; + primary_actor?: string | null; + /** @constant */ + schema?: "marreq.use-case.v1"; + secondary_actors?: string[]; + summary?: string | null; + title: string; + }; + UseCaseDocumentResponse: { + branches: components["schemas"]["UseCaseBranchResponse"][]; + main: components["schemas"]["UseCaseElement"][]; + postconditions: components["schemas"]["UseCaseElement"][]; + preconditions: components["schemas"]["UseCaseElement"][]; + primary_actor: string | null; + /** @constant */ + schema: "marreq.use-case.v1"; + secondary_actors: string[]; + summary: string | null; + title: string; + }; + UseCaseEffect: ({ + from: string; + id: string; + to: string; + /** @constant */ + type: "relabelled"; + } & { + [key: string]: unknown; + }) | ({ + id: string; + /** @constant */ + type: "removed"; + } & { + [key: string]: unknown; + }) | ({ + from_step: string; + id: string; + /** @constant */ + type: "rebranched"; + } & { + [key: string]: unknown; + }) | ({ + id: string; + /** @constant */ + type: "created"; + } & { + [key: string]: unknown; + }); + UseCaseElement: { + id: string; + text: string; + }; + UseCaseElementDiff: { + changes: ("added" | "removed" | "edited" | "moved" | "rebranched")[]; + id: string; + /** @enum {string} */ + kind: "precondition" | "postcondition" | "step" | "branch"; + new: components["schemas"]["UseCaseDiffSide"] | null; + old: components["schemas"]["UseCaseDiffSide"] | null; + text_diff?: ({ + /** @enum {string} */ + op: "equal" | "delete" | "insert"; + text: string; + } & { + [key: string]: unknown; + })[]; + } & { + [key: string]: unknown; + }; + UseCaseListRef: ("preconditions" | "main" | "postconditions") | ({ + branch: string; + } & { + [key: string]: unknown; + }); + UseCaseNewBranch: { + end: components["schemas"]["UseCaseBranchEnd"]; + from_step: string; + id?: string | null; + /** @enum {string} */ + kind: "alternative" | "exception"; + steps?: components["schemas"]["UseCaseNewElement"][]; + title: string; + }; + UseCaseNewElement: { + id?: string | null; + text: string; + }; + UseCaseOperation: { + /** @constant */ + op: "set_header"; + primary_actor?: string | null; + secondary_actors?: string[] | null; + summary?: string | null; + title?: string | null; + } | { + after?: string | null; + element: components["schemas"]["UseCaseNewElement"]; + list: components["schemas"]["UseCaseListRef"]; + /** @constant */ + op: "add_element"; + } | { + id: string; + /** @constant */ + op: "update_text"; + text: string; + } | { + after?: string | null; + id: string; + list?: components["schemas"]["UseCaseListRef"] | null; + /** @constant */ + op: "move_element"; + } | { + dependents?: components["schemas"]["UseCaseDependents"] | null; + id: string; + /** @constant */ + op: "remove_element"; + } | { + branch: components["schemas"]["UseCaseNewBranch"]; + /** @constant */ + op: "add_branch"; + } | { + end?: components["schemas"]["UseCaseBranchEnd"] | null; + from_step?: string | null; + id: string; + kind?: ("alternative" | "exception") | null; + /** @constant */ + op: "update_branch"; + title?: string | null; + } | { + id: string; + /** @constant */ + op: "remove_branch"; + }; + UseCasePublishResponse: { + removed_links: components["schemas"]["use_cases_LinkView_response"][]; + version: components["schemas"]["use_cases_VersionView_response"]; + } & { + [key: string]: unknown; + }; + use_cases_BaselineUseCaseView_response: { + approval_state: string; + current_version_id: number | null; + current_version_number: number | null; + link_count: number; + primary_actor: string | null; + reference_code: string; + title: string; + use_case_id: number; + version_id: number; + version_number: number; + } & { + [key: string]: unknown; + }; + use_cases_ClearSuspectResponse_response: { + cleared: boolean; + status: string; + } & { + [key: string]: unknown; + }; + use_cases_DraftChangeView_response: { + base_version_id: number | null; + document: components["schemas"]["UseCaseDocumentResponse"]; + effects: components["schemas"]["UseCaseEffect"][]; + labels: { + [key: string]: string; + }; + revision: number; + updated_at: string; + updated_by: number | null; + updated_by_name: string | null; + use_case_id: number; + } & { + [key: string]: unknown; + }; + use_cases_DraftSummary_response: { + base_version_id: number | null; + revision: number; + updated_at: string; + updated_by: number | null; + updated_by_name: string | null; + } & { + [key: string]: unknown; + }; + use_cases_DraftView_response: { + base_version_id: number | null; + document: components["schemas"]["UseCaseDocumentResponse"]; + labels: { + [key: string]: string; + }; + revision: number; + updated_at: string; + updated_by: number | null; + updated_by_name: string | null; + use_case_id: number; + } & { + [key: string]: unknown; + }; + use_cases_LinkView_response: { + created_at: string; + created_by: number | null; + current_requirement_version_id: number | null; + element_id: string | null; + element_label: string | null; + id: number; + linked_requirement_version_id: number | null; + rationale: string | null; + requirement_id: number | null; + suspect: boolean; + suspect_at: string | null; + suspect_reason: string | null; + target_reference_code: string; + target_title: string; + /** @enum {string} */ + target_type: "requirement" | "verification"; + verification_id: number | null; + } & { + [key: string]: unknown; + }; + use_cases_NextCode_response: { + reference_code: string; + } & { + [key: string]: unknown; + }; + use_cases_PutDraftRequest_request: { + base_revision?: number | null; + document: components["schemas"]["UseCaseDocument"]; + } & { + [key: string]: unknown; + }; + use_cases_UseCaseCommentView_response: { + author_id: number; + author_name: string; + body: string; + created_at: string; + element_id: string | null; + element_label: string | null; + id: number; + use_case_id: number; + use_case_version_id: number | null; + } & { + [key: string]: unknown; + }; + use_cases_UseCaseDetail_response: { + created_at: string; + created_by: number | null; + current_version: components["schemas"]["use_cases_VersionView_response"] | null; + draft: components["schemas"]["use_cases_DraftSummary_response"] | null; + id: number; + project_id: number; + reference_code: string; + title: string; + updated_at: string; + } & { + [key: string]: unknown; + }; + use_cases_UseCaseRef_response: { + element_id: string | null; + element_label: string | null; + link_id: number; + reference_code: string; + suspect: boolean; + title: string; + use_case_id: number; + } & { + [key: string]: unknown; + }; + use_cases_UseCaseRow_response: { + alternative_count: number; + approval_state: string | null; + current_version_number: number | null; + exception_count: number; + has_draft: boolean; + id: number; + link_count: number; + primary_actor: string | null; + reference_code: string; + step_count: number; + suspect_count: number; + title: string; + updated_at: string; + } & { + [key: string]: unknown; + }; + use_cases_VersionSummary_response: { + approval_state: string; + approved_at: string | null; + approved_by: number | null; + change_note: string | null; + created_at: string; + created_by: number | null; + created_by_name: string | null; + id: number; + primary_actor: string | null; + reviewed_at: string | null; + reviewed_by: number | null; + title: string; + version_number: number; + } & { + [key: string]: unknown; + }; + use_cases_VersionView_response: { + approval_state: string; + approved_at: string | null; + approved_by: number | null; + change_note: string | null; + created_at: string; + created_by: number | null; + created_by_name: string | null; + document: components["schemas"]["UseCaseDocumentResponse"]; + id: number; + labels: { + [key: string]: string; + }; + primary_actor: string | null; + reviewed_at: string | null; + reviewed_by: number | null; + title: string; + version_number: number; + } & { + [key: string]: unknown; + }; + }; + responses: { + /** @description API error; use cases may include code and structured details. Guard/fairing errors can have an empty body. */ + ApiError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiError"]; + }; + }; + }; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} diff --git a/mcp-server/src/remote.ts b/mcp-server/src/remote.ts index f2ed51cc..40ff9138 100644 --- a/mcp-server/src/remote.ts +++ b/mcp-server/src/remote.ts @@ -1,3 +1,4 @@ +import type { components } from "./generated-contract.js"; import { randomUUID } from "node:crypto"; import type { Server } from "node:http"; import type { Request, Response } from "express"; @@ -9,13 +10,7 @@ import { loadContext, type SessionContext } from "./context.js"; import { MarreqAuthenticationError, MarreqClient } from "./client.js"; import { withBearer } from "./credentials.js"; -interface Principal { - user_id: number; - authentication_type: string; - principal_id: string; - client_id?: string; - grant_id?: number; -} +type Principal = components["schemas"]["McpPrincipalResponse"]; type PrincipalResolution = | { kind: "ok"; principal: Principal } diff --git a/package-lock.json b/package-lock.json index 4372a619..43e773a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "commitizen": "^4.3.2", "cz-conventional-changelog": "^3.3.0", "happy-dom": "^20.14.6", + "openapi-typescript": "7.13.0", "playwright": "^1.64.1", "purgecss": "^8.0.0", "stylelint": "^17.16.0", @@ -513,6 +514,70 @@ "dev": true, "license": "MIT" }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.22", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.22.tgz", + "integrity": "sha512-dEyx4uHh6Pc6ZkdU9Xe8rA4h12Odq6QKU/fqseBn07xwVwskhHiF3KHhqmw5BR+jAiFTvKTmwCafFql/k1pSzA==", + "dev": true, + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.2", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.14", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", @@ -629,9 +694,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -650,9 +712,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -671,9 +730,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -692,9 +748,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -713,9 +766,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -734,9 +784,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1047,6 +1094,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -1064,6 +1120,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1323,6 +1388,12 @@ "node": ">=4" } }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true + }, "node_modules/chardet": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", @@ -1400,6 +1471,12 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2183,6 +2260,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -2279,6 +2369,18 @@ "node": ">=8" } }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2547,6 +2649,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -2773,9 +2884,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2798,9 +2906,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2823,9 +2928,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2848,9 +2950,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3261,6 +3360,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript/node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/openapi-typescript/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -3469,6 +3629,15 @@ "node": ">=20" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.30", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", @@ -4417,12 +4586,10 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "Apache-2.0", - "optional": true, "peer": true, "bin": { "tsc": "bin/tsc", @@ -4462,6 +4629,12 @@ "node": ">= 10.0.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.2.tgz", + "integrity": "sha512-VeBBt/nMxedczht3KDL2yBBMJqIxpCQLMZp3UJ1otcWwUHdQW4IiyHpJivExzSK+FdxTowRfGL4FmwDqH9+S8Q==", + "dev": true + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4819,6 +4992,21 @@ "optional": true } } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } } } } diff --git a/package.json b/package.json index 1d48e1c8..753985ad 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "commitizen": "^4.3.2", "cz-conventional-changelog": "^3.3.0", "happy-dom": "^20.14.6", + "openapi-typescript": "7.13.0", "playwright": "^1.64.1", "purgecss": "^8.0.0", "stylelint": "^17.16.0", diff --git a/scripts/check-api-compatibility.sh b/scripts/check-api-compatibility.sh new file mode 100755 index 00000000..93f662a4 --- /dev/null +++ b/scripts/check-api-compatibility.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec cargo run --quiet --locked -p marreq-contracts -- verify "$@" diff --git a/scripts/contracts/__pycache__/check.cpython-314.pyc b/scripts/contracts/__pycache__/check.cpython-314.pyc new file mode 100644 index 00000000..2df299a0 Binary files /dev/null and b/scripts/contracts/__pycache__/check.cpython-314.pyc differ diff --git a/scripts/contracts/__pycache__/check_baseline.cpython-314.pyc b/scripts/contracts/__pycache__/check_baseline.cpython-314.pyc new file mode 100644 index 00000000..74006e60 Binary files /dev/null and b/scripts/contracts/__pycache__/check_baseline.cpython-314.pyc differ diff --git a/scripts/contracts/__pycache__/compatibility.cpython-314.pyc b/scripts/contracts/__pycache__/compatibility.cpython-314.pyc new file mode 100644 index 00000000..57d09d00 Binary files /dev/null and b/scripts/contracts/__pycache__/compatibility.cpython-314.pyc differ diff --git a/scripts/contracts/__pycache__/generate.cpython-314.pyc b/scripts/contracts/__pycache__/generate.cpython-314.pyc new file mode 100644 index 00000000..1607de76 Binary files /dev/null and b/scripts/contracts/__pycache__/generate.cpython-314.pyc differ diff --git a/scripts/contracts/__pycache__/inventory.cpython-314.pyc b/scripts/contracts/__pycache__/inventory.cpython-314.pyc new file mode 100644 index 00000000..d6ed2a49 Binary files /dev/null and b/scripts/contracts/__pycache__/inventory.cpython-314.pyc differ diff --git a/scripts/contracts/__pycache__/test_compatibility.cpython-314.pyc b/scripts/contracts/__pycache__/test_compatibility.cpython-314.pyc new file mode 100644 index 00000000..fa90f3c3 Binary files /dev/null and b/scripts/contracts/__pycache__/test_compatibility.cpython-314.pyc differ diff --git a/scripts/contracts/__pycache__/test_contract_tooling.cpython-314.pyc b/scripts/contracts/__pycache__/test_contract_tooling.cpython-314.pyc new file mode 100644 index 00000000..a19fddea Binary files /dev/null and b/scripts/contracts/__pycache__/test_contract_tooling.cpython-314.pyc differ diff --git a/scripts/contracts/__pycache__/verify_transcript.cpython-314.pyc b/scripts/contracts/__pycache__/verify_transcript.cpython-314.pyc new file mode 100644 index 00000000..1d06712b Binary files /dev/null and b/scripts/contracts/__pycache__/verify_transcript.cpython-314.pyc differ diff --git a/scripts/generate-api-types.mjs b/scripts/generate-api-types.mjs new file mode 100644 index 00000000..2f3144a1 --- /dev/null +++ b/scripts/generate-api-types.mjs @@ -0,0 +1,26 @@ +// Standard OpenAPI -> TypeScript generation; only component-reachable schemas. +// Committed declarations keep Rust/global OpenAPI out of consumer Docker builds. +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import openapiTS, { astToString } from 'openapi-typescript'; +const check = process.argv.includes('--check'); +const directory = mkdtempSync(join(tmpdir(), 'marreq-types-')); +try { + for (const component of ['frontend', 'mcp']) { + const path = join(directory, `${component}.json`); + // Inventory-only entries generate no invented types. Strict release/CI + // coverage is checked separately by `marreq-contracts validate --consumers`. + execFileSync('cargo', ['run', '--quiet', '--locked', '-p', 'marreq-contracts', '--', 'select', '--component', component, '--allow-unverified', '--output', path]); + const spec = JSON.parse(readFileSync(path, 'utf8')); + spec.paths = {}; // Clients currently import schemas, not generated SDK paths. + const ast = await openapiTS(spec); + const declarations = ast.filter(node => node.name?.escapedText === 'components'); + const result = '// Generated by scripts/generate-api-types.mjs; do not edit.\n' + astToString(declarations); + const output = component === 'frontend' ? 'frontend/src/api/generated-contract.ts' : 'mcp-server/src/generated-contract.ts'; + if (check) { + if (readFileSync(output, 'utf8') !== result) throw new Error(`${output}: generated type drift; run node scripts/generate-api-types.mjs`); + } else writeFileSync(output, result); + } +} finally { rmSync(directory, { recursive: true, force: true }); } diff --git a/scripts/install-oasdiff.sh b/scripts/install-oasdiff.sh new file mode 100755 index 00000000..9cd25409 --- /dev/null +++ b/scripts/install-oasdiff.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Pinned upstream standards engine, used only by developer/CI Rust tooling. +set -euo pipefail +[[ $(uname -s) == Linux && $(uname -m) == x86_64 ]] || { echo 'Install oasdiff 1.34.0 for your platform and set OASDIFF.' >&2; exit 1; } +destination=${1:-"$HOME/.cache/marreq-contracts"} +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + https://github.com/oasdiff/oasdiff/releases/download/v1.34.0/oasdiff_1.34.0_linux_amd64.tar.gz \ + --output "$work/oasdiff.tar.gz" +printf '%s %s\n' 7c88e09fd500b231af2819eb21f6fdfc7bcdfe2741d2c1937c59e7b72c8a575a "$work/oasdiff.tar.gz" | sha256sum --check +mkdir -p "$destination" +tar -xzf "$work/oasdiff.tar.gz" -C "$work" oasdiff +install -m 755 "$work/oasdiff" "$destination/oasdiff" +if [[ -n ${GITHUB_PATH:-} ]]; then printf '%s\n' "$destination" >> "$GITHUB_PATH"; fi diff --git a/scripts/smoke-api-consumers.mjs b/scripts/smoke-api-consumers.mjs new file mode 100644 index 00000000..8de0086d --- /dev/null +++ b/scripts/smoke-api-consumers.mjs @@ -0,0 +1,72 @@ +// Real HTTP producer/consumer smoke test. Run only through test-api-contracts.sh: +// it provides an isolated fixture DB and loopback-only Rocket process. +import assert from 'node:assert/strict'; +import { appendFileSync } from 'node:fs'; +import { MarreqClient } from '../mcp-server/dist/client.js'; +import { fetchJson } from '../frontend/src/api/transport.ts'; +import { getCsrfToken, loginJson, getMe, logoutJson } from '../frontend/src/api/auth.ts'; +import { requestPasswordReset } from '../frontend/src/api/publicAuth.ts'; + +const base = process.env.MARREQ_CONTRACT_BACKEND_URL; +assert.match(base ?? '', /^http:\/\/127\.0\.0\.1:\d+$/); +assert.ok(process.env.MARREQ_CONTRACT_TRANSCRIPT); +const mode = process.env.MARREQ_CONTRACT_VARIANT ?? 'server'; +const cookies = new Map(); +const networkFetch = globalThis.fetch; +globalThis.fetch = async (input, init = {}) => { + const url = new URL(input, base); + assert.equal(url.origin, base, 'contract smoke must stay on the isolated backend'); + const headers = new Headers(init.headers); + headers.set('Origin', base); + if (cookies.size) headers.set('Cookie', [...cookies].map(([k,v]) => `${k}=${v}`).join('; ')); + const response = await networkFetch(url, {...init, headers}); + for (const value of response.headers.getSetCookie()) { + const cookie = value.split(';')[0]; const equal = cookie.indexOf('='); + cookies.set(cookie.slice(0,equal), cookie.slice(equal+1)); + } + const text = await response.clone().text(); + let body = null; + try { body = JSON.parse(text); } catch { /* Empty guard and 204 bodies are valid. */ } + // Authorization headers/cookies are never retained in the transcript. + appendFileSync(process.env.MARREQ_CONTRACT_TRANSCRIPT, JSON.stringify({ + variant: mode, method: init.method ?? 'GET', path: url.pathname + url.search, + status: response.status, + request: url.pathname === '/api/auth/login' ? {username:'[test-user]',password:'[redacted]'} : typeof init.body === 'string' ? JSON.parse(init.body) : null, + response: body, + }) + '\n'); + return response; +}; + +// Execute the actual frontend transport against the running Rocket binary. +const build = await fetchJson('/api/meta/build'); +assert.equal(build.deployment_mode, mode); +const capabilities = await fetchJson('/api/meta/deployment'); +assert.equal(capabilities.allows_self_registration, mode === 'cloud'); +assert.equal(capabilities.allows_database_backup, mode === 'server'); +assert.equal(capabilities.allows_self_administered_user_creation, mode === 'server'); +const discovery = await fetchJson('/.well-known/oauth-authorization-server'); +assert.ok(discovery.scopes_supported.includes('use_cases:write')); +assert.deepEqual(discovery.token_endpoint_auth_methods_supported, ['none']); +const csrf = await getCsrfToken(); +await loginJson('manager', 'Contract-only-fixture-58!z', csrf); +assert.equal((await getMe()).username, 'manager'); +await logoutJson(await getCsrfToken()); +await assert.rejects(() => getMe(), (error) => error.status === 401); +if (mode === 'cloud') await requestPasswordReset({email:'absent-contract-user@example.test'}); + +// These delegated fixture credentials exist only in the ephemeral DB. +const context = { baseUrl: base, apiToken: 'uc-write', projectId: 1, mode: 'draft_write', traceWrite: false }; +const writer = new MarreqClient(context); +const reader = new MarreqClient({ ...context, apiToken: 'uc-read' }); +assert.equal((await reader.getPrincipal()).authentication_type, 'delegated_oauth'); +assert.ok(Array.isArray(await reader.listUseCases())); +const created = await writer.createUseCase({ title: 'Network contract use case', primary_actor: 'Tester' }, `network-contract-create-key-${mode}`); +assert.equal(typeof created.id, 'number'); +const document = { schema: 'marreq.use-case.v1', title: 'Network contract use case', main: [{ id: 's_net01', text: 'Exercise the real client against Rocket' }] }; +const saved = await writer.putUseCaseDraft(created.id, created.draft.revision, document); +assert.equal(saved.document.main[0].id, 's_net01'); +const detail = await reader.getUseCase(created.id); +assert.equal(detail.id, created.id); +await assert.rejects(() => reader.createUseCase({ title: 'Denied write' }, `network-contract-denied-key-${mode}`), (error) => error.status === 403 && error.challenge?.includes('use_cases:write')); +await assert.rejects(() => reader.withProject(2).listUseCases(), (error) => error.status === 403 || /403/.test(error.message)); +console.log('Real frontend transport and MCP HTTP read/write/scope/project-boundary checks passed.'); diff --git a/scripts/test-api-contracts.sh b/scripts/test-api-contracts.sh new file mode 100755 index 00000000..64866abe --- /dev/null +++ b/scripts/test-api-contracts.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Creates a disposable DB; ignores DATABASE_URL and .env. No production access. +set -euo pipefail +cd "$(dirname "$0")/.." +work=$(mktemp -d) +container="marreq-contract-$RANDOM-$$" +backend_pid= +cleanup() { if [[ -n "$backend_pid" ]]; then kill "$backend_pid" 2>/dev/null || true; wait "$backend_pid" 2>/dev/null || true; fi; docker rm -f "$container" >/dev/null 2>&1 || true; rm -rf "$work"; } +trap cleanup EXIT +# No bind mounts, persistent volumes or remotely supplied database URLs. +docker run --detach --rm --name "$container" \ + --tmpfs /var/lib/postgresql/data \ + -e POSTGRES_DB=marreq_contract_test -e POSTGRES_USER=contract_test \ + -e POSTGRES_PASSWORD=disposable-contract-test \ + -p 127.0.0.1::5432 pgvector/pgvector:pg17@sha256:ac08538c6f8b9904c33c8224c5e5706dbe760aca29db1d096972b4052c22a75d >/dev/null +ready=false +for attempt in {1..60}; do + if docker exec "$container" pg_isready -U contract_test -d marreq_contract_test >/dev/null 2>&1; then ready=true; break; fi + sleep 1 +done +if [[ "$ready" != true ]]; then echo "isolated contract DB did not become ready" >&2; exit 1; fi +for migration in marreq-core/migrations/*/up.sql; do + docker exec -i "$container" psql -v ON_ERROR_STOP=1 -U contract_test -d marreq_contract_test < "$migration" > "$work/migration.log" 2>&1 || { cat "$work/migration.log"; exit 1; } +done +port=$(docker port "$container" 5432/tcp) +port=${port##*:} +export MARREQ_TEST_DATABASE_URL="postgres://contract_test:disposable-contract-test@127.0.0.1:$port/marreq_contract_test" +export DATABASE_URL="$MARREQ_TEST_DATABASE_URL" +export MARREQ_CONTRACT_TRANSCRIPT="$work/exchanges.jsonl" +cargo test --locked -p marreq-core --no-default-features --test delegated_oauth_mcp_tools_postgres_integration --no-run --message-format=json > "$work/test-build.jsonl" +test_executable=$(node -e 'const fs=require("node:fs"); console.log(fs.readFileSync(process.argv[1],"utf8").trim().split("\n").map(JSON.parse).find(r=>r.executable&&r.target.name==="delegated_oauth_mcp_tools_postgres_integration").executable)' "$work/test-build.jsonl") +# A clean working directory and environment prevent dotenv/SMTP/semantic-search +# configuration inherited from a developer machine from entering test processes. +(cd "$work" && env -i PATH="$PATH" LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" \ + MARREQ_TEST_DATABASE_URL="$MARREQ_TEST_DATABASE_URL" \ + MARREQ_CONTRACT_TRANSCRIPT="$MARREQ_CONTRACT_TRANSCRIPT" \ + MARREQ_UI_MODE=api_only "$test_executable" --nocapture) +# Run the actual frontend transport and MCP HTTP client against a live socket. +# All database and service configuration is pinned to this ephemeral test stack. +npm run build --prefix mcp-server +cargo build --locked -p marreq-server -p marreq-cloud --message-format=json > "$work/server-build.jsonl" +frontend/node_modules/.bin/rolldown scripts/smoke-api-consumers.mjs --platform node --format esm --file "$work/smoke.mjs" +export ROCKET_DATABASES="{my_db={url=\"$DATABASE_URL\",pool_size=2}}" +export MARREQ_PUBLIC_BASE_URL=http://localhost:8080 MARREQ_MCP_PUBLIC_URL=http://localhost:8080/mcp +export MARREQ_MCP_AUDIT_SECRET=integration-test-audit-secret-32-bytes +for mode in server cloud; do + executable=$(node -e 'const fs=require("node:fs"); console.log(fs.readFileSync(process.argv[1],"utf8").trim().split("\n").map(JSON.parse).find(r=>r.executable&&r.target.name===process.argv[2]).executable)' "$work/server-build.jsonl" "marreq-$mode") + backend_port=$(node -e 'const s=require("node:net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})') + export MARREQ_CONTRACT_BACKEND_URL="http://127.0.0.1:$backend_port" MARREQ_CONTRACT_VARIANT="$mode" + (cd "$work" && exec env -i PATH="$PATH" LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" \ + DATABASE_URL="$DATABASE_URL" ROCKET_ADDRESS=127.0.0.1 ROCKET_PORT="$backend_port" \ + ROCKET_DATABASES="$ROCKET_DATABASES" MARREQ_PUBLIC_BASE_URL="$MARREQ_PUBLIC_BASE_URL" \ + MARREQ_MCP_PUBLIC_URL="$MARREQ_MCP_PUBLIC_URL" MARREQ_MCP_AUDIT_SECRET="$MARREQ_MCP_AUDIT_SECRET" \ + CSRF_ALLOWED_ORIGINS="$MARREQ_CONTRACT_BACKEND_URL" \ + MARREQ_AUTO_SEED=false MARREQ_UI_MODE=api_only "$executable") > "$work/backend.log" 2>&1 & + backend_pid=$! + ready=false + for attempt in {1..60}; do + if ! kill -0 "$backend_pid" 2>/dev/null; then cat "$work/backend.log"; exit 1; fi + if curl --silent --fail "$MARREQ_CONTRACT_BACKEND_URL/api/meta/health" > /dev/null; then ready=true; break; fi + sleep 1 + done + if [[ "$ready" != true ]]; then cat "$work/backend.log"; exit 1; fi + node "$work/smoke.mjs" + kill "$backend_pid" + wait "$backend_pid" || true + backend_pid= +done +# Missing/skipped critical exchanges fail, not just malformed response bodies. +cargo run --quiet --locked -p marreq-contracts -- verify-traffic "$MARREQ_CONTRACT_TRANSCRIPT" diff --git a/tools/marreq-contracts/Cargo.toml b/tools/marreq-contracts/Cargo.toml new file mode 100644 index 00000000..fdeff3f2 --- /dev/null +++ b/tools/marreq-contracts/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "marreq-contracts" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +sha2.workspace = true +clap = { version = "4.6", features = ["derive"] } +semver = { version = "1.0", features = ["serde"] } +serde_yaml_ng = "0.10" +jsonschema = { version = "0.60", default-features = false } +tempfile = "3" +walkdir = "2" +tree-sitter = "0.25" +tree-sitter-typescript = "0.23.2" diff --git a/tools/marreq-contracts/src/artifact.rs b/tools/marreq-contracts/src/artifact.rs new file mode 100644 index 00000000..a5e12f7e --- /dev/null +++ b/tools/marreq-contracts/src/artifact.rs @@ -0,0 +1,326 @@ +//! Marreq artifact identity/policy. Contract hashes pin files, NOT Docker images. +use crate::{contract, coverage}; +use anyhow::{Context, Result, ensure}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, + process::Command, +}; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Policy { + pub requires_backend_min: Version, + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_backend_max: Option, +} +impl Policy { + pub fn validate(&self) -> Result<()> { + ensure!( + self.requires_backend_max + .as_ref() + .is_none_or(|max| max.cmp_precedence(&self.requires_backend_min).is_ge()), + "inverted backend compatibility range" + ); + Ok(()) + } + pub fn accepts(&self, backend: &Version) -> Result<()> { + self.validate()?; + ensure!( + backend.cmp_precedence(&self.requires_backend_min).is_ge() + && self + .requires_backend_max + .as_ref() + .is_none_or(|max| backend.cmp_precedence(max).is_le()), + "backend {backend} outside consumer policy" + ); + Ok(()) + } +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FrontendRange { + pub min_version: Version, + pub max_version: Version, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Bundle { + pub format: String, + pub component: String, + pub version: Version, + pub source_sha: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub variant: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compatibility: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub frontend_compatibility: Option, + pub contract: Value, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Artifact { + pub file: PathBuf, + pub sha256: String, + pub version: Version, + pub source_sha: String, +} +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Plan { + pub format: String, + pub variant: String, + pub backend: Artifact, + pub frontend: Artifact, + pub mcp: Artifact, +} +pub fn hash(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} +fn sha(value: &str, len: usize) -> bool { + value.len() == len + && value + .bytes() + .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f')) +} +fn component(value: &str) -> Result<()> { + ensure!( + ["backend", "frontend", "mcp"].contains(&value), + "invalid component {value}" + ); + Ok(()) +} +pub fn variant(value: &str) -> Result<()> { + ensure!( + ["server", "cloud"].contains(&value), + "invalid deployment variant {value}" + ); + Ok(()) +} +fn identity(bundle: &Bundle) -> Result<()> { + ensure!( + bundle.format == "marreq.contract.v2", + "unsupported bundle; historical consumer dependencies must be evidenced, not inferred from current main" + ); + component(&bundle.component)?; + ensure!(sha(&bundle.source_sha, 40), "immutable source SHA required"); + if bundle.component == "backend" { + variant( + bundle + .variant + .as_deref() + .context("backend variant required")?, + )?; + ensure!( + bundle.frontend_compatibility.is_some(), + "backend frontend policy missing" + ); + } else { + ensure!( + bundle.variant.is_none(), + "consumer artifacts are deployment-independent" + ); + bundle + .compatibility + .as_ref() + .context("consumer compatibility policy missing")? + .validate()?; + } + ensure!( + !contract::operations(&bundle.contract, None)?.is_empty(), + "empty artifact contract is missing evidence" + ); + Ok(()) +} +pub fn export(root: &Path, who: &str, version: Version, mode: Option) -> Result { + component(who)?; + let spec = contract::load(&root.join("docs/developer/openapi.yaml"))?; + let required: BTreeSet<_> = if who == "backend" { + variant(mode.as_deref().context("backend --variant required")?)?; + contract::operations(&spec, mode.as_deref())? + .into_iter() + .filter(|(_, op)| { + !op.get("x-marreq-unverified") + .is_some_and(|v| v != &Value::Bool(false)) + }) + .map(|(key, _)| key) + .collect() + } else { + coverage::scan(root, who, &spec)?.operations + }; + let selected = contract::select(&spec, &required, mode.as_deref(), true)?; + if who == "backend" { + contract::validate(&selected)?; + } else { + contract::validate_wire_contract(&selected)?; + } + let directory = if who == "mcp" { "mcp-server" } else { who }; + let mut inputs: Vec = [ + "docs/developer/openapi.yaml", + "tools/marreq-contracts", + "Cargo.toml", + "Cargo.lock", + ] + .into_iter() + .map(String::from) + .collect(); + if who == "backend" { + inputs.extend( + ["marreq-core", "marreq-cloud", "marreq-server"] + .into_iter() + .map(String::from), + ); + } else { + inputs.extend( + [ + "src", + "package.json", + "package-lock.json", + "compatibility.json", + ] + .into_iter() + .map(|path| format!("{directory}/{path}")), + ); + } + let status = Command::new("git") + .args(["status", "--porcelain", "--untracked-files=all", "--"]) + .args(&inputs) + .current_dir(root) + .output()?; + ensure!( + status.status.success() && status.stdout.is_empty(), + "commit source/contract inputs before exporting an immutable bundle" + ); + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output()?; + ensure!(output.status.success(), "cannot determine source identity"); + let source_sha = String::from_utf8(output.stdout)?.trim().to_owned(); + let policy = if who != "backend" { + Some(serde_json::from_value(contract::load( + &root.join(directory).join("compatibility.json"), + )?)?) + } else { + None + }; + let frontend_compatibility = if who == "backend" { + Some(serde_json::from_value(contract::load( + &root.join("marreq-core/frontend_compatibility.json"), + )?)?) + } else { + None + }; + let bundle = Bundle { + format: "marreq.contract.v2".into(), + component: who.into(), + version, + source_sha, + variant: mode, + compatibility: policy, + frontend_compatibility, + contract: selected, + }; + identity(&bundle)?; + Ok(bundle) +} +fn read(directory: &Path, descriptor: &Artifact, who: &str) -> Result { + ensure!( + !descriptor.file.is_absolute() + && !descriptor + .file + .components() + .any(|x| matches!(x, std::path::Component::ParentDir)), + "artifact file must stay inside plan directory" + ); + let path = directory.join(&descriptor.file); + let canonical = path.canonicalize().with_context(|| { + format!( + "missing historical {who} artifact {}; refusing substitution with main", + path.display() + ) + })?; + ensure!( + canonical.starts_with(directory.canonicalize()?), + "artifact symlink escapes plan directory" + ); + let bytes = std::fs::read(canonical)?; + ensure!( + sha(&descriptor.sha256, 64) && hash(&bytes) == descriptor.sha256, + "{who}: contract SHA-256 mismatch (not an image digest)" + ); + let bundle: Bundle = + serde_json::from_slice(&bytes).with_context(|| format!("invalid {who} bundle"))?; + identity(&bundle)?; + ensure!( + bundle.component == who + && bundle.version == descriptor.version + && bundle.source_sha == descriptor.source_sha, + "{who}: artifact component/version/source SHA mismatch" + ); + contract::validate(&bundle.contract)?; + Ok(bundle) +} +pub fn verify(path: &Path, candidate: Option<(&str, &Version, &str, &Path)>) -> Result { + let plan: Plan = serde_json::from_value(contract::load(path)?)?; + ensure!( + plan.format == "marreq.deployment-contract.v2", + "unsupported deployment plan format" + ); + variant(&plan.variant)?; + let directory = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let backend = read(directory, &plan.backend, "backend")?; + let frontend = read(directory, &plan.frontend, "frontend")?; + let mcp = read(directory, &plan.mcp, "mcp")?; + ensure!( + backend.variant.as_deref() == Some(&plan.variant), + "backend deployment variant mismatch" + ); + if let Some((who, ver, source, file)) = candidate { + let descriptor = match who { + "backend" => &plan.backend, + "frontend" => &plan.frontend, + "mcp" => &plan.mcp, + _ => anyhow::bail!("invalid candidate component"), + }; + ensure!( + &descriptor.version == ver + && descriptor.source_sha == source + && hash(&std::fs::read(file)?) == descriptor.sha256, + "plan does not reference the source-exported tagged candidate" + ); + } + let range = backend.frontend_compatibility.as_ref().unwrap(); + ensure!( + range.min_version.cmp_precedence(&range.max_version).is_le(), + "invalid backend frontend range" + ); + ensure!( + frontend.version.cmp_precedence(&range.min_version).is_ge() + && frontend.version.cmp_precedence(&range.max_version).is_le(), + "frontend version outside backend policy" + ); + for consumer in [&frontend, &mcp] { + consumer + .compatibility + .as_ref() + .unwrap() + .accepts(&backend.version) + .with_context(|| consumer.component.clone())?; + contract::compare(&consumer.contract, &backend.contract, &plan.variant) + .with_context(|| consumer.component.clone())?; + } + Ok( + serde_json::json!({"compatible":true,"variant":plan.variant,"versions":{"backend":backend.version,"frontend":frontend.version,"mcp":mcp.version}}), + ) +} diff --git a/tools/marreq-contracts/src/contract.rs b/tools/marreq-contracts/src/contract.rs new file mode 100644 index 00000000..1a829d30 --- /dev/null +++ b/tools/marreq-contracts/src/contract.rs @@ -0,0 +1,387 @@ +//! OpenAPI selection and validation. Standards parsing/comparison is delegated +//! to pinned oasdiff; JSON instances use the jsonschema crate in offline mode. +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + process::Command, +}; + +pub const ENGINE_VERSION: &str = "1.34.0"; +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Operation { + pub path: String, + pub method: String, +} +impl std::fmt::Display for Operation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.method.to_uppercase(), self.path) + } +} +pub fn load(path: &Path) -> Result { + serde_yaml_ng::from_str( + &std::fs::read_to_string(path) + .with_context(|| format!("missing contract evidence: {}", path.display()))?, + ) + .context("invalid JSON/YAML") +} +pub fn write(path: &Path, value: &impl Serialize) -> Result<()> { + std::fs::write(path, serde_json::to_vec_pretty(value)?).context("write contract") +} +pub fn operations(spec: &Value, variant: Option<&str>) -> Result> { + let mut result = BTreeMap::new(); + for (path, item) in spec["paths"].as_object().context("OpenAPI paths missing")? { + for (method, op) in item.as_object().context("invalid path item")? { + if !["get", "post", "put", "patch", "delete", "head", "options"] + .contains(&method.as_str()) + { + continue; + } + if variant.is_some_and(|v| { + op.get("x-marreq-variants") + .and_then(Value::as_array) + .is_some_and(|a| !a.iter().any(|x| x == v)) + }) { + continue; + } + let mut op = op.clone(); + if let Some(parameters) = item.get("parameters") { + let mut merged = parameters.as_array().context("path parameters")?.clone(); + merged.extend(op["parameters"].as_array().cloned().unwrap_or_default()); + op["parameters"] = json!(merged); + } + if op.get("security").is_none() { + op["security"] = spec.get("security").cloned().unwrap_or(json!([])); + } + result.insert( + Operation { + path: path.clone(), + method: method.clone(), + }, + op, + ); + } + } + Ok(result) +} +// Compact a consumer bundle to its operations and reachable local components. +// No dereferencing/schema comparison is implemented here. +pub fn select( + spec: &Value, + required: &BTreeSet, + variant: Option<&str>, + verified: bool, +) -> Result { + let available = operations(spec, variant)?; + let mut selected = json!({"openapi":spec["openapi"],"info":spec["info"],"servers":spec.get("servers").cloned().unwrap_or(json!([{ "url":"/" }])),"paths":{},"components":{}}); + for key in required { + let op = available.get(key).with_context(|| { + format!( + "required operation missing in {} backend: {key}", + variant.unwrap_or("selected") + ) + })?; + if verified + && op + .get("x-marreq-unverified") + .is_some_and(|v| v != &Value::Bool(false)) + { + bail!("required consumer operation has unverified wire contract: {key}"); + } + selected["paths"][&key.path][&key.method] = op.clone(); + } + // Include just referenced components, including security schemes inherited + // at the operation level. External refs are always rejected before tooling. + loop { + let mut references = BTreeSet::new(); + collect_refs(&selected, &mut references)?; + for item in selected["paths"].as_object().unwrap().values() { + for op in item.as_object().unwrap().values() { + for req in op["security"].as_array().into_iter().flatten() { + for name in req.as_object().context("security requirement")?.keys() { + references.insert(format!("#/components/securitySchemes/{name}")); + } + } + } + } + let mut changed = false; + for reference in references { + let pointer = &reference[1..]; + if selected.pointer(pointer).is_none() { + let parts: Vec<_> = pointer.trim_start_matches('/').split('/').collect(); + ensure!( + parts.len() == 3 && parts[0] == "components", + "unsupported non-component reference {reference}" + ); + selected["components"][parts[1]][parts[2].replace("~1", "/").replace("~0", "~")] = + spec.pointer(pointer) + .with_context(|| format!("missing reference {reference}"))? + .clone(); + changed = true; + } + } + if !changed { + break; + } + } + Ok(selected) +} +fn collect_refs(value: &Value, out: &mut BTreeSet) -> Result<()> { + match value { + Value::Object(map) => { + if let Some(r) = map.get("$ref") { + let r = r.as_str().context("invalid $ref")?; + ensure!( + r.starts_with("#/"), + "external references are forbidden: {r}" + ); + out.insert(r.into()); + } + for v in map.values() { + collect_refs(v, out)?; + } + } + Value::Array(a) => { + for v in a { + collect_refs(v, out)?; + } + } + _ => (), + } + Ok(()) +} +fn engine() -> Result { + let binary = std::env::var_os("OASDIFF").unwrap_or_else(|| "oasdiff".into()); + let output = Command::new(&binary).arg("--version").output().context( + "install checksum-pinned oasdiff using scripts/install-oasdiff.sh (or set OASDIFF)", + )?; + let version = String::from_utf8_lossy(&output.stdout); + ensure!( + output.status.success() + && version + .split_whitespace() + .any(|v| v.trim_start_matches('v') == ENGINE_VERSION), + "expected oasdiff {ENGINE_VERSION}, got {version}" + ); + Ok(Command::new(binary)) +} +pub fn validate(spec: &Value) -> Result<()> { + collect_refs(spec, &mut BTreeSet::new())?; + let directory = tempfile::tempdir()?; + let path = directory.path().join("contract.json"); + write(&path, spec)?; + let output = engine()? + .args([ + "validate", + "--allow-external-refs=false", + "--fail-on", + "WARN", + "--format", + "json", + ]) + .arg(path) + .output()?; + ensure!( + output.status.success(), + "OpenAPI validation failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} +// Explicitly bound the schema dialect whose compatibility the pinned engine +// can establish. Advanced semantics are localized to selected consumer schemas, +// never silently ignored or applied to unrelated backend definitions. +fn schema_policy(schema: &Value) -> Result<()> { + let Some(map) = schema.as_object() else { + return Ok(()); + }; + let supported = [ + "$schema", + "$ref", + "$id", + "$defs", + "definitions", + "type", + "const", + "enum", + "allOf", + "anyOf", + "oneOf", + "properties", + "required", + "additionalProperties", + "items", + "minItems", + "maxItems", + "uniqueItems", + "minLength", + "maxLength", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minProperties", + "maxProperties", + "pattern", + "format", + "title", + "description", + "default", + "examples", + "example", + "deprecated", + "readOnly", + "writeOnly", + "nullable", + "discriminator", + "externalDocs", + "xml", + "x-extensible-enum", + "x-marreq-unverified", + ]; + for key in map.keys() { + ensure!( + supported.contains(&key.as_str()), + "schema keyword {key} is outside the supported compatibility dialect; affected operation needs explicit evidence" + ); + } + ensure!( + !map.get("x-marreq-unverified") + .is_some_and(|v| v != &Value::Bool(false)), + "selected schema is unverified" + ); + for key in ["properties", "$defs", "definitions"] { + if let Some(values) = map.get(key).and_then(Value::as_object) { + for value in values.values() { + schema_policy(value)?; + } + } + } + for key in ["items", "additionalProperties"] { + if let Some(value) = map.get(key) { + schema_policy(value)?; + } + } + for key in ["allOf", "anyOf", "oneOf"] { + if let Some(values) = map.get(key).and_then(Value::as_array) { + for value in values { + schema_policy(value)?; + } + } + } + Ok(()) +} +fn supported_schemas(document: &Value) -> Result<()> { + if let Some(schemas) = document + .pointer("/components/schemas") + .and_then(Value::as_object) + { + for schema in schemas.values() { + schema_policy(schema)?; + } + } + fn inline(value: &Value) -> Result<()> { + match value { + Value::Object(map) => { + for (key, value) in map { + if key == "schema" { + schema_policy(value)?; + } else if !matches!(key.as_str(), "example" | "examples" | "default") { + inline(value)?; + } + } + } + Value::Array(values) => { + for value in values { + inline(value)?; + } + } + _ => (), + } + Ok(()) + } + inline(document) +} + +pub fn validate_wire_contract(document: &Value) -> Result<()> { + validate(document)?; + supported_schemas(document) +} + +pub fn compare(consumer: &Value, backend: &Value, variant: &str) -> Result { + crate::artifact::variant(variant)?; + let required = operations(consumer, Some(variant))?.into_keys().collect(); + let old = select(consumer, &required, Some(variant), true)?; + let new = select(backend, &required, Some(variant), true)?; + validate_wire_contract(&old)?; + validate_wire_contract(&new)?; + // These are Marreq-specific authorization semantics not standard OpenAPI + // security schemes. Compare per operation, never globally across the API. + let a = operations(&old, None)?; + let b = operations(&new, None)?; + for (key, op) in &a { + ensure!( + op.get("x-marreq-csrf") == b[key].get("x-marreq-csrf"), + "{key}: CSRF authentication requirement changed" + ); + let scopes = |v: &Value| -> Result> { + v.as_array() + .context("scope declarations must be arrays")? + .iter() + .map(|s| Ok(s.as_str().context("scope must be string")?.to_owned())) + .collect() + }; + let old = op.get("x-marreq-bearer-scopes"); + let new = b[key].get("x-marreq-bearer-scopes"); + match (old, new) { + (Some(old), Some(new)) => ensure!( + scopes(new)?.is_subset(&scopes(old)?), + "{key}: newly required Bearer scopes" + ), + (None, None) => (), + _ => anyhow::bail!("{key}: Bearer authorization evidence changed"), + } + } + let dir = tempfile::tempdir()?; + let base = dir.path().join("consumer.json"); + let revision = dir.path().join("backend.json"); + write(&base, &old)?; + write(&revision, &new)?; + let output = engine()? + .args([ + "breaking", + "--allow-external-refs=false", + "--fail-on", + "WARN", + "--format", + "json", + "--flatten-allof", + ]) + .arg(base) + .arg(revision) + .output()?; + let findings: Value = serde_json::from_slice(&output.stdout).unwrap_or(json!([])); + ensure!( + output.status.success(), + "incompatible {variant} producer/consumer contract: {findings} {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(findings) +} +pub fn validate_instance(spec: &Value, schema: &Value, instance: &Value) -> Result<()> { + let mut schema = schema.clone(); + if let Some(obj) = schema.as_object_mut() { + obj.insert("components".into(), spec["components"].clone()); + } + let validator = jsonschema::options() + .offline() + .build(&schema) + .context("invalid/offline JSON schema")?; + validator + .validate(instance) + .map_err(|e| anyhow::anyhow!("wire schema violation at {}: {}", e.instance_path(), e)) +} diff --git a/tools/marreq-contracts/src/coverage.rs b/tools/marreq-contracts/src/coverage.rs new file mode 100644 index 00000000..2a85d84f --- /dev/null +++ b/tools/marreq-contracts/src/coverage.rs @@ -0,0 +1,714 @@ +//! Conservative TypeScript call analysis using the real TS/TSX grammar. +//! Unknown URL or HTTP-method expressions fail instead of dropping dependencies. +use crate::contract::{Operation, operations}; +use anyhow::{Context, Result, bail}; +use serde::Serialize; +use serde_json::Value; +use std::{ + collections::{BTreeMap, BTreeSet, HashSet}, + path::Path, +}; +use tree_sitter::Node; + +#[derive(Debug, Serialize)] +pub struct Coverage { + pub operations: BTreeSet, + pub call_sites: usize, +} + +fn children(node: Node<'_>) -> Vec> { + let mut cursor = node.walk(); + node.named_children(&mut cursor).collect() +} +fn descend<'a>(node: Node<'a>, kind: &str, found: &mut Vec>) { + if node.kind() == kind { + found.push(node); + } + for child in children(node) { + descend(child, kind, found); + } +} +fn text<'a>(node: Node<'_>, source: &'a str) -> &'a str { + &source[node.byte_range()] +} +fn field<'a>(node: Node<'a>, name: &str) -> Result> { + node.child_by_field_name(name).context("missing AST field") +} +fn name(node: Node<'_>, source: &str) -> String { + if matches!( + node.kind(), + "await_expression" | "parenthesized_expression" | "instantiation_expression" + ) && let Some(inner) = children(node).first() + { + return name(*inner, source); + } + if node.kind() == "subscript_expression" + && let Some(index) = node + .child_by_field_name("index") + .filter(|n| n.kind() == "string") + { + return text(index, source).trim_matches(['\'', '"']).into(); + } + node.child_by_field_name("property") + .map_or_else(|| text(node, source), |n| text(n, source)) + .to_owned() +} +fn enclosing(mut node: Node<'_>) -> Node<'_> { + while let Some(parent) = node.parent() { + if matches!( + node.kind(), + "function_declaration" | "method_definition" | "arrow_function" + ) { + return node; + } + node = parent; + } + node +} +fn combine(left: Vec, right: Vec) -> Vec { + left.into_iter() + .flat_map(|a| right.iter().map(move |b| format!("{a}{b}"))) + .collect() +} + +// Only finite string construction is evaluated. Unknown parameter values become +// a segment wildcard; an entirely unknown endpoint or HTTP method is rejected. +fn values( + node: Node<'_>, + source: &str, + root: Node<'_>, + bound: &BTreeMap>, + depth: usize, +) -> Vec { + if depth > 16 { + return vec!["*".into()]; + } + let eval = |n| values(n, source, root, bound, depth + 1); + match node.kind() { + "string" => vec![text(node, source)[1..text(node, source).len() - 1].to_owned()], + "template_string" => { + let mut result = vec![String::new()]; + for child in children(node) { + let part = if child.kind() == "string_fragment" { + vec![text(child, source).to_owned()] + } else if child.kind() == "template_substitution" { + children(child) + .first() + .map_or(vec!["*".into()], |n| eval(*n)) + } else { + vec![text(child, source).to_owned()] + }; + result = combine(result, part); + if result.len() > 128 { + return vec!["*".into()]; + } + } + result + } + "ternary_expression" => ["consequence", "alternative"] + .into_iter() + .filter_map(|f| node.child_by_field_name(f)) + .flat_map(eval) + .collect(), + "binary_expression" + if node + .child_by_field_name("operator") + .is_some_and(|n| text(n, source) == "+") => + { + combine( + eval(field(node, "left").unwrap()), + eval(field(node, "right").unwrap()), + ) + } + "identifier" => { + let id = text(node, source); + if let Some(v) = bound.get(id) { + return v.clone(); + } + let scope = enclosing(node); + let mut vars = vec![]; + descend(scope, "variable_declarator", &mut vars); + if let Some(value) = vars + .into_iter() + .filter(|v| { + v.start_byte() < node.start_byte() + && v.parent() + .and_then(|p| p.child(0)) + .is_some_and(|n| n.kind() == "const") + }) + .filter(|v| { + let mut block = v.parent(); + while block.is_some_and(|p| !matches!(p.kind(), "statement_block" | "program")) + { + block = block.and_then(|p| p.parent()); + } + let mut parent = Some(node); + while let Some(p) = parent { + if Some(p) == block { + return true; + } + parent = p.parent(); + } + false + }) + .rev() + .find_map(|v| { + (v.child_by_field_name("name") + .is_some_and(|n| text(n, source) == id)) + .then(|| v.child_by_field_name("value")) + .flatten() + }) + { + return eval(value); + } + if let Some(parameters) = scope.child_by_field_name("parameters") { + for parameter in children(parameters) { + let ident = parameter + .child_by_field_name("pattern") + .or_else(|| parameter.child_by_field_name("name")); + let numeric = parameter.child_by_field_name("type").is_some_and(|t| { + text(t, source).trim().trim_start_matches(':').trim() == "number" + }); + if numeric && ident.is_some_and(|n| text(n, source) == id) { + return vec!["#".into()]; + } + } + } + vec!["*".into()] + } + "number" => vec![text(node, source).into()], + "member_expression" + if node.child_by_field_name("property").is_some_and(|n| { + let property = text(n, source); + property == "id" || property.ends_with("Id") || property.ends_with("_id") + }) => + { + vec!["#".into()] + } + "parenthesized_expression" | "as_expression" | "non_null_expression" => children(node) + .first() + .map_or(vec!["*".into()], |n| eval(*n)), + "call_expression" => { + let Ok(fun) = field(node, "function") else { + return vec!["*".into()]; + }; + let fun_name = name(fun, source); + let args = node + .child_by_field_name("arguments") + .map(children) + .unwrap_or_default(); + if matches!(fun_name.as_str(), "encodeURIComponent" | "String") { + return vec!["*".into()]; + } + if fun_name == "withQuery" { + return args.first().map_or(vec!["*".into()], |n| eval(*n)); + } + let mut defs = vec![]; + descend(root, "function_declaration", &mut defs); + descend(root, "method_definition", &mut defs); + // Arrow-valued helpers such as `const base = (projectId) => ...`. + let mut vars = vec![]; + descend(root, "variable_declarator", &mut vars); + for var in vars { + if var + .child_by_field_name("name") + .is_some_and(|n| text(n, source) == fun_name) + && let Some(v) = var + .child_by_field_name("value") + .filter(|n| n.kind() == "arrow_function") + { + defs.push(v); + } + } + for def in defs { + if def.kind() != "arrow_function" + && !def + .child_by_field_name("name") + .is_some_and(|n| text(n, source) == fun_name) + { + continue; + } + let mut scope = bound.clone(); + if let Some(parameters) = def.child_by_field_name("parameters") { + for (i, p) in children(parameters).into_iter().enumerate() { + let id = p + .child_by_field_name("pattern") + .or_else(|| p.child_by_field_name("name")) + .unwrap_or(p); + let default = p.child_by_field_name("value"); + scope.insert( + text(id, source).into(), + args.get(i).map_or_else( + || default.map_or(vec!["*".into()], eval), + |n| eval(*n), + ), + ); + } + } + if let Some(body) = def.child_by_field_name("body") { + let mut returns = vec![]; + if body.kind() == "statement_block" { + descend(body, "return_statement", &mut returns); + } else { + returns.push(body); + } + let result: Vec<_> = returns + .into_iter() + .flat_map(|r| { + let v = if r.kind() == "return_statement" { + children(r).first().copied() + } else { + Some(r) + }; + v.map_or(vec![], |v| values(v, source, root, &scope, depth + 1)) + }) + .collect(); + if !result.is_empty() { + return result; + } + } + } + vec!["*".into()] + } + _ => vec!["*".into()], + } +} + +fn methods(init: Option>, source: &str) -> Result> { + let Some(init) = init else { + return Ok(vec!["get".into()]); + }; + if init.kind() == "call_expression" { + let fun = name(field(init, "function")?, source); + if fun == "write" { + let args = children(field(init, "arguments")?); + if let Some(n) = args.first().filter(|n| n.kind() == "string") { + return Ok(vec![ + text(*n, source)[1..text(*n, source).len() - 1].to_lowercase(), + ]); + } + } + bail!("unresolved request options: {}", text(init, source)); + } + if init.kind() != "object" { + bail!( + "request options need an explicit HTTP method: {}", + text(init, source) + ); + } + anyhow::ensure!( + !children(init).iter().any(|n| n.kind() == "spread_element"), + "request option spreads need explicit method evidence" + ); + for p in children(init).into_iter().filter(|n| n.kind() == "pair") { + if text(field(p, "key")?, source).trim_matches(['\'', '"']) == "method" { + let v = field(p, "value")?; + return match v.kind() { + "string" => Ok(vec![ + text(v, source)[1..text(v, source).len() - 1].to_lowercase(), + ]), + // Both branch methods are required; dynamic arbitrary methods fail. + "ternary_expression" => ["consequence", "alternative"] + .into_iter() + .map(|f| { + let n = field(v, f)?; + if n.kind() != "string" { + bail!("unresolved method") + }; + Ok(text(n, source)[1..text(n, source).len() - 1].to_lowercase()) + }) + .collect(), + _ => bail!("unresolved HTTP method: {}", text(v, source)), + }; + } + } + Ok(vec!["get".into()]) +} + +// Intersection of segment globs. '*' never crosses '/', so a collection call +// cannot accidentally require every nested resource beneath it. +pub fn intersects(a: &str, b: &str) -> bool { + fn go(a: &[u8], b: &[u8], i: usize, j: usize, seen: &mut HashSet<(usize, usize)>) -> bool { + if !seen.insert((i, j)) { + return false; + } + if i == a.len() && j == b.len() { + return true; + } + // '#' is a nonempty numeric identifier. It keeps a typed id lookup + // from accidentally selecting a reserved sibling such as /index_status. + match (a.get(i), b.get(j)) { + (Some(b'#'), Some(b'#')) => return go(a, b, i + 1, j + 1, seen), + (Some(b'#'), Some(b'*')) => { + return go(a, b, i + 1, j, seen) || go(a, b, i, j + 1, seen); + } + (Some(b'*'), Some(b'#')) => { + return go(a, b, i, j + 1, seen) || go(a, b, i + 1, j, seen); + } + (Some(b'#'), Some(d)) if d.is_ascii_digit() => { + return go(a, b, i + 1, j + 1, seen) || go(a, b, i, j + 1, seen); + } + (Some(d), Some(b'#')) if d.is_ascii_digit() => { + return go(a, b, i + 1, j + 1, seen) || go(a, b, i + 1, j, seen); + } + (Some(b'#'), _) | (_, Some(b'#')) => return false, + _ => (), + } + if a.get(i) == Some(&b'*') { + return go(a, b, i + 1, j, seen) + || (j < b.len() && b[j] != b'/' && go(a, b, i, j + 1, seen)); + } + if b.get(j) == Some(&b'*') { + return go(a, b, i, j + 1, seen) + || (i < a.len() && a[i] != b'/' && go(a, b, i + 1, j, seen)); + } + i < a.len() && j < b.len() && a[i] == b[j] && go(a, b, i + 1, j + 1, seen) + } + go(a.as_bytes(), b.as_bytes(), 0, 0, &mut HashSet::new()) +} +fn path_glob(path: &str) -> String { + let mut out = String::new(); + let mut param = false; + for c in path.chars() { + if c == '{' { + param = true; + out.push('*'); + } else if c == '}' { + param = false; + } else if !param { + out.push(c); + } + } + out +} + +pub fn analyze( + source: &str, + tsx: bool, + declared: &BTreeMap, + transport: bool, +) -> Result { + analyze_file(source, tsx, declared, transport, false) +} + +fn analyze_file( + source: &str, + tsx: bool, + declared: &BTreeMap, + transport: bool, + user_content_links: bool, +) -> Result { + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&if tsx { + tree_sitter_typescript::LANGUAGE_TSX.into() + } else { + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into() + })?; + let tree = parser.parse(source, None).context("parse TypeScript")?; + let root = tree.root_node(); + if root.has_error() { + let mut nodes = vec![]; + descend(root, "ERROR", &mut nodes); + // The upstream TSX grammar treats a bare ampersand in JSX text as an + // error although TypeScript accepts it. No executable expression is + // inside such a text fragment; every other parse error fails closed. + let harmless = |n: &Node<'_>| { + tsx && n.parent().is_some_and(|p| p.kind() == "jsx_element") + && text(*n, source).starts_with('&') + && !text(*n, source).contains(['{', '}', '<', '>']) + }; + if nodes.is_empty() || !nodes.iter().all(harmless) { + bail!( + "TypeScript syntax errors prevent reliable coverage analysis: {:?}", + nodes + .iter() + .map(|n| (n.start_position().row + 1, text(*n, source))) + .collect::>() + ); + } + } + let mut calls = vec![]; + descend(root, "call_expression", &mut calls); + let mut result = Coverage { + operations: BTreeSet::new(), + call_sites: 0, + }; + let known = |name: &str| { + matches!( + name, + "fetch" | "fetchJson" | "fetchBlob" | "fetchDownload" | "request" | "download" + ) + }; + let mut aliases = BTreeMap::new(); + let mut imports = vec![]; + descend(root, "import_specifier", &mut imports); + for import in imports { + if let (Some(original), Some(alias)) = ( + import.child_by_field_name("name"), + import.child_by_field_name("alias"), + ) && known(text(original, source)) + { + aliases.insert( + text(alias, source).to_owned(), + text(original, source).to_owned(), + ); + } + } + let mut variables = vec![]; + descend(root, "variable_declarator", &mut variables); + for var in variables { + if let (Some(id), Some(value)) = ( + var.child_by_field_name("name"), + var.child_by_field_name("value"), + ) { + let original = name(value, source); + let original = aliases.get(&original).cloned().unwrap_or(original); + if known(&original) { + aliases.insert(text(id, source).to_owned(), original); + } + } + } + for call in calls { + let fun = field(call, "function")?; + let fun_name = name(fun, source); + let fun_name = aliases.get(&fun_name).cloned().unwrap_or(fun_name); + if !matches!( + fun_name.as_str(), + "fetch" | "fetchJson" | "fetchBlob" | "fetchDownload" | "request" | "download" + ) { + let args = call + .child_by_field_name("arguments") + .map(children) + .unwrap_or_default(); + let mut endpoints = vec![]; + if let Some(first) = args.first() { + endpoints.extend(values(*first, source, root, &BTreeMap::new(), 0)); + if first.kind() == "object" { + for pair in children(*first).into_iter().filter(|n| n.kind() == "pair") { + if pair + .child_by_field_name("key") + .is_some_and(|n| matches!(text(n, source), "url" | "path" | "uri")) + && let Some(value) = pair.child_by_field_name("value") + { + endpoints.extend(values(value, source, root, &BTreeMap::new(), 0)); + } + } + } + } + if endpoints.iter().any(|p| p.contains("/api/")) + && !matches!( + fun_name.as_str(), + "base" | "project" | "useCases" | "withQuery" + ) + { + bail!( + "unclassified use of an API URL by {fun_name} at line {}; add an explicit transport adapter", + call.start_position().row + 1 + ); + } + continue; + } + // Exactly the shared transports forward arbitrary paths. Their callers + // are checked here; adding another forwarding layer is not auto-excluded. + if transport && fun_name == "fetch" { + let owner = enclosing(call) + .child_by_field_name("name") + .map(|n| text(n, source)); + if matches!( + owner, + Some("request" | "fetchJson" | "fetchBlob" | "fetchDownload") + ) { + continue; + } + } + if fun_name == "download" { + let mut defs = vec![]; + descend(root, "function_declaration", &mut defs); + let forwards = defs + .into_iter() + .filter(|d| { + d.child_by_field_name("name") + .is_some_and(|n| text(n, source) == "download") + }) + .any(|d| { + let mut inner = vec![]; + descend(d, "call_expression", &mut inner); + inner.into_iter().any(|c| { + c.child_by_field_name("function") + .is_some_and(|f| name(f, source) == "fetchDownload") + }) + }); + if !forwards { + continue; + } + } + let args = children(field(call, "arguments")?); + let Some(path) = args.first() else { + bail!("HTTP call has no path") + }; + let mut paths = values(*path, source, root, &BTreeMap::new(), 0); + // A forwarding download helper is covered at each concrete call site. + if fun_name == "fetchDownload" + && paths == ["*"] + && enclosing(call) + .child_by_field_name("name") + .is_some_and(|n| text(n, source) == "download") + { + continue; + } + paths = paths + .into_iter() + .map(|p| p.split('?').next().unwrap().to_owned()) + .collect(); + paths.sort(); + paths.dedup(); + let method = methods( + if fun_name == "download" { + None + } else { + args.get(1).copied() + }, + source, + ) + .with_context(|| format!("line {}", call.start_position().row + 1))?; + for path in paths { + if !path.starts_with("/api/") { + bail!( + "unresolved HTTP dependency at line {}: {}", + call.start_position().row + 1, + text(*args.first().unwrap(), source) + ); + } + for method in &method { + let found: Vec<_> = declared + .keys() + .filter(|op| op.method == *method && intersects(&path, &path_glob(&op.path))) + .cloned() + .collect(); + if found.is_empty() { + bail!( + "undocumented consumer operation: {} {} at line {}", + method, + path, + call.start_position().row + 1 + ); + } + result.operations.extend(found); + } + } + result.call_sites += 1; + } + // Browser navigation to fixed API paths is also a consumer dependency. + let mut attributes = vec![]; + descend(root, "jsx_attribute", &mut attributes); + for attribute in attributes { + let parts = children(attribute); + if parts.first().is_none_or(|n| text(*n, source) != "href") { + continue; + } + let Some(value) = parts.get(1) else { + continue; + }; + let value = if value.kind() == "jsx_expression" { + children(*value).first().copied().context("empty href")? + } else { + *value + }; + if user_content_links && text(value, source) == "n.href" { + continue; + } + for path in values(value, source, root, &BTreeMap::new(), 0) { + let path = path.split('?').next().unwrap(); + if path == "*" { + bail!( + "unresolved browser navigation at line {}", + attribute.start_position().row + 1 + ); + } + if !path.starts_with("/api/") { + continue; + } + let keys: Vec<_> = declared + .keys() + .filter(|key| key.method == "get" && intersects(path, &path_glob(&key.path))) + .cloned() + .collect(); + if keys.is_empty() { + bail!("undocumented browser API dependency {path}"); + } + result.operations.extend(keys); + } + } + Ok(result) +} + +pub fn scan(root: &Path, component: &str, spec: &Value) -> Result { + anyhow::ensure!( + matches!(component, "frontend" | "mcp"), + "consumer must be frontend or mcp" + ); + let directory = root.join(if component == "frontend" { + "frontend/src" + } else { + "mcp-server/src" + }); + let declared = operations(spec, None)?; + let mut result = Coverage { + operations: BTreeSet::new(), + call_sites: 0, + }; + for entry in walkdir::WalkDir::new(directory) { + let entry = entry?; + let path = entry.path(); + if !entry.file_type().is_file() + || !matches!( + path.extension().and_then(|x| x.to_str()), + Some("ts" | "tsx") + ) + { + continue; + } + let relative = path.strip_prefix(root)?.to_string_lossy(); + if relative.contains("/__tests__/") + || relative.ends_with(".test.ts") + || relative.ends_with("generated-contract.ts") + { + continue; + } + let source = std::fs::read_to_string(path)?; + let transport = + relative == "frontend/src/api/transport.ts" || relative == "mcp-server/src/client.ts"; + let coverage = analyze_file( + &source, + path.extension().is_some_and(|x| x == "tsx"), + &declared, + transport, + relative == "frontend/src/components/StatementText.tsx", + ) + .with_context(|| relative.to_string())?; + result.call_sites += coverage.call_sites; + result.operations.extend(coverage.operations); + } + // Protocol navigation/discovery isn't fetch(): explicit, small, tested roots. + let protocol = if component == "frontend" { + vec!["auth_auth_external_start", "auth_auth_external_callback"] + } else { + vec![ + "oauth_authorization_server_metadata", + "oauth_protected_resource_metadata_mcp", + "oauth_register", + "oauth_authorize_page", + "oauth_authorize_decision", + "oauth_token", + ] + }; + for id in protocol { + let key = declared + .iter() + .find(|(_, v)| v["operationId"] == id) + .map(|(k, _)| k.clone()) + .with_context(|| format!("missing protocol operation {id}"))?; + result.operations.insert(key); + } + Ok(result) +} diff --git a/tools/marreq-contracts/src/lib.rs b/tools/marreq-contracts/src/lib.rs new file mode 100644 index 00000000..d0ca8ac9 --- /dev/null +++ b/tools/marreq-contracts/src/lib.rs @@ -0,0 +1,4 @@ +pub mod artifact; +pub mod contract; +pub mod coverage; +pub mod traffic; diff --git a/tools/marreq-contracts/src/main.rs b/tools/marreq-contracts/src/main.rs new file mode 100644 index 00000000..0f47dc2c --- /dev/null +++ b/tools/marreq-contracts/src/main.rs @@ -0,0 +1,204 @@ +use clap::{Parser, Subcommand}; +use marreq_contracts::{artifact, contract, coverage, traffic}; +use std::path::PathBuf; +#[derive(Parser)] +struct Cli { + #[command(subcommand)] + command: Action, +} +#[derive(Subcommand)] +enum Action { + Coverage { + #[arg(long, default_value = ".")] + root: PathBuf, + #[arg(long, default_value = "docs/developer/openapi.yaml")] + spec: PathBuf, + #[arg(long)] + component: String, + }, + Validate { + #[arg(long, default_value = "docs/developer/openapi.yaml")] + spec: PathBuf, + #[arg(long, default_value = ".")] + root: PathBuf, + #[arg(long)] + document_only: bool, + }, + Bundle { + #[arg(long, default_value = ".")] + root: PathBuf, + #[arg(long)] + component: String, + #[arg(long)] + version: semver::Version, + #[arg(long)] + variant: Option, + #[arg(long)] + output: PathBuf, + }, + Verify { + #[arg(long)] + manifest: PathBuf, + #[arg(long,requires_all=["candidate_version","candidate_source_sha","candidate_bundle"])] + candidate_component: Option, + #[arg(long)] + candidate_version: Option, + #[arg(long)] + candidate_source_sha: Option, + #[arg(long)] + candidate_bundle: Option, + }, + VerifyTraffic { + transcript: PathBuf, + #[arg(long, default_value = "docs/developer/openapi.yaml")] + spec: PathBuf, + }, + Select { + #[arg(long, default_value = ".")] + root: PathBuf, + #[arg(long)] + component: String, + #[arg(long, default_value = "docs/developer/openapi.yaml")] + spec: PathBuf, + #[arg(long)] + output: PathBuf, + #[arg(long)] + allow_unverified: bool, + }, + Compare { + consumer: PathBuf, + backend: PathBuf, + #[arg(long)] + variant: String, + }, +} +fn run() -> anyhow::Result { + match Cli::parse().command { + Action::Coverage { + root, + spec, + component, + } => Ok(serde_json::to_value(coverage::scan( + &root, + &component, + &contract::load(&spec)?, + )?)?), + Action::Bundle { + root, + component, + version, + variant, + output, + } => { + let bundle = artifact::export(&root, &component, version, variant)?; + contract::write(&output, &bundle)?; + Ok( + serde_json::json!({"file":output,"sha256":artifact::hash(&std::fs::read(&output)?),"source_sha":bundle.source_sha,"version":bundle.version}), + ) + } + Action::Verify { + manifest, + candidate_component, + candidate_version, + candidate_source_sha, + candidate_bundle, + } => { + let any = candidate_component.is_some() + || candidate_version.is_some() + || candidate_source_sha.is_some() + || candidate_bundle.is_some(); + let all = candidate_component.is_some() + && candidate_version.is_some() + && candidate_source_sha.is_some() + && candidate_bundle.is_some(); + anyhow::ensure!( + !any || all, + "candidate verification requires component, version, source SHA and source-exported bundle together" + ); + let candidate = candidate_component.as_deref().map(|who| { + ( + who, + candidate_version.as_ref().unwrap(), + candidate_source_sha.as_deref().unwrap(), + candidate_bundle.as_deref().unwrap(), + ) + }); + artifact::verify(&manifest, candidate) + } + Action::VerifyTraffic { transcript, spec } => { + traffic::verify(&contract::load(&spec)?, &transcript) + } + Action::Select { + root, + component, + spec, + output, + allow_unverified, + } => { + let spec = contract::load(&spec)?; + let coverage = coverage::scan(&root, &component, &spec)?; + contract::write( + &output, + &contract::select(&spec, &coverage.operations, None, !allow_unverified)?, + )?; + Ok( + serde_json::json!({"component":component,"operations":coverage.operations.len(),"output":output}), + ) + } + Action::Validate { + spec, + root, + document_only, + } => { + let spec = contract::load(&spec)?; + contract::validate(&spec)?; + for dir in ["frontend", "mcp-server"] { + let p: artifact::Policy = serde_json::from_value(contract::load( + &root.join(dir).join("compatibility.json"), + )?)?; + p.validate()?; + } + let range: artifact::FrontendRange = serde_json::from_value(contract::load( + &root.join("marreq-core/frontend_compatibility.json"), + )?)?; + anyhow::ensure!( + range.min_version.cmp_precedence(&range.max_version).is_le(), + "invalid backend frontend compatibility range" + ); + let mut counts = serde_json::Map::new(); + if !document_only { + for who in ["frontend", "mcp"] { + let c = coverage::scan(&root, who, &spec)?; + let selected = contract::select(&spec, &c.operations, None, true)?; + contract::validate_wire_contract(&selected)?; + counts.insert(who.into(), serde_json::json!(c.operations.len())); + } + } + Ok(serde_json::json!({"valid":true,"consumer_operations":counts})) + } + Action::Compare { + consumer, + backend, + variant, + } => { + let findings = contract::compare( + &contract::load(&consumer)?, + &contract::load(&backend)?, + &variant, + )?; + Ok(serde_json::json!({"compatible":true,"findings":findings})) + } + } +} +fn main() { + match run() { + Ok(value) => println!("{}", serde_json::to_string_pretty(&value).unwrap()), + Err(e) => { + println!( + "{}", + serde_json::json!({"compatible":false,"error":format!("{e:#}")}) + ); + std::process::exit(1); + } + } +} diff --git a/tools/marreq-contracts/src/traffic.rs b/tools/marreq-contracts/src/traffic.rs new file mode 100644 index 00000000..03f73598 --- /dev/null +++ b/tools/marreq-contracts/src/traffic.rs @@ -0,0 +1,103 @@ +//! Real request/response evidence collected by the established Rocket test. +use crate::{contract, coverage}; +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; +use std::{collections::BTreeSet, path::Path}; +pub fn verify(spec: &Value, path: &Path) -> Result { + let mut checked = 0; + let mut unverified = 0; + let mut seen = BTreeSet::new(); + for line in std::fs::read_to_string(path)? + .lines() + .filter(|l| !l.trim().is_empty()) + { + let record: Value = serde_json::from_str(line)?; + let mode = record["variant"].as_str().unwrap_or("server"); + crate::artifact::variant(mode)?; + let operations = contract::operations(spec, Some(mode))?; + let actual = record["path"] + .as_str() + .context("request path")? + .split('?') + .next() + .unwrap(); + let method = record["method"] + .as_str() + .context("HTTP method")? + .to_lowercase(); + let mut matches: Vec<_> = operations + .iter() + .filter(|(key, _)| { + key.method == method && coverage::intersects(actual, &route_pattern(&key.path)) + }) + .collect(); + matches.sort_by_key(|(key, _)| key.path.matches('{').count()); + let (key, op) = matches + .first() + .context("actual HTTP call missing in declared contract")?; + if op + .get("x-marreq-unverified") + .is_some_and(|v| v != &Value::Bool(false)) + { + unverified += 1; + continue; + } + let status = record["status"] + .as_u64() + .context("response status")? + .to_string(); + let response = op["responses"] + .get(&status) + .or_else(|| op["responses"].get("default")) + .with_context(|| format!("{key}: undocumented actual HTTP status {status}"))?; + let response = if let Some(r) = response["$ref"].as_str() { + spec.pointer(&r[1..]).context("response ref")? + } else { + response + }; + if let Some(schema) = response.pointer("/content/application~1json/schema") { + contract::validate_instance(spec, schema, &record["response"])?; + } + if status.starts_with('2') && !record["request"].is_null() { + let schema = op + .pointer("/requestBody/content/application~1json/schema") + .with_context(|| format!("{key}: undeclared JSON body"))?; + contract::validate_instance(spec, schema, &record["request"])?; + } + checked += 1; + seen.insert((key.method.clone(), key.path.clone(), status)); + } + for (method, path, status) in [ + ("get", "/api/meta/build", "200"), + ("get", "/api/meta/deployment", "200"), + ("post", "/api/projects/{project_id}/use-cases", "201"), + ( + "put", + "/api/projects/{project_id}/use-cases/{id}/draft", + "200", + ), + ("get", "/api/projects/{project_id}/use-cases", "403"), + ] { + ensure!( + seen.contains(&(method.into(), path.into(), status.into())), + "critical real-backend evidence missing: {method} {path} {status}" + ); + } + Ok(json!({"validated_exchanges":checked,"unverified_exchanges":unverified})) +} +fn route_pattern(path: &str) -> String { + let mut out = String::new(); + let mut param = false; + for c in path.chars() { + match c { + '{' => { + param = true; + out.push('*'); + } + '}' => param = false, + _ if !param => out.push(c), + _ => (), + } + } + out +} diff --git a/tools/marreq-contracts/tests/compatibility.rs b/tools/marreq-contracts/tests/compatibility.rs new file mode 100644 index 00000000..036b940c --- /dev/null +++ b/tools/marreq-contracts/tests/compatibility.rs @@ -0,0 +1,459 @@ +use marreq_contracts::{ + artifact::{self, Artifact, Bundle, FrontendRange, Plan, Policy}, + contract::{self, Operation}, + coverage, +}; +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + process::Command, +}; +fn spec() -> Value { + json!({"openapi":"3.1.0","info":{"title":"fixture","version":"1.0.0"},"paths":{"/api/things":{"post":{"operationId":"create","security":[{"cookie":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string"}},"required":["title"],"additionalProperties":false}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"integer"},"state":{"type":"string","enum":["draft","approved"]}},"required":["id","state"],"additionalProperties":true}}}}}}}},"components":{"securitySchemes":{"cookie":{"type":"apiKey","in":"cookie","name":"session"}}}}) +} +fn request(s: &mut Value) -> &mut Value { + &mut s["paths"]["/api/things"]["post"]["requestBody"]["content"]["application/json"]["schema"] +} +fn response(s: &mut Value) -> &mut Value { + &mut s["paths"]["/api/things"]["post"]["responses"]["200"]["content"]["application/json"]["schema"] +} +#[test] +fn valid_openapi() { + contract::validate(&spec()).unwrap(); +} +#[test] +fn missing_endpoint() { + let mut new = spec(); + new["paths"] = json!({}); + assert!(contract::compare(&spec(), &new, "server").is_err()); +} +#[test] +fn required_response_removed() { + let mut new = spec(); + response(&mut new)["required"] = json!(["state"]); + assert!(contract::compare(&spec(), &new, "server").is_err()); +} +#[test] +fn response_type_changed() { + let mut new = spec(); + response(&mut new)["properties"]["id"] = json!({"type":"string"}); + assert!(contract::compare(&spec(), &new, "server").is_err()); +} +#[test] +fn new_required_request() { + let mut new = spec(); + request(&mut new)["properties"]["reason"] = json!({"type":"string"}); + request(&mut new)["required"] = json!(["title", "reason"]); + assert!(contract::compare(&spec(), &new, "server").is_err()); +} +#[test] +fn additive_request_and_response() { + let mut new = spec(); + request(&mut new)["properties"]["optional"] = json!({"type":"integer"}); + response(&mut new)["properties"]["extra"] = json!({"type":"boolean"}); + contract::compare(&spec(), &new, "server").unwrap(); +} +#[test] +fn response_enum_extension() { + let mut new = spec(); + response(&mut new)["properties"]["state"]["enum"] = json!(["draft", "approved", "reviewed"]); + assert!(contract::compare(&spec(), &new, "cloud").is_err()); +} +#[test] +fn request_enum_restriction() { + let mut old = spec(); + let mut new = spec(); + request(&mut old)["properties"]["title"]["enum"] = json!(["a", "b"]); + request(&mut new)["properties"]["title"]["enum"] = json!(["a"]); + assert!(contract::compare(&old, &new, "cloud").is_err()); +} +#[test] +fn authentication_requirement() { + let mut new = spec(); + new["components"]["securitySchemes"]["bearer"] = json!({"type":"http","scheme":"bearer"}); + new["paths"]["/api/things"]["post"]["security"] = json!([{"bearer":[]}]); + assert!(contract::compare(&spec(), &new, "server").is_err()); +} +#[test] +fn unrelated_security_scheme_is_not_global_coupling() { + let mut new = spec(); + new["components"]["securitySchemes"]["unrelated"] = json!({"type":"http","scheme":"bearer"}); + contract::compare(&spec(), &new, "server").unwrap(); +} +#[test] +fn per_operation_scope_change() { + let mut new = spec(); + new["paths"]["/api/things"]["post"]["x-marreq-bearer-scopes"] = json!(["things:write"]); + assert!(contract::compare(&spec(), &new, "server").is_err()); +} +#[test] +fn unrelated_backend_endpoint_can_change() { + let mut old = spec(); + old["paths"]["/api/unrelated"] = json!({"get":{"responses":{"200":{"description":"old"}}}}); + let mut new = old.clone(); + new["paths"]["/api/unrelated"] = + json!({"delete":{"responses":{"204":{"description":"different"}}}}); + let required = BTreeSet::from([Operation { + method: "post".into(), + path: "/api/things".into(), + }]); + let consumer = contract::select(&old, &required, None, true).unwrap(); + contract::compare(&consumer, &new, "server").unwrap(); +} +#[test] +fn cloud_operation_not_required_on_server() { + let mut consumer = spec(); + consumer["paths"]["/api/register"] = + json!({"post":{"x-marreq-variants":["cloud"],"responses":{"200":{"description":"ok"}}}}); + contract::compare(&consumer, &spec(), "server").unwrap(); + assert!(contract::compare(&consumer, &spec(), "cloud").is_err()); +} +#[test] +fn required_unverified_operation_fails() { + let mut new = spec(); + new["paths"]["/api/things"]["post"]["x-marreq-unverified"] = json!(true); + assert!(contract::compare(&spec(), &new, "server").is_err()); +} +#[test] +fn external_refs_fail_without_network() { + let mut new = spec(); + response(&mut new)["properties"]["id"] = json!({"$ref":"https://example.test/schema"}); + assert!( + contract::validate(&new) + .unwrap_err() + .to_string() + .contains("external references") + ); +} +#[test] +fn actual_json_instances_are_checked() { + let schema = response(&mut spec()).clone(); + contract::validate_instance(&spec(), &schema, &json!({"id":1,"state":"draft"})).unwrap(); + assert!(contract::validate_instance(&spec(), &schema, &json!({"state":"draft"})).is_err()); + assert!( + contract::validate_instance(&spec(), &schema, &json!({"id":"wrong","state":"draft"})) + .is_err() + ); +} +#[test] +fn invalid_policy() { + for value in [ + json!({}), + json!({"requires_backend_min":"1.x"}), + json!({"requires_backend_min":"2.0.0","requires_backend_max":"1.0.0"}), + json!({"requires_backend_min":"1.0.0","typo":"x"}), + ] { + let policy = serde_json::from_value::(value); + assert!(policy.is_err() || policy.unwrap().validate().is_err()); + } +} +fn plan( + dir: &Path, + backend_version: &str, + mcp_version: &str, + incompatible: bool, +) -> std::path::PathBuf { + let mut descriptors = BTreeMap::new(); + for (who, version) in [ + ("backend", backend_version), + ("frontend", "0.2.1"), + ("mcp", mcp_version), + ] { + let mut document = spec(); + if incompatible && who == "backend" { + response(&mut document)["required"] = json!(["state"]); + } + let bundle = Bundle { + format: "marreq.contract.v2".into(), + component: who.into(), + version: version.parse().unwrap(), + source_sha: "a".repeat(40), + variant: (who == "backend").then(|| "server".into()), + compatibility: (who != "backend").then(|| Policy { + requires_backend_min: "0.2.0".parse().unwrap(), + requires_backend_max: Some("0.2.99".parse().unwrap()), + }), + frontend_compatibility: (who == "backend").then(|| FrontendRange { + min_version: "0.2.0".parse().unwrap(), + max_version: "0.3.0".parse().unwrap(), + }), + contract: document, + }; + let filename = format!("{who}.json"); + contract::write(&dir.join(&filename), &bundle).unwrap(); + descriptors.insert( + who, + Artifact { + file: filename.into(), + sha256: artifact::hash(&std::fs::read(dir.join(format!("{who}.json"))).unwrap()), + version: bundle.version, + source_sha: bundle.source_sha, + }, + ); + } + let plan = Plan { + format: "marreq.deployment-contract.v2".into(), + variant: "server".into(), + backend: descriptors.remove("backend").unwrap(), + frontend: descriptors.remove("frontend").unwrap(), + mcp: descriptors.remove("mcp").unwrap(), + }; + let path = dir.join("plan.json"); + contract::write(&path, &plan).unwrap(); + path +} +#[test] +fn compatible_backend_only_and_mcp_only_updates() { + let dir = tempfile::tempdir().unwrap(); + for (b, m) in [("0.2.4", "0.3.0"), ("0.2.3", "0.3.1")] { + let path = plan(dir.path(), b, m, false); + assert_eq!(artifact::verify(&path, None).unwrap()["compatible"], true); + } +} +#[test] +fn incompatible_backend_only_update() { + let dir = tempfile::tempdir().unwrap(); + assert!(artifact::verify(&plan(dir.path(), "0.2.4", "0.3.0", true), None).is_err()); +} +#[test] +fn missing_historical_artifact() { + let dir = tempfile::tempdir().unwrap(); + let path = plan(dir.path(), "0.2.4", "0.3.0", false); + std::fs::remove_file(dir.path().join("mcp.json")).unwrap(); + assert!( + artifact::verify(&path, None) + .unwrap_err() + .to_string() + .contains("missing historical") + ); +} +#[test] +fn descriptor_source_and_version_mismatch() { + let dir = tempfile::tempdir().unwrap(); + for key in ["source_sha", "version"] { + let path = plan(dir.path(), "0.2.4", "0.3.0", false); + let mut p = contract::load(&path).unwrap(); + p["mcp"][key] = json!(if key == "source_sha" { + "b".repeat(40) + } else { + "0.3.9".into() + }); + contract::write(&path, &p).unwrap(); + assert!( + artifact::verify(&path, None) + .unwrap_err() + .to_string() + .contains("mismatch") + ); + } +} +#[test] +fn tampered_contract_hash() { + let dir = tempfile::tempdir().unwrap(); + let path = plan(dir.path(), "0.2.4", "0.3.0", false); + std::fs::write(dir.path().join("mcp.json"), "{}").unwrap(); + assert!( + artifact::verify(&path, None) + .unwrap_err() + .to_string() + .contains("SHA-256 mismatch") + ); +} +#[test] +fn cli_exit_codes_and_json_diagnostics() { + let dir = tempfile::tempdir().unwrap(); + for bad in [false, true] { + let path = plan(dir.path(), "0.2.4", "0.3.0", bad); + let result = Command::new(env!("CARGO_BIN_EXE_marreq-contracts")) + .arg("verify") + .arg("--manifest") + .arg(path) + .output() + .unwrap(); + assert_eq!(result.status.success(), !bad); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(body["compatible"], !bad); + } +} +#[test] +fn type_script_grammar_resolves_helpers_and_union_methods() { + let mut document = spec(); + document["paths"]["/api/projects/{project_id}/things/{id}"] = json!({"get":{"responses":{"200":{"description":"ok"}}},"delete":{"responses":{"204":{"description":"ok"}}}}); + let ops = contract::operations(&document, None).unwrap(); + let source = "const base = (id: number) => `/api/projects/${id}/things`; function read(id: number) { return fetchJson(`${base(1)}/${id}`, {method: remove ? 'DELETE' : 'GET'}); }"; + let coverage = coverage::analyze(source, false, &ops, false).unwrap(); + assert_eq!(coverage.operations.len(), 2); +} +#[test] +fn new_unmapped_call_cannot_bypass_coverage() { + let ops = contract::operations(&spec(), None).unwrap(); + assert!(coverage::analyze("fetch('/api/new-api')", false, &ops, false).is_err()); + assert!(coverage::analyze("fetch(url)", false, &ops, false).is_err()); +} +#[test] +fn dynamic_unverified_dependency_is_rejected() { + let mut doc = spec(); + doc["paths"]["/api/things"]["post"]["x-marreq-unverified"] = json!(true); + let coverage = coverage::analyze( + "fetchJson('/api/things', {method:'POST'})", + false, + &contract::operations(&doc, None).unwrap(), + false, + ) + .unwrap(); + assert!(contract::select(&doc, &coverage.operations, None, true).is_err()); +} +#[test] +fn segment_globs_do_not_capture_unrelated_children() { + assert!(!coverage::intersects( + "/api/things/*", + "/api/things/*/versions" + )); + assert!(coverage::intersects("/api/things/*.pdf", "/api/things/*")); +} + +#[test] +fn await_generic_calls_are_dependencies() { + let coverage = coverage::analyze( + "async function f(){ return await fetchJson('/api/things',{method:'POST'}); }", + false, + &contract::operations(&spec(), None).unwrap(), + false, + ) + .unwrap(); + assert_eq!(coverage.call_sites, 1); + assert_eq!(coverage.operations.len(), 1); +} +#[test] +fn browser_navigation_is_a_dependency() { + let mut document = spec(); + document["paths"]["/api/things"]["get"] = json!({"responses":{"200":{"description":"ok"}}}); + let ops = contract::operations(&document, None).unwrap(); + assert_eq!( + coverage::analyze( + "const a=Reports & exports", + true, + &ops, + false + ) + .unwrap() + .operations + .len(), + 1 + ); + assert!( + coverage::analyze( + "const a=link", + true, + &ops, + false + ) + .is_err() + ); +} +#[test] +fn unsupported_schema_semantics_fail_locally() { + let mut new = spec(); + response(&mut new)["unevaluatedProperties"] = json!(false); + assert!( + contract::compare(&spec(), &new, "server") + .unwrap_err() + .to_string() + .contains("unevaluatedProperties") + ); +} +#[test] +fn fewer_required_scopes_are_compatible() { + let mut old = spec(); + old["paths"]["/api/things"]["post"]["x-marreq-bearer-scopes"] = + json!(["things:read", "things:write"]); + let mut new = old.clone(); + new["paths"]["/api/things"]["post"]["x-marreq-bearer-scopes"] = json!(["things:read"]); + contract::compare(&old, &new, "server").unwrap(); +} + +#[test] +fn numeric_resource_ids_do_not_require_reserved_siblings() { + let mut document = spec(); + document["paths"]["/api/things/{id}"] = + json!({"get":{"responses":{"200":{"description":"ok"}}}}); + document["paths"]["/api/things/internal_stats"] = + json!({"get":{"responses":{"200":{"description":"unused"}}}}); + let coverage = coverage::analyze( + "function get(id:number){return fetchJson(`/api/things/${id}`);}", + false, + &contract::operations(&document, None).unwrap(), + false, + ) + .unwrap(); + assert_eq!(coverage.operations.len(), 1); + assert!(coverage::intersects("/api/things/#", "/api/things/123")); + assert!(!coverage::intersects( + "/api/things/#", + "/api/things/internal_stats" + )); +} + +#[test] +fn aliases_and_unrecognized_clients_cannot_hide_new_calls() { + let ops = contract::operations(&spec(), None).unwrap(); + for source in [ + "const f=fetch; f('/api/missing');", + "import {fetchJson as api} from './transport'; api('/api/missing');", + "axios.get('/api/missing');", + "axios({url:'/api/missing'});", + ] { + assert!( + coverage::analyze(source, false, &ops, false).is_err(), + "{source}" + ); + } + assert!(coverage::analyze("function f(url:string){if(true){const url='/api/things';}return fetchJson(url,{method:'POST'});}",false,&ops,false).is_err()); + assert!( + coverage::analyze( + "let path='/api/things';path=external;fetchJson(path,{method:'POST'});", + false, + &ops, + false + ) + .is_err() + ); +} + +#[test] +fn semver_build_metadata_does_not_change_policy_precedence() { + Policy { + requires_backend_min: "0.2.4".parse().unwrap(), + requires_backend_max: Some("0.2.4".parse().unwrap()), + } + .accepts(&"0.2.4+source.sha".parse().unwrap()) + .unwrap(); +} + +#[test] +fn unknown_schema_keywords_are_not_silently_accepted() { + let mut new = spec(); + response(&mut new)["customLimit"] = json!(1); + assert!( + contract::compare(&spec(), &new, "server") + .unwrap_err() + .to_string() + .contains("customLimit") + ); +} +#[test] +fn relative_plan_paths_work() { + let dir = tempfile::tempdir().unwrap(); + plan(dir.path(), "0.2.4", "0.3.0", false); + let result = Command::new(env!("CARGO_BIN_EXE_marreq-contracts")) + .current_dir(dir.path()) + .args(["verify", "--manifest", "plan.json"]) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stdout) + ); +}