fix(api): normalize trailing slashes — no slash on param routes

Rule: trailing slash only on collection roots (/). Any route containing
a path parameter {…} uses no trailing slash. Applies across all routers,
scripts, Makefile, and tests. Fixes 307-redirect fragility on POST/PATCH
from naive clients (curl, Codex HTTP calls).

Also adds POST /repos/{slug}/sync — runs ADR-001 consistency check with
--fix via HTTP, so non-MCP agents (Codex) can self-service DB sync without
operator intervention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 15:13:01 +02:00
parent fa3d7d8e8c
commit 8dd15efde1
13 changed files with 74 additions and 21 deletions

View File

@@ -127,7 +127,7 @@ rename-domain:
register-path:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
@test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths/" \
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \
-H "Content-Type: application/json" \
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool

View File

@@ -78,7 +78,7 @@ async def create_contribution(
return contrib
@router.get("/{contribution_id}/", response_model=ContributionRead)
@router.get("/{contribution_id}", response_model=ContributionRead)
async def get_contribution(
contribution_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
@@ -118,7 +118,7 @@ async def patch_contribution_status(
return contrib
@router.delete("/{contribution_id}/", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT)
async def withdraw_contribution(
contribution_id: uuid.UUID,
session: AsyncSession = Depends(get_session),

View File

@@ -45,7 +45,7 @@ async def create_domain(
return domain
@router.get("/{slug}/", response_model=DomainDetail)
@router.get("/{slug}", response_model=DomainDetail)
async def get_domain(
slug: str,
session: AsyncSession = Depends(get_session),

View File

@@ -1,11 +1,17 @@
import asyncio
import json
import socket
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from api.database import get_session
from api.doi_engine import compute_fingerprint, evaluate as _doi_evaluate
from api.models.doi_cache import DOICache
@@ -337,7 +343,7 @@ async def get_repo_by_id(
return repo
@router.get("/{slug}/", response_model=RepoRead)
@router.get("/{slug}", response_model=RepoRead)
async def get_repo(
slug: str,
session: AsyncSession = Depends(get_session),
@@ -345,7 +351,7 @@ async def get_repo(
return await _get_repo_by_slug(slug, session)
@router.patch("/{slug}/", response_model=RepoRead)
@router.patch("/{slug}", response_model=RepoRead)
async def update_repo(
slug: str,
body: RepoUpdate,
@@ -359,7 +365,7 @@ async def update_repo(
return repo
@router.post("/{slug}/paths/", response_model=RepoRead)
@router.post("/{slug}/paths", response_model=RepoRead)
async def register_host_path(
slug: str,
body: RepoPathRegister,
@@ -471,6 +477,53 @@ async def get_repo_dispatch(
)
@router.post("/{slug}/sync")
async def sync_repo_consistency(
slug: str,
fix: bool = True,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Run ADR-001 consistency check (and optional --fix) for a repo via HTTP.
Intended for non-Claude-Code agents (e.g. Codex) that cannot use MCP tools
but need to sync workplan file state to the state-hub DB after making changes.
Returns the raw JSON output from consistency_check.py.
Query param ?fix=false to run check-only without writing.
"""
repo = await _get_repo_by_slug(slug, session)
hostname = socket.gethostname()
host_paths = repo.host_paths or {}
repo_path = host_paths.get(hostname)
if not repo_path or not Path(repo_path).exists():
raise HTTPException(
status_code=503,
detail=(
f"No accessible path for repo '{slug}' on host '{hostname}'. "
f"Register with: POST /repos/{slug}/paths/"
),
)
script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
cmd = [sys.executable, str(script), "--repo", slug, "--json",
"--api-base", settings.api_base]
if fix:
cmd.append("--fix")
result = await asyncio.to_thread(
subprocess.run, cmd, capture_output=True, text=True
)
try:
return json.loads(result.stdout)
except Exception:
raise HTTPException(
status_code=500,
detail=f"Consistency check failed: {result.stderr or result.stdout or '(no output)'}",
)
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none()

View File

@@ -112,7 +112,7 @@ async def list_snapshots(
return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()]
@router.get("/snapshots/{snapshot_id}/", response_model=SBOMSnapshotDetail)
@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail)
async def get_snapshot(
snapshot_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
@@ -209,7 +209,7 @@ async def licence_report(
return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count)
@router.get("/{repo_slug}/", response_model=SBOMRepoView)
@router.get("/{repo_slug}", response_model=SBOMRepoView)
async def get_repo_sbom(
repo_slug: str,
session: AsyncSession = Depends(get_session),

View File

@@ -110,7 +110,7 @@ async def defer_td(
# ── Notes ─────────────────────────────────────────────────────────────────────
@router.get("/{td_id}/notes/", response_model=list[TDNoteRead])
@router.get("/{td_id}/notes", response_model=list[TDNoteRead])
async def list_notes(
td_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
@@ -124,7 +124,7 @@ async def list_notes(
return list(result.scalars().all())
@router.post("/{td_id}/notes/", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED)
@router.post("/{td_id}/notes", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED)
async def add_note(
td_id: uuid.UUID,
body: TDNoteCreate,

View File

@@ -37,7 +37,7 @@ PROMPT_FILE = SCRIPT_DIR.parent / "prompts" / "sbom-capture-agent.md"
def resolve_repo_path(repo_slug: str) -> Path | None:
"""Look up the registered path for a repo slug via the state-hub API."""
url = f"{API_BASE}/repos/{repo_slug}/"
url = f"{API_BASE}/repos/{repo_slug}"
try:
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read())

View File

@@ -69,7 +69,7 @@ def _print_report(report, use_color: bool = True) -> None:
async def check_repo(slug: str, as_json: bool) -> bool:
try:
repo = _get(f"/repos/{slug}/")
repo = _get(f"/repos/{slug}")
except Exception as e:
print(f"✗ Could not fetch repo '{slug}': {e}", file=sys.stderr)
return False

View File

@@ -1083,7 +1083,7 @@ def fix_repo(
hostname = socket.gethostname()
if (repo_record.get("host_paths") or {}).get(hostname) != repo_path:
result = _api_post(
api_base, f"/repos/{repo_slug}/paths/",
api_base, f"/repos/{repo_slug}/paths",
{"host": hostname, "path": repo_path},
)
if result and "_error" not in result:
@@ -1333,7 +1333,7 @@ def fix_repo(
from datetime import timezone as _tz
import datetime as _dt
now_iso = _dt.datetime.now(_tz.utc).isoformat()
_api_patch(api_base, f"/repos/{repo_slug}/", {"last_state_synced_at": now_iso})
_api_patch(api_base, f"/repos/{repo_slug}", {"last_state_synced_at": now_iso})
# Write the worker orientation brief (.custodian-brief.md)
if repo_path:

View File

@@ -509,7 +509,7 @@ def post_ingest(api_base: str, repo_slug: str, entries: list[dict]) -> dict:
def _resolve_repo_path_from_hub(api_base: str, repo_slug: str) -> Path | None:
"""Query the hub for this host's registered path for repo_slug."""
try:
url = f"{api_base}/repos/{repo_slug}/"
url = f"{api_base}/repos/{repo_slug}"
with urllib.request.urlopen(url) as resp:
data = json.loads(resp.read())
hostname = socket.gethostname()

View File

@@ -45,7 +45,7 @@ resolve_path() {
local slug="$1"
# Try the registered local_path first
local api_path
api_path=$(curl -sf "${API_BASE}/repos/${slug}/" | python3 -c \
api_path=$(curl -sf "${API_BASE}/repos/${slug}" | python3 -c \
"import json,sys; d=json.load(sys.stdin); print(d.get('local_path') or '')" 2>/dev/null || true)
if [[ -n "$api_path" && -d "$api_path" ]]; then
echo "$api_path"

View File

@@ -73,7 +73,7 @@ echo " API OK"
# ── Step 2: Verify domain exists ───────────────────────────────────────────────
echo "==> Verifying domain '$DOMAIN' ..."
DOMAIN_JSON="$(curl -sf "$API_BASE/domains/$DOMAIN/" 2>/dev/null || echo 'NOT_FOUND')"
DOMAIN_JSON="$(curl -sf "$API_BASE/domains/$DOMAIN" 2>/dev/null || echo 'NOT_FOUND')"
if [[ "$DOMAIN_JSON" == "NOT_FOUND" ]] || ! echo "$DOMAIN_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('slug') else 1)" 2>/dev/null; then
echo "ERROR: Domain '$DOMAIN' not found in the State Hub."
echo " To create: make add-domain DOMAIN=$DOMAIN NAME=\"<display name>\""
@@ -252,7 +252,7 @@ fi
# ── Step 7: Register this machine's local path ────────────────────────────────
echo "==> Registering host path for $(hostname) ..."
curl -sf -X POST "$API_BASE/repos/$REPO_SLUG/paths/" \
curl -sf -X POST "$API_BASE/repos/$REPO_SLUG/paths" \
-H "Content-Type: application/json" \
-d "{\"host\": \"$(hostname)\", \"path\": \"$PROJECT_PATH\"}" > /dev/null \
&& echo " host_paths[$(hostname)] = $PROJECT_PATH"

View File

@@ -68,13 +68,13 @@ class TestTechnicalDebt:
})
td_id = r.json()["id"]
r2 = await client.post(f"/technical-debt/{td_id}/notes/", json={
r2 = await client.post(f"/technical-debt/{td_id}/notes", json={
"step": "analysis",
"content": "Root cause identified.",
})
assert r2.status_code == 201
r3 = await client.get(f"/technical-debt/{td_id}/notes/")
r3 = await client.get(f"/technical-debt/{td_id}/notes")
assert r3.status_code == 200
assert len(r3.json()) == 1