diff --git a/.github/scripts/copy-private-artifacts.py b/.github/scripts/copy-private-artifacts.py
new file mode 100644
index 000000000..c6647d604
--- /dev/null
+++ b/.github/scripts/copy-private-artifacts.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""Copy version artifacts out of a public R2 bucket, verifying both byte streams.
+
+This never deletes source objects or changes CDN configuration. AWS CLI credentials
+and endpoint come from the operator's environment; no credentials are logged.
+"""
+
+import argparse
+import hashlib
+import json
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+
+PREFIXES = ("files/", "modpack-overrides/", "modpacks/")
+MAX_OBJECT_BYTES = 1024 * 1024 * 1024
+
+
+def aws(endpoint, *args):
+ command = ["aws", "--endpoint-url", endpoint, "s3api", *args, "--output", "json", "--no-cli-pager"]
+ result = subprocess.run(command, check=True, text=True, capture_output=True)
+ return json.loads(result.stdout) if result.stdout.strip() else {}
+
+
+def inventory(endpoint, bucket):
+ seen = set()
+ for prefix in PREFIXES:
+ continuation = None
+ while True:
+ args = ["list-objects-v2", "--bucket", bucket, "--prefix", prefix,
+ "--max-keys", "1000", "--no-paginate"]
+ if continuation:
+ args.extend(("--continuation-token", continuation))
+ page = aws(endpoint, *args)
+ for item in page.get("Contents", []):
+ key = item["Key"]
+ if not key.startswith(prefix) or key in seen:
+ raise ValueError("Unexpected or duplicate artifact key in source inventory")
+ seen.add(key)
+ size = item["Size"]
+ if not isinstance(size, int) or size < 0 or size > MAX_OBJECT_BYTES:
+ raise ValueError("Artifact exceeds migration byte limit")
+ yield key, size
+ if not page.get("IsTruncated"):
+ break
+ next_token = page.get("NextContinuationToken")
+ if not next_token or next_token == continuation:
+ raise ValueError("Artifact inventory pagination did not advance")
+ continuation = next_token
+
+
+def download(endpoint, bucket, key, path, expected_size):
+ metadata = aws(endpoint, "get-object", "--bucket", bucket, "--key", key, str(path))
+ actual_size = path.stat().st_size
+ if actual_size != expected_size or metadata.get("ContentLength") != expected_size:
+ raise ValueError("Artifact size changed during migration")
+ with path.open("rb") as source:
+ digest = hashlib.file_digest(source, "sha256").hexdigest()
+ return metadata, digest
+
+
+def destination_exists(endpoint, bucket, key):
+ try:
+ aws(endpoint, "head-object", "--bucket", bucket, "--key", key)
+ return True
+ except subprocess.CalledProcessError as error:
+ # AWS CLI emits its diagnostic on stderr. Only an absent object may be copied.
+ if "404" in error.stderr or "Not Found" in error.stderr or "NoSuchKey" in error.stderr:
+ return False
+ raise
+
+
+def copy_and_verify(endpoint, source, destination, key, size, workdir):
+ source_file = workdir / "source"
+ destination_file = workdir / "destination"
+ source_metadata, source_digest = download(endpoint, source, key, source_file, size)
+ already_present = destination_exists(endpoint, destination, key)
+ if not already_present:
+ args = ["put-object", "--bucket", destination, "--key", key, "--body", str(source_file)]
+ args.extend(("--cache-control", "private, no-store"))
+ for field, flag in (("ContentType", "--content-type"), ("ContentDisposition", "--content-disposition")):
+ if source_metadata.get(field):
+ args.extend((flag, source_metadata[field]))
+ aws(endpoint, *args)
+ _, destination_digest = download(endpoint, destination, key, destination_file, size)
+ if destination_digest != source_digest:
+ raise ValueError("Private artifact bytes do not match source")
+ source_file.unlink()
+ destination_file.unlink()
+ return already_present, source_digest
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--endpoint", required=True)
+ parser.add_argument("--source-bucket", required=True)
+ parser.add_argument("--destination-bucket", required=True)
+ parser.add_argument("--copy", action="store_true", help="Copy and verify every object; default only inventories")
+ parser.add_argument("--report", type=Path, required=True, help="Write an atomic summary without artifact keys")
+ args = parser.parse_args()
+ if args.source_bucket == args.destination_bucket:
+ parser.error("Source and destination buckets must differ")
+ if not args.endpoint.startswith("https://"):
+ parser.error("An HTTPS R2 endpoint is required")
+ count = 0
+ total_bytes = 0
+ copied = 0
+ already_equal = 0
+ manifest_digest = hashlib.sha256()
+ with tempfile.TemporaryDirectory(prefix="modtale-artifact-copy-") as directory:
+ for key, size in inventory(args.endpoint, args.source_bucket):
+ count += 1
+ total_bytes += size
+ if args.copy:
+ existed, digest = copy_and_verify(args.endpoint, args.source_bucket,
+ args.destination_bucket, key, size, Path(directory))
+ already_equal += int(existed)
+ copied += int(not existed)
+ manifest_digest.update(key.encode("utf-8") + b"\0" + digest.encode("ascii") + b"\n")
+ report = {"sourceBucket": args.source_bucket, "destinationBucket": args.destination_bucket,
+ "objectCount": count, "totalBytes": total_bytes, "copied": copied,
+ "alreadyEqual": already_equal, "copyVerified": args.copy,
+ "keyAndByteManifestSha256": manifest_digest.hexdigest() if args.copy else None}
+ args.report.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(mode="w", dir=args.report.parent, delete=False) as file:
+ json.dump(report, file, sort_keys=True, indent=2)
+ file.write("\n")
+ temporary = file.name
+ os.replace(temporary, args.report)
+ print(json.dumps(report, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/should-run-tests-workflow.sh b/.github/scripts/should-run-tests-workflow.sh
index ae0b4b6db..c8e627002 100755
--- a/.github/scripts/should-run-tests-workflow.sh
+++ b/.github/scripts/should-run-tests-workflow.sh
@@ -5,6 +5,7 @@ event_name="${GITHUB_EVENT_NAME:-}"
repo="${GITHUB_REPOSITORY:-}"
repo_owner="${GITHUB_REPOSITORY_OWNER:-${repo%%/*}}"
ref_name="${GITHUB_REF_NAME:-}"
+head_sha="${GITHUB_SHA:-}"
should_run=true
reason="This workflow run owns the work."
@@ -13,27 +14,41 @@ gh_available() {
command -v gh >/dev/null 2>&1 && [[ -n "${GH_TOKEN:-}" ]]
}
-open_pr_count_for_branch() {
- gh api --method GET "repos/$repo/pulls" \
+open_pr_can_own_tests() {
+ local numbers number state mergeable pr_head
+ numbers="$(gh api --method GET "repos/$repo/pulls" \
-f state=open \
-f head="$repo_owner:$ref_name" \
- --jq 'length'
+ --jq '.[].number')" || return 1
+ while IFS= read -r number; do
+ [[ -z "$number" ]] && continue
+ [[ "$number" =~ ^[0-9]+$ ]] || return 1
+ state="$(gh api "repos/$repo/pulls/$number" --jq '[.mergeable, .head.sha] | @tsv')" || return 1
+ IFS=$'\t' read -r mergeable pr_head <<< "$state"
+ if [[ "$mergeable" == "true" && "$pr_head" == "$head_sha" ]]; then
+ echo true
+ return 0
+ fi
+ done <<< "$numbers"
+ echo false
}
# PR runs always own their tests. A queued push may itself skip because a PR
# exists, so its presence cannot prove that the commit has test coverage.
+# Conflicted PRs do not trigger pull_request workflows. Unknown mergeability
+# or a different head must therefore retain the push run's test coverage.
if [[ "$event_name" == "push" ]]; then
- if gh_available && [[ -n "$repo" && -n "$repo_owner" && -n "$ref_name" ]]; then
- if open_pr_count="$(open_pr_count_for_branch)"; then
- if [[ "$open_pr_count" =~ ^[0-9]+$ && "$open_pr_count" -gt 0 ]]; then
+ if gh_available && [[ -n "$repo" && -n "$repo_owner" && -n "$ref_name" && "$head_sha" =~ ^[[:xdigit:]]{40}$ ]]; then
+ if pr_can_own_tests="$(open_pr_can_own_tests)"; then
+ if [[ "$pr_can_own_tests" == "true" ]]; then
should_run=false
- reason="Skipping push workflow because this branch has an open PR; the pull_request run owns this commit."
+ reason="Skipping push workflow because an open, mergeable PR has this exact head; the pull_request run owns this commit."
fi
else
echo "::warning::Could not check for open pull requests; running tests to avoid missing coverage."
fi
else
- echo "::warning::GitHub CLI or token unavailable; running tests to avoid missing coverage."
+ echo "::warning::GitHub CLI, token, or commit context unavailable; running tests to avoid missing coverage."
fi
fi
diff --git a/.github/scripts/verify-private-artifact-edge.py b/.github/scripts/verify-private-artifact-edge.py
new file mode 100644
index 000000000..eab00862b
--- /dev/null
+++ b/.github/scripts/verify-private-artifact-edge.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+"""Read-only R2/CDN cutover check. Requires CLOUDFLARE_API_TOKEN and known sample keys."""
+
+import argparse
+import json
+import os
+import urllib.error
+import urllib.parse
+import urllib.request
+
+
+PREFIXES = ("files/", "modpack-overrides/", "modpacks/")
+
+
+def cloudflare(account, bucket, route, token):
+ path = "/client/v4/accounts/{}/r2/buckets/{}/domains/{}".format(
+ urllib.parse.quote(account, safe=""), urllib.parse.quote(bucket, safe=""), route)
+ request = urllib.request.Request("https://api.cloudflare.com" + path,
+ headers={"Authorization": "Bearer " + token})
+ with urllib.request.urlopen(request, timeout=15) as response:
+ result = json.load(response)
+ if result.get("success") is not True or not isinstance(result.get("result"), dict):
+ raise ValueError("Cloudflare did not return a valid domain configuration")
+ return result["result"]
+
+
+def domains_are_private(account, public_bucket, private_bucket, cdn_host, token):
+ for bucket in (public_bucket, private_bucket):
+ managed = cloudflare(account, bucket, "managed", token)
+ if managed.get("enabled") is not False:
+ raise ValueError("An R2-managed public endpoint is enabled or unverified")
+ public_domains = cloudflare(account, public_bucket, "custom", token).get("domains")
+ private_domains = cloudflare(account, private_bucket, "custom", token).get("domains")
+ if not isinstance(public_domains, list) or not isinstance(private_domains, list):
+ raise ValueError("Cloudflare custom-domain inventory is incomplete")
+ if any(domain.get("enabled") is not False for domain in private_domains):
+ raise ValueError("Private artifact bucket has a public custom domain")
+ active = [domain.get("domain") for domain in public_domains if domain.get("enabled") is True]
+ if active != [cdn_host]:
+ raise ValueError("Public bucket has unexpected enabled custom domains")
+
+
+def probe(cdn_host, key, method, headers=None):
+ url = "https://" + cdn_host + "/" + urllib.parse.quote(key, safe="/-_.")
+ request = urllib.request.Request(url, method=method, headers=headers or {})
+ try:
+ with urllib.request.urlopen(request, timeout=15) as response:
+ return response.status
+ except urllib.error.HTTPError as error:
+ return error.code
+
+
+def verify_samples(cdn_host, artifact_keys, media_key):
+ if {next((prefix for prefix in PREFIXES if key.startswith(prefix)), None)
+ for key in artifact_keys} != set(PREFIXES):
+ raise ValueError("Provide one sample artifact key for each protected prefix")
+ if any(media_key.startswith(prefix) for prefix in PREFIXES):
+ raise ValueError("Media sample must not use an artifact prefix")
+ for key in artifact_keys:
+ if probe(cdn_host, key, "HEAD") != 403:
+ raise ValueError("CDN did not deny an artifact HEAD request")
+ if probe(cdn_host, key, "GET", {"Range": "bytes=0-0"}) != 403:
+ raise ValueError("CDN did not deny an artifact byte-range request")
+ if probe(cdn_host, media_key, "HEAD") != 200:
+ raise ValueError("Public media did not remain available")
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--account-id", required=True)
+ parser.add_argument("--public-bucket", required=True)
+ parser.add_argument("--private-bucket", required=True)
+ parser.add_argument("--cdn-host", required=True)
+ parser.add_argument("--artifact-key", action="append", required=True)
+ parser.add_argument("--media-key", required=True)
+ args = parser.parse_args()
+ token = os.environ.get("CLOUDFLARE_API_TOKEN")
+ if not token:
+ parser.error("CLOUDFLARE_API_TOKEN is required")
+ if args.public_bucket == args.private_bucket:
+ parser.error("Buckets must differ")
+ if args.cdn_host != "cdn.modtale.net":
+ parser.error("The production CDN host must be checked explicitly")
+ domains_are_private(args.account_id, args.public_bucket, args.private_bucket, args.cdn_host, token)
+ verify_samples(args.cdn_host, args.artifact_key, args.media_key)
+ print("Private artifact domain settings and CDN sample denials verified")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/warden-persisted-test-resources.gradle b/.github/scripts/warden-persisted-test-resources.gradle
new file mode 100644
index 000000000..68b9d1433
--- /dev/null
+++ b/.github/scripts/warden-persisted-test-resources.gradle
@@ -0,0 +1,8 @@
+// Keep large persisted fixtures isolated from other suites' retained test state.
+allprojects {
+ tasks.withType(Test).configureEach {
+ maxHeapSize = '2g'
+ maxParallelForks = 1
+ forkEvery = 1
+ }
+}
diff --git a/.github/scripts/warden-persisted-tests.py b/.github/scripts/warden-persisted-tests.py
new file mode 100644
index 000000000..70dff5ce9
--- /dev/null
+++ b/.github/scripts/warden-persisted-tests.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""Select the existing opt-in database suites and reject vacuous CI results."""
+import argparse
+from pathlib import Path
+import re
+import sys
+import xml.etree.ElementTree as ET
+
+GATES = {"WARDEN_REVIEW_DB_TEST", "WARDEN_REPAIR_TX_DB_TEST"}
+ANNOTATION = re.compile(r'@EnabledIfEnvironmentVariable\s*\(\s*named\s*=\s*"([^"]+)"\s*,\s*matches\s*=\s*"true"\s*\)')
+
+def suites(source):
+ selected = []
+ for path in sorted(source.rglob("*.java")):
+ text = path.read_text()
+ if not GATES.intersection(ANNOTATION.findall(text)):
+ continue
+ package = re.search(r'^package\s+([\w.]+)\s*;', text, re.M)
+ if not package or not re.search(r'\bclass\s+' + re.escape(path.stem) + r'\b', text):
+ raise ValueError(f"Cannot establish suite identity: {path}")
+ selected.append(f"{package.group(1)}.{path.stem}")
+ if not selected:
+ raise ValueError("No persisted Warden test suites found")
+ return selected
+
+def verify(source, reports):
+ selected = suites(source)
+ total = 0
+ for suite in selected:
+ report = reports / f"TEST-{suite}.xml"
+ root = ET.parse(report).getroot()
+ if root.tag != "testsuite" or root.get("name") != suite:
+ raise ValueError(f"Wrong suite identity: {report}")
+ tests = int(root.attrib["tests"])
+ skipped = int(root.attrib["skipped"])
+ failures = int(root.attrib["failures"])
+ errors = int(root.attrib["errors"])
+ cases = root.findall("testcase")
+ if tests <= 0 or skipped or failures or errors or len(cases) != tests:
+ raise ValueError(f"Incomplete or failing suite: {suite} (tests={tests}, skipped={skipped}, failures={failures}, errors={errors})")
+ if any(case.find(tag) is not None for case in cases for tag in ("skipped", "failure", "error")):
+ raise ValueError(f"Non-passing test case: {suite}")
+ total += tests
+ return len(selected), total
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("command", choices=("list", "verify"))
+ parser.add_argument("--source", type=Path, default=Path("backend/src/test/java"))
+ parser.add_argument("--reports", type=Path, default=Path("backend/build/test-results/test"))
+ args = parser.parse_args()
+ try:
+ if args.command == "list":
+ print("\n".join(suites(args.source)))
+ else:
+ count, total = verify(args.source, args.reports)
+ print(f"Verified {total} executed tests across {count} persisted Warden suites; zero skips, failures or errors.")
+ except (OSError, ValueError, KeyError, ET.ParseError) as error:
+ print(str(error), file=sys.stderr)
+ return 1
+ return 0
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.github/tests/copy-private-artifacts.test.py b/.github/tests/copy-private-artifacts.test.py
new file mode 100644
index 000000000..40db1a731
--- /dev/null
+++ b/.github/tests/copy-private-artifacts.test.py
@@ -0,0 +1,80 @@
+import importlib.util
+import hashlib
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+
+SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "copy-private-artifacts.py"
+spec = importlib.util.spec_from_file_location("private_artifact_copy", SCRIPT)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+
+
+class PrivateArtifactCopyTest(unittest.TestCase):
+ def test_inventory_paginates_all_artifact_prefixes_and_rejects_stalls(self):
+ def aws(_endpoint, *args):
+ prefix = args[args.index("--prefix") + 1]
+ if prefix == "files/" and "--continuation-token" not in args:
+ return {"Contents": [{"Key": "files/mod/a.jar", "Size": 3}],
+ "IsTruncated": True, "NextContinuationToken": "next"}
+ if prefix == "files/":
+ return {"Contents": [{"Key": "files/mod/b.jar", "Size": 4}], "IsTruncated": False}
+ return {"Contents": [], "IsTruncated": False}
+
+ with patch.object(module, "aws", side_effect=aws):
+ self.assertEqual([("files/mod/a.jar", 3), ("files/mod/b.jar", 4)],
+ list(module.inventory("https://r2.test", "public")))
+ with patch.object(module, "aws", return_value={"Contents": [], "IsTruncated": True}):
+ with self.assertRaisesRegex(ValueError, "did not advance"):
+ list(module.inventory("https://r2.test", "public"))
+
+ def test_copy_verifies_destination_bytes_and_preserves_source(self):
+ calls = []
+
+ def aws(_endpoint, *args):
+ calls.append(args)
+ if args[0] == "head-object":
+ return {}
+ if args[0] == "get-object":
+ bucket = args[args.index("--bucket") + 1]
+ Path(args[-1]).write_bytes(b"abc" if bucket == "public" else b"bad")
+ return {"ContentLength": 3}
+ raise AssertionError(args)
+
+ with tempfile.TemporaryDirectory() as directory, patch.object(module, "aws", side_effect=aws):
+ with self.assertRaisesRegex(ValueError, "do not match"):
+ module.copy_and_verify("https://r2.test", "public", "private", "files/mod/a.jar", 3,
+ Path(directory))
+ self.assertFalse(any(args[0] == "delete-object" for args in calls))
+
+ def test_new_copy_preserves_bytes_and_sets_private_cache_policy(self):
+ stored = {}
+ calls = []
+
+ def aws(_endpoint, *args):
+ calls.append(args)
+ if args[0] == "get-object":
+ bucket = args[args.index("--bucket") + 1]
+ content = b"good" if bucket == "public" else stored["bytes"]
+ Path(args[-1]).write_bytes(content)
+ return {"ContentLength": len(content), "ContentType": "application/java-archive"}
+ if args[0] == "put-object":
+ stored["bytes"] = Path(args[args.index("--body") + 1]).read_bytes()
+ return {}
+ raise AssertionError(args)
+
+ with tempfile.TemporaryDirectory() as directory, patch.object(module, "aws", side_effect=aws), \
+ patch.object(module, "destination_exists", return_value=False):
+ existed, digest = module.copy_and_verify("https://r2.test", "public", "private",
+ "files/mod/a.jar", 4, Path(directory))
+ self.assertFalse(existed)
+ self.assertEqual(b"good", stored["bytes"])
+ self.assertEqual(hashlib.sha256(b"good").hexdigest(), digest)
+ self.assertTrue(any(args[0] == "put-object" and
+ args[args.index("--cache-control") + 1] == "private, no-store" for args in calls))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/tests/verify-private-artifact-edge.test.py b/.github/tests/verify-private-artifact-edge.test.py
new file mode 100644
index 000000000..1c33e2b2f
--- /dev/null
+++ b/.github/tests/verify-private-artifact-edge.test.py
@@ -0,0 +1,50 @@
+import importlib.util
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+
+SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "verify-private-artifact-edge.py"
+spec = importlib.util.spec_from_file_location("artifact_edge_verify", SCRIPT)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+
+
+class ArtifactEdgeVerifyTest(unittest.TestCase):
+ def test_rejects_any_public_route_to_private_bucket_or_managed_public_endpoint(self):
+ def cloudflare(_account, bucket, route, _token):
+ if route == "managed":
+ return {"enabled": False}
+ return {"domains": [{"domain": "cdn.modtale.net", "enabled": True}]}
+
+ with patch.object(module, "cloudflare", side_effect=cloudflare):
+ with self.assertRaisesRegex(ValueError, "Private artifact bucket"):
+ module.domains_are_private("account", "public", "private", "cdn.modtale.net", "token")
+
+ def managed_bypass(_account, bucket, route, _token):
+ if route == "managed":
+ return {"enabled": bucket == "public"}
+ return {"domains": []}
+
+ with patch.object(module, "cloudflare", side_effect=managed_bypass):
+ with self.assertRaisesRegex(ValueError, "R2-managed"):
+ module.domains_are_private("account", "public", "private", "cdn.modtale.net", "token")
+
+ def test_requires_all_prefixes_denied_for_head_and_range_while_media_works(self):
+ keys = ["files/mod/a.jar", "modpack-overrides/b.zip", "modpacks/c.zip"]
+
+ def probe(_cdn, key, method, _headers=None):
+ return 200 if key == "images/media.png" else 403
+
+ with patch.object(module, "probe", side_effect=probe) as observed:
+ module.verify_samples("cdn.modtale.net", keys, "images/media.png")
+ self.assertEqual(7, observed.call_count)
+ with patch.object(module, "probe", return_value=200):
+ with self.assertRaisesRegex(ValueError, "HEAD"):
+ module.verify_samples("cdn.modtale.net", keys, "images/media.png")
+ with self.assertRaisesRegex(ValueError, "each protected prefix"):
+ module.verify_samples("cdn.modtale.net", keys[:2], "images/media.png")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/tests/warden-persisted-tests.test.mjs b/.github/tests/warden-persisted-tests.test.mjs
new file mode 100644
index 000000000..ba46c7b2f
--- /dev/null
+++ b/.github/tests/warden-persisted-tests.test.mjs
@@ -0,0 +1,90 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { afterEach, beforeEach, test } from 'node:test';
+
+const script = fileURLToPath(new URL('../scripts/warden-persisted-tests.py', import.meta.url));
+let directory;
+beforeEach(() => { directory = fs.mkdtempSync(path.join(os.tmpdir(), 'warden-persisted-')); });
+afterEach(() => fs.rmSync(directory, { recursive: true, force: true }));
+function source(name, gate) {
+ fs.writeFileSync(path.join(directory, `${name}.java`), `package net.modtale.test;\n@EnabledIfEnvironmentVariable(named="${gate}", matches="true")\nclass ${name} {}\n`);
+}
+function run(command) {
+ return spawnSync('python3', [script, command, '--source', directory, '--reports', directory], { encoding: 'utf8' });
+}
+function report(name, attributes = 'tests="1" skipped="0" failures="0" errors="0"', children = '') {
+ fs.writeFileSync(path.join(directory, `TEST-net.modtale.test.${name}.xml`), `${children}`);
+}
+test('selects both database gates without activating unrelated live suites', () => {
+ source('ReviewTest', 'WARDEN_REVIEW_DB_TEST');
+ source('TransactionTest', 'WARDEN_REPAIR_TX_DB_TEST');
+ source('LiveTest', 'NYOCF_LIVE_TESTS');
+ const result = run('list');
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(result.stdout, 'net.modtale.test.ReviewTest\nnet.modtale.test.TransactionTest\n');
+});
+test('rejects empty suite selection and missing reports', () => {
+ assert.notEqual(run('list').status, 0);
+ source('ReviewTest', 'WARDEN_REVIEW_DB_TEST');
+ assert.notEqual(run('verify').status, 0);
+});
+test('requires every selected suite to execute successfully', () => {
+ source('ReviewTest', 'WARDEN_REVIEW_DB_TEST');
+ source('TransactionTest', 'WARDEN_REPAIR_TX_DB_TEST');
+ report('ReviewTest');
+ assert.notEqual(run('verify').status, 0);
+ report('TransactionTest');
+ const result = run('verify');
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stdout, /2 executed tests across 2 persisted Warden suites/);
+});
+test('rejects skipped, failed, empty, malformed and miscounted results', () => {
+ source('ReviewTest', 'WARDEN_REVIEW_DB_TEST');
+ for (const attributes of [
+ 'tests="1" skipped="1" failures="0" errors="0"',
+ 'tests="1" skipped="0" failures="1" errors="0"',
+ 'tests="1" skipped="0" failures="0" errors="1"',
+ 'tests="0" skipped="0" failures="0" errors="0"',
+ 'tests="2" skipped="0" failures="0" errors="0"',
+ 'tests="unknown" skipped="0" failures="0" errors="0"',
+ ]) {
+ report('ReviewTest', attributes);
+ assert.notEqual(run('verify').status, 0, attributes);
+ }
+ report('ReviewTest', undefined, '');
+ assert.notEqual(run('verify').status, 0);
+});
+
+test('fixture commands use supported mongosh results rather than legacy shell assertions', async () => {
+ const { runInNewContext } = await import('node:vm');
+ const workflow = fs.readFileSync(new URL('../workflows/warden-persisted.yml', import.meta.url), 'utf8');
+ const expressions = [...workflow.matchAll(/^[ \t]+(?:--eval )?'(quit\(.+\))';?[ \t]*(?:then)?$/gm)].map(match => match[1]);
+ assert.equal(expressions.length, 3);
+ for (const expression of expressions) {
+ for (const valid of [true, false]) {
+ let status;
+ runInNewContext(expression, {
+ rs: { initiate: () => ({ ok: valid ? 1 : 0 }) },
+ db: { adminCommand: () => ({ ok: valid ? 1 : 0 }), hello: () => ({ isWritablePrimary: valid }) },
+ quit: code => { status = code; },
+ });
+ assert.equal(status, valid ? 0 : 1, expression);
+ }
+ }
+ assert.doesNotMatch(workflow, /assert\.(?:commandWorked|soon)/);
+});
+
+test('large persisted fixtures receive bounded independent test workers', () => {
+ const workflow = fs.readFileSync(new URL('../workflows/warden-persisted.yml', import.meta.url), 'utf8');
+ const resources = fs.readFileSync(new URL('../scripts/warden-persisted-test-resources.gradle', import.meta.url), 'utf8');
+ assert.match(workflow, /-I "\$PWD\/\.github\/scripts\/warden-persisted-test-resources\.gradle"/);
+ assert.match(resources, /maxHeapSize = '2g'/);
+ assert.match(resources, /maxParallelForks = 1/);
+ assert.match(resources, /forkEvery = 1/);
+ assert.match(workflow, /timeout-minutes: 35/);
+ assert.match(workflow, /name: Run every persisted Warden suite\n timeout-minutes: 30/);
+});
diff --git a/.github/tests/workflow-scripts.test.mjs b/.github/tests/workflow-scripts.test.mjs
index fe61436f9..3493a656a 100644
--- a/.github/tests/workflow-scripts.test.mjs
+++ b/.github/tests/workflow-scripts.test.mjs
@@ -25,6 +25,7 @@ function run(script, overrides = {}) {
GITHUB_REPOSITORY: 'Modtale/modtale',
GITHUB_REPOSITORY_OWNER: 'Modtale',
GITHUB_REF_NAME: 'audit',
+ GITHUB_SHA: 'a'.repeat(40),
GH_TOKEN: 'test-token',
...overrides,
},
@@ -42,14 +43,46 @@ test('PR creation and synchronization always retain their own test coverage', ()
}
});
-test('push skips only when the GitHub API confirms an open PR', () => {
+function mockPullRequestApi() {
const bin = path.join(directory, 'bin');
fs.mkdirSync(bin);
const gh = path.join(bin, 'gh');
- fs.writeFileSync(gh, '#!/bin/sh\nprintf "1\\n"\n', { mode: 0o755 });
- const env = { GITHUB_EVENT_NAME: 'push', PATH: `${bin}${path.delimiter}${process.env.PATH}` };
+ fs.writeFileSync(gh, `#!/bin/sh
+case "$*" in
+ *"/pulls/"*)
+ [ "\${MOCK_PR_ERROR:-}" = "yes" ] && exit 1
+ printf '%s\\t%s\\n' "\${MOCK_MERGEABLE:-true}" "\${MOCK_HEAD:-$GITHUB_SHA}"
+ ;;
+ *) printf '%s\\n' "\${MOCK_PR_NUMBERS-25}" ;;
+esac
+`, { mode: 0o755 });
+ return { GITHUB_EVENT_NAME: 'push', PATH: `${bin}${path.delimiter}${process.env.PATH}` };
+}
+
+test('push skips only when an open PR is positively mergeable at the same head', () => {
+ const env = mockPullRequestApi();
assert.match(run('should-run-tests-workflow.sh', env), /^should_run=false$/m);
- fs.writeFileSync(gh, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
+});
+
+test('conflicted, unknown, stale, and absent PRs keep push tests enabled', () => {
+ const env = mockPullRequestApi();
+ for (const scenario of [
+ { MOCK_MERGEABLE: 'false' },
+ { MOCK_MERGEABLE: 'null' },
+ { MOCK_MERGEABLE: 'unexpected' },
+ { MOCK_HEAD: 'b'.repeat(40) },
+ { MOCK_PR_NUMBERS: '' },
+ { MOCK_PR_NUMBERS: 'not-a-number' },
+ { MOCK_PR_ERROR: 'yes' },
+ { GITHUB_SHA: '' },
+ ]) {
+ assert.match(run('should-run-tests-workflow.sh', { ...env, ...scenario }), /^should_run=true$/m);
+ }
+});
+
+test('PR-list API failure keeps push tests enabled', () => {
+ const env = mockPullRequestApi();
+ fs.writeFileSync(path.join(directory, 'bin', 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
assert.match(run('should-run-tests-workflow.sh', env), /^should_run=true$/m);
});
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
index af4d46bb1..22587afcb 100644
--- a/.github/workflows/ci-cd.yml
+++ b/.github/workflows/ci-cd.yml
@@ -1,4 +1,4 @@
-name: Modtale CI/CD
+name: Modtale CI/CD
on:
push:
@@ -105,7 +105,7 @@ jobs:
fi
echo "GIT_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
echo "BRANCH_SLUG=$BRANCH_SLUG" >> $GITHUB_ENV
-
+
if [ "$BRANCH_NAME" = "main" ]; then
echo "ENV_TYPE=prod" >> $GITHUB_ENV
echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV
@@ -123,6 +123,7 @@ jobs:
echo "FRONTEND_DOMAIN=https://modtale.net" >> $GITHUB_ENV
echo "TAG=latest" >> $GITHUB_ENV
echo "R2_BUCKET_NAME=modtale-binaries" >> $GITHUB_ENV
+ echo "R2_ARTIFACT_BUCKET_NAME=${{ vars.GCP_PRIVATE_R2_BUCKET_NAME }}" >> $GITHUB_ENV
echo "R2_ACCESS_KEY_SECRET_NAME=R2_ACCESS_KEY" >> $GITHUB_ENV
echo "R2_SECRET_KEY_SECRET_NAME=R2_SECRET_KEY" >> $GITHUB_ENV
echo "R2_ENDPOINT_SECRET_NAME=R2_ENDPOINT" >> $GITHUB_ENV
@@ -130,7 +131,7 @@ jobs:
echo "WARDEN_ENABLED=true" >> $GITHUB_ENV
echo "OAUTH_ENABLED=true" >> $GITHUB_ENV
echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV
-
+
elif [ "$BRANCH_NAME" = "develop" ]; then
echo "ENV_TYPE=dev" >> $GITHUB_ENV
echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV
@@ -148,6 +149,7 @@ jobs:
echo "FRONTEND_DOMAIN=https://dev.modtale.net" >> $GITHUB_ENV
echo "TAG=dev" >> $GITHUB_ENV
echo "R2_BUCKET_NAME=modtale-binaries" >> $GITHUB_ENV
+ echo "R2_ARTIFACT_BUCKET_NAME=${{ vars.GCP_PRIVATE_R2_BUCKET_NAME }}" >> $GITHUB_ENV
echo "R2_ACCESS_KEY_SECRET_NAME=R2_ACCESS_KEY" >> $GITHUB_ENV
echo "R2_SECRET_KEY_SECRET_NAME=R2_SECRET_KEY" >> $GITHUB_ENV
echo "R2_ENDPOINT_SECRET_NAME=R2_ENDPOINT" >> $GITHUB_ENV
@@ -155,7 +157,7 @@ jobs:
echo "WARDEN_ENABLED=true" >> $GITHUB_ENV
echo "OAUTH_ENABLED=true" >> $GITHUB_ENV
echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV
-
+
else
SLUG="$BRANCH_SLUG"
BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME="${{ vars.GCP_BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME }}"
@@ -203,6 +205,7 @@ jobs:
echo "MONGODB_SECRET_NAME=BRANCH_PREVIEW_MONGODB_URI" >> $GITHUB_ENV
echo "TAG=$SLUG" >> $GITHUB_ENV
echo "R2_BUCKET_NAME=modtale-branch-$SLUG" >> $GITHUB_ENV
+ echo "R2_ARTIFACT_BUCKET_NAME=" >> $GITHUB_ENV
echo "R2_ACCESS_KEY_SECRET_NAME=branch-preview-$SLUG-r2-access-key" >> $GITHUB_ENV
echo "R2_SECRET_KEY_SECRET_NAME=branch-preview-$SLUG-r2-secret-key" >> $GITHUB_ENV
echo "R2_ENDPOINT_SECRET_NAME=branch-preview-$SLUG-r2-endpoint" >> $GITHUB_ENV
@@ -511,7 +514,7 @@ jobs:
else
echo "Reusing the existing backend image for a configuration-only rollout."
fi
-
+
ARGS=(
"--image" "gcr.io/$PROJECT_ID/modtale-backend:${{ env.TAG }}"
"--region" "$REGION"
@@ -531,9 +534,17 @@ jobs:
"--update-env-vars" "FRONTEND_URL=${FRONTEND_DOMAIN:-placeholder}"
"--update-env-vars" "BACKEND_URL=${BACKEND_DOMAIN:-placeholder}"
"--update-env-vars" "R2_BUCKET_NAME=${{ env.R2_BUCKET_NAME }}"
+ "--update-env-vars" "R2_ARTIFACT_BUCKET_NAME=${{ env.R2_ARTIFACT_BUCKET_NAME }}"
"--update-env-vars" "R2_PUBLIC_DOMAIN=${{ env.R2_PUBLIC_DOMAIN }}"
)
+ if [ "${{ env.ENV_TYPE }}" != "preview" ] && [ -n "${{ env.R2_PUBLIC_DOMAIN }}" ]; then
+ if [ -z "${{ env.R2_ARTIFACT_BUCKET_NAME }}" ] || [ "${{ env.R2_ARTIFACT_BUCKET_NAME }}" = "${{ env.R2_BUCKET_NAME }}" ]; then
+ echo "::error::A separate private R2 artifact bucket is required before this backend can deploy."
+ exit 1
+ fi
+ fi
+
R2_ACCESS_KEY_SECRET_VERSION="${R2_ACCESS_KEY_SECRET_VERSION:-latest}"
R2_SECRET_KEY_SECRET_VERSION="${R2_SECRET_KEY_SECRET_VERSION:-latest}"
R2_ENDPOINT_SECRET_VERSION="${R2_ENDPOINT_SECRET_VERSION:-latest}"
@@ -857,7 +868,7 @@ jobs:
else
PUBLIC_BACKEND_URL=$B_URL
fi
-
+
if [ "$MODTALE_SECRET_BUNDLES_ENABLED" = "true" ]; then
python3 .github/scripts/secret_bundle_ci.py deploy -- \
--update-env-vars "FRONTEND_URL=$FINAL_FRONTEND_URL,BACKEND_URL=$PUBLIC_BACKEND_URL" >/dev/null
@@ -953,7 +964,7 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Component | Status | Target Service | Service URL |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|---|---|" >> $GITHUB_STEP_SUMMARY
-
+
if [ -n "$PUBLIC_API_URL" ]; then
echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | [API Endpoint]($PUBLIC_API_URL) |" >> $GITHUB_STEP_SUMMARY
elif [ -n "$B_URL" ]; then
@@ -961,13 +972,13 @@ jobs:
else
echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY
fi
-
+
if [ -n "$F_URL" ]; then
echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | [App URL]($F_URL) |" >> $GITHUB_STEP_SUMMARY
else
echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY
fi
-
+
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "*View deployment details in [Google Cloud Console](https://console.cloud.google.com/run?project=${{ env.PROJECT_ID }}).* " >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 3e9796427..a5baf25b1 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -117,6 +117,7 @@ jobs:
MONGODB_URI: mongodb://localhost:27017/modtale-test
MONGODB_DATABASE_NAME: modtale-test
R2_BUCKET_NAME: modtale-test
+ R2_ARTIFACT_BUCKET_NAME: modtale-test-artifacts
R2_ACCESS_KEY: test-access-key
R2_SECRET_KEY: test-secret-key
R2_ENDPOINT: https://example.com
diff --git a/.github/workflows/warden-persisted.yml b/.github/workflows/warden-persisted.yml
new file mode 100644
index 000000000..c265f5523
--- /dev/null
+++ b/.github/workflows/warden-persisted.yml
@@ -0,0 +1,144 @@
+name: Warden Persisted State
+
+on:
+ push:
+ branches: [warden-v3]
+ paths:
+ - 'backend/**'
+ - '.github/workflows/warden-persisted.yml'
+ - '.github/scripts/warden-persisted-tests.py'
+ - '.github/scripts/warden-persisted-test-resources.gradle'
+ - '.github/tests/warden-persisted-tests.test.mjs'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: warden-persisted-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ persisted:
+ name: MongoDB ${{ matrix.version }} persisted review
+ runs-on: ubuntu-24.04
+ timeout-minutes: 35
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - version: '7.0.41'
+ image: 'mongo:7.0.41@sha256:afef081f9a06e810d1781214234b8c0dab77f9f694567bf24b193b78d445491e'
+ - version: '8.0.23'
+ image: 'mongo:8.0.23@sha256:9e53b28fb3904fa3c68e561902b64ac996a5c71fc56dda279f4964b7f937a749'
+ env:
+ WARDEN_REVIEW_DB_TEST: 'true'
+ WARDEN_REVIEW_DB_PORT: '27030'
+ WARDEN_REPAIR_TX_DB_TEST: 'true'
+ WARDEN_REPAIR_TX_DB_PORT: '27032'
+ MONGODB_URI: mongodb://127.0.0.1:27030/modtale-test
+ MONGODB_DATABASE_NAME: modtale-test
+ R2_BUCKET_NAME: modtale-test
+ R2_ARTIFACT_BUCKET_NAME: modtale-test-artifacts
+ R2_ACCESS_KEY: test-access-key
+ R2_SECRET_KEY: test-secret-key
+ R2_ENDPOINT: https://example.com
+ R2_PUBLIC_DOMAIN: https://cdn.example.com
+ FRONTEND_URL: http://localhost:5173
+ BACKEND_URL: http://localhost:8080
+ WARDEN_ENABLED: 'false'
+ PRE_AUTH_SECRET: test-pre-auth-secret
+ steps:
+ - name: Check out candidate
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
+
+ - name: Set up Java
+ uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
+ with:
+ distribution: temurin
+ java-version: '26.0.2+101'
+
+ - name: Test persisted-suite selection and report validation
+ run: node --test .github/tests/warden-persisted-tests.test.mjs
+
+ - name: Start isolated local database fixtures
+ env:
+ MONGO_IMAGE: ${{ matrix.image }}
+ run: |
+ set -euo pipefail
+ docker pull "$MONGO_IMAGE"
+ docker run --detach --name warden-standalone --network host "$MONGO_IMAGE" \
+ mongod --bind_ip 127.0.0.1 --port 27030 --wiredTigerCacheSizeGB 0.25
+ docker run --detach --name warden-replica --network host "$MONGO_IMAGE" \
+ mongod --bind_ip 127.0.0.1 --port 27032 --replSet warden-ci --wiredTigerCacheSizeGB 0.25
+ for fixture in standalone replica; do
+ port=27030
+ if [[ "$fixture" == replica ]]; then port=27032; fi
+ ready=false
+ for attempt in {1..60}; do
+ if docker exec "warden-$fixture" mongosh --quiet --port "$port" \
+ --eval 'quit(db.adminCommand({ping: 1}).ok === 1 ? 0 : 1)'; then
+ ready=true
+ break
+ fi
+ sleep 1
+ done
+ [[ "$ready" == true ]]
+ done
+ docker exec warden-replica mongosh --quiet --port 27032 --eval \
+ 'quit(rs.initiate({_id: "warden-ci", members: [{_id: 0, host: "127.0.0.1:27032"}]}).ok === 1 ? 0 : 1)'
+ primary=false
+ for attempt in {1..60}; do
+ if docker exec warden-replica mongosh --quiet --port 27032 --eval \
+ 'quit(db.hello().isWritablePrimary === true ? 0 : 1)'; then
+ primary=true
+ break
+ fi
+ sleep 1
+ done
+ [[ "$primary" == true ]]
+
+ - name: Verify closure transaction race before the full workload
+ timeout-minutes: 5
+ run: |
+ bash backend/gradlew -p backend -I "$PWD/.github/scripts/warden-persisted-test-resources.gradle" \
+ cleanTest test --tests net.modtale.service.admin.review.ReviewRepairClosureTest \
+ --no-daemon --max-workers=2 -Dorg.gradle.jvmargs='-Xmx1g -XX:MaxMetaspaceSize=512m'
+
+ - name: Run every persisted Warden suite
+ timeout-minutes: 30
+ run: |
+ set -euo pipefail
+ python3 .github/scripts/warden-persisted-tests.py list > "$RUNNER_TEMP/warden-suites.txt"
+ mapfile -t suites < "$RUNNER_TEMP/warden-suites.txt"
+ (( ${#suites[@]} > 0 ))
+ selectors=()
+ for suite in "${suites[@]}"; do selectors+=(--tests "$suite"); done
+ bash backend/gradlew -p backend -I "$PWD/.github/scripts/warden-persisted-test-resources.gradle" \
+ cleanTest test "${selectors[@]}" \
+ --no-daemon --max-workers=2 -Dorg.gradle.jvmargs='-Xmx1g -XX:MaxMetaspaceSize=512m'
+ python3 .github/scripts/warden-persisted-tests.py verify
+
+ - name: Retain test and fixture reports
+ if: ${{ !cancelled() }}
+ run: |
+ mkdir -p backend/build/warden-fixture-logs
+ docker logs warden-standalone > backend/build/warden-fixture-logs/standalone.log 2>&1 || true
+ docker logs warden-replica > backend/build/warden-fixture-logs/replica.log 2>&1 || true
+
+ - name: Upload persisted-state evidence
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: warden-persisted-mongo-${{ matrix.version }}
+ path: |
+ backend/build/reports/tests/test
+ backend/build/test-results/test
+ backend/build/warden-fixture-logs
+ retention-days: 7
+ if-no-files-found: error
+
+ - name: Remove isolated fixtures
+ if: always()
+ run: docker rm --force --volumes warden-standalone warden-replica || true
diff --git a/backend/build.gradle b/backend/build.gradle
index a92a331e1..c157b1b60 100644
--- a/backend/build.gradle
+++ b/backend/build.gradle
@@ -67,6 +67,10 @@ dependencies {
}
tasks.named('test') {
+ // Explicit database checks must exercise the current database, not cached results.
+ if (System.getenv('WARDEN_REVIEW_DB_TEST') == 'true' || System.getenv('WARDEN_REPAIR_TX_DB_TEST') == 'true') {
+ outputs.upToDateWhen { false }
+ }
useJUnitPlatform()
// Mockito's documented Java 21+ setup avoids unsupported dynamic self-attachment.
// Test-only instrumentation uses the same Spring-managed Mockito version.
diff --git a/backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java b/backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java
new file mode 100644
index 000000000..febf31505
--- /dev/null
+++ b/backend/src/main/java/net/modtale/config/db/ArtifactManifestStore.java
@@ -0,0 +1,8 @@
+package net.modtale.config.db;
+
+import java.util.Map;
+
+public interface ArtifactManifestStore {
+ String put(Map entries);
+ Map get(String identity);
+}
diff --git a/backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java b/backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java
new file mode 100644
index 000000000..201f94a1c
--- /dev/null
+++ b/backend/src/main/java/net/modtale/config/db/MongoArtifactManifestStore.java
@@ -0,0 +1,40 @@
+package net.modtale.config.db;
+
+import com.mongodb.MongoWriteException;
+import net.modtale.model.project.SecurityManifest;
+import org.bson.Document;
+import org.springframework.data.mongodb.MongoDatabaseFactory;
+import org.springframework.data.mapping.MappingException;
+import java.util.*;
+
+/** Immutable, content-addressed records; raw driver access avoids recursive mapping conversions. */
+public final class MongoArtifactManifestStore implements ArtifactManifestStore {
+ public static final String COLLECTION = "artifact_manifests";
+ private final MongoDatabaseFactory database;
+ public MongoArtifactManifestStore(MongoDatabaseFactory database) { this.database = database; }
+ @Override public String put(Map entries) {
+ if (!SecurityManifest.valid(entries, false)) throw new MappingException("Invalid artifact manifest");
+ var snapshot = Collections.unmodifiableMap(new TreeMap<>(entries));
+ String identity = SecurityManifest.identity(snapshot);
+ var values = new ArrayList();
+ snapshot.forEach((path, hash) -> values.add(new Document("path", path).append("sha256", hash)));
+ try {
+ database.getMongoDatabase().getCollection(COLLECTION).insertOne(new Document("_id", identity)
+ .append("entries", values).append("createdAt", new Date()));
+ } catch (MongoWriteException duplicate) {
+ if (duplicate.getError().getCode() != 11000) throw duplicate;
+ get(identity); // An existing record must really contain the same immutable manifest.
+ }
+ return identity;
+ }
+ @Override public Map get(String identity) {
+ if (!SecurityManifest.digest(identity)) throw new MappingException("Invalid artifact manifest reference");
+ Document document = database.getMongoDatabase().getCollection(COLLECTION).find(new Document("_id", identity)).first();
+ if (document == null) throw new MappingException("Artifact manifest is unavailable");
+ var evidence = new SecurityEvidenceConverters.Read().convert(new Document("entryHashes", document.get("entries")));
+ var entries = evidence.entryHashes();
+ if (!SecurityManifest.valid(entries, false) || !identity.equals(SecurityManifest.identity(entries)))
+ throw new MappingException("Artifact manifest integrity check failed");
+ return Collections.unmodifiableMap(new TreeMap<>(entries));
+ }
+}
diff --git a/backend/src/main/java/net/modtale/config/db/MongoConfig.java b/backend/src/main/java/net/modtale/config/db/MongoConfig.java
index a839e36e7..b4750a80e 100644
--- a/backend/src/main/java/net/modtale/config/db/MongoConfig.java
+++ b/backend/src/main/java/net/modtale/config/db/MongoConfig.java
@@ -12,8 +12,19 @@
public class MongoConfig {
@Bean
+ public ArtifactManifestStore artifactManifestStore(org.springframework.data.mongodb.MongoDatabaseFactory database) {
+ return new MongoArtifactManifestStore(database);
+ }
+
+ @Bean
+ public MongoCustomConversions mongoCustomConversions(ArtifactManifestStore store) {
+ return new MongoCustomConversions(List.of(new StringToOAuthProviderConverter(),
+ new SecurityEvidenceConverters.Write(store), new SecurityEvidenceConverters.Read(store)));
+ }
+
public MongoCustomConversions mongoCustomConversions() {
- return new MongoCustomConversions(List.of(new StringToOAuthProviderConverter()));
+ return new MongoCustomConversions(List.of(new StringToOAuthProviderConverter(),
+ new SecurityEvidenceConverters.Write(), new SecurityEvidenceConverters.Read()));
}
@ReadingConverter
diff --git a/backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java b/backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java
new file mode 100644
index 000000000..ca43784ab
--- /dev/null
+++ b/backend/src/main/java/net/modtale/config/db/ReferencedArtifactManifest.java
@@ -0,0 +1,31 @@
+package net.modtale.config.db;
+
+import net.modtale.model.project.SecurityManifest;
+import java.util.*;
+
+/** Existing Map-based consumers still inspect real verified entries before granting clearance. */
+final class ReferencedArtifactManifest extends AbstractMap {
+ final ArtifactManifestStore store;
+ final String identity;
+ private volatile Map loaded;
+ ReferencedArtifactManifest(ArtifactManifestStore store, String identity) {
+ if (store == null || !SecurityManifest.digest(identity)) throw new IllegalArgumentException("Invalid artifact manifest reference");
+ this.store = store; this.identity = identity;
+ }
+ private Map load() {
+ var snapshot = loaded;
+ if (snapshot != null) return snapshot;
+ synchronized (this) {
+ if (loaded == null) {
+ try {
+ var entries = store.get(identity);
+ if (!SecurityManifest.valid(entries, false) || !identity.equals(SecurityManifest.identity(entries)))
+ throw new IllegalStateException("Artifact manifest integrity check failed");
+ loaded = Collections.unmodifiableMap(new TreeMap<>(entries));
+ } catch (RuntimeException unavailable) { throw new SecurityManifest.Unavailable(unavailable); }
+ }
+ return loaded;
+ }
+ }
+ @Override public Set> entrySet() { return load().entrySet(); }
+}
diff --git a/backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java b/backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java
new file mode 100644
index 000000000..72377eba9
--- /dev/null
+++ b/backend/src/main/java/net/modtale/config/db/SecurityEvidenceConverters.java
@@ -0,0 +1,78 @@
+package net.modtale.config.db;
+
+import java.util.*;
+import net.modtale.model.project.ScanResult.SecurityEvidence;
+import net.modtale.model.project.SecurityManifest;
+import org.bson.Document;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.data.convert.ReadingConverter;
+import org.springframework.data.convert.WritingConverter;
+import org.springframework.data.mapping.MappingException;
+
+public final class SecurityEvidenceConverters {
+ private SecurityEvidenceConverters() {}
+
+ @WritingConverter
+ public static final class Write implements Converter {
+ private final ArtifactManifestStore store;
+ public Write() { this(null); }
+ public Write(ArtifactManifestStore store) { this.store = store; }
+ @Override public Document convert(SecurityEvidence evidence) {
+ if (store != null && evidence.entryHashes() instanceof ReferencedArtifactManifest reference && reference.store == store)
+ return header(evidence).append("manifestRef", reference.identity);
+ if (!SecurityManifest.valid(evidence.entryHashes(), true)) throw new MappingException("Invalid or oversized security manifest");
+ if (store != null && evidence.entryHashes() != null && !evidence.entryHashes().isEmpty())
+ return header(evidence).append("manifestRef", store.put(evidence.entryHashes()));
+ var entries = new ArrayList();
+ if (evidence.entryHashes() != null) evidence.entryHashes().forEach((path, hash) ->
+ entries.add(new Document("path", path).append("sha256", hash)));
+ return header(evidence).append("entryHashes", entries);
+ }
+ private Document header(SecurityEvidence evidence) {
+ return new Document("policyVersion", evidence.policyVersion())
+ .append("artifactSha256", evidence.artifactSha256())
+ .append("contentSha256", evidence.contentSha256())
+ .append("complete", evidence.complete())
+ .append("clearanceGranted", evidence.clearanceGranted())
+ .append("reviewState", evidence.reviewState());
+ }
+ }
+
+ @ReadingConverter
+ public static final class Read implements Converter {
+ private final ArtifactManifestStore store;
+ public Read() { this(null); }
+ public Read(ArtifactManifestStore store) { this.store = store; }
+ @Override public SecurityEvidence convert(Document source) {
+ if (source.containsKey("manifestRef")) {
+ if (source.containsKey("entryHashes") || store == null || !(source.get("manifestRef") instanceof String identity)
+ || !SecurityManifest.digest(identity)) throw new MappingException("Invalid artifact manifest reference");
+ return evidence(source, new ReferencedArtifactManifest(store, identity));
+ }
+ var entries = new LinkedHashMap();
+ Object stored = source.get("entryHashes");
+ if (stored instanceof List> list) {
+ if (list.size() > 20_000) throw new MappingException("Security manifest exceeds entry limit");
+ for (Object value : list) {
+ if (!(value instanceof Document entry) || !(entry.get("path") instanceof String path)
+ || !(entry.get("sha256") instanceof String hash) || entries.putIfAbsent(path, hash) != null)
+ throw new MappingException("Invalid security manifest entry");
+ }
+ } else if (stored instanceof Map, ?> map) {
+ if (map.size() > 20_000) throw new MappingException("Security manifest exceeds entry limit");
+ for (var entry : map.entrySet()) {
+ if (!(entry.getKey() instanceof String path) || !(entry.getValue() instanceof String hash))
+ throw new MappingException("Invalid legacy security manifest entry");
+ entries.put(path, hash);
+ }
+ } else if (stored != null) throw new MappingException("Invalid security manifest");
+ if (!SecurityManifest.valid(entries, true)) throw new MappingException("Invalid or oversized security manifest");
+ return evidence(source, entries);
+ }
+ private SecurityEvidence evidence(Document source, Map entries) {
+ return new SecurityEvidence(source.getString("policyVersion"), source.getString("artifactSha256"),
+ source.getString("contentSha256"), Boolean.TRUE.equals(source.get("complete")),
+ Boolean.TRUE.equals(source.get("clearanceGranted")), source.getString("reviewState"), entries);
+ }
+ }
+}
diff --git a/backend/src/main/java/net/modtale/config/properties/AppR2Properties.java b/backend/src/main/java/net/modtale/config/properties/AppR2Properties.java
index 16eedd280..47a0673de 100644
--- a/backend/src/main/java/net/modtale/config/properties/AppR2Properties.java
+++ b/backend/src/main/java/net/modtale/config/properties/AppR2Properties.java
@@ -1,6 +1,7 @@
package net.modtale.config.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.bind.ConstructorBinding;
@ConfigurationProperties(prefix = "app.r2")
public record AppR2Properties(
@@ -8,6 +9,13 @@ public record AppR2Properties(
String accessKey,
String secretKey,
String endpoint,
- String publicDomain
+ String publicDomain,
+ String artifactBucket
) {
+ @ConstructorBinding
+ public AppR2Properties {}
+
+ public AppR2Properties(String bucket, String accessKey, String secretKey, String endpoint, String publicDomain) {
+ this(bucket, accessKey, secretKey, endpoint, publicDomain, null);
+ }
}
diff --git a/backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java b/backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java
new file mode 100644
index 000000000..68039f39b
--- /dev/null
+++ b/backend/src/main/java/net/modtale/config/properties/AppReviewRepairProperties.java
@@ -0,0 +1,32 @@
+package net.modtale.config.properties;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.bind.DefaultValue;
+import java.util.*;
+
+@ConfigurationProperties(prefix="app.warden.repair")
+public record AppReviewRepairProperties(@DefaultValue("false") boolean enabled,@DefaultValue("") String activeKey,
+ Map signingKeys,@DefaultValue("1") int concurrency,@DefaultValue("300000") long preparationLifetimeMillis) {
+ public AppReviewRepairProperties {
+ signingKeys=signingKeys==null?Map.of():Map.copyOf(signingKeys);
+ if(enabled) {
+ if(concurrency<1 || concurrency>2 || preparationLifetimeMillis<1000 || preparationLifetimeMillis>900000
+ || activeKey==null || !signingKeys.containsKey(activeKey) || signingKeys.isEmpty() || signingKeys.size()>8)throw invalid();
+ decode(signingKeys);
+ }
+ }
+ public Map decodedKeys(){if(!enabled)throw invalid();return decode(signingKeys);}
+ private static Map decode(Map keys) {
+ var decoded=new HashMap();
+ keys.forEach((id,value)->{
+ try {
+ if(id==null || !id.matches("[A-Za-z0-9_-]{1,64}") || value==null || value.length()>172)throw invalid();
+ byte[] key=Base64.getDecoder().decode(value);
+ if(key.length<32 || key.length>128 || !Base64.getEncoder().encodeToString(key).equals(value))throw invalid();
+ decoded.put(id,key);
+ } catch(IllegalArgumentException invalid){throw invalid();}
+ });return Map.copyOf(decoded);
+ }
+ private static IllegalArgumentException invalid(){return new IllegalArgumentException("Invalid review repair settings");}
+ @Override public String toString(){return "ReviewRepairSettings[enabled="+enabled+"]";}
+}
diff --git a/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java b/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java
index 1068854be..fca64a432 100644
--- a/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java
+++ b/backend/src/main/java/net/modtale/config/properties/AppWardenProperties.java
@@ -8,7 +8,7 @@ public record AppWardenProperties(
@DefaultValue("http://localhost:8081") String url,
@DefaultValue("") String apiKey,
@DefaultValue("true") boolean enabled,
- @DefaultValue("3") int maxAttempts,
- @DefaultValue("75") long requestTimeoutSeconds
+ @DefaultValue("1") int maxAttempts,
+ @DefaultValue("600") long requestTimeoutSeconds
) {
}
diff --git a/backend/src/main/java/net/modtale/config/r2/R2Config.java b/backend/src/main/java/net/modtale/config/r2/R2Config.java
index 22fdb09e6..afdc93b15 100644
--- a/backend/src/main/java/net/modtale/config/r2/R2Config.java
+++ b/backend/src/main/java/net/modtale/config/r2/R2Config.java
@@ -25,18 +25,6 @@ public R2Config(AppR2Properties r2Properties) {
this.r2Properties = r2Properties;
}
- @Bean
- public software.amazon.awssdk.services.s3.presigner.S3Presigner s3Presigner() {
- URI endpoint = URI.create(r2Properties.endpoint());
- return software.amazon.awssdk.services.s3.presigner.S3Presigner.builder()
- .endpointOverride(URI.create(endpoint.getScheme() + "://" + endpoint.getAuthority()))
- .region(Region.of("auto"))
- .credentialsProvider(StaticCredentialsProvider.create(
- AwsBasicCredentials.create(r2Properties.accessKey(), r2Properties.secretKey())))
- .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
- .build();
- }
-
@Bean
public S3Client s3Client() {
String endpoint = r2Properties.endpoint();
diff --git a/backend/src/main/java/net/modtale/config/r2/R2HealthIndicator.java b/backend/src/main/java/net/modtale/config/r2/R2HealthIndicator.java
index 2c54e7199..7176db0ac 100644
--- a/backend/src/main/java/net/modtale/config/r2/R2HealthIndicator.java
+++ b/backend/src/main/java/net/modtale/config/r2/R2HealthIndicator.java
@@ -13,16 +13,25 @@ public class R2HealthIndicator implements HealthIndicator {
private final S3Client s3Client;
private final String bucketName;
+ private final String artifactBucketName;
+ private final String publicDomain;
public R2HealthIndicator(S3Client s3Client, AppR2Properties r2Properties) {
this.s3Client = s3Client;
this.bucketName = r2Properties.bucket();
+ this.artifactBucketName = r2Properties.artifactBucket();
+ this.publicDomain = r2Properties.publicDomain();
}
@Override
public Health health() {
try {
+ if (publicDomain != null && !publicDomain.isBlank()
+ && (artifactBucketName == null || artifactBucketName.isBlank() || artifactBucketName.equals(bucketName)))
+ return Health.down().withDetail("error", "Private artifact bucket is not configured").build();
s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build());
+ if (artifactBucketName != null && !artifactBucketName.isBlank() && !artifactBucketName.equals(bucketName))
+ s3Client.headBucket(HeadBucketRequest.builder().bucket(artifactBucketName).build());
return Health.up().withDetail("bucket", bucketName).build();
} catch (S3Exception e) {
return Health.down().withDetail("error", e.getMessage()).build();
diff --git a/backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java b/backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java
new file mode 100644
index 000000000..1e8ca055c
--- /dev/null
+++ b/backend/src/main/java/net/modtale/controller/admin/ArtifactInspectionController.java
@@ -0,0 +1,199 @@
+package net.modtale.controller.admin;
+
+import net.modtale.model.project.*;
+import net.modtale.service.admin.review.ProjectReviewSnapshot;
+import net.modtale.service.project.query.ProjectService;
+import net.modtale.service.security.scan.WardenClientService;
+import net.modtale.service.storage.StorageService;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+import java.util.*;
+
+@RestController
+@RequestMapping("/api/v1/admin/projects/{id}/versions/{version}")
+@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)")
+public class ArtifactInspectionController {
+ private final ProjectService projects;
+ private final StorageService storage;
+ private final WardenClientService inspector;
+ public ArtifactInspectionController(ProjectService projects, StorageService storage, WardenClientService inspector) {
+ this.projects=projects;this.storage=storage;this.inspector=inspector;
+ }
+ @GetMapping("/structure")
+ public ResponseEntity> structure(@PathVariable String id,@PathVariable String version, @RequestHeader(value="If-Match", required=false) String expected) {
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(inspect(id,version,null,expected).paths());
+ }
+ @GetMapping(value="/file",produces=MediaType.TEXT_PLAIN_VALUE)
+ public ResponseEntity file(@PathVariable String id,@PathVariable String version,@RequestParam String path, @RequestHeader(value="If-Match", required=false) String expected) {
+ var response=inspect(id,version,path,expected);
+ String prefix="JVM_BYTECODE".equals(response.format()) ? "// JVM bytecode of the uploaded class. LINE entries refer to original source lines.\n" : "";
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff").body(prefix+response.content());
+ }
+ public record FileWindow(String identity, String content, String format, int start, int end, int totalCharacters,
+ int firstLine, boolean lineMatched, boolean representationComplete, List gaps) {}
+ @GetMapping("/file-window")
+ public ResponseEntity window(@PathVariable String id, @PathVariable String version, @RequestParam String path,
+ @RequestParam(defaultValue="0") int offset, @RequestParam(defaultValue="32000") int characters,
+ @RequestParam(defaultValue="0") int sourceLine, @RequestParam(required=false) String identity,
+ @RequestHeader(value="If-Match",required=false) String expected) {
+ Project project = projects.getRawProjectById(id);
+ if (project == null || project.getVersions() == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ ProjectReviewSnapshot.requireCurrent(project, expected);
+ String snapshot = ProjectReviewSnapshot.token(project);
+ if (path == null || path.isBlank() || path.length() > 8192 || offset < 0 || offset > 4_000_000
+ || characters < 1 || characters > 32000 || sourceLine < 0 || sourceLine > 4_000_001
+ || sourceLine > 0 && offset != 0 || offset > 0 && identity == null || identity != null && !identity.matches("[0-9a-f]{64}"))
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid inspection window");
+ var selected = requireVersion(project, version);
+ byte[] bytes = verifiedBytes(selected);
+ var response = inspector.inspectWindow(bytes, path, offset, characters, sourceLine);
+ requireUnchanged(id, snapshot);
+ if (!validWindow(response, selected.getHash(), path, offset, characters, sourceLine))
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "A consistent inspection window is unavailable");
+ var fields = new ArrayList<>(List.of(response.artifactSha256(), response.path(), response.entrySha256(), response.policyVersion(),
+ response.representationSha256(), response.format(), Integer.toString(response.totalCharacters()), Boolean.toString(response.representationComplete())));
+ fields.addAll(response.gaps());
+ StringBuilder binding = new StringBuilder(); fields.forEach(field -> binding.append(field.length()).append(':').append(field));
+ String actual = digest(binding.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ if (identity != null && !identity.equals(actual)) throw new ResponseStatusException(HttpStatus.CONFLICT, "Inspection changed; reopen the file");
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options", "nosniff")
+ .body(new FileWindow(actual, response.content(), response.format(), response.start(), response.end(), response.totalCharacters(),
+ response.firstLine(), response.lineMatched(), response.representationComplete(), response.gaps()));
+ }
+ private static boolean validWindow(WardenClientService.InspectionWindow w, String hash, String path, int offset, int characters, int sourceLine) {
+ return w != null && Objects.equals(hash,w.artifactSha256()) && path.equals(w.path())
+ && w.entrySha256() != null && w.entrySha256().matches("[0-9a-f]{64}") && w.representationSha256() != null && w.representationSha256().matches("[0-9a-f]{64}")
+ && w.policyVersion() != null && !w.policyVersion().isBlank() && w.policyVersion().length() <= 256 && w.format() != null
+ && Set.of("JVM_BYTECODE","TEXT_RESOURCE","JSON_RESOURCE","STRUCTURED_JSON","VALIDATED_RASTER","AUDIO_SUMMARY","KOTLIN_MODULE","ICC_PROFILE","OPAQUE_RESOURCE","UNREPRESENTED").contains(w.format())
+ && w.start() >= 0 && (sourceLine != 0 || offset == w.start()) && w.end() >= w.start() && w.end() <= w.totalCharacters() && w.totalCharacters() <= 4_000_000
+ && (w.start() == w.totalCharacters() || w.end() > w.start()) && w.firstLine() >= 1 && w.firstLine() <= w.start() + 1
+ && w.content() != null && w.content().length() == w.end()-w.start() && w.content().length() <= characters
+ && w.gaps() != null && w.gaps().size() <= 8 && w.gaps().stream().allMatch(gap -> gap != null && gap.length() <= 512)
+ && (!w.representationComplete() || w.gaps().isEmpty() && !Set.of("OPAQUE_RESOURCE","AUDIO_SUMMARY","UNREPRESENTED").contains(w.format()));
+ }
+ private static String digest(byte[] bytes) {
+ try { return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); }
+ catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
+ }
+ private byte[] verifiedBytes(ProjectVersion version) {
+ if (version.getFileUrl() == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ byte[] bytes = storage.downloadBounded(version.getFileUrl(), StorageService.MAX_REVIEW_ARTIFACT_BYTES);
+ if (!Objects.equals(digest(bytes),version.getHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"Stored artifact hash mismatch");
+ return bytes;
+ }
+ public record FileChange(String path, String change) {}
+ public record ArtifactChanges(String reviewToken, String baselineVersion, boolean contextComparable, boolean contextChanged,
+ List contextChanges,
+ int added, int modified, int removed, int unchanged, List files) {}
+
+ @GetMapping("/changes")
+ public ResponseEntity changes(@PathVariable String id, @PathVariable String version, @RequestHeader(value="If-Match", required=false) String expected) {
+ Project project=projects.getRawProjectById(id);
+ if(project==null || project.getVersions()==null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ ProjectReviewSnapshot.requireCurrent(project, expected);
+ String snapshot = ProjectReviewSnapshot.token(project);
+ ProjectVersion current = requireVersion(project, version);
+ var candidates=project.getVersions().stream().filter(Objects::nonNull).filter(v -> v.getReviewStatus()==ProjectVersion.ReviewStatus.APPROVED
+ && !Objects.equals(v.getId(),current.getId()))
+ .sorted(Comparator.comparingLong(ProjectVersion::getSecurityApprovedAt).reversed()).limit(256).toList();
+ if(candidates.isEmpty()) return ResponseEntity.ok().cacheControl(CacheControl.noStore())
+ .body(new ArtifactChanges(snapshot,null,false,false,List.of(),0,0,0,0,List.of()));
+ ProjectVersion baseline=candidates.getFirst();
+ requireVersion(project,baseline.getVersionNumber());
+ var after=inspect(current,null);
+ ProjectReviewSnapshot.requireCurrent(project,snapshot);
+ Map retained=null;
+ for(var candidate:candidates) {
+ var evidence=candidate.getApprovedSecurityEvidence();
+ if(evidence==null || !Objects.equals(after.policyVersion(),evidence.policyVersion())
+ || !Objects.equals(candidate.getApprovedSecurityContextSha256(),
+ net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(candidate))) continue;
+ var manifest=approvedManifest(project,candidate,after.policyVersion());
+ if(manifest!=null) {baseline=candidate;retained=manifest;break;}
+ }
+ requireVersion(project,baseline.getVersionNumber());
+ Map beforeEntries;
+ String beforePolicy;
+ if(retained==null) {
+ var before=inspect(baseline,null);
+ beforeEntries=before.entryHashes();beforePolicy=before.policyVersion();
+ } else {
+ verifiedBytes(baseline);
+ beforeEntries=retained;beforePolicy=after.policyVersion();
+ }
+ requireUnchanged(id, snapshot);
+ if(!validManifest(beforeEntries) || !validManifest(after.entryHashes()) || beforePolicy==null
+ || !beforePolicy.equals(after.policyVersion()))
+ throw new ResponseStatusException(HttpStatus.CONFLICT,"A consistent artifact comparison is unavailable");
+ String priorContext=baseline.getApprovedSecurityContextSha256();
+ String currentContext=net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(current);
+ boolean comparable=retained!=null && priorContext!=null && currentContext!=null
+ && priorContext.equals(net.modtale.service.security.scan.ArtifactReviewContext.fingerprint(baseline));
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(compare(snapshot,baseline.getVersionNumber(),
+ comparable, comparable && !priorContext.equals(currentContext),
+ comparable ? net.modtale.service.security.scan.ArtifactReviewContext.changedFields(baseline,current) : List.of(),
+ beforeEntries,after.entryHashes()));
+ }
+ private static Map approvedManifest(Project project,ProjectVersion baseline,String currentPolicy) {
+ var evidence=baseline.getApprovedSecurityEvidence();
+ if(evidence==null || currentPolicy==null || !currentPolicy.equals(evidence.policyVersion())
+ || !evidence.complete() || !Objects.equals(baseline.getHash(),evidence.artifactSha256())
+ || !validManifest(evidence.entryHashes()) || !SecurityManifest.identity(evidence.entryHashes()).equals(evidence.contentSha256())
+ || net.modtale.service.security.scan.ArtifactReviewLineage.extend(project,baseline)==null) return null;
+ return Map.copyOf(evidence.entryHashes());
+ }
+ private static boolean validManifest(Map entries) {
+ return net.modtale.model.project.SecurityManifest.valid(entries, false);
+ }
+ static ArtifactChanges compare(String snapshot, String baseline, boolean comparable, boolean contextChanged, List contextChanges,
+ Map before, Map after) {
+ var paths=new TreeSet(); paths.addAll(before.keySet()); paths.addAll(after.keySet());
+ var changes=new ArrayList(); int added=0,modified=0,removed=0,unchanged=0;
+ for(String path:paths) {
+ String change;
+ if(!before.containsKey(path)) {change="ADDED";added++;}
+ else if(!after.containsKey(path)) {change="REMOVED";removed++;}
+ else if(!Objects.equals(before.get(path),after.get(path))) {change="MODIFIED";modified++;}
+ else {change="UNCHANGED";unchanged++;}
+ changes.add(new FileChange(path,change));
+ }
+ return new ArtifactChanges(snapshot,baseline,comparable,contextChanged,contextChanged ? List.copyOf(contextChanges) : List.of(),
+ added,modified,removed,unchanged,List.copyOf(changes));
+ }
+ private WardenClientService.InspectionResponse inspect(String id,String number,String path,String expected) {
+ Project project=projects.getRawProjectById(id);
+ if(project==null || project.getVersions()==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ ProjectReviewSnapshot.requireCurrent(project, expected);
+ String snapshot = ProjectReviewSnapshot.token(project);
+ var result = inspect(requireVersion(project, number), path);
+ requireUnchanged(id, snapshot);
+ return result;
+ }
+ private ProjectVersion requireVersion(Project project, String number) {
+ var matches = project.getVersions().stream().filter(Objects::nonNull)
+ .filter(v -> Objects.equals(v.getVersionNumber(), number)).toList();
+ if (matches.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ if (matches.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "Version identity is ambiguous");
+ return matches.getFirst();
+ }
+ private void requireUnchanged(String id, String snapshot) {
+ var current = projects.getRawProjectById(id);
+ if (current == null || current.getVersions() == null) throw ProjectReviewSnapshot.conflict();
+ ProjectReviewSnapshot.requireCurrent(current, snapshot);
+ }
+ private WardenClientService.InspectionResponse inspect(ProjectVersion version, String path) {
+ if(version.getFileUrl()==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ byte[] bytes=storage.downloadBounded(version.getFileUrl(), StorageService.MAX_REVIEW_ARTIFACT_BYTES);
+ try {
+ String actual=HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
+ if(!actual.equals(version.getHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"Stored artifact hash mismatch");
+ } catch(java.security.NoSuchAlgorithmException impossible) {throw new IllegalStateException(impossible);}
+ var response=inspector.inspectFile(bytes,"artifact.zip",path);
+ if(response==null || !Objects.equals(version.getHash(),response.artifactSha256())) {
+ throw new ResponseStatusException(HttpStatus.CONFLICT,"Artifact contents no longer match this version");
+ }
+ return response;
+ }
+}
diff --git a/backend/src/main/java/net/modtale/controller/admin/DependencyInspectionController.java b/backend/src/main/java/net/modtale/controller/admin/DependencyInspectionController.java
new file mode 100644
index 000000000..dcf61d396
--- /dev/null
+++ b/backend/src/main/java/net/modtale/controller/admin/DependencyInspectionController.java
@@ -0,0 +1,175 @@
+package net.modtale.controller.admin;
+
+import net.modtale.model.project.Project;
+import net.modtale.model.project.ProjectClassification;
+import net.modtale.model.project.ProjectVersion;
+import net.modtale.service.admin.review.ProjectReviewSnapshot;
+import net.modtale.service.project.query.ProjectService;
+import net.modtale.service.security.scan.*;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+import java.util.*;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+@RestController
+@RequestMapping("/api/v1/admin/projects/{id}/version-ids/{versionId}")
+@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)")
+public class DependencyInspectionController {
+ private final ProjectService projects;
+ private final Supplier sources;
+ private final DependencyArtifactVerifier verifier;
+ private final ModpackOverrideInspector overrideInspector;
+ @Autowired public DependencyInspectionController(ProjectService projects,MongoTemplate mongo,net.modtale.service.storage.StorageService storage) {
+ this(projects,()->new DependencyReviewSource(mongo),new DependencyArtifactVerifier(storage),new ModpackOverrideInspector(storage));
+ }
+ DependencyInspectionController(ProjectService projects,Supplier sources,DependencyArtifactVerifier verifier,
+ ModpackOverrideInspector overrideInspector) {
+ this.projects=projects;this.sources=sources;this.verifier=verifier;this.overrideInspector=overrideInspector;
+ }
+ public record OverrideInspection(String reviewToken,String artifactSha256,ModpackOverrideInspector.Result observation) {}
+ @GetMapping("/override-contents")
+ public ResponseEntity inspectOverrideContents(@PathVariable String id,@PathVariable String versionId,
+ @RequestParam String artifactSha256,@RequestHeader(value="If-Match",required=false) String expected) {
+ var checked=readOverride(id,versionId,artifactSha256,expected,version->overrideInspector.inspect(
+ version.getOverrideFileUrl(),artifactSha256,version.getModpackConfigs(),version.getDependencies()));
+ return overrideResponse(checked,artifactSha256);
+ }
+ @GetMapping("/override-config-window")
+ public ResponseEntity inspectOverrideConfigWindow(@PathVariable String id,@PathVariable String versionId,
+ @RequestParam String artifactSha256,@RequestParam String path,@RequestParam(defaultValue="0") int offset,
+ @RequestParam(defaultValue="32768") int characters,@RequestHeader(value="If-Match",required=false) String expected) {
+ if(path==null||path.isBlank()||path.length()>2048||offset<0||characters<1||characters>32768)
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid config window request");
+ var checked=readOverride(id,versionId,artifactSha256,expected,version->{
+ if(version.getModpackConfigs()==null||version.getModpackConfigs().stream()
+ .noneMatch(config->config!=null&&path.equals(config.path())))
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Config is not attached to this version");
+ return overrideInspector.inspectWindow(version.getOverrideFileUrl(),artifactSha256,
+ version.getModpackConfigs(),version.getDependencies(),path,offset,characters);
+ });
+ return overrideResponse(checked,artifactSha256);
+ }
+ private static ResponseEntity overrideResponse(CheckedOverride checked,
+ String artifactSha256) {
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff")
+ .body(new OverrideInspection(checked.reviewToken(),artifactSha256,checked.observation()));
+ }
+ private record CheckedOverride(String reviewToken,T observation) {}
+ private CheckedOverride readOverride(String id,String versionId,String artifactSha256,String expected,
+ Function read) {
+ if(artifactSha256==null||!artifactSha256.matches("[0-9a-f]{64}"))
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid uploaded artifact identity");
+ var before=inspect(id,versionId,expected).getBody();
+ var project=projects.getRawProjectById(id);
+ if(project==null||project.getClassification()!=ProjectClassification.MODPACK||project.getVersions()==null)
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"No modpack override is available");
+ ProjectReviewSnapshot.requireCurrent(project,before.reviewToken());
+ var versions=project.getVersions().stream().filter(Objects::nonNull).filter(v->versionId.equals(v.getId())).toList();
+ if(versions.size()!=1)throw ProjectReviewSnapshot.conflict();
+ var version=versions.getFirst();
+ if(version.getOverrideFileUrl()==null||version.getOverrideFileUrl().isBlank()
+ ||!artifactSha256.equals(version.getHash())
+ ||version.getModpackConfigs()!=null&&version.getModpackConfigs().size()>100
+ ||version.getDependencies()!=null&&version.getDependencies().size()>256
+ ||before.inventory().gaps().stream().anyMatch(gap->gap.reason()==DependencyReviewGraph.Reason.CHANGED))
+ throw ProjectReviewSnapshot.conflict();
+ var observation=read.apply(version);
+ var after=inspect(id,versionId,expected).getBody();
+ if(observation==null||!before.reviewToken().equals(after.reviewToken())
+ ||!before.inventory().root().equals(after.inventory().root())
+ ||!before.inventory().nodes().equals(after.inventory().nodes())
+ ||!before.inventory().edges().equals(after.inventory().edges())
+ ||!before.inventory().gaps().equals(after.inventory().gaps()))
+ throw new ResponseStatusException(HttpStatus.CONFLICT,"Modpack override changed during inspection");
+ return new CheckedOverride<>(after.reviewToken(),observation);
+ }
+ public record ByteInspection(String reviewToken,String inventoryIdentity,DependencyArtifactVerifier.Result verification) {}
+ public record RootByteInspection(String reviewToken,String artifactSha256,DependencyArtifactVerifier.Result verification) {}
+ @GetMapping("/root-bytes")
+ public ResponseEntity verifyRootBytes(@PathVariable String id,@PathVariable String versionId,
+ @RequestParam String artifactSha256,@RequestHeader(value="If-Match",required=false) String expected) {
+ if(artifactSha256==null||!artifactSha256.matches("[0-9a-f]{64}"))
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid uploaded artifact identity");
+ var before=inspect(id,versionId,expected).getBody();
+ var root=before.inventory().nodes().stream().filter(node->node.projectId().equals(before.inventory().root().projectId())
+ &&node.versionId().equals(before.inventory().root().versionId())).findFirst()
+ .orElseThrow(ProjectReviewSnapshot::conflict);
+ if(!artifactSha256.equals(root.artifactSha256())||before.inventory().gaps().stream()
+ .anyMatch(gap->gap.reason()==DependencyReviewGraph.Reason.CHANGED))
+ throw ProjectReviewSnapshot.conflict();
+ var verification=verifier.verifyRoot(root);
+ var after=inspect(id,versionId,expected).getBody();
+ if(!Objects.equals(before.reviewToken(),after.reviewToken())
+ ||!before.inventory().root().equals(after.inventory().root())
+ ||!before.inventory().nodes().equals(after.inventory().nodes())
+ ||!before.inventory().edges().equals(after.inventory().edges())
+ ||!before.inventory().gaps().equals(after.inventory().gaps())
+ ||verification==null)
+ throw new ResponseStatusException(HttpStatus.CONFLICT,"Uploaded artifact changed during byte verification");
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff")
+ .body(new RootByteInspection(after.reviewToken(),artifactSha256,verification));
+ }
+ @GetMapping("/dependency-bytes")
+ public ResponseEntity verifyBytes(@PathVariable String id,@PathVariable String versionId,
+ @RequestParam String inventoryIdentity,@RequestHeader(value="If-Match",required=false) String expected) {
+ if(inventoryIdentity==null||!inventoryIdentity.matches("[0-9a-f]{64}"))
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid dependency inventory identity");
+ var before=inspect(id,versionId,expected).getBody();
+ if(!before.inventory().resolved()||!inventoryIdentity.equals(before.inventory().identity()))
+ throw new ResponseStatusException(HttpStatus.CONFLICT,"Dependency inventory changed; inspect it again");
+ var verification=verifier.verify(before.inventory());
+ // Use a fresh database budget and observations after storage reads, never the pre-read cache.
+ var after=inspect(id,versionId,expected).getBody();
+ if(!after.inventory().resolved()||!inventoryIdentity.equals(after.inventory().identity())
+ ||verification==null||!inventoryIdentity.equals(verification.inventoryIdentity()))
+ throw new ResponseStatusException(HttpStatus.CONFLICT,"Dependency inventory changed during byte verification");
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff")
+ .body(new ByteInspection(after.reviewToken(),inventoryIdentity,verification));
+ }
+ private static boolean sameDeclarations(ProjectVersion version,DependencyReviewGraph.Snapshot root) {
+ try {
+ var declarations=new ArrayList();
+ if(version.getDependencies()!=null)for(var d:version.getDependencies())
+ declarations.add(new DependencyReviewGraph.Dependency(d.getSource(),d.getDependencyType(),
+ new DependencyReviewGraph.Reference(d.getProjectId(),d.getVersionNumber())));
+ return declarations.equals(root.dependencies());
+ } catch(RuntimeException invalid) {return false;}
+ }
+ public record Inspection(String reviewToken,boolean artifactBytesVerified,boolean modpackOverrideAvailable,
+ DependencyReviewGraph.Inventory inventory) {}
+ @GetMapping("/dependencies")
+ public ResponseEntity inspect(@PathVariable String id,@PathVariable String versionId,
+ @RequestHeader(value="If-Match",required=false) String expected) {
+ try {new DependencyReviewGraph.Reference(id,versionId);}
+ catch(IllegalArgumentException invalid){throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid dependency inspection identity");}
+ Project project=projects.getRawProjectById(id);
+ if(project==null||project.getVersions()==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ ProjectReviewSnapshot.requireCurrent(project,expected);
+ String token=ProjectReviewSnapshot.token(project);
+ var matches=project.getVersions().stream().filter(Objects::nonNull).filter(v->versionId.equals(v.getId())).toList();
+ if(matches.isEmpty())throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ if(matches.size()!=1)throw ProjectReviewSnapshot.conflict();
+ ProjectVersion version=matches.getFirst();
+ var source=sources.get();var selected=source.readRoot(id,versionId);
+ if(selected.state()!=DependencyReviewGraph.State.FOUND)throw new ResponseStatusException(HttpStatus.CONFLICT,"Dependency root is unavailable or ambiguous");
+ var root=selected.snapshot();
+ if(!id.equals(root.projectId())||!versionId.equals(root.versionId())
+ ||!Objects.equals(version.getVersionNumber(),root.versionNumber())||!Objects.equals(version.getHash(),root.artifactSha256())
+ ||!Objects.equals(ArtifactReviewContext.inspectionFileReference(version,project.getClassification()),root.fileReference())
+ ||!Objects.equals(ArtifactReviewContext.fingerprint(version),root.contextSha256())
+ ||ArtifactReviewContext.hasSupplementalContent(version)!=root.supplementalContent()
+ ||!sameDeclarations(version,root))throw ProjectReviewSnapshot.conflict();
+ var inventory=DependencyReviewGraph.inspect(root,source,DependencyReviewGraph.Limits.defaults());
+ Project current=projects.getRawProjectById(id);
+ if(current==null||current.getVersions()==null)throw ProjectReviewSnapshot.conflict();
+ ProjectReviewSnapshot.requireCurrent(current,token);
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options","nosniff")
+ .body(new Inspection(token,false,project.getClassification()==ProjectClassification.MODPACK
+ &&version.getOverrideFileUrl()!=null&&!version.getOverrideFileUrl().isBlank(),inventory));
+ }
+}
diff --git a/backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java b/backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java
new file mode 100644
index 000000000..7a3675177
--- /dev/null
+++ b/backend/src/main/java/net/modtale/controller/admin/FindingReviewController.java
@@ -0,0 +1,41 @@
+package net.modtale.controller.admin;
+
+import net.modtale.service.security.issue.FindingReviewService;
+import net.modtale.service.user.account.AccountService;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/admin/projects/{projectId}/versions/{versionId}/finding-decisions")
+public class FindingReviewController {
+ private final FindingReviewService reviews;
+ private final AccountService accounts;
+ public FindingReviewController(FindingReviewService reviews, AccountService accounts) {
+ this.reviews = reviews; this.accounts = accounts;
+ }
+ @GetMapping
+ @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)")
+ public ResponseEntity history(@PathVariable String projectId,
+ @PathVariable String versionId, @RequestHeader("If-Match") String token,
+ @RequestParam(defaultValue = "0") int offset) {
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reviews.history(projectId, versionId, token, offset));
+ }
+ @PostMapping
+ @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)")
+ public ResponseEntity record(@PathVariable String projectId,
+ @PathVariable String versionId, @RequestHeader("If-Match") String token,
+ @RequestBody FindingReviewService.Request request) {
+ var actor = accounts.requireCurrentUser("recording finding decisions");
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reviews.record(projectId, versionId, token, actor.getId(), request));
+ }
+ public record Revocation(String rationale) {}
+ @PostMapping("/{decisionId}/revoke")
+ @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)")
+ public ResponseEntity revoke(@PathVariable String projectId,
+ @PathVariable String versionId, @PathVariable String decisionId,
+ @RequestHeader("If-Match") String token, @RequestBody Revocation request) {
+ var actor = accounts.requireCurrentUser("revoking finding decisions");
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reviews.revoke(projectId, versionId, token, actor.getId(), decisionId, request.rationale()));
+ }
+}
diff --git a/backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java b/backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java
new file mode 100644
index 000000000..d3e4d2cfe
--- /dev/null
+++ b/backend/src/main/java/net/modtale/controller/admin/ModerationQueuePageController.java
@@ -0,0 +1,30 @@
+package net.modtale.controller.admin;
+
+import java.util.List;
+import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO;
+import net.modtale.service.admin.review.ModerationQueueCursor;
+import net.modtale.service.admin.review.ModerationQueuePageReader;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+
+@RestController
+@RequestMapping("/api/v1/admin/verification/queue")
+public class ModerationQueuePageController {
+ public record Page(List items,String nextCursor,int unavailableItems,String order,ModerationQueuePageReader.Filter filter) {}
+ private final ModerationQueuePageReader reader;
+ public ModerationQueuePageController(ModerationQueuePageReader reader) {this.reader=reader;}
+ @GetMapping("/page")
+ @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)")
+ public ResponseEntity read(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit,@RequestParam(defaultValue="ALL") ModerationQueuePageReader.Filter filter) {
+ ModerationQueuePageReader.Cursor position;
+ try {
+ if(limit<1 || limit>50)throw new IllegalArgumentException();
+ position=ModerationQueueCursor.decode(cursor);
+ if(filter==null || position!=null && position.filter()!=filter)throw new IllegalArgumentException();
+ } catch(IllegalArgumentException invalid) {throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid queue pagination");}
+ var page=reader.page(position,limit,filter);
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(new Page(page.items(),ModerationQueueCursor.encode(page.next()),page.unavailableItems(),"PROJECT_VERSION",filter));
+ }
+}
diff --git a/backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java b/backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java
new file mode 100644
index 000000000..6a00c8165
--- /dev/null
+++ b/backend/src/main/java/net/modtale/controller/admin/PriorFindingReasoningController.java
@@ -0,0 +1,19 @@
+package net.modtale.controller.admin;
+
+import net.modtale.service.security.issue.PriorFindingReasoningService;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/admin/projects/{projectId}/versions/{versionId}/prior-finding-reasoning")
+public class PriorFindingReasoningController {
+ private final PriorFindingReasoningService reasoning;
+ public PriorFindingReasoningController(PriorFindingReasoningService reasoning) { this.reasoning=reasoning; }
+ @GetMapping
+ @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)")
+ public ResponseEntity read(@PathVariable String projectId,@PathVariable String versionId,
+ @RequestParam String sourceVersionId,@RequestParam int issueIndex,@RequestHeader("If-Match") String token) {
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(reasoning.read(projectId,versionId,sourceVersionId,issueIndex,token));
+ }
+}
diff --git a/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java b/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java
index 5426e1897..e7a098d7b 100644
--- a/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java
+++ b/backend/src/main/java/net/modtale/controller/admin/ProjectManagementController.java
@@ -4,7 +4,9 @@
import java.util.List;
import net.modtale.model.dto.admin.AdminProjectDTO;
import net.modtale.model.dto.admin.AdminProjectReviewDTO;
-import net.modtale.model.dto.admin.AdminVerificationQueueItemDTO;
+import org.springframework.http.ProblemDetail;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.CacheControl;
import net.modtale.model.dto.project.ProjectSummaryDTO;
import net.modtale.model.dto.request.admin.RejectReasonRequest;
import net.modtale.model.project.Project;
@@ -21,6 +23,7 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -45,8 +48,10 @@ public ProjectManagementController(
@GetMapping("/verification/queue")
@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)")
- public ResponseEntity> getVerificationQueue() {
- return ResponseEntity.ok(projectReviewAdminService.getVerificationQueue());
+ public ResponseEntity getVerificationQueue() {
+ return ResponseEntity.status(HttpStatus.GONE).cacheControl(CacheControl.noStore())
+ .header("Link", "; rel=\"successor-version\"")
+ .body(ProblemDetail.forStatusAndDetail(HttpStatus.GONE, "Use the paginated moderation queue endpoint. Refresh the application to load the current review interface."));
}
@GetMapping("/projects/{id}/review-details")
@@ -63,41 +68,41 @@ public ResponseEntity getProjectById(@PathVariable String id) {
@PutMapping("/projects/{id}/raw")
@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_RAW_EDIT', authentication)")
- public ResponseEntity updateRawProject(@PathVariable String id, @RequestBody Project updatedProject) {
+ public ResponseEntity updateRawProject(@PathVariable String id, @RequestBody java.util.Map metadata, @RequestHeader("If-Match") String token) {
User currentUser = accountService.requireCurrentUser("editing raw project data");
- projectAdminOperationsService.updateRawProject(currentUser.getId(), id, updatedProject);
+ projectAdminOperationsService.updateRawProject(currentUser.getId(), id, metadata, token);
return ResponseEntity.ok().build();
}
@PostMapping("/projects/{id}/publish")
@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)")
- public ResponseEntity publishProject(@PathVariable String id) {
+ public ResponseEntity publishProject(@PathVariable String id, @RequestHeader("If-Match") String reviewToken, @RequestParam(required = false) String versionId) {
User currentUser = accountService.requireCurrentUser("publishing projects");
- projectReviewAdminService.publishProject(currentUser, id);
+ projectReviewAdminService.publishProject(currentUser, id, reviewToken, versionId);
return ResponseEntity.ok().build();
}
@PostMapping("/projects/{id}/versions/{versionId}/approve")
@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)")
- public ResponseEntity approveVersion(@PathVariable String id, @PathVariable String versionId) {
+ public ResponseEntity approveVersion(@PathVariable String id, @PathVariable String versionId, @RequestHeader("If-Match") String reviewToken) {
User currentUser = accountService.requireCurrentUser("approving project versions");
- projectReviewAdminService.approveVersion(currentUser, id, versionId);
+ projectReviewAdminService.approveVersion(currentUser, id, versionId, reviewToken);
return ResponseEntity.ok().build();
}
@PostMapping("/projects/{id}/versions/{versionId}/reject")
@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)")
- public ResponseEntity rejectVersion(@PathVariable String id, @PathVariable String versionId, @Valid @RequestBody RejectReasonRequest requestPayload) {
+ public ResponseEntity rejectVersion(@PathVariable String id, @PathVariable String versionId, @Valid @RequestBody RejectReasonRequest requestPayload, @RequestHeader("If-Match") String reviewToken) {
User currentUser = accountService.requireCurrentUser("rejecting project versions");
- projectReviewAdminService.rejectVersion(currentUser, id, versionId, requestPayload.getReason());
+ projectReviewAdminService.rejectVersion(currentUser, id, versionId, requestPayload.getReason(), reviewToken);
return ResponseEntity.ok().build();
}
@PostMapping("/projects/{id}/reject")
@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_DECIDE', authentication)")
- public ResponseEntity rejectProject(@PathVariable String id, @Valid @RequestBody RejectReasonRequest requestPayload) {
+ public ResponseEntity rejectProject(@PathVariable String id, @Valid @RequestBody RejectReasonRequest requestPayload, @RequestHeader("If-Match") String reviewToken) {
User currentUser = accountService.requireCurrentUser("rejecting projects");
- projectReviewAdminService.rejectProject(currentUser, id, requestPayload.getReason());
+ projectReviewAdminService.rejectProject(currentUser, id, requestPayload.getReason(), reviewToken);
return ResponseEntity.ok().build();
}
diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java
new file mode 100644
index 000000000..1b7889b48
--- /dev/null
+++ b/backend/src/main/java/net/modtale/controller/admin/ReviewCancellationController.java
@@ -0,0 +1,31 @@
+package net.modtale.controller.admin;
+
+import net.modtale.service.admin.review.*;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/admin/verification/cancellations")
+@ConditionalOnProperty(name="app.warden.repair.cancellation.enabled",havingValue="true")
+@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication) and @apiSecurity.hasAdminPermission('PROJECT_VERSION_RESCAN', authentication)")
+public class ReviewCancellationController {
+ public record Prepare(String isolationId) {}
+ private final ReviewCancellationAccess access;
+ public ReviewCancellationController(ReviewCancellationAccess access){this.access=access;}
+ @GetMapping("/capabilities") public ResponseEntity capabilities(){return response(access.capability());}
+ @GetMapping("/targets/{id}") public ResponseEntity preview(@PathVariable String id){return response(access.preview(id));}
+ @GetMapping("/operations/{id}") public ResponseEntity recover(@PathVariable String id){return response(access.recover(id));}
+ @PostMapping("/prepare") public ResponseEntity prepare(@RequestBody Prepare request){return response(access.prepare(request==null?null:request.isolationId()));}
+ @PostMapping("/execute") public ResponseEntity execute(@RequestBody ReviewOrphanCancellationJournal.Prepared request){return response(access.execute(request));}
+ @PostMapping("/receipt") public ResponseEntity receipt(@RequestBody ReviewOrphanCancellationJournal.Prepared request){return response(access.receipt(request));}
+ @PostMapping("/checks") public ResponseEntity check(@RequestBody ReviewCancellationAccess.Check request){return response(access.check(request));}
+ @PostMapping("/checks/receipt") public ResponseEntity checkReceipt(@RequestBody ReviewCancellationAccess.Check request){return response(access.checkReceipt(request));}
+ @PostMapping("/checks/history") public ResponseEntity history(@RequestBody ReviewCancellationAccess.History request){return response(access.history(request));}
+ private static ResponseEntity response(T value){return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value);}
+ @ExceptionHandler(SecurityException.class) ResponseEntity forbidden(){return ResponseEntity.status(HttpStatus.FORBIDDEN).cacheControl(CacheControl.noStore()).build();}
+ @ExceptionHandler({IllegalArgumentException.class,org.springframework.http.converter.HttpMessageNotReadableException.class}) ResponseEntity invalid(){return ResponseEntity.badRequest().cacheControl(CacheControl.noStore()).build();}
+ @ExceptionHandler(IllegalStateException.class) ResponseEntity conflict(){return ResponseEntity.status(HttpStatus.CONFLICT).cacheControl(CacheControl.noStore()).build();}
+ @ExceptionHandler(com.mongodb.MongoException.class) ResponseEntity unavailable(){return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).cacheControl(CacheControl.noStore()).build();}
+}
diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java
new file mode 100644
index 000000000..23d94eb62
--- /dev/null
+++ b/backend/src/main/java/net/modtale/controller/admin/ReviewOriginController.java
@@ -0,0 +1,38 @@
+package net.modtale.controller.admin;
+
+import java.util.List;
+import net.modtale.service.admin.review.*;
+import net.modtale.service.security.scan.RemoteReviewDiscovery;
+import org.bson.types.ObjectId;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+
+@RestController
+@RequestMapping("/api/v1/admin/verification/origins")
+public class ReviewOriginController {
+ public record Position(String projectIdType,String projectId,int versionIndex) {}
+ public record Item(Position position,String versionId,boolean ambiguousVersion,String requestId,String jobId,ReviewOriginInventory.OriginState originState) {}
+ public record Page(List- items,String nextCursor,int examinedSlots,String scope) {}
+ private final ReviewOriginInventory inventory;
+ public ReviewOriginController(ReviewOriginInventory inventory){this.inventory=inventory;}
+ @GetMapping("/page")
+ @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)")
+ public ResponseEntity read(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit) {
+ RemoteReviewDiscovery.Cursor position;
+ try {
+ if(limit<1 || limit>64)throw new IllegalArgumentException();
+ position=ReviewOriginCursor.decode(cursor);
+ }catch(IllegalArgumentException invalid){throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid origin pagination");}
+ ReviewOriginInventory.Page page;
+ try {page=inventory.page(position,limit);}
+ catch(RuntimeException unavailable){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Review origin inventory unavailable");}
+ var items=page.items().stream().map(item->{
+ boolean oid=item.projectId() instanceof ObjectId;
+ return new Item(new Position(oid?"OBJECT_ID":"STRING",oid?((ObjectId)item.projectId()).toHexString():(String)item.projectId(),item.versionIndex()),
+ item.versionId(),item.ambiguousVersion(),item.requestId(),item.jobId(),item.originState());
+ }).toList();
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(new Page(items,ReviewOriginCursor.encode(page.next()),page.examined(),"RETAINED_REVIEW_ORIGINS"));
+ }
+}
diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java
new file mode 100644
index 000000000..486e64783
--- /dev/null
+++ b/backend/src/main/java/net/modtale/controller/admin/ReviewRepairController.java
@@ -0,0 +1,29 @@
+package net.modtale.controller.admin;
+
+import net.modtale.service.admin.review.*;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/v1/admin/verification/repairs")
+@ConditionalOnProperty(name="app.warden.repair.enabled",havingValue="true")
+@PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication) and @apiSecurity.hasAdminPermission('PROJECT_VERSION_RESCAN', authentication)")
+public class ReviewRepairController {
+ private final ReviewRepairAccess access;
+ public ReviewRepairController(ReviewRepairAccess access){this.access=access;}
+ @GetMapping("/capabilities") public ResponseEntity capabilities(){return response(access.capability());}
+ @GetMapping("/operations") public ResponseEntity operations(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit){return response(access.operations(cursor,limit));}
+ @GetMapping("/operations/{id}") public ResponseEntity recover(@PathVariable String id){return response(access.recover(id));}
+ @PostMapping("/inspect") public ResponseEntity inspect(@RequestBody ReviewRepairAccess.Inspect request){return response(access.inspect(request));}
+ @PostMapping("/prepare") public ResponseEntity prepare(@RequestBody ReviewRepairAccess.Prepare request){return response(access.prepare(request));}
+ @PostMapping("/execute") public ResponseEntity execute(@RequestBody ReviewRepairPreparation.Prepared request){return response(access.execute(request));}
+ @PostMapping("/close-expired") public ResponseEntity closeExpired(@RequestBody ReviewRepairPreparation.Prepared request){return response(access.closeExpired(request));}
+ @PostMapping("/receipt") public ResponseEntity receipt(@RequestBody ReviewRepairPreparation.Prepared request){return response(access.receipt(request));}
+ private static ResponseEntity response(T value){return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value);}
+ @ExceptionHandler(SecurityException.class) ResponseEntity forbidden(){return ResponseEntity.status(HttpStatus.FORBIDDEN).cacheControl(CacheControl.noStore()).build();}
+ @ExceptionHandler({IllegalArgumentException.class,org.springframework.http.converter.HttpMessageNotReadableException.class}) ResponseEntity invalid(){return ResponseEntity.badRequest().cacheControl(CacheControl.noStore()).build();}
+ @ExceptionHandler(IllegalStateException.class) ResponseEntity conflict(){return ResponseEntity.status(HttpStatus.CONFLICT).cacheControl(CacheControl.noStore()).build();}
+ @ExceptionHandler(com.mongodb.MongoException.class) ResponseEntity unavailable(){return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).cacheControl(CacheControl.noStore()).build();}
+}
diff --git a/backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java b/backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java
new file mode 100644
index 000000000..94c08458c
--- /dev/null
+++ b/backend/src/main/java/net/modtale/controller/admin/ReviewStateDiagnosticController.java
@@ -0,0 +1,35 @@
+package net.modtale.controller.admin;
+
+import java.util.List;
+import net.modtale.service.admin.review.*;
+import net.modtale.service.security.scan.RemoteReviewDiscovery;
+import org.bson.types.ObjectId;
+import org.springframework.http.*;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+
+@RestController
+@RequestMapping("/api/v1/admin/verification/diagnostics")
+public class ReviewStateDiagnosticController {
+ public record Position(String projectIdType,String projectId,int versionIndex) {}
+ public record Item(Position position,String versionId,List reasons) {}
+ public record Page(List
- items,String nextCursor,int examinedSlots,String scope) {}
+ private final ReviewStateDiagnosticReader reader;
+ public ReviewStateDiagnosticController(ReviewStateDiagnosticReader reader){this.reader=reader;}
+ @GetMapping("/page")
+ @PreAuthorize("@apiSecurity.hasAdminPermission('PROJECT_REVIEW_READ', authentication)")
+ public ResponseEntity read(@RequestParam(required=false) String cursor,@RequestParam(defaultValue="25") int limit) {
+ RemoteReviewDiscovery.Cursor position;
+ try {
+ if(limit<1 || limit>64)throw new IllegalArgumentException();
+ position=ReviewStateDiagnosticCursor.decode(cursor);
+ } catch(IllegalArgumentException invalid) {throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Invalid diagnostic pagination");}
+ var page=reader.page(position,limit);
+ var items=page.items().stream().map(item->{
+ Object id=item.projectId();boolean objectId=id instanceof ObjectId;
+ return new Item(new Position(objectId?"OBJECT_ID":"STRING",objectId?((ObjectId)id).toHexString():(String)id,item.versionIndex()),item.versionId(),item.reasons());
+ }).toList();
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(new Page(items,ReviewStateDiagnosticCursor.encode(page.next()),page.examined(),"PENDING_SCAN_STRUCTURE"));
+ }
+}
diff --git a/backend/src/main/java/net/modtale/controller/project/VersionController.java b/backend/src/main/java/net/modtale/controller/project/VersionController.java
index e9272c3df..d659d90fc 100644
--- a/backend/src/main/java/net/modtale/controller/project/VersionController.java
+++ b/backend/src/main/java/net/modtale/controller/project/VersionController.java
@@ -213,11 +213,6 @@ public ResponseEntity downloadBundleWithToken(
}
private ResponseEntity asDownloadResponse(VersionDownloadPayload payload) {
- if (payload.redirectUri() != null) {
- return ResponseEntity.status(302).location(payload.redirectUri())
- .cacheControl(org.springframework.http.CacheControl.noStore())
- .header("Referrer-Policy", "no-referrer").build();
- }
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + payload.filename() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
diff --git a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java
index 26d7eb781..c823b9694 100644
--- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java
+++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java
@@ -196,7 +196,8 @@ public static AdminProjectDTO toAdminDTO(Project project) {
project.getProjectRoles(),
project.getTeamMembers(),
project.getTeamInvites(),
- toAdminVersionSummaryDTOs(project.getVersions())
+ toAdminVersionSummaryDTOs(project.getVersions()),
+ net.modtale.service.admin.review.ProjectReviewSnapshot.token(project)
);
}
@@ -321,7 +322,7 @@ public static ProjectVersionDTO toVersionDTO(ProjectVersion version, boolean inc
dto.setId(version.getId());
dto.setVersionNumber(version.getVersionNumber());
dto.setGameVersions(version.getGameVersions());
- dto.setFileUrl(version.getFileUrl());
+ dto.setFileName(displayArtifactName(version.getFileUrl()));
dto.setDownloadCount(version.getDownloadCount());
dto.setReleaseDate(version.getReleaseDate());
if (includeChangelog) {
@@ -367,7 +368,8 @@ public static AdminProjectVersionSummaryDTO toAdminVersionSummaryDTO(ProjectVers
version.getChannel(),
version.getReviewStatus(),
version.getRejectionReason(),
- version.getScanResult()
+ version.getScanResult(),
+ net.modtale.service.admin.review.VersionReviewSnapshot.token(version)
);
}
@@ -388,6 +390,11 @@ public static AdminVerificationQueueItemDTO toVerificationQueueItemDTO(Project p
.findFirst()
.orElseGet(() -> project.getVersions().stream().filter(java.util.Objects::nonNull).findFirst().orElse(null));
+ return toVerificationQueueItemDTO(project, pendingVersion);
+ }
+
+ public static AdminVerificationQueueItemDTO toVerificationQueueItemDTO(Project project, ProjectVersion pendingVersion) {
+ if (project == null) return null;
return new AdminVerificationQueueItemDTO(
project.getId(),
project.getTitle(),
@@ -417,6 +424,9 @@ private static AdminVerificationQueueScanDTO toVerificationQueueScanDTO(ScanResu
return new AdminVerificationQueueScanDTO(
scanResult.getStatus(),
scanResult.getVerdict(),
+ scanResult.getScanState(),
+ scanResult.getSecurityEvidence() == null ? null : scanResult.getSecurityEvidence().reviewState(),
+ false,
scanResult.getRiskScore(),
scanResult.getKnownIssueCount(),
scanResult.getNewIssueCount(),
@@ -437,7 +447,6 @@ public static ProjectDependencyDTO toDependencyDTO(ProjectDependency dependency)
dependency.getExternalUrl(),
dependency.getExternalFileUrl(),
dependency.getExternalFileName(),
- dependency.getCachedFileUrl(),
dependency.isHytaleProjectConfirmed(),
dependency.getIcon(),
dependency.getTitle() != null ? dependency.getTitle() : dependency.getProjectTitle(),
@@ -454,4 +463,12 @@ public static List toDependencyDTOs(List projectRoles,
List teamMembers,
List teamInvites,
- List versions
+ List versions,
+ String reviewToken
) {}
diff --git a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java
index 0a0f60779..6374806f2 100644
--- a/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java
+++ b/backend/src/main/java/net/modtale/model/dto/admin/AdminProjectVersionSummaryDTO.java
@@ -15,5 +15,6 @@ public record AdminProjectVersionSummaryDTO(
ProjectVersion.Channel channel,
ProjectVersion.ReviewStatus reviewStatus,
String rejectionReason,
- ScanResult scanResult
+ ScanResult scanResult,
+ String reviewToken
) {}
diff --git a/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java b/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java
index fa1ff4ab9..c1dc56d9d 100644
--- a/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java
+++ b/backend/src/main/java/net/modtale/model/dto/admin/AdminVerificationQueueScanDTO.java
@@ -7,6 +7,9 @@
public record AdminVerificationQueueScanDTO(
ScanStatus status,
String verdict,
+ String scanState,
+ String reviewState,
+ boolean serviceAttention,
int riskScore,
int knownIssueCount,
int newIssueCount,
diff --git a/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java
index 31dc0282f..875823772 100644
--- a/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java
+++ b/backend/src/main/java/net/modtale/model/dto/project/ProjectDependencyDTO.java
@@ -16,7 +16,6 @@ public record ProjectDependencyDTO(
String externalUrl,
String externalFileUrl,
String externalFileName,
- String cachedFileUrl,
boolean hytaleProjectConfirmed,
String icon,
String title,
diff --git a/backend/src/main/java/net/modtale/model/dto/project/ProjectVersionDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ProjectVersionDTO.java
index 1dcbb7c07..4194ac851 100644
--- a/backend/src/main/java/net/modtale/model/dto/project/ProjectVersionDTO.java
+++ b/backend/src/main/java/net/modtale/model/dto/project/ProjectVersionDTO.java
@@ -15,7 +15,7 @@ public class ProjectVersionDTO {
private String id;
private String versionNumber;
private List gameVersions;
- private String fileUrl;
+ private String fileName;
private int downloadCount;
private String releaseDate;
private String changelog;
@@ -29,8 +29,8 @@ public class ProjectVersionDTO {
public void setVersionNumber(String versionNumber) { this.versionNumber = versionNumber; }
public List getGameVersions() { return gameVersions; }
public void setGameVersions(List gameVersions) { this.gameVersions = gameVersions; }
- public String getFileUrl() { return fileUrl; }
- public void setFileUrl(String fileUrl) { this.fileUrl = fileUrl; }
+ public String getFileName() { return fileName; }
+ public void setFileName(String fileName) { this.fileName = fileName; }
public int getDownloadCount() { return downloadCount; }
public void setDownloadCount(int downloadCount) { this.downloadCount = downloadCount; }
public String getReleaseDate() { return releaseDate; }
diff --git a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java
index 8fe7cb1e9..c0bb944f7 100644
--- a/backend/src/main/java/net/modtale/model/project/ProjectVersion.java
+++ b/backend/src/main/java/net/modtale/model/project/ProjectVersion.java
@@ -3,6 +3,49 @@
import java.util.List;
public class ProjectVersion {
+ private ReviewReplacement versionMutation;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public ReviewReplacement getVersionMutation() { return versionMutation; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setVersionMutation(ReviewReplacement value) { versionMutation=value; }
+ private RemoteReviewBinding retainedRemoteReview;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public RemoteReviewBinding getRetainedRemoteReview() { return retainedRemoteReview; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setRetainedRemoteReview(RemoteReviewBinding value) { retainedRemoteReview=value; }
+ private String replacementSecurityHold;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public String getReplacementSecurityHold() { return replacementSecurityHold; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setReplacementSecurityHold(String value) { replacementSecurityHold=value; }
+ private ReviewReplacement reviewReplacement;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public ReviewReplacement getReviewReplacement() { return reviewReplacement; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setReviewReplacement(ReviewReplacement value) { reviewReplacement=value; }
+ public record ReviewReplacement(String operationId,String beforeSha256,String requestId) {
+ public ReviewReplacement {
+ if(operationId==null || !operationId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+ || requestId==null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+ || beforeSha256==null || !beforeSha256.matches("[0-9a-f]{64}"))throw new IllegalArgumentException("Invalid replacement provenance");
+ }
+ }
+ private ReviewIsolation reviewIsolation;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public ReviewIsolation getReviewIsolation() { return reviewIsolation; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setReviewIsolation(ReviewIsolation value) { reviewIsolation = value; }
+ public record ReviewIsolation(String operationId, String actorId, String beforeSha256, java.util.Date isolatedAt) {
+ public ReviewIsolation {
+ if (operationId == null || !operationId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+ || actorId == null || actorId.isBlank() || actorId.length() > 256
+ || beforeSha256 == null || !beforeSha256.matches("[0-9a-f]{64}") || isolatedAt == null)
+ throw new IllegalArgumentException("Invalid review isolation provenance");
+ isolatedAt = new java.util.Date(isolatedAt.getTime());
+ }
+ @Override public java.util.Date isolatedAt() { return new java.util.Date(isolatedAt.getTime()); }
+ }
+
private List modpackConfigs;
public List getModpackConfigs() { return modpackConfigs; }
public void setModpackConfigs(List value) { modpackConfigs = value; }
@@ -22,8 +65,29 @@ public class ProjectVersion {
private List incompatibleProjectIds;
private Channel channel;
+ private ScanResult.SecurityEvidence approvedSecurityEvidence;
+ private long securityApprovedAt;
+ private String approvedSecurityContextSha256;
+ public String getApprovedSecurityContextSha256() { return approvedSecurityContextSha256; }
+ public void setApprovedSecurityContextSha256(String value) { approvedSecurityContextSha256 = value; }
+ public ScanResult.SecurityEvidence getApprovedSecurityEvidence() { return approvedSecurityEvidence; }
+ public void setApprovedSecurityEvidence(ScanResult.SecurityEvidence value) { approvedSecurityEvidence = value; }
+ public long getSecurityApprovedAt() { return securityApprovedAt; }
+ public void setSecurityApprovedAt(long value) { securityApprovedAt = value; }
private ScanResult scanResult;
private List approvedIssueBaselines;
+ private String securityApprovalProjectId;
+ public String getSecurityApprovalProjectId() { return securityApprovalProjectId; }
+ public void setSecurityApprovalProjectId(String value) { securityApprovalProjectId = value; }
+ private java.util.Map approvedReviewOrigins;
+ public java.util.Map getApprovedReviewOrigins() { return approvedReviewOrigins; }
+ public void setApprovedReviewOrigins(java.util.Map value) { approvedReviewOrigins = value; }
+ private String approvedFindingReviewHead;
+ public String getApprovedFindingReviewHead() { return approvedFindingReviewHead; }
+ public void setApprovedFindingReviewHead(String value) { approvedFindingReviewHead = value; }
+ private String findingReviewHead;
+ public String getFindingReviewHead() { return findingReviewHead; }
+ public void setFindingReviewHead(String value) { findingReviewHead = value; }
private ReviewStatus reviewStatus = ReviewStatus.PENDING;
private String rejectionReason;
@@ -36,6 +100,8 @@ public enum ReviewStatus { PENDING, SCHEDULED, APPROVED, REJECTED }
public static class ApprovedIssueBaseline {
private String fingerprint;
private String looseFingerprint;
+ private String evidenceIdentity;
+ private String reasoningEvidenceIdentity;
private String severity;
private int scoreImpact;
private int confidence;
@@ -59,6 +125,13 @@ public ApprovedIssueBaseline(
this.approvedAt = approvedAt;
}
+ @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
+ public String getReasoningEvidenceIdentity() { return reasoningEvidenceIdentity; }
+ public void setReasoningEvidenceIdentity(String value) { reasoningEvidenceIdentity = value; }
+
+ public String getEvidenceIdentity() { return evidenceIdentity; }
+ public void setEvidenceIdentity(String value) { evidenceIdentity = value; }
+
public String getFingerprint() { return fingerprint; }
public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; }
diff --git a/backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java b/backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java
new file mode 100644
index 000000000..4c3274f08
--- /dev/null
+++ b/backend/src/main/java/net/modtale/model/project/RemoteReviewBinding.java
@@ -0,0 +1,29 @@
+package net.modtale.model.project;
+
+public record RemoteReviewBinding(String projectId, String versionId, String requestId, int attempt,
+ String filePath, String artifactSha256, String contextSha256, String policyVersion,
+ String reviewConfigSha256, String jobId, boolean manualRescan, RemoteReviewOrigin origin) {
+ public RemoteReviewBinding(String projectId,String versionId,String requestId,int attempt,String filePath,String artifactSha256,
+ String contextSha256,String policyVersion,String reviewConfigSha256,String jobId) {
+ this(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,jobId,false);
+ }
+ // Legacy records remain readable, but their missing origin cannot authorize remote requests.
+ public RemoteReviewBinding(String projectId,String versionId,String requestId,int attempt,String filePath,String artifactSha256,
+ String contextSha256,String policyVersion,String reviewConfigSha256,String jobId,boolean manualRescan) {
+ this(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,jobId,manualRescan,null);
+ }
+ public RemoteReviewBinding {
+ if (!text(projectId,128) || !text(versionId,128) || !uuid(requestId) || attempt < 1
+ || !text(filePath,4096) || !digest(artifactSha256) || !digest(contextSha256)
+ || policyVersion == null || !policyVersion.matches("warden-3\\.0\\.0:[0-9a-f]{64}")
+ || !digest(reviewConfigSha256) || jobId != null && !uuid(jobId))
+ throw new IllegalArgumentException("Invalid remote review binding");
+ }
+ public RemoteReviewBinding withJobId(String value) {
+ if (!uuid(value) || jobId != null && !jobId.equals(value)) throw new IllegalArgumentException("Remote job identity cannot change");
+ return new RemoteReviewBinding(projectId,versionId,requestId,attempt,filePath,artifactSha256,contextSha256,policyVersion,reviewConfigSha256,value,manualRescan,origin);
+ }
+ private static boolean text(String value,int max) { return value != null && !value.isBlank() && value.length() <= max && value.chars().noneMatch(Character::isISOControl); }
+ private static boolean digest(String value) { return value != null && value.matches("[0-9a-f]{64}"); }
+ private static boolean uuid(String value) { return value != null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); }
+}
diff --git a/backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java b/backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java
new file mode 100644
index 000000000..97517cac1
--- /dev/null
+++ b/backend/src/main/java/net/modtale/model/project/RemoteReviewOrigin.java
@@ -0,0 +1,8 @@
+package net.modtale.model.project;
+
+public record RemoteReviewOrigin(String deploymentId,String callerScope) {
+ public RemoteReviewOrigin {
+ if(deploymentId==null || !deploymentId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
+ || callerScope==null || !callerScope.matches("[0-9a-f]{64}"))throw new IllegalArgumentException("Invalid remote review origin");
+ }
+}
diff --git a/backend/src/main/java/net/modtale/model/project/ScanResult.java b/backend/src/main/java/net/modtale/model/project/ScanResult.java
index 617c47ff7..c4f4500b9 100644
--- a/backend/src/main/java/net/modtale/model/project/ScanResult.java
+++ b/backend/src/main/java/net/modtale/model/project/ScanResult.java
@@ -6,6 +6,56 @@
@JsonIgnoreProperties(ignoreUnknown = true)
public class ScanResult {
+ private boolean manualRescan;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public boolean isManualRescan() { return manualRescan; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setManualRescan(boolean value) { manualRescan = value; }
+ private RemoteReviewStatus remoteStatus;
+ public record RemoteReviewStatus(String jobId,String state,boolean artifactRetained,long createdAt,long expiresAt,String workState) {}
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public RemoteReviewStatus getRemoteStatus() { return remoteStatus; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setRemoteStatus(RemoteReviewStatus value) { remoteStatus = value; }
+ private RemoteReviewPoll remotePoll;
+ public record RemoteReviewPoll(String token, java.util.Date leaseUntil, java.util.Date nextPollAt) {}
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public RemoteReviewPoll getRemotePoll() { return remotePoll; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setRemotePoll(RemoteReviewPoll value) { remotePoll = value; }
+ private RemoteReviewBinding remoteReview;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public RemoteReviewBinding getRemoteReview() { return remoteReview; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setRemoteReview(RemoteReviewBinding value) { remoteReview = value; }
+ private SecurityEvidence securityEvidence;
+ private boolean artifactVerified;
+ private String reviewedContextSha256;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public String getReviewedContextSha256() { return reviewedContextSha256; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setReviewedContextSha256(String value) { reviewedContextSha256 = value; }
+ private java.util.Map reusedReviewOrigins;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public java.util.Map getReusedReviewOrigins() { return reusedReviewOrigins; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setReusedReviewOrigins(java.util.Map value) { reusedReviewOrigins = value; }
+ private long reusedReviewApprovedAt;
+ public long getReusedReviewApprovedAt() { return reusedReviewApprovedAt; }
+ public void setReusedReviewApprovedAt(long value) { reusedReviewApprovedAt = value; }
+ private String reusedReviewVersion;
+ public SecurityEvidence getSecurityEvidence() { return securityEvidence; }
+ public void setSecurityEvidence(SecurityEvidence value) { securityEvidence = value; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public boolean isArtifactVerified() { return artifactVerified; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setArtifactVerified(boolean value) { artifactVerified = value; }
+ public String getReusedReviewVersion() { return reusedReviewVersion; }
+ public void setReusedReviewVersion(String value) { reusedReviewVersion = value; if (value == null) reusedReviewOrigins = null; }
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record SecurityEvidence(String policyVersion, String artifactSha256, String contentSha256,
+ boolean complete, boolean clearanceGranted, String reviewState, java.util.Map entryHashes) {}
+
private ScanStatus status;
private String verdict;
private String riskLevel;
@@ -14,6 +64,11 @@ public class ScanResult {
private int riskScore;
private int confidenceScore;
private int scanAttempt;
+ private String scanRequestId;
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public String getScanRequestId() { return scanRequestId; }
+ @com.fasterxml.jackson.annotation.JsonIgnore
+ public void setScanRequestId(String value) { scanRequestId = value; }
private long scanTimestamp;
private long holdUntilTimestamp;
@@ -204,6 +259,7 @@ public static class ScanIssue {
private boolean resolved;
private String fingerprint;
private boolean knownIssue;
+ private boolean historicalFileEvidenceIdentical;
private boolean escalated;
private String baselineVersion;
private int baselineScoreImpact;
@@ -259,6 +315,9 @@ public ScanIssue() {}
public String getFingerprint() { return fingerprint; }
public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; }
+ public boolean isHistoricalFileEvidenceIdentical() { return historicalFileEvidenceIdentical; }
+ public void setHistoricalFileEvidenceIdentical(boolean value) { historicalFileEvidenceIdentical = value; }
+
public boolean isKnownIssue() { return knownIssue; }
public void setKnownIssue(boolean knownIssue) { this.knownIssue = knownIssue; }
diff --git a/backend/src/main/java/net/modtale/model/project/SecurityManifest.java b/backend/src/main/java/net/modtale/model/project/SecurityManifest.java
new file mode 100644
index 000000000..7a2743770
--- /dev/null
+++ b/backend/src/main/java/net/modtale/model/project/SecurityManifest.java
@@ -0,0 +1,44 @@
+package net.modtale.model.project;
+
+import java.nio.charset.StandardCharsets;
+import java.security.*;
+import java.util.*;
+import java.util.regex.Pattern;
+
+/** Shared bounds and identity rules for artifact entry manifests. */
+public final class SecurityManifest {
+ private static final Pattern SHA256 = Pattern.compile("[0-9a-f]{64}");
+ private SecurityManifest() {}
+ public static final class Unavailable extends IllegalStateException {
+ public Unavailable(Throwable cause) { super("Artifact manifest is unavailable or invalid", cause); }
+ }
+ public static boolean valid(Map entries, boolean allowEmpty) {
+ try { return validLoaded(entries, allowEmpty); }
+ catch (Unavailable unavailable) { return false; }
+ }
+ private static boolean validLoaded(Map entries, boolean allowEmpty) {
+ if (entries == null || entries.isEmpty()) return allowEmpty;
+ if (entries.size() > 20_000) return false;
+ int characters = 0;
+ for (var entry : entries.entrySet()) {
+ String path = entry.getKey();
+ if (path == null || path.isBlank() || path.length() > 8192 || !digest(entry.getValue())) return false;
+ characters += path.length();
+ if (characters > 1_000_000) return false;
+ }
+ return true;
+ }
+ public static boolean digest(String value) { return value != null && SHA256.matcher(value).matches(); }
+ public static String identity(Map entries) {
+ if (!valid(entries, false)) throw new IllegalArgumentException("Invalid artifact manifest");
+ try {
+ MessageDigest hash = MessageDigest.getInstance("SHA-256");
+ for (var entry : new TreeMap<>(entries).entrySet()) {
+ hash.update((entry.getKey().length() + ":").getBytes(StandardCharsets.UTF_8));
+ hash.update(entry.getKey().getBytes(StandardCharsets.UTF_8));
+ hash.update((":" + entry.getValue() + "\n").getBytes(StandardCharsets.UTF_8));
+ }
+ return HexFormat.of().formatHex(hash.digest());
+ } catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
+ }
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java
index 9c3d3dcc7..98f6ce29b 100644
--- a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java
+++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminOperationsService.java
@@ -25,8 +25,8 @@ public AdminProjectDTO getProjectById(String id) {
return projectAdminQueryService.getProjectById(id);
}
- public void updateRawProject(String adminId, String id, Project updatedProject) {
- projectAdminQueryService.updateRawProject(adminId, id, updatedProject);
+ public void updateRawProject(String adminId, String id, java.util.Map metadata, String token) {
+ projectAdminQueryService.updateRawProject(adminId, id, metadata, token);
}
public void deleteProject(net.modtale.model.user.User adminUser, String id, String reason) {
diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java
index 5a708476d..f5fc31235 100644
--- a/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java
+++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectAdminQueryService.java
@@ -8,7 +8,8 @@
import net.modtale.model.project.Project;
import net.modtale.model.project.ProjectSort;
import net.modtale.model.project.ProjectViewCategory;
-import net.modtale.repository.project.ProjectRepository;
+import net.modtale.service.admin.review.ProjectReviewPersistence;
+import net.modtale.service.admin.review.ProjectReviewSnapshot;
import net.modtale.service.admin.audit.AdminAuditLogger;
import net.modtale.service.project.query.ProjectService;
import net.modtale.service.project.query.SearchService;
@@ -18,18 +19,18 @@
@Service
public class ProjectAdminQueryService {
- private final ProjectRepository projectRepository;
+ private final ProjectReviewPersistence reviewPersistence;
private final ProjectService projectService;
private final SearchService searchService;
private final AdminAuditLogger adminAuditLogger;
public ProjectAdminQueryService(
- ProjectRepository projectRepository,
+ ProjectReviewPersistence reviewPersistence,
ProjectService projectService,
SearchService searchService,
AdminAuditLogger adminAuditLogger
) {
- this.projectRepository = projectRepository;
+ this.reviewPersistence = reviewPersistence;
this.projectService = projectService;
this.searchService = searchService;
this.adminAuditLogger = adminAuditLogger;
@@ -43,12 +44,19 @@ public AdminProjectDTO getProjectById(String id) {
return ProjectMapper.toAdminDTO(project);
}
- public void updateRawProject(String adminId, String id, Project updatedProject) {
- Project existing = requireProject(id);
- updatedProject.setId(existing.getId());
- projectRepository.save(updatedProject);
- projectService.evictProjectDetailsCaches(List.of(existing, updatedProject), List.of());
- adminAuditLogger.logAction(adminId, "RAW_UPDATE_PROJECT", existing.getId(), "PROJECT", "Updated via Raw JSON");
+ public void updateRawProject(String adminId, String id, java.util.Map metadata, String token) {
+ ProjectMetadataRepair.validate(metadata);
+ var snapshot = reviewPersistence.capture(id, token);
+ if (!reviewPersistence.applyMetadataRepair(snapshot, metadata)) throw ProjectReviewSnapshot.conflict();
+ // Conditional repair writes do not mutate the captured project. Invalidate both title-based handles.
+ Project updatedRoute = new Project();
+ updatedRoute.setId(snapshot.project().getId());
+ updatedRoute.setTitle(metadata.containsKey("title") ? (String) metadata.get("title") : snapshot.project().getTitle());
+ updatedRoute.setSlug(snapshot.project().getSlug());
+ updatedRoute.setClassification(snapshot.project().getClassification());
+ projectService.evictProjectDetailsCaches(List.of(snapshot.project(), updatedRoute), List.of());
+ adminAuditLogger.logAction(adminId, "RAW_UPDATE_PROJECT", id, "PROJECT",
+ "Repaired metadata fields: " + String.join(", ", new java.util.TreeSet<>(metadata.keySet())));
}
public List searchProjects(String query, boolean deleted) {
diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java
new file mode 100644
index 000000000..0b0b82b6f
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectMetadataRepair.java
@@ -0,0 +1,53 @@
+package net.modtale.service.admin.project;
+
+import java.util.*;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+
+public final class ProjectMetadataRepair {
+ private ProjectMetadataRepair() {}
+ private static final Set STRINGS = Set.of("title", "about", "description", "imageUrl", "bannerUrl",
+ "repositoryUrl", "license", "hmWikiSlug");
+ private static final Set FLAGS = Set.of("customLicenseOpenSource", "allowModpacks", "allowComments",
+ "hmWikiEnabled", "galleryCarouselEnabled");
+ private static final Set LISTS = Set.of("tags", "galleryImages");
+ private static final Set MAPS = Set.of("links", "galleryImageCaptions");
+ public static void validate(Map metadata) {
+ if (metadata == null || metadata.isEmpty() || metadata.size() > 17) throw invalid();
+ long characters = 0;
+ for (var entry : metadata.entrySet()) {
+ String field = entry.getKey(); Object value = entry.getValue();
+ if (field == null) throw invalid();
+ if (STRINGS.contains(field)) {
+ if (value != null && !(value instanceof String)) throw invalid();
+ if (field.equals("title") && (!(value instanceof String text) || text.isBlank())) throw invalid();
+ characters += textSize(value, field.equals("description") ? 100_000 : 4_096);
+ } else if (FLAGS.contains(field)) {
+ if (!(value instanceof Boolean)) throw invalid();
+ } else if (LISTS.contains(field)) {
+ if (!(value instanceof List> values) || values.size() > 1000) throw invalid();
+ for (Object item : values) {
+ if (!(item instanceof String)) throw invalid();
+ characters += textSize(item, 4096);
+ }
+ } else if (MAPS.contains(field)) {
+ if (!(value instanceof Map, ?> values) || values.size() > 1000) throw invalid();
+ for (var pair : values.entrySet()) {
+ if (!(pair.getKey() instanceof String) || !(pair.getValue() instanceof String)) throw invalid();
+ characters += textSize(pair.getKey(), 4096) + textSize(pair.getValue(), 4096);
+ }
+ } else throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
+ "Only editable project metadata is accepted. Use the dedicated workflow for versions, access, and review decisions.");
+ if (characters > 200_000) throw invalid();
+ }
+ }
+ private static int textSize(Object value, int maximum) {
+ if (value == null) return 0;
+ int size = ((String) value).length();
+ if (size > maximum) throw invalid();
+ return size;
+ }
+ private static ResponseStatusException invalid() {
+ return new ResponseStatusException(HttpStatus.BAD_REQUEST, "Project metadata has invalid fields, types, or size.");
+ }
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java b/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java
index 204fe5de0..29af16ffb 100644
--- a/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java
+++ b/backend/src/main/java/net/modtale/service/admin/project/ProjectModerationService.java
@@ -5,7 +5,8 @@
import net.modtale.exception.ResourceNotFoundException;
import net.modtale.model.project.Project;
import net.modtale.model.project.ProjectStatus;
-import net.modtale.repository.project.ProjectRepository;
+import net.modtale.service.admin.review.ProjectReviewPersistence;
+import net.modtale.service.admin.review.ProjectReviewSnapshot;
import net.modtale.service.admin.audit.AdminAuditLogger;
import net.modtale.service.analytics.ScoringService;
import net.modtale.service.communication.NotificationService;
@@ -19,7 +20,7 @@
@Service
public class ProjectModerationService {
- private final ProjectRepository projectRepository;
+ private final ProjectReviewPersistence reviewPersistence;
private final ProjectService projectService;
private final ProjectRetentionService projectRetentionService;
private final ProjectDeletionService projectDeletionService;
@@ -30,7 +31,7 @@ public class ProjectModerationService {
private final AdminAuditLogger adminAuditLogger;
public ProjectModerationService(
- ProjectRepository projectRepository,
+ ProjectReviewPersistence reviewPersistence,
ProjectService projectService,
ProjectRetentionService projectRetentionService,
ProjectDeletionService projectDeletionService,
@@ -40,7 +41,7 @@ public ProjectModerationService(
ProjectVersionAccessService projectVersionAccessService,
AdminAuditLogger adminAuditLogger
) {
- this.projectRepository = projectRepository;
+ this.reviewPersistence = reviewPersistence;
this.projectService = projectService;
this.projectRetentionService = projectRetentionService;
this.projectDeletionService = projectDeletionService;
@@ -85,10 +86,12 @@ public void restoreProject(net.modtale.model.user.User adminUser, String id, Pro
public void unlistProject(net.modtale.model.user.User adminUser, String id, String reason) {
Project targetProject = requireProject(id);
+ var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(targetProject));
+ targetProject = snapshot.project();
targetProject.setStatus(ProjectStatus.UNLISTED);
targetProject.setExpiresAt(null);
scoringService.markProjectRankingDirty(targetProject);
- projectRepository.save(targetProject);
+ if (!reviewPersistence.unlist(snapshot)) throw ProjectReviewSnapshot.conflict();
projectService.evictProjectCache(targetProject);
notificationService.sendNotifcation(
@@ -103,18 +106,14 @@ public void unlistProject(net.modtale.model.user.User adminUser, String id, Stri
public void deleteProjectVersion(net.modtale.model.user.User adminUser, String id, String versionId) {
Project project = requireProject(id);
- projectVersionAccessService.requireById(project, versionId,
+ var snapshot = reviewPersistence.capture(id, ProjectReviewSnapshot.token(project));
+ project = snapshot.project();
+ var removed = projectVersionAccessService.requireById(project, versionId,
() -> new ResourceNotFoundException("Version not found."));
- project.getVersions().removeIf(version -> {
- if (!version.getId().equals(versionId)) {
- return false;
- }
- projectDeletionService.deleteVersionFile(version);
- return true;
- });
-
- projectRepository.save(project);
+ project.getVersions().removeIf(version -> version.getId().equals(versionId));
+ if (!reviewPersistence.applyVersionList(snapshot)) throw ProjectReviewSnapshot.conflict();
projectService.evictProjectCache(project);
+ projectDeletionService.deleteVersionFile(removed);
adminAuditLogger.logAction(adminUser.getId(), "DELETE_VERSION", id, "VERSION", "VerID: " + versionId);
}
diff --git a/backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java
new file mode 100644
index 000000000..01910a5e4
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueueCursor.java
@@ -0,0 +1,39 @@
+package net.modtale.service.admin.review;
+
+import org.bson.types.ObjectId;
+import java.nio.ByteBuffer;
+import java.nio.charset.*;
+import java.util.Base64;
+
+public final class ModerationQueueCursor {
+ private ModerationQueueCursor() {}
+ public static String encode(ModerationQueuePageReader.Cursor cursor) {
+ if(cursor==null)return null;
+ Object id=cursor.projectId();String type=id instanceof ObjectId?"o":"s";
+ String value=id instanceof ObjectId objectId?objectId.toHexString():(String)id;
+ if(!StandardCharsets.UTF_8.newEncoder().canEncode(value))throw new IllegalArgumentException("Invalid queue cursor");
+ String encoded=Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8));
+ return (cursor.filter()==ModerationQueuePageReader.Filter.ALL?"1.":"2."+cursor.filter().name()+".")+type+"."+cursor.versionIndex()+"."+encoded;
+ }
+ public static ModerationQueuePageReader.Cursor decode(String token) {
+ if(token==null)return null;
+ if(token.length()>800 || !token.matches("(1|2\\.(SECURITY|OPERATIONS))\\.[os]\\.(0|[1-9][0-9]{0,7})\\.[A-Za-z0-9_-]{1,684}"))throw new IllegalArgumentException("Invalid queue cursor");
+ try {
+ boolean filtered=token.startsWith("2.");
+ var parts=token.split("\\.");
+ var filter=filtered?ModerationQueuePageReader.Filter.valueOf(parts[1]):ModerationQueuePageReader.Filter.ALL;
+ var fields=filtered?new String[]{parts[0],parts[2],parts[3],parts[4]}:parts;
+ byte[] bytes=Base64.getUrlDecoder().decode(fields[3]);
+ String value=StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes)).toString();
+ Object id=value;
+ if(fields[1].equals("o")) {
+ if(!value.matches("[0-9a-f]{24}"))throw new IllegalArgumentException("Invalid queue cursor");
+ id=new ObjectId(value);
+ }
+ var cursor=new ModerationQueuePageReader.Cursor(id,Long.parseLong(fields[2]),filter);
+ if(!encode(cursor).equals(token))throw new IllegalArgumentException("Invalid queue cursor");
+ return cursor;
+ } catch(CharacterCodingException | IllegalArgumentException invalid) {throw new IllegalArgumentException("Invalid queue cursor");}
+ }
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java
new file mode 100644
index 000000000..f622af7ba
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/review/ModerationQueuePageReader.java
@@ -0,0 +1,113 @@
+package net.modtale.service.admin.review;
+
+import com.mongodb.*;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.model.Collation;
+import net.modtale.model.dto.admin.*;
+import net.modtale.model.project.*;
+import org.bson.Document;
+import org.bson.types.ObjectId;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.stereotype.Service;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+@Service
+public final class ModerationQueuePageReader {
+ public enum Filter { ALL, SECURITY, OPERATIONS }
+ private static final List SERVICE_FAILURES = List.of("RATE_LIMITED", "TIMEOUT", "UPSTREAM_ERROR", "INTERRUPTED",
+ "AUTHENTICATION_ERROR", "DISABLED", "CLOSED", "JOURNAL_REQUIRED", "REQUEST_REJECTED", "REQUEST_BINDING_ERROR", "TOOL_REPLAY_REQUIRED");
+ public record Cursor(Object projectId,long versionIndex,Filter filter) {
+ public Cursor(Object projectId,long versionIndex) {this(projectId,versionIndex,Filter.ALL);}
+ public Cursor {
+ if (!(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.codePointCount(0,s.length())<=128)
+ || versionIndex<0 || versionIndex>16*1024*1024 || filter==null) throw new IllegalArgumentException("Invalid queue cursor");
+ }
+ }
+ public record Page(List items,Cursor next,int unavailableItems) {
+ public Page { items=List.copyOf(items); }
+ }
+ private final MongoCollection projects;
+ public ModerationQueuePageReader(MongoTemplate mongo) {
+ projects=mongo.getCollection(mongo.getCollectionName(Project.class)).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY);
+ }
+ public Page page(Cursor cursor,int limit) {return page(cursor,limit,Filter.ALL);}
+ public Page page(Cursor cursor,int limit,Filter filter) {
+ if(filter==null || cursor!=null && cursor.filter()!=filter)throw new IllegalArgumentException("Queue filter changed");
+ if(limit<1 || limit>50)throw new IllegalArgumentException("Invalid queue page size");
+ var stages=new ArrayList();
+ var match=new Document("status",new Document("$in",List.of("PENDING","PUBLISHED")))
+ .append("_id",new Document("$type",List.of("string","objectId")));
+ var idBound=new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type","$_id"),"string")),
+ new Document("$and",List.of(new Document("$gt",List.of(new Document("$strLenCP","$_id"),0)),new Document("$lte",List.of(new Document("$strLenCP","$_id"),128)))),true));
+ match.append("$expr",cursor==null?idBound:new Document("$and",List.of(idBound,new Document("$gte",List.of("$_id",literal(cursor.projectId()))))));
+ stages.add(new Document("$match",match));stages.add(new Document("$sort",new Document("_id",1)));
+ var versions=new Document("$cond",List.of(new Document("$isArray","$versions"),"$versions",List.of()));
+ var pendingOrScanning=new Document("$filter",new Document("input",versions).append("as","v").append("cond",new Document("$or",List.of(
+ new Document("$eq",List.of("$$v.reviewStatus","PENDING")),new Document("$eq",List.of("$$v.scanResult.status","SCANNING"))))));
+ var projectOnly=new Document("$and",List.of(new Document("$eq",List.of("$status","PENDING")),new Document("$eq",List.of(new Document("$size",pendingOrScanning),0))));
+ stages.add(new Document("$set",new Document("queueProjectOnly",projectOnly)
+ .append("queueVersion",new Document("$cond",List.of(projectOnly,Collections.singletonList(null),versions)))));
+ stages.add(new Document("$unwind",new Document("path","$queueVersion").append("includeArrayIndex","queueIndex").append("preserveNullAndEmptyArrays",true)));
+ stages.add(new Document("$match",new Document("$or",List.of(new Document("queueProjectOnly",true),
+ new Document("queueVersion.reviewStatus","PENDING").append("queueVersion.scanResult.status",new Document("$ne","SCANNING"))))));
+ var serviceOnly=new Document("$and",List.of(
+ new Document("$eq",List.of("$queueVersion.scanResult.status","SUSPICIOUS")),
+ new Document("$eq",List.of("$queueVersion.scanResult.verdict","REVIEW")),
+ new Document("$eq",List.of("$queueVersion.scanResult.scanState","COMPLETED")),
+ new Document("$eq",List.of("$queueVersion.scanResult.securityEvidence.complete",true)),
+ new Document("$in",List.of("$queueVersion.scanResult.securityEvidence.reviewState",SERVICE_FAILURES)),
+ new Document("$in",List.of(new Document("$type","$queueVersion.scanResult.newIssueCount"),List.of("int","long"))),
+ new Document("$in",List.of(new Document("$type","$queueVersion.scanResult.escalatedIssueCount"),List.of("int","long"))),
+ new Document("$eq",List.of("$queueVersion.scanResult.newIssueCount",0)),
+ new Document("$eq",List.of("$queueVersion.scanResult.escalatedIssueCount",0))));
+ stages.add(new Document("$set",new Document("queueServiceAttention",new Document("$or",List.of(
+ new Document("$eq",List.of("$queueVersion.scanResult.status","FAILED")),serviceOnly)))));
+ if(cursor!=null)stages.add(new Document("$match",new Document("$expr",new Document("$or",List.of(
+ new Document("$gt",List.of("$_id",literal(cursor.projectId()))),new Document("$gt",List.of("$queueIndex",cursor.versionIndex())))))));
+ if(filter==Filter.OPERATIONS)stages.add(new Document("$match",new Document("queueServiceAttention",true)));
+ if(filter==Filter.SECURITY)stages.add(new Document("$match",new Document("$or",List.of(
+ new Document("queueVersion.scanResult.status",new Document("$in",List.of("INFECTED","FLAGGED"))),
+ new Document("queueVersion.scanResult.status","SUSPICIOUS").append("queueServiceAttention",false),
+ new Document("queueVersion.scanResult.verdict","BLOCK"),
+ new Document("queueVersion.scanResult.newIssueCount",new Document("$gt",0)),
+ new Document("queueVersion.scanResult.escalatedIssueCount",new Document("$gt",0))))));
+ stages.add(new Document("$limit",limit+1));
+ var projection=new Document("_id",1).append("queueIndex",1).append("queueProjectOnly",1).append("queueServiceAttention",1)
+ .append("title",text("$title",256)).append("description",text("$description",1024)).append("author",text("$author",128))
+ .append("imageUrl",text("$imageUrl",2048)).append("classification",text("$classification",32)).append("status",1).append("updatedAt",text("$updatedAt",64))
+ .append("versionId",text("$queueVersion._id",129)).append("versionNumber",text("$queueVersion.versionNumber",128))
+ .append("changelog",text("$queueVersion.changelog",1024)).append("scanStatus",text("$queueVersion.scanResult.status",32))
+ .append("verdict",text("$queueVersion.scanResult.verdict",32)).append("scanState",text("$queueVersion.scanResult.scanState",64))
+ .append("reviewState",text("$queueVersion.scanResult.securityEvidence.reviewState",64));
+ for(String field:List.of("riskScore","knownIssueCount","newIssueCount","escalatedIssueCount"))projection.append(field,new Document("$convert",new Document("input","$queueVersion.scanResult."+field).append("to","int").append("onError",0).append("onNull",0)));
+ projection.append("idCount",new Document("$size",new Document("$filter",new Document("input",versions).append("as","v")
+ .append("cond",new Document("$eq",List.of("$$v._id","$queueVersion._id"))))));
+ stages.add(new Document("$project",projection));
+ var rows=new ArrayList();
+ try(var iterator=projects.aggregate(stages).collation(Collation.builder().locale("simple").build()).allowDiskUse(false)
+ .maxTime(5,TimeUnit.SECONDS).batchSize(limit+1).iterator()) {while(iterator.hasNext())rows.add(iterator.next());}
+ boolean more=rows.size()>limit;if(more)rows.removeLast();
+ var items=new ArrayList();int unavailable=0;
+ for(var row:rows) {
+ Object id=row.get("_id");String projectId=id instanceof ObjectId oid?oid.toHexString():(String)id;
+ boolean only=Boolean.TRUE.equals(row.get("queueProjectOnly"));String versionId=row.getString("versionId");
+ if(!validId(projectId) || !only && (!validId(versionId) || row.getInteger("idCount",0)!=1)) {unavailable++;continue;}
+ var status=enumValue(ScanStatus.class,row.getString("scanStatus"));
+ if(row.getString("scanStatus")!=null && status==null) {unavailable++;continue;}
+ var scan=row.getString("scanStatus")==null?null:new AdminVerificationQueueScanDTO(status,row.getString("verdict"),row.getString("scanState"),
+ row.getString("reviewState"),Boolean.TRUE.equals(row.getBoolean("queueServiceAttention")),
+ row.getInteger("riskScore",0),row.getInteger("knownIssueCount",0),row.getInteger("newIssueCount",0),row.getInteger("escalatedIssueCount",0));
+ items.add(new AdminVerificationQueueItemDTO(projectId,row.getString("title"),row.getString("description"),row.getString("author"),row.getString("imageUrl"),
+ enumValue(ProjectClassification.class,row.getString("classification")),enumValue(ProjectStatus.class,row.getString("status")),row.getString("updatedAt"),
+ only?null:new AdminVerificationQueueVersionDTO(versionId,row.getString("versionNumber"),row.getString("changelog"),ProjectVersion.ReviewStatus.PENDING,scan)));
+ }
+ Cursor next=null;
+ if(more) {var last=rows.getLast();next=new Cursor(last.get("_id"),((Number)last.get("queueIndex")).longValue(),filter);}
+ return new Page(items,next,unavailable);
+ }
+ private static boolean validId(String s) {return s!=null && !s.isBlank() && s.codePointCount(0,s.length())<=128 && s.chars().noneMatch(Character::isISOControl);}
+ private static > T enumValue(Class type,String value) {try{return value==null?null:Enum.valueOf(type,value);}catch(IllegalArgumentException invalid){return null;}}
+ private static Document text(String field,int limit) {return new Document("$cond",Arrays.asList(new Document("$eq",List.of(new Document("$type",field),"string")),new Document("$substrCP",List.of(field,0,limit)),null));}
+ private static Document literal(Object value) {return new Document("$literal",value);}
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationActivator.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationActivator.java
new file mode 100644
index 000000000..2b783bb77
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationActivator.java
@@ -0,0 +1,127 @@
+package net.modtale.service.admin.review;
+
+import com.mongodb.*;
+import com.mongodb.client.model.*;
+import org.bson.*;
+import org.bson.codecs.DocumentCodec;
+import org.bson.types.Binary;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
+
+/** Atomic admission only; the durable remote scheduler delivers the fixed request. */
+public final class ProjectMutationActivator {
+ public static final String ADMISSIONS="project_mutation_admissions";
+ public record Result(String state,String afterSha256) {}
+ private static final Collation BINARY=Collation.builder().locale("simple").build();
+ private static final TransactionOptions OPTIONS=TransactionOptions.builder().readConcern(ReadConcern.SNAPSHOT).readPreference(ReadPreference.primary())
+ .writeConcern(WriteConcern.MAJORITY.withJournal(true)).maxCommitTime(5000L,TimeUnit.MILLISECONDS).build();
+ private final MongoTemplate mongo;private final ReviewRepairWorkflow budget;private final ProjectMutationAdmissionPreparation preparation;
+ private final ReviewSnapshotArchive archive;private final ReviewRepairJournal journal;private final RawReviewSnapshotReader reader;
+ public ProjectMutationActivator(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationAdmissionPreparation preparation,ReviewSnapshotArchive archive,ReviewRepairJournal journal) {
+ this.mongo=Objects.requireNonNull(mongo);this.budget=Objects.requireNonNull(budget);this.preparation=Objects.requireNonNull(preparation);
+ this.archive=Objects.requireNonNull(archive);this.journal=Objects.requireNonNull(journal);reader=new RawReviewSnapshotReader(mongo);
+ }
+ public Result activate(ProjectMutationAdmissionPreparation.Prepared prepared,String actor,BooleanSupplier permitted) {
+ return budget.call(allowed->activateWithinBudget(prepared,actor,allowed),permitted);
+ }
+ Result activateWithinBudget(ProjectMutationAdmissionPreparation.Prepared prepared,String actor,BooleanSupplier allowed) {
+ var previous=receiptWithinBudget(prepared,actor,allowed);if(!"UNKNOWN".equals(previous.state()))return previous;
+ preparation.verifyCurrent(prepared,actor,allowed);var source=archive.load(prepared.id());var projected=projected(source);String afterSha=digest(bytes(projected));
+ var hello=ReviewRepairIo.database(mongo.getDb()).runCommand(new Document("hello",1),ReadPreference.primary());
+ if(!(hello.get("setName") instanceof String) && !"isdbgrid".equals(hello.get("msg")))throw invalid();
+ ensureAdmissionCollection(allowed);
+ var claim=journal.claim(new ReviewRepairPreparation.Prepared(source.id(),prepared.decisionSha256(),source.createdAt(),source.expiresAt()),actor,source.action(),allowed);
+ if(claim==null)return receiptWithinBudget(prepared,actor,allowed);
+ try {
+ for(int transactionAttempt=0;;transactionAttempt++){
+ boolean commitAttempted=false;
+ try(var session=mongo.getMongoDatabaseFactory().getSession(ReviewRepairIo.sessionOptions())) {
+ try {
+ session.startTransaction(ReviewRepairIo.transactionOptions(OPTIONS));permission(allowed);
+ var operations=ReviewRepairIo.collection(mongo.getCollection(ReviewRepairJournal.COLLECTION),session);
+ var op=operations.find(session,new Document("_id",claim.id()).append("token",claim.token()).append("state","EXECUTING")).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();if(op==null)throw invalid();
+ var current=reader.capture(session,source.projectId(),source.versionIndex(),prepared.versionId());
+ String state="NOT_APPLIED",after=null;
+ if(prepared.heldSha256().equals(current.sha256())) {
+ var query=new Document("_id",source.projectId()).append("status",new Document("$in",List.of("PENDING","PUBLISHED","UNLISTED","PRIVATE")))
+ .append("$expr",new Document("$and",List.of(new Document("$eq",List.of(new Document("$type","$status"),"string")),
+ new Document("$gte",List.of("$$NOW",new Date(source.createdAt()))),new Document("$lt",List.of("$$NOW",new Date(source.expiresAt()))))));
+ permission(allowed);
+ if(ReviewRepairIo.collection(mongo.getCollection("projects"),session).updateOne(session,query,new Document("$set",new Document("versions."+source.versionIndex(),projected)),new UpdateOptions().collation(BINARY)).getModifiedCount()==1) {
+ var actual=reader.capture(session,source.projectId(),source.versionIndex(),prepared.versionId());if(!afterSha.equals(actual.sha256()))throw invalid();
+ // One retained admission per fixed request also fences competing signed decisions.
+ ReviewRepairIo.collection(mongo.getCollection(ADMISSIONS),session).insertOne(session,admission(source,prepared,afterSha));
+ state="APPLIED";after=afterSha;
+ }
+ }
+ permission(allowed);
+ var fields=new Document("state",state).append("afterSha256",after).append("mutationId",prepared.mutationId()).append("requestId",prepared.binding().requestId()).append("finishedAt","$$NOW");
+ if(operations.updateOne(session,op,List.of(new Document("$set",fields)),new UpdateOptions().collation(BINARY)).getModifiedCount()!=1)throw invalid();
+ permission(allowed);commitAttempted=true;session.commitTransaction();return new Result(state,after);
+ }catch(RuntimeException failure){
+ boolean aborted=false;
+ if(!commitAttempted)try{session.abortTransaction();aborted=true;}catch(RuntimeException ignored){}
+ if(!commitAttempted && aborted && transactionAttempt<2 && failure instanceof MongoException conflict
+ && conflict.getCode()==112 && conflict.hasErrorLabel(MongoException.TRANSIENT_TRANSACTION_ERROR_LABEL)){
+ permission(allowed);continue;
+ }
+ throw failure;
+ }
+ }
+ }
+ }catch(RuntimeException uncertain) {
+ try(var cleanup=ReviewRepairIo.cleanup()) {
+ if(uncertain instanceof SecurityException){try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}throw uncertain;}
+ try{var result=receiptWithinBudget(prepared,actor,allowed);if(!"UNKNOWN".equals(result.state()))return result;}catch(RuntimeException ignored){}
+ try{journal.markUnknown(claim,()->true);}catch(RuntimeException ignored){}return new Result("UNKNOWN",null);
+ }
+ }
+ }
+ private void ensureAdmissionCollection(BooleanSupplier allowed) {
+ permission(allowed);
+ var database=ReviewRepairIo.database(mongo.getDb().withReadPreference(ReadPreference.primary())
+ .withWriteConcern(WriteConcern.MAJORITY.withJournal(true)));
+ // Avoid implicit namespace creation inside competing admission transactions.
+ if(database.listCollections().filter(new Document("name",ADMISSIONS)).first()==null) {
+ permission(allowed);
+ try{database.createCollection(ADMISSIONS);}
+ catch(MongoCommandException concurrent){if(concurrent.getErrorCode()!=48)throw concurrent;}
+ }
+ permission(allowed);
+ }
+ public Result receipt(ProjectMutationAdmissionPreparation.Prepared prepared,String actor,BooleanSupplier permitted) {
+ return budget.call(allowed->receiptWithinBudget(prepared,actor,allowed),permitted);
+ }
+ Result receiptWithinBudget(ProjectMutationAdmissionPreparation.Prepared prepared,String actor,BooleanSupplier allowed) {
+ permission(allowed);if(!prepared.equals(preparation.recoverWithinBudget(prepared.id(),actor,allowed)))throw invalid();var source=archive.load(prepared.id());
+ var op=ReviewRepairIo.collection(mongo.getCollection(ReviewRepairJournal.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY))
+ .find(new Document("_id",prepared.id())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();permission(allowed);
+ if(op==null || !actor.equals(op.get("actor")) || !source.action().name().equals(op.get("action")) || !prepared.decisionSha256().equals(op.get("sha256"))
+ || !Long.valueOf(source.createdAt()).equals(op.get("createdAt")) || !Long.valueOf(source.expiresAt()).equals(op.get("expiresAt"))
+ || !prepared.mutationId().equals(op.get("mutationId")) || !prepared.binding().requestId().equals(op.get("requestId")) || !(op.get("finishedAt") instanceof Date))return new Result("UNKNOWN",null);
+ if("NOT_APPLIED".equals(op.get("state")) && op.get("afterSha256")==null)return new Result("NOT_APPLIED",null);
+ String expected=digest(bytes(projected(source)));
+ if("APPLIED".equals(op.get("state")) && expected.equals(op.get("afterSha256"))) {
+ var record=ReviewRepairIo.collection(mongo.getCollection(ADMISSIONS).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY))
+ .find(new Document("_id",prepared.binding().requestId())).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();
+ if(record==null || !Arrays.equals(bytes(record),bytes(admission(source,prepared,expected))))throw invalid();permission(allowed);return new Result("APPLIED",expected);
+ }
+ return new Result("UNKNOWN",null);
+ }
+ static Document projected(ReviewSnapshotArchive.Snapshot source) {
+ var payload=decode(source.versionBytes());var held=decode(payload.get("heldVersion",Binary.class).getData());var scan=held.get("scanResult",Document.class);
+ scan.put("scanState","REMOTE_REVIEW");scan.put("scanTimestamp",source.createdAt());scan.put("remoteReview",payload.get("binding",Document.class));return held;
+ }
+ static Document admission(ReviewSnapshotArchive.Snapshot source,ProjectMutationAdmissionPreparation.Prepared prepared,String afterSha) {
+ return new Document("_id",prepared.binding().requestId()).append("decisionId",prepared.id()).append("decisionSha256",prepared.decisionSha256())
+ .append("projectId",source.projectId()).append("versionId",prepared.versionId()).append("mutationId",prepared.mutationId())
+ .append("actor",source.actorId()).append("heldSha256",prepared.heldSha256()).append("afterSha256",afterSha).append("state","ACTIVE");
+ }
+ private static Document decode(byte[] bytes){return new RawBsonDocument(bytes).decode(new DocumentCodec());}
+ private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();var result=new byte[buffer.remaining()];buffer.get(result);return result;}
+ private static String digest(byte[] value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));}catch(Exception failure){throw new IllegalStateException(failure);}}
+ private static void permission(BooleanSupplier allowed){if(!allowed.getAsBoolean())throw new SecurityException("Mutation activation is not permitted");}
+ private static IllegalStateException invalid(){return new IllegalStateException("Mutation activation changed or is unavailable");}
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionAttempts.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionAttempts.java
new file mode 100644
index 000000000..7f3227f63
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionAttempts.java
@@ -0,0 +1,103 @@
+package net.modtale.service.admin.review;
+
+import com.mongodb.*;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.model.*;
+import org.bson.*;
+import org.bson.codecs.DocumentCodec;
+import org.bson.types.ObjectId;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
+
+/** Durable scheduling bookkeeping only. Callers must authenticate candidates and outcome evidence. */
+public final class ProjectMutationAdmissionAttempts {
+ public static final String COLLECTION="project_mutation_admission_attempts";
+ public enum Outcome { WAITING, YIELDED, ATTENTION, ADMITTED }
+ public record Scope(Object projectId,String versionId,String mutationId,String requestId,int scanAttempt) {
+ public Scope {
+ if(!(projectId instanceof ObjectId || projectId instanceof String s && !s.isEmpty() && s.length()<=128)
+ || versionId==null || versionId.isBlank() || versionId.length()>128 || versionId.chars().anyMatch(Character::isISOControl)
+ || !uuid(mutationId) || !uuid(requestId) || scanAttempt<1)throw invalid();
+ }
+ }
+ public record Claim(Scope scope,int sequence,String token,String decisionId,String heldSha256) {}
+ public record Status(String state,int attempts,Claim current,Outcome outcome,Long nextAttemptAt) {}
+ private static final Collation BINARY=Collation.builder().locale("simple").build();
+ private final MongoCollection records;private final ReviewRepairWorkflow budget;private final int maxAttempts;private final long cooldownMillis;
+ public ProjectMutationAdmissionAttempts(MongoTemplate mongo,ReviewRepairWorkflow budget){this(mongo,budget,256,60000);}
+ public ProjectMutationAdmissionAttempts(MongoTemplate mongo,ReviewRepairWorkflow budget,int maxAttempts,long cooldownMillis) {
+ if(maxAttempts<1 || maxAttempts>256 || cooldownMillis<100 || cooldownMillis>3600000)throw invalid();this.maxAttempts=maxAttempts;this.cooldownMillis=cooldownMillis;this.budget=Objects.requireNonNull(budget);
+ records=mongo.getCollection(COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY)
+ .withWriteConcern(WriteConcern.MAJORITY.withJournal(true).withWTimeout(5,TimeUnit.SECONDS)).withTimeout(5000,TimeUnit.MILLISECONDS);
+ }
+ public Claim begin(Scope scope,String heldSha256,BooleanSupplier permitted){return budget.call(allowed->beginWithinBudget(scope,heldSha256,allowed),permitted);}
+ Claim beginWithinBudget(Scope scope,String heldSha256,BooleanSupplier allowed) {
+ permission(allowed);if(scope==null || !sha(heldSha256))throw invalid();var stored=read(scope.requestId());
+ if(stored==null) {
+ var initial=new Document("_id",scope.requestId()).append("scope",scope(scope)).append("state","WAITING").append("nextAttemptAt",new Date(0)).append("attempts",List.of());
+ permission(allowed);try{ReviewRepairIo.collection(records).insertOne(initial);}catch(MongoException uncertain){/* Exact read-back below; creation itself never grants a claim. */}
+ stored=read(scope.requestId());
+ }
+ var status=decode(scope,stored);if(!Set.of("WAITING","YIELDED").contains(status.state()) || status.attempts()>=maxAttempts)return null;
+ int sequence=status.attempts()+1;String token=UUID.randomUUID().toString(),decision=decision(scope,sequence);
+ var attempt=new Document("sequence",sequence).append("token",token).append("decisionId",decision).append("heldSha256",heldSha256);
+ var nextAttempt=new Document("$mergeObjects",List.of(literal(attempt),new Document("startedAt","$$NOW")));
+ var query=exact(stored);query.append("$expr",new Document("$and",List.of(new Document("$eq",List.of("$$ROOT",literal(stored))),new Document("$lte",List.of("$nextAttemptAt","$$NOW")))));
+ permission(allowed);
+ try {
+ var result=ReviewRepairIo.collection(records).findOneAndUpdate(query,List.of(new Document("$set",new Document("state","RUNNING")
+ .append("attempts",new Document("$concatArrays",List.of("$attempts",List.of(nextAttempt)))))),new FindOneAndUpdateOptions().collation(BINARY).returnDocument(ReturnDocument.AFTER));
+ if(result==null)return null;var found=decode(scope,result);permission(allowed);return token.equals(found.current().token())?found.current():null;
+ }catch(MongoException uncertain){var found=decode(scope,read(scope.requestId()));permission(allowed);return found.current()!=null && token.equals(found.current().token())?found.current():null;}
+ }
+ public Status status(Scope scope,BooleanSupplier permitted){return budget.call(allowed->statusWithinBudget(scope,allowed),permitted);}
+ Status statusWithinBudget(Scope scope,BooleanSupplier allowed){permission(allowed);var value=read(scope.requestId());var result=value==null?new Status("ABSENT",0,null,null,null):decode(scope,value);permission(allowed);return result;}
+ public Status finish(Claim claim,Outcome outcome,BooleanSupplier permitted){return budget.call(allowed->finishWithinBudget(claim,outcome,allowed),permitted);}
+ Status finishWithinBudget(Claim claim,Outcome outcome,BooleanSupplier allowed) {
+ permission(allowed);Objects.requireNonNull(outcome);var stored=read(claim.scope().requestId());var current=decode(claim.scope(),stored);
+ if(!claim.equals(current.current()))throw invalid();if(!"RUNNING".equals(current.state())){if(outcome!=current.outcome())throw invalid();return current;}
+ String state=continuation(outcome) && current.attempts()>=maxAttempts?"ATTENTION":outcome.name();
+ var history=new ArrayList<>(stored.getList("attempts",Document.class));var last=history.removeLast();
+ var completed=new Document("$mergeObjects",List.of(literal(last),new Document("outcome",outcome.name()).append("finishedAt","$$NOW")));
+ var fields=new Document("state",state).append("nextAttemptAt",new Document("$add",List.of("$$NOW",outcome==Outcome.YIELDED?100L:cooldownMillis)))
+ .append("attempts",new Document("$concatArrays",List.of(literal(history),List.of(completed))));
+ permission(allowed);try{ReviewRepairIo.collection(records).updateOne(exact(stored),List.of(new Document("$set",fields)),new UpdateOptions().collation(BINARY));}
+ catch(MongoException uncertain){/* A lost finish reply never starts another attempt. */}
+ var found=decode(claim.scope(),read(claim.scope().requestId()));permission(allowed);
+ if(!claim.equals(found.current()) || found.outcome()!=outcome)throw invalid();return found;
+ }
+ private Status decode(Scope scope,Document stored) {
+ if(stored==null || stored.size()!=5 || !scope.requestId().equals(stored.get("_id")) || !(stored.get("scope") instanceof Document raw)
+ || !Arrays.equals(bytes(raw),bytes(scope(scope))) || !(stored.get("nextAttemptAt") instanceof Date next))throw invalid();
+ var history=stored.getList("attempts",Document.class);if(history==null || history.size()>256)throw invalid();String state=stored.getString("state");
+ if(history.isEmpty()){if(!"WAITING".equals(state) || next.getTime()!=0)throw invalid();return new Status(state,0,null,null,0L);}
+ Claim claim=null;Outcome outcome=null;Date previous=null;
+ for(int i=0;i observations,boolean acknowledgeUncertainty) {
+ public Request {
+ if(observations==null || observations.size()>256)throw invalid();observations=Map.copyOf(observations);
+ }
+ }
+ public record Prepared(String id,String decisionSha256,String mutationId,String versionId,String heldSha256,
+ RemoteReviewBinding binding,String rule,long createdAt,long expiresAt) {}
+ private final MongoTemplate mongo;private final ReviewRepairWorkflow budget;private final ReviewSnapshotArchive archive;
+ private final ProjectMutationPriorWorkReader prior;private final ProjectMutationJobAccounting accounting;
+ private final ProjectMutationReferenceReader history;private final RawReviewSnapshotReader reader;private final RemoteReviewClient client;
+ private final Clock clock;private final long lifetimeMillis;
+ public ProjectMutationAdmissionPreparation(MongoTemplate mongo,ReviewRepairWorkflow budget,ReviewSnapshotArchive archive,
+ ProjectMutationReferenceReader history,ProjectMutationPriorWorkReader prior,ProjectMutationJobAccounting accounting,
+ RemoteReviewClient client,Clock clock,long lifetimeMillis) {
+ if(lifetimeMillis<1000 || lifetimeMillis>120000)throw invalid();this.mongo=Objects.requireNonNull(mongo);this.budget=Objects.requireNonNull(budget);
+ this.archive=Objects.requireNonNull(archive);this.history=Objects.requireNonNull(history);this.prior=Objects.requireNonNull(prior);
+ this.accounting=Objects.requireNonNull(accounting);this.client=Objects.requireNonNull(client);this.clock=Objects.requireNonNull(clock);
+ this.lifetimeMillis=lifetimeMillis;reader=new RawReviewSnapshotReader(mongo);
+ }
+ public Prepared prepare(Request request,BooleanSupplier permitted) {return budget.call(allowed->prepareWithinBudget(request,allowed),permitted);}
+ Prepared prepareWithinBudget(Request request,BooleanSupplier allowed) {
+ if(request==null || !uuid(request.id()) || !uuid(request.mutationId()) || !digestValue(request.heldSha256())
+ || request.actor()==null || request.actor().isBlank() || request.actor().length()>256)throw invalid();
+ permission(allowed);var captured=reader.capture(request.projectId(),request.versionIndex(),request.versionId());
+ if(!captured.sha256().equals(request.heldSha256()))throw invalid();var held=decode(captured.versionBytes());
+ var pointer=held.get("versionMutation",Document.class);var scan=held.get("scanResult",Document.class);
+ if(pointer==null || !request.mutationId().equals(pointer.get("operationId")) || scan==null
+ || !"MUTATION_HELD".equals(scan.get("scanState")) || !"SCANNING".equals(scan.get("status"))
+ || !"PENDING".equals(held.get("reviewStatus")) || !Boolean.FALSE.equals(scan.get("manualRescan")))throw invalid();
+ history.requireHeldHeads(request.projectId(),bytes(new Document("_id",request.projectId()).append("versions",List.of(held))),allowed);
+ var inventory=prior.readWithinBudget(request.projectId(),request.mutationId(),allowed);var evidence=new ArrayList();
+ var used=new HashSet();boolean uncertain=false;
+ try(var receipts=accounting.readBatch(request.projectId(),allowed)){
+ for(var work:inventory.work()) {
+ permission(allowed);var item=new Document("mutationId",work.mutationId()).append("versionId",work.versionId()).append("beforeSha256",work.beforeSha256())
+ .append("kind",work.kind().name()).append("reason",work.reason());
+ if(work.kind()==ProjectMutationPriorWorkReader.Kind.REMOTE_JOB) {
+ String key=work.mutationId()+"/"+work.versionId(),id=request.observations().get(key);if(!uuid(id))throw invalid();used.add(key);
+ var observation=receipts.receipt(id,request.projectId(),work.mutationId(),work.versionId());
+ if(!Set.of("OBSERVED","UNKNOWN").contains(observation.state()))throw invalid();
+ ReviewOrphanCancellationJournal.validateObservation(observation.observation(),work.binding());
+ var status=observation.observation().status();boolean completed="OBSERVED".equals(observation.state())
+ && "REMOTE_STATUS".equals(observation.observation().kind()) && status!=null
+ && "COMPLETED".equals(status.state()) && "COMPLETED".equals(status.workState());
+ uncertain|=!completed;item.append("observation",new Document("id",id).append("state",observation.state())
+ .append("receivedAt",observation.receivedAt()).append("body",ReviewOrphanCancellationJournal.observationDocument(observation.observation())));
+ } else if(work.kind()==ProjectMutationPriorWorkReader.Kind.UNRESOLVED)uncertain=true;
+ evidence.add(item);
+ }
+ }
+ if(!used.equals(request.observations().keySet()) || uncertain && !request.acknowledgeUncertainty())throw invalid();
+ var configuration=client.configuration(allowed,ReviewRepairIo::currentRemainingNanos);permission(allowed);
+ var version=mongo.getConverter().read(ProjectVersion.class,held);
+ String context=ArtifactReviewContext.automaticallyReviewableFingerprint(version);
+ if(context==null || !(scan.get("scanAttempt") instanceof Integer || scan.get("scanAttempt") instanceof Long))throw invalid();
+ long attempt=((Number)scan.get("scanAttempt")).longValue();if(attempt<1 || attempt>Integer.MAX_VALUE)throw invalid();
+ var binding=new RemoteReviewBinding(request.projectId().toString(),request.versionId(),scan.getString("scanRequestId"),(int)attempt,
+ held.getString("fileUrl"),held.getString("hash"),context,configuration.policyVersion(),configuration.reviewConfigSha256(),null,false,configuration.origin());
+ var rawBinding=new Document();mongo.getConverter().write(binding,rawBinding);
+ String rule=uncertain?"ACKNOWLEDGED_UNCERTAINTY":"PRIOR_WORK_ACCOUNTED";
+ var observationIds=new Document();request.observations().entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(e->observationIds.append(e.getKey(),e.getValue()));
+ var payload=new Document("schema",1).append("mutationId",request.mutationId()).append("versionId",request.versionId()).append("heldSha256",captured.sha256())
+ .append("heldVersion",new Binary(captured.versionBytes())).append("binding",rawBinding).append("groups",inventory.groups())
+ .append("evidence",evidence).append("observations",observationIds).append("acknowledgedUncertainty",request.acknowledgeUncertainty()).append("rule",rule);
+ var existing=archive.find(request.id());long created=existing==null?clock.millis():existing.createdAt(),expires=existing==null?Math.addExact(created,lifetimeMillis):existing.expiresAt();live(created,expires);
+ var candidate=new ReviewSnapshotArchive.Snapshot(request.id(),request.projectId(),request.versionIndex(),request.actor(),ReviewSnapshotArchive.Action.PROJECT_MUTATION_ADMISSION,created,expires,bytes(payload));
+ permission(allowed);
+ try{archive.retain(candidate);}catch(RuntimeException uncertainWrite){var found=archive.find(candidate.id());if(found==null || !same(candidate,found))throw uncertainWrite;}
+ permission(allowed);if(!reader.isCurrent(captured))throw invalid();
+ var result=recoverWithinBudget(request.id(),request.actor(),allowed);live(result.createdAt(),result.expiresAt());return result;
+ }
+ public Prepared recover(String id,String actor,BooleanSupplier permitted) {return budget.call(allowed->recoverWithinBudget(id,actor,allowed),permitted);}
+ Prepared recoverWithinBudget(String id,String actor,BooleanSupplier allowed) {
+ permission(allowed);var stored=archive.load(id);
+ if(!stored.actorId().equals(actor))throw invalid();
+ var result=decodeStored(mongo,stored);permission(allowed);return result;
+ }
+ static Prepared decodeStored(MongoTemplate mongo,ReviewSnapshotArchive.Snapshot stored) {
+ if(stored.action()!=ReviewSnapshotArchive.Action.PROJECT_MUTATION_ADMISSION || stored.expiresAt()-stored.createdAt()>120000)throw invalid();
+ var payload=decode(stored.versionBytes());
+ if(payload.size()!=11 || !Integer.valueOf(1).equals(payload.get("schema")) || !uuid(payload.getString("mutationId"))
+ || !(payload.get("heldVersion") instanceof Binary original) || !digest(original.getData()).equals(payload.get("heldSha256"))
+ || !(payload.get("acknowledgedUncertainty") instanceof Boolean ack)
+ || !Set.of("PRIOR_WORK_ACCOUNTED","ACKNOWLEDGED_UNCERTAINTY").contains(Objects.toString(payload.get("rule"),""))
+ || "ACKNOWLEDGED_UNCERTAINTY".equals(payload.get("rule")) && !ack)throw invalid();
+ var held=decode(original.getData());var binding=mongo.getConverter().read(RemoteReviewBinding.class,payload.get("binding",Document.class));
+ var scan=held.get("scanResult",Document.class);
+ if(binding.jobId()!=null || binding.origin()==null || binding.manualRescan() || !binding.projectId().equals(stored.projectId().toString())
+ || !binding.versionId().equals(payload.get("versionId")) || !binding.versionId().equals(held.get("_id"))
+ || !binding.requestId().equals(scan.get("scanRequestId")) || binding.attempt()!=((Number)scan.get("scanAttempt")).longValue()
+ || !binding.filePath().equals(held.get("fileUrl")) || !binding.artifactSha256().equals(held.get("hash"))
+ || !binding.contextSha256().equals(ArtifactReviewContext.automaticallyReviewableFingerprint(mongo.getConverter().read(ProjectVersion.class,held))))throw invalid();
+ return new Prepared(stored.id(),digest(stored.versionBytes()),payload.getString("mutationId"),payload.getString("versionId"),payload.getString("heldSha256"),binding,payload.getString("rule"),stored.createdAt(),stored.expiresAt());
+ }
+ Prepared verifyCurrent(Prepared expected,String actor,BooleanSupplier allowed) {
+ if(!expected.equals(recoverWithinBudget(expected.id(),actor,allowed)))throw invalid();
+ var stored=archive.load(expected.id());var payload=decode(stored.versionBytes());var ids=new HashMap();
+ payload.get("observations",Document.class).forEach((key,value)->{if(!(value instanceof String id))throw invalid();ids.put(key,id);});
+ var request=new Request(expected.id(),stored.projectId(),stored.versionIndex(),expected.versionId(),expected.heldSha256(),expected.mutationId(),actor,ids,payload.getBoolean("acknowledgedUncertainty"));
+ var found=prepareWithinBudget(request,allowed);if(!expected.equals(found))throw invalid();return found;
+ }
+ private void live(long created,long expires){long now=clock.millis();if(now=expires)throw invalid();}
+ private static boolean same(ReviewSnapshotArchive.Snapshot a,ReviewSnapshotArchive.Snapshot b){return a.id().equals(b.id()) && a.projectId().equals(b.projectId()) && a.versionIndex()==b.versionIndex() && a.actorId().equals(b.actorId()) && a.action()==b.action() && a.createdAt()==b.createdAt() && a.expiresAt()==b.expiresAt() && Arrays.equals(a.versionBytes(),b.versionBytes());}
+ private static Document decode(byte[] value){return new RawBsonDocument(value).decode(new DocumentCodec());}
+ private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();var result=new byte[buffer.remaining()];buffer.get(result);return result;}
+ private static String digest(byte[] value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));}catch(Exception failure){throw new IllegalStateException(failure);}}
+ private static boolean digestValue(String value){return value!=null && value.matches("[0-9a-f]{64}");}
+ private static boolean uuid(String value){return value!=null && value.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");}
+ private static void permission(BooleanSupplier allowed){if(!allowed.getAsBoolean())throw new SecurityException("Mutation admission is not permitted");}
+ private static IllegalStateException invalid(){return new IllegalStateException("Mutation admission evidence changed or is unavailable");}
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionReader.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionReader.java
new file mode 100644
index 000000000..1f9887510
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionReader.java
@@ -0,0 +1,67 @@
+package net.modtale.service.admin.review;
+
+import com.mongodb.*;
+import com.mongodb.client.model.Collation;
+import net.modtale.model.project.RemoteReviewBinding;
+import org.bson.*;
+import org.bson.codecs.DocumentCodec;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
+
+/** Authenticates admission provenance, not the truth or approval authority of later scan outcomes. */
+public final class ProjectMutationAdmissionReader {
+ public record History(ReviewSnapshotArchive.Snapshot source,ProjectMutationAdmissionPreparation.Prepared decision) {}
+ private static final Collation BINARY=Collation.builder().locale("simple").build();
+ private static final Set OUTCOME=Set.of("scanResult","retainedRemoteReview","reviewStatus","rejectionReason","scheduledPublishDate",
+ "securityApprovedAt","approvedSecurityEvidence","approvedSecurityContextSha256","approvedReviewOrigins","approvedFindingReviewHead","securityApprovalProjectId","approvedIssueBaselines");
+ private final MongoTemplate mongo;private final ReviewSnapshotArchive archive;private final ReviewRemoteTargetReader targets;
+ public ProjectMutationAdmissionReader(MongoTemplate mongo,ReviewSnapshotArchive archive){this.mongo=Objects.requireNonNull(mongo);this.archive=Objects.requireNonNull(archive);targets=new ReviewRemoteTargetReader(mongo);}
+ public History read(Object projectId,String requestId,BooleanSupplier allowed) {
+ permission(allowed);if(requestId==null || !requestId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))throw invalid();
+ var record=read(ProjectMutationActivator.ADMISSIONS,requestId);if(record==null || !projectId.equals(record.get("projectId")) || !(record.get("decisionId") instanceof String id))throw invalid();
+ var source=archive.load(id);var prepared=ProjectMutationAdmissionPreparation.decodeStored(mongo,source);
+ if(!projectId.equals(source.projectId()) || !requestId.equals(prepared.binding().requestId()))throw invalid();
+ String after=digest(bytes(ProjectMutationActivator.projected(source)));
+ if(!Arrays.equals(bytes(record),bytes(ProjectMutationActivator.admission(source,prepared,after))))throw invalid();
+ var op=read(ReviewRepairJournal.COLLECTION,id);
+ if(op==null || !"APPLIED".equals(op.get("state")) || !source.actorId().equals(op.get("actor")) || !source.action().name().equals(op.get("action"))
+ || !prepared.decisionSha256().equals(op.get("sha256")) || !Long.valueOf(source.createdAt()).equals(op.get("createdAt"))
+ || !Long.valueOf(source.expiresAt()).equals(op.get("expiresAt")) || !prepared.mutationId().equals(op.get("mutationId"))
+ || !requestId.equals(op.get("requestId")) || !after.equals(op.get("afterSha256")) || !(op.get("finishedAt") instanceof Date))throw invalid();
+ permission(allowed);return new History(source,prepared);
+ }
+ public void requireNoAdmission(Document version,BooleanSupplier allowed) {
+ var scan=version.get("scanResult") instanceof Document doc?doc:null;
+ var retained=version.get("retainedRemoteReview") instanceof Document doc?doc:null;
+ var remote=scan!=null && scan.get("remoteReview") instanceof Document doc?doc:null;
+ for(var source:Arrays.asList(scan,remote,retained)) {
+ if(source==null)continue;String field=source==scan?"scanRequestId":"requestId";
+ if(source.get(field) instanceof String request)requireUnadmitted(request,allowed);
+ }
+ }
+ public void requireUnadmitted(String requestId,BooleanSupplier allowed) {
+ permission(allowed);if(read(ProjectMutationActivator.ADMISSIONS,requestId)!=null)throw invalid();permission(allowed);
+ }
+ public void requireHead(Object projectId,Document current,BooleanSupplier allowed) {
+ permission(allowed);var pointer=current.get("versionMutation",Document.class);
+ if(pointer==null || pointer.size()!=3 || !(pointer.get("requestId") instanceof String request))throw invalid();
+ var history=read(projectId,request,allowed);var expected=ProjectMutationActivator.projected(history.source());
+ if(!history.decision().mutationId().equals(pointer.get("operationId")) || !history.decision().versionId().equals(current.get("_id")))throw invalid();
+ var left=new Document(expected);var right=new Document(current);for(var field:OUTCOME){left.remove(field);right.remove(field);}
+ if(VersionReviewTransition.classify(List.of(left),List.of(right)).getFirst().changes().stream().anyMatch(c->c!=VersionReviewTransition.Change.METADATA))throw invalid();
+ // Validate the original artifact/context/attempt/origin even before a remote job ID has been attached.
+ var copy=new RawBsonDocument(bytes(current)).decode(new DocumentCodec());var scan=copy.get("scanResult",Document.class);
+ var remote=scan==null?copy.get("retainedRemoteReview",Document.class):scan.get("remoteReview",Document.class);if(remote==null)throw invalid();
+ String job=remote.get("jobId")==null?"00000000-0000-0000-0000-000000000000":remote.getString("jobId");remote.put("jobId",job);
+ RemoteReviewBinding binding=targets.validate(projectId,copy);
+ if(!history.decision().binding().withJobId(job).equals(binding))throw invalid();permission(allowed);
+ }
+ private Document read(String collection,String id){return ReviewRepairIo.collection(mongo.getCollection(collection).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY))
+ .find(new Document("_id",id)).collation(BINARY).maxTime(5,TimeUnit.SECONDS).first();}
+ private static byte[] bytes(Document value){var buffer=new RawBsonDocument(value,new DocumentCodec()).getByteBuffer().asNIO();var result=new byte[buffer.remaining()];buffer.get(result);return result;}
+ private static String digest(byte[] value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value));}catch(Exception failure){throw new IllegalStateException(failure);}}
+ private static void permission(BooleanSupplier allowed){if(!allowed.getAsBoolean())throw new SecurityException("Mutation admission history is not permitted");}
+ private static IllegalStateException invalid(){return new IllegalStateException("Mutation admission history changed or is unavailable");}
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionScheduler.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionScheduler.java
new file mode 100644
index 000000000..5645a1664
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionScheduler.java
@@ -0,0 +1,18 @@
+package net.modtale.service.admin.review;
+
+import net.modtale.service.security.scan.BoundedReviewScheduler;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicLong;
+
+public final class ProjectMutationAdmissionScheduler extends BoundedReviewScheduler {
+ private final AtomicLong unavailable;
+ public ProjectMutationAdmissionScheduler(ProjectMutationDiscovery discovery,ProjectMutationAutomaticAdmission automatic,Settings settings) {
+ this(discovery,automatic,settings,new AtomicLong());
+ }
+ private ProjectMutationAdmissionScheduler(ProjectMutationDiscovery discovery,ProjectMutationAutomaticAdmission automatic,Settings settings,AtomicLong unavailable) {
+ super("mutation-admission",(cursor,limit)->{var page=discovery.page(cursor,limit);unavailable.addAndGet(page.unavailable());return new Page<>(page.candidates(),page.next());},
+ (candidate,running)->automatic.advance(candidate,running).state(),settings);
+ Objects.requireNonNull(discovery);Objects.requireNonNull(automatic);this.unavailable=unavailable;
+ }
+ public long unavailableCandidates(){return unavailable.get();}
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionSchedulerConfiguration.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionSchedulerConfiguration.java
new file mode 100644
index 000000000..15d06c8cc
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAdmissionSchedulerConfiguration.java
@@ -0,0 +1,33 @@
+package net.modtale.service.admin.review;
+
+import net.modtale.service.security.scan.RemoteReviewScheduler;
+import org.springframework.context.annotation.*;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+
+@Configuration(proxyBeanMethods=false)
+@ConditionalOnProperty(name="app.warden.repair.admission.scheduler.enabled",havingValue="true")
+public class ProjectMutationAdmissionSchedulerConfiguration {
+ @Bean(destroyMethod="close")
+ ProjectMutationRecoveryScheduler projectMutationRecoveryScheduler(ProjectMutationAttemptRecovery recovery,ProjectMutationAdmissionScheduler admission,
+ @Value("${app.warden.repair.admission.scheduler.page-size:16}") int pageSize,
+ @Value("${app.warden.repair.admission.scheduler.poll-millis:1000}") long poll,
+ @Value("${app.warden.repair.admission.scheduler.drain-millis:10000}") long drain) {
+ java.util.Objects.requireNonNull(admission);
+ return new ProjectMutationRecoveryScheduler(recovery,new ProjectMutationRecoveryScheduler.Settings(1,pageSize,poll,drain));
+ }
+
+ @Bean(destroyMethod="close")
+ ProjectMutationAdmissionScheduler projectMutationAdmissionScheduler(ProjectMutationDiscovery discovery,ProjectMutationAutomaticAdmission automatic,
+ RemoteReviewScheduler delivery,
+ @Value("${app.warden.repair.enabled:false}") boolean repair,
+ @Value("${app.warden.jobs.enabled:false}") boolean jobs,
+ @Value("${app.warden.repair.admission.scheduler.workers:1}") int workers,
+ @Value("${app.warden.repair.admission.scheduler.page-size:16}") int pageSize,
+ @Value("${app.warden.repair.admission.scheduler.poll-millis:1000}") long poll,
+ @Value("${app.warden.repair.admission.scheduler.drain-millis:10000}") long drain) {
+ if(!repair || !jobs)throw new IllegalStateException("Automatic admission requires repair and remote review enabled");
+ java.util.Objects.requireNonNull(delivery);
+ return new ProjectMutationAdmissionScheduler(discovery,automatic,new ProjectMutationAdmissionScheduler.Settings(workers,pageSize,poll,drain));
+ }
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAttemptRecovery.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAttemptRecovery.java
new file mode 100644
index 000000000..611921218
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAttemptRecovery.java
@@ -0,0 +1,56 @@
+package net.modtale.service.admin.review;
+
+import com.mongodb.*;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.model.*;
+import org.bson.Document;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
+
+/** Finds interrupted bookkeeping and reconciles only an already committed, authenticated admission. */
+public final class ProjectMutationAttemptRecovery {
+ public static final String INDEX="mutation_attempt_state_id";
+ private static final String UUID="[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
+ private static final Collation BINARY=Collation.builder().locale("simple").build();
+ public record Page(List candidates,String next,int examined,int unavailable) {public Page{candidates=List.copyOf(candidates);}}
+ private final MongoCollection records;private final ReviewRepairWorkflow budget;private final ProjectMutationAdmissionAttempts attempts;private final ProjectMutationAdmissionReader admissions;
+ public ProjectMutationAttemptRecovery(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationAdmissionAttempts attempts,ReviewSnapshotArchive archive) {
+ this.budget=Objects.requireNonNull(budget);this.attempts=Objects.requireNonNull(attempts);admissions=new ProjectMutationAdmissionReader(mongo,archive);
+ records=mongo.getCollection(ProjectMutationAdmissionAttempts.COLLECTION).withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY).withTimeout(5000,TimeUnit.MILLISECONDS);
+ }
+ public void initialize(){records.withWriteConcern(WriteConcern.MAJORITY.withJournal(true)).createIndex(new Document("state",1).append("_id",1),new IndexOptions().name(INDEX).collation(BINARY));}
+ public Page page(String cursor,int limit) {
+ if(limit<1 || limit>64 || cursor!=null && !cursor.matches(UUID))throw invalid();
+ var ids=new Document("$type","string").append("$regex","^"+UUID+"$");if(cursor!=null)ids.append("$gt",cursor);
+ var match=new Document("state","RUNNING").append("_id",ids).append("$expr",new Document("$eq",List.of(new Document("$type","$state"),"string")));
+ var projection=new Document("_id",1).append("projectId",new Document("$cond",Arrays.asList(new Document("$eq",List.of(new Document("$type","$scope.projectId"),"objectId")),"$scope.projectId",safeString("$scope.projectId",128))))
+ .append("versionId",safeString("$scope.versionId",128)).append("mutationId",safeString("$scope.mutationId",36)).append("requestId",safeString("$scope.requestId",36))
+ .append("scopeSize",new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type","$scope"),"object")),new Document("$size",new Document("$objectToArray","$scope")),-1)))
+ .append("attempt",new Document("$cond",Arrays.asList(new Document("$in",List.of(new Document("$type","$scope.scanAttempt"),List.of("int","long"))),"$scope.scanAttempt",null)));
+ var rows=ReviewRepairIo.collection(records).aggregate(List.of(new Document("$match",match),new Document("$sort",new Document("_id",1)),new Document("$limit",limit+1),new Document("$project",projection)))
+ .hintString(INDEX).collation(BINARY).allowDiskUse(false).maxTime(5,TimeUnit.SECONDS).batchSize(limit+1).into(new ArrayList<>());
+ var candidates=new ArrayList();int unavailable=0,count=Math.min(limit,rows.size());
+ for(var row:rows.subList(0,count)) {
+ try {
+ if(!Integer.valueOf(5).equals(row.get("scopeSize")) || !row.get("_id").equals(row.get("requestId")) || !(row.get("attempt") instanceof Number number) || number.longValue()<1 || number.longValue()>Integer.MAX_VALUE)throw invalid();
+ candidates.add(new ProjectMutationAdmissionAttempts.Scope(row.get("projectId"),row.getString("versionId"),row.getString("mutationId"),row.getString("requestId"),((Number)row.get("attempt")).intValue()));
+ }catch(IllegalStateException | ClassCastException malformed){unavailable++;}
+ }
+ return new Page(candidates,rows.size()>limit?rows.get(count-1).getString("_id"):null,count,unavailable);
+ }
+ public String recover(ProjectMutationAdmissionAttempts.Scope scope,BooleanSupplier running) {
+ return budget.call(allowed->{
+ var status=attempts.statusWithinBudget(scope,allowed);if(!"RUNNING".equals(status.state()))return status.state();
+ var claim=status.current();ProjectMutationAdmissionReader.History proof;
+ try{proof=admissions.read(scope.projectId(),scope.requestId(),allowed);}catch(IllegalStateException unavailable){return "UNRESOLVED";}
+ var decision=proof.decision();
+ if(!ProjectMutationAutomaticAdmission.ACTOR.equals(proof.source().actorId()) || !claim.decisionId().equals(decision.id()) || !claim.heldSha256().equals(decision.heldSha256())
+ || !scope.mutationId().equals(decision.mutationId()) || !scope.versionId().equals(decision.versionId()) || scope.scanAttempt()!=decision.binding().attempt())return "UNRESOLVED";
+ return attempts.finishWithinBudget(claim,ProjectMutationAdmissionAttempts.Outcome.ADMITTED,allowed).state();
+ },running);
+ }
+ private static Document safeString(String field,int max) {return new Document("$cond",Arrays.asList(new Document("$cond",List.of(new Document("$eq",List.of(new Document("$type",field),"string")),new Document("$and",List.of(new Document("$gt",List.of(new Document("$strLenCP",field),0)),new Document("$lte",List.of(new Document("$strLenCP",field),max)))),false)),field,null));}
+ private static IllegalStateException invalid(){return new IllegalStateException("Invalid automatic admission recovery scope");}
+}
diff --git a/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAutomaticAdmission.java b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAutomaticAdmission.java
new file mode 100644
index 000000000..8a5e43dba
--- /dev/null
+++ b/backend/src/main/java/net/modtale/service/admin/review/ProjectMutationAutomaticAdmission.java
@@ -0,0 +1,98 @@
+package net.modtale.service.admin.review;
+
+import com.mongodb.*;
+import net.modtale.service.security.scan.RemoteReviewClient;
+import org.bson.*;
+import org.bson.codecs.DocumentCodec;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
+
+/** Automatic coordination never acknowledges uncertain duplicate execution or grants clearance. */
+public final class ProjectMutationAutomaticAdmission {
+ public static final String ACTOR="security-admission";
+ public record Result(String state,String decisionId) {}
+ private final MongoTemplate mongo;private final ReviewRepairWorkflow budget;private final RawReviewSnapshotReader reader;
+ private final ProjectMutationReferenceReader history;private final ProjectMutationPriorWorkReader prior;
+ private final ProjectMutationObservationProgress progress;
+ private final ProjectMutationAdmissionAttempts attempts;private final ProjectMutationJobAccounting accounting;
+ private final ProjectMutationAdmissionPreparation preparation;private final ProjectMutationActivator activator;private final ProjectMutationAdmissionReader admissions;
+ public ProjectMutationAutomaticAdmission(MongoTemplate mongo,ReviewRepairWorkflow budget,ProjectMutationReferenceReader history,ProjectMutationPriorWorkReader prior,
+ ProjectMutationAdmissionAttempts attempts,ProjectMutationJobAccounting accounting,ProjectMutationAdmissionPreparation preparation,ProjectMutationActivator activator,ReviewSnapshotArchive archive) {
+ this.mongo=Objects.requireNonNull(mongo);this.budget=Objects.requireNonNull(budget);this.history=Objects.requireNonNull(history);this.prior=Objects.requireNonNull(prior);
+ this.attempts=Objects.requireNonNull(attempts);this.accounting=Objects.requireNonNull(accounting);this.preparation=Objects.requireNonNull(preparation);this.activator=Objects.requireNonNull(activator);
+ progress=new ProjectMutationObservationProgress(mongo,accounting);reader=new RawReviewSnapshotReader(mongo);admissions=new ProjectMutationAdmissionReader(mongo,archive);
+ }
+ public Result advance(ProjectMutationDiscovery.Candidate candidate,BooleanSupplier running) {
+ return budget.call(allowed->advanceWithinBudget(candidate,()->allowed.getAsBoolean() && activeProject(candidate.projectId())),running);
+ }
+ private Result advanceWithinBudget(ProjectMutationDiscovery.Candidate candidate,BooleanSupplier allowed) {
+ permission(allowed);var scope=new ProjectMutationAdmissionAttempts.Scope(candidate.projectId(),candidate.versionId(),candidate.mutationId(),candidate.requestId(),candidate.attempt());
+ var captured=reader.capture(candidate.projectId(),candidate.versionIndex(),candidate.versionId());var version=new RawBsonDocument(captured.versionBytes()).decode(new DocumentCodec());
+ var pointer=version.get("versionMutation",Document.class);var scan=version.get("scanResult",Document.class);
+ if(pointer==null || !candidate.mutationId().equals(pointer.get("operationId")) || !candidate.requestId().equals(pointer.get("requestId")))return new Result("NO_WORK",null);
+ if(scan==null || !"MUTATION_HELD".equals(scan.get("scanState")))return reconcileAdmission(scope,version,allowed);
+ if(!candidate.requestId().equals(scan.get("scanRequestId")) || !(scan.get("scanAttempt") instanceof Integer || scan.get("scanAttempt") instanceof Long)
+ || ((Number)scan.get("scanAttempt")).longValue()!=candidate.attempt())return new Result("NO_WORK",null);
+ history.requireHeldHeads(candidate.projectId(),bytes(new Document("_id",candidate.projectId()).append("versions",List.of(version))),allowed);
+ var claim=attempts.beginWithinBudget(scope,captured.sha256(),allowed);
+ if(claim==null){var state=attempts.statusWithinBudget(scope,allowed);return new Result(state.state(),state.current()==null?null:state.current().decisionId());}
+ ProjectMutationPriorWorkReader.Inventory inventory;
+ try{inventory=prior.readWithinBudget(candidate.projectId(),candidate.mutationId(),allowed);}
+ catch(ProjectMutationPriorWorkReader.LimitExceeded limit){return finish(claim,ProjectMutationAdmissionAttempts.Outcome.ATTENTION,allowed);}
+ if(inventory.work().stream().anyMatch(work->work.kind()==ProjectMutationPriorWorkReader.Kind.UNRESOLVED))return finish(claim,ProjectMutationAdmissionAttempts.Outcome.ATTENTION,allowed);
+ var observations=new LinkedHashMap();boolean waiting=false,attention=false,yielded=false,madeProgress=false;int reads=0;
+ try(var receipts=accounting.readBatch(candidate.projectId(),allowed)){
+ for(var work:inventory.work()) {
+ if(work.kind()!=ProjectMutationPriorWorkReader.Kind.REMOTE_JOB)continue;
+ String key=work.mutationId()+"/"+work.versionId();String id=UUID.nameUUIDFromBytes(("automatic-status-1:"+claim.decisionId()+":"+key).getBytes(StandardCharsets.UTF_8)).toString();
+ var receipt=progress.completed(scope,work,allowed,receipts);
+ if(receipt==null) {
+ // Leave time to retain the result and finish bookkeeping before yielding this generation.
+ if(reads>=1 || ReviewRepairIo.currentRemainingNanos()ReviewSnapshotArchive.MAX_BYTES || afterProject && offset!=0)throw invalid();}
+ }
+ public record Candidate(Object projectId,int versionIndex,String versionId,String mutationId,String requestId,int attempt) {}
+ public record Page(List candidates,Cursor next,int examined,int unavailable) {
+ public Page {candidates=List.copyOf(candidates);}
+ }
+ private final MongoCollection projects;
+ public ProjectMutationDiscovery(MongoTemplate mongo) {
+ projects=mongo.getCollection("projects").withReadPreference(ReadPreference.primary()).withReadConcern(ReadConcern.MAJORITY);
+ }
+ public Page page(Cursor cursor,int limit) {
+ if(limit<1 || limit>64)throw invalid();
+ var terms=new ArrayList