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: register-path:
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1) @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) @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" \ -H "Content-Type: application/json" \
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool -d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool

View File

@@ -78,7 +78,7 @@ async def create_contribution(
return contrib return contrib
@router.get("/{contribution_id}/", response_model=ContributionRead) @router.get("/{contribution_id}", response_model=ContributionRead)
async def get_contribution( async def get_contribution(
contribution_id: uuid.UUID, contribution_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -118,7 +118,7 @@ async def patch_contribution_status(
return contrib 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( async def withdraw_contribution(
contribution_id: uuid.UUID, contribution_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),

View File

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

View File

@@ -1,11 +1,17 @@
import asyncio import asyncio
import json
import socket
import subprocess
import sys
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import case, func, select from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from api.database import get_session from api.database import get_session
from api.doi_engine import compute_fingerprint, evaluate as _doi_evaluate from api.doi_engine import compute_fingerprint, evaluate as _doi_evaluate
from api.models.doi_cache import DOICache from api.models.doi_cache import DOICache
@@ -337,7 +343,7 @@ async def get_repo_by_id(
return repo return repo
@router.get("/{slug}/", response_model=RepoRead) @router.get("/{slug}", response_model=RepoRead)
async def get_repo( async def get_repo(
slug: str, slug: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -345,7 +351,7 @@ async def get_repo(
return await _get_repo_by_slug(slug, session) return await _get_repo_by_slug(slug, session)
@router.patch("/{slug}/", response_model=RepoRead) @router.patch("/{slug}", response_model=RepoRead)
async def update_repo( async def update_repo(
slug: str, slug: str,
body: RepoUpdate, body: RepoUpdate,
@@ -359,7 +365,7 @@ async def update_repo(
return repo return repo
@router.post("/{slug}/paths/", response_model=RepoRead) @router.post("/{slug}/paths", response_model=RepoRead)
async def register_host_path( async def register_host_path(
slug: str, slug: str,
body: RepoPathRegister, 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: async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
repo = result.scalar_one_or_none() 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()] 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( async def get_snapshot(
snapshot_id: uuid.UUID, snapshot_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -209,7 +209,7 @@ async def licence_report(
return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count) 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( async def get_repo_sbom(
repo_slug: str, repo_slug: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),

View File

@@ -110,7 +110,7 @@ async def defer_td(
# ── Notes ───────────────────────────────────────────────────────────────────── # ── Notes ─────────────────────────────────────────────────────────────────────
@router.get("/{td_id}/notes/", response_model=list[TDNoteRead]) @router.get("/{td_id}/notes", response_model=list[TDNoteRead])
async def list_notes( async def list_notes(
td_id: uuid.UUID, td_id: uuid.UUID,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@@ -124,7 +124,7 @@ async def list_notes(
return list(result.scalars().all()) 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( async def add_note(
td_id: uuid.UUID, td_id: uuid.UUID,
body: TDNoteCreate, 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: def resolve_repo_path(repo_slug: str) -> Path | None:
"""Look up the registered path for a repo slug via the state-hub API.""" """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: try:
with urllib.request.urlopen(url, timeout=10) as resp: with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read()) 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: async def check_repo(slug: str, as_json: bool) -> bool:
try: try:
repo = _get(f"/repos/{slug}/") repo = _get(f"/repos/{slug}")
except Exception as e: except Exception as e:
print(f"✗ Could not fetch repo '{slug}': {e}", file=sys.stderr) print(f"✗ Could not fetch repo '{slug}': {e}", file=sys.stderr)
return False return False

View File

@@ -1083,7 +1083,7 @@ def fix_repo(
hostname = socket.gethostname() hostname = socket.gethostname()
if (repo_record.get("host_paths") or {}).get(hostname) != repo_path: if (repo_record.get("host_paths") or {}).get(hostname) != repo_path:
result = _api_post( result = _api_post(
api_base, f"/repos/{repo_slug}/paths/", api_base, f"/repos/{repo_slug}/paths",
{"host": hostname, "path": repo_path}, {"host": hostname, "path": repo_path},
) )
if result and "_error" not in result: if result and "_error" not in result:
@@ -1333,7 +1333,7 @@ def fix_repo(
from datetime import timezone as _tz from datetime import timezone as _tz
import datetime as _dt import datetime as _dt
now_iso = _dt.datetime.now(_tz.utc).isoformat() 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) # Write the worker orientation brief (.custodian-brief.md)
if repo_path: 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: 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.""" """Query the hub for this host's registered path for repo_slug."""
try: try:
url = f"{api_base}/repos/{repo_slug}/" url = f"{api_base}/repos/{repo_slug}"
with urllib.request.urlopen(url) as resp: with urllib.request.urlopen(url) as resp:
data = json.loads(resp.read()) data = json.loads(resp.read())
hostname = socket.gethostname() hostname = socket.gethostname()

View File

@@ -45,7 +45,7 @@ resolve_path() {
local slug="$1" local slug="$1"
# Try the registered local_path first # Try the registered local_path first
local api_path 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) "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 if [[ -n "$api_path" && -d "$api_path" ]]; then
echo "$api_path" echo "$api_path"

View File

@@ -73,7 +73,7 @@ echo " API OK"
# ── Step 2: Verify domain exists ─────────────────────────────────────────────── # ── Step 2: Verify domain exists ───────────────────────────────────────────────
echo "==> Verifying domain '$DOMAIN' ..." 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 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 "ERROR: Domain '$DOMAIN' not found in the State Hub."
echo " To create: make add-domain DOMAIN=$DOMAIN NAME=\"<display name>\"" echo " To create: make add-domain DOMAIN=$DOMAIN NAME=\"<display name>\""
@@ -252,7 +252,7 @@ fi
# ── Step 7: Register this machine's local path ──────────────────────────────── # ── Step 7: Register this machine's local path ────────────────────────────────
echo "==> Registering host path for $(hostname) ..." 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" \ -H "Content-Type: application/json" \
-d "{\"host\": \"$(hostname)\", \"path\": \"$PROJECT_PATH\"}" > /dev/null \ -d "{\"host\": \"$(hostname)\", \"path\": \"$PROJECT_PATH\"}" > /dev/null \
&& echo " host_paths[$(hostname)] = $PROJECT_PATH" && echo " host_paths[$(hostname)] = $PROJECT_PATH"

View File

@@ -68,13 +68,13 @@ class TestTechnicalDebt:
}) })
td_id = r.json()["id"] 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", "step": "analysis",
"content": "Root cause identified.", "content": "Root cause identified.",
}) })
assert r2.status_code == 201 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 r3.status_code == 200
assert len(r3.json()) == 1 assert len(r3.json()) == 1