diff --git a/api/app/auth.py b/api/app/auth.py new file mode 100644 index 0000000..c75932e --- /dev/null +++ b/api/app/auth.py @@ -0,0 +1,230 @@ +"""Identity: humans, agents, and internal services (G3.2 / G3.6 / I-6). + +Three caller classes, all first-class, none a bypass: + + * **human** — account-server RS256 JWT (OIDC) + * **agent** — Eternitas ES256 EPT + * **service** — `X-Service-Token`, for the Cloud portal calling `/internal/*` + +There is deliberately no fourth class and no escape hatch. The Windy Word desktop +control server is the best Principle-#5 artifact in the ecosystem partly because +it has **no bypass environment variable**, and that is copied here on purpose. + +I-6 — EPT parity plus asymmetry: an agent in good standing gets exactly what a +human of the same tier gets. Where a sibling cell silently demotes a tiered agent +to FREE because its EPT carries no tier, we do the opposite, and a test proves it. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import StrEnum + +import httpx +from fastapi import Header, Request + +from api.app.config import Settings +from api.app.errors import RepairPointer, passport_unresolvable + +log = logging.getLogger(__name__) + + +class ActorType(StrEnum): + """G3.7 — these three are the ONLY legal values. + + A sibling service emits `actor_type: 'service'` into a telemetry ingest whose + Literal allows only human|agent|system, so every batch 422s and is dropped + with a single console warning. `service` is not spelled `service` here. + """ + + human = "human" + agent = "agent" + system = "system" + + +@dataclass(frozen=True) +class Caller: + actor_type: ActorType + identity_id: str | None = None + passport: str | None = None + band: str | None = None + allowed_actions: tuple[str, ...] = () + + @property + def subject(self) -> str: + return self.identity_id or self.passport or "system" + + +# EI_CAPABILITY_MATRIX.v1 — velocity multipliers by integrity band. +BAND_MULTIPLIER: dict[str, float] = { + "platinum": 10.0, + "gold": 4.0, + "standard": 1.0, + "proven": 1.0, + "watch": 0.5, + "untrusted": 0.0, # read-only + # Eternitas began emitting this band on 2026-07-30 and it is not in the + # documented enum yet. Treating an unknown band as untrusted would lock out + # every freshly hatched agent; treating it as trusted would be a hole. + # Standard-with-no-bonus is the honest middle. + "unproven": 1.0, +} + + +async def resolve_passport(settings: Settings, passport: str) -> tuple[str, tuple[str, ...]]: + """G3.6 — THE STATUS-CODE LAW. + + 404 and 400 REFUSE. 429 and 5xx retry with backoff, then REFUSE. + + A sibling service maps 400 and 429 to "unreachable" and then soft-ALLOWS. + That is a live residual bypass, because 429 is trivially inducible at + 100/min/IP: an attacker who wants the check skipped only has to make the + check rate-limit itself. There is no code path here where an unresolvable + passport is permitted to write. + """ + if not settings.eternitas_configured: + raise RepairPointer( + status_code=503, + code="trust_unavailable", + speak="We can't confirm helper IDs right now, so we didn't let that change through.", + machine_cause="eternitas is not configured; policy is fail-closed", + remediation_tool=None, + ) + + url = f"{settings.eternitas_base_url}/api/v1/trust/{passport}" + headers = {"X-API-Key": settings.eternitas_platform_api_key} + last_status = 0 + for attempt in range(3): + async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client: + try: + r = await client.get(url, headers=headers) + except httpx.RequestError as exc: + last_status = 599 + log.warning("eternitas unreachable (attempt %s): %s", attempt + 1, exc) + continue + last_status = r.status_code + if r.status_code == 200: + body = r.json() + return body.get("band", "unproven"), tuple(body.get("allowed_actions", [])) + if r.status_code in (400, 404): + # Malformed or not-issued. Refuse immediately — retrying cannot help + # and pretending it might is how a soft-allow gets written. + break + # 429 / 5xx: retry, then refuse. Never allow. + raise passport_unresolvable(passport, last_status) + + +async def get_caller( + request: Request, + authorization: str | None = Header(default=None), + x_service_token: str | None = Header(default=None), +) -> Caller: + settings: Settings = request.app.state.settings + + # --- internal service caller (the Cloud portal) ------------------------ + if x_service_token: + expected = settings.service_token + if not expected: + raise RepairPointer( + status_code=503, + code="service_auth_unconfigured", + speak="That connection isn't set up yet.", + machine_cause="SERVICE_TOKEN is unset; refusing to accept service calls", + remediation_tool=None, + ) + # Constant-time compare, copied from the desktop control server's + # control-auth pattern rather than reinvented. + import hmac + + if not hmac.compare_digest(x_service_token, expected): + raise RepairPointer( + status_code=401, + code="service_token_invalid", + speak="That connection isn't authorised.", + machine_cause="X-Service-Token did not match", + remediation_tool=None, + ) + return Caller(actor_type=ActorType.system, identity_id="system") + + if not authorization or not authorization.lower().startswith("bearer "): + raise RepairPointer( + status_code=401, + code="not_signed_in", + speak="You'll need to sign in first.", + machine_cause="no bearer token and no service token presented", + remediation_tool=None, + ) + + token = authorization.split(" ", 1)[1].strip() + + # --- agent (Eternitas EPT) -------------------------------------------- + # An EPT names its passport; the trust API is the authority on whether that + # passport may act. We never read a band out of the token itself. + passport = _unverified_claim(token, "passport") or _unverified_claim(token, "sub_passport") + if passport: + band, actions = await resolve_passport(settings, passport) + if band.lower() == "untrusted": + raise RepairPointer( + status_code=403, + code="agent_read_only", + speak="That helper can look, but it isn't allowed to make changes yet.", + machine_cause=f"passport {passport} band=untrusted is read-only", + remediation_tool=None, + ) + return Caller( + actor_type=ActorType.agent, + passport=passport, + band=band, + allowed_actions=actions, + ) + + # --- human (account-server RS256) ------------------------------------- + if settings.is_production and settings.require_verified_jwt: + # I-8, applied to ourselves. G3.2's JWKS verifier is not written yet, and + # an unverified JWT is an authentication bypass rather than a shortcut. + # Refusing is the only honest answer until the verifier exists. + raise RepairPointer( + status_code=503, + code="human_signin_not_ready", + speak="Signing in isn't switched on yet. Nothing you have is affected.", + machine_cause=( + "JWKS verification (G3.2) is not implemented; refusing to accept " + "an unverified human token in production" + ), + remediation_tool=None, + ) + + identity_id = _unverified_claim(token, "windy_identity_id") or _unverified_claim(token, "sub") + if not identity_id: + raise RepairPointer( + status_code=401, + code="token_unrecognised", + speak="We couldn't read that sign-in. Try signing in again.", + machine_cause="token carried neither a passport nor an identity claim", + remediation_tool=None, + ) + return Caller(actor_type=ActorType.human, identity_id=identity_id) + + +def _unverified_claim(token: str, claim: str) -> str | None: + """Read a claim WITHOUT verifying the signature. + + Used only to decide which verifier a token belongs to. Every path that acts + on the result re-establishes trust independently: an agent's authority comes + from a live Eternitas trust lookup, never from the token's own assertions. + + ⚠️ Full RS256/ES256 JWKS verification for the human path lands in G3.2's + verifier and MUST be in place before `api.windygit.com` accepts a human + token from outside. Until then the human path is reachable only from inside + the tunnel, and `settings.require_verified_jwt` refuses it in production. + """ + import base64 + import json + + try: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)).get(claim) + except Exception: # noqa: BLE001 + return None diff --git a/api/app/config.py b/api/app/config.py index 6328ee3..d4cfd95 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -50,6 +50,17 @@ class Settings(BaseSettings): # ---- account-server OIDC (human identity) ----------------------------- account_server_base_url: str = "https://account.windyword.ai" + # Internal callers (the Cloud portal calling /internal/*). A first-class + # caller class, not a bypass: unset means service calls are REFUSED. + service_token: str = "" + + # ⚠️ FAIL-CLOSED GATE. Full RS256/ES256 JWKS verification lands in G3.2. + # Until it does, the human token path must not be reachable in production — + # accepting an unverified JWT is not a shortcut, it is an authentication + # bypass. Agents are unaffected: their authority comes from a live Eternitas + # trust lookup, not from anything the token asserts about itself. + require_verified_jwt: bool = True + # ---- storage law (I-3, G4.4) ------------------------------------------ # Git object databases MUST live on a POSIX filesystem. A test asserts this # path does not resolve to a network mount. diff --git a/api/app/main.py b/api/app/main.py index 25801b4..e0ae03c 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -13,7 +13,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse -from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from api.app.buildinfo import get_build_info from api.app.config import get_settings @@ -24,7 +24,7 @@ from api.app.providers.registry import ( GiteaProvider, R2Provider, ) -from api.app.routes import health +from api.app.routes import health, repos logging.basicConfig( level=logging.INFO, @@ -80,6 +80,9 @@ async def lifespan(app: FastAPI): app.state.settings = settings app.state.engine = engine + app.state.sessionmaker = ( + async_sessionmaker(engine, expire_on_commit=False) if engine is not None else None + ) app.state.providers = [ DatabaseProvider(engine), GiteaProvider(settings), @@ -112,6 +115,7 @@ app = FastAPI( ) app.include_router(health.router) +app.include_router(repos.router) @app.exception_handler(RepairPointer) diff --git a/api/app/routes/repos.py b/api/app/routes/repos.py new file mode 100644 index 0000000..76eb361 --- /dev/null +++ b/api/app/routes/repos.py @@ -0,0 +1,436 @@ +"""The shelter — repos, grants and version history (strand G5, D-8). + +Windy Cloud today has **no sharing, no permissions and no versioning of any +kind**: verified 2026-08-11 against `routes/storage.py` and its models, which +contain zero occurrences of share / permission / acl / collaborat / seat / +version / snapshot / history / revision. This plane is not a feature bolted onto +something that already had one — it fills a hole that has never been filled. + +D-8 also fixes the order: **permissions and history ship before the git +protocol.** "I want someone to help me with my website" is a real problem for a +real person, and it does not require them to know what a repository is. + +Every string a person sees here obeys the D-9 vocabulary law: *version* and +*save point*, never *commit*, and never the countable form of the word "Git" on +any surface, ever. See `scripts/vocab_audit.py`, which enforces this. +""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime, timedelta +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from api.app.auth import ActorType, Caller, get_caller +from api.app.errors import RepairPointer +from api.app.models.core import ( + CreatedVia, + GrantRole, + Repo, + RepoGrant, + RepoState, + RepoType, + RepoVersion, + Visibility, +) +from api.app.services.gitea_client import GiteaClient + +router = APIRouter(prefix="/api/v1/repos", tags=["repos"]) + +# Gitea's permission vocabulary, mapped from ours. Ours is the one users see. +_ROLE_TO_GITEA = { + GrantRole.owner: "admin", + GrantRole.maintainer: "admin", + GrantRole.writer: "write", + GrantRole.reader: "read", +} + +_RESERVED_SLUGS = { + "api", "admin", "login", "logout", "signup", "settings", "explore", + "new", "user", "org", "repo", "assets", "static", "help", "about", +} + + +# -------------------------------------------------------------------------- +# schemas +# -------------------------------------------------------------------------- +class CreateRepo(BaseModel): + name: str = Field(min_length=1, max_length=100) + display_name: str | None = None + description: str = "" + # I-7 — required, never defaulted at read time, never inferred. + repo_type: RepoType + visibility: Visibility = Visibility.private + + @field_validator("name") + @classmethod + def _slug(cls, v: str) -> str: + slug = "".join(c if (c.isalnum() or c in "-_") else "-" for c in v.strip().lower()) + slug = "-".join(filter(None, slug.split("-"))) + if not slug: + raise ValueError("name must contain at least one letter or number") + if slug in _RESERVED_SLUGS: + raise ValueError(f"'{slug}' is reserved") + return slug + + +class CreateGrant(BaseModel): + role: GrantRole + identity_id: str | None = None + passport: str | None = None + + @field_validator("passport") + @classmethod + def _one_of(cls, v: str | None, info) -> str | None: + if bool(info.data.get("identity_id")) == bool(v): + # Mirrors the database CHECK constraint. Both layers, deliberately: + # this ecosystem already has an invariant enforced only in + # application code across two files, and a double-mint to show for it. + raise ValueError("give exactly one of identity_id or passport") + return v + + +# -------------------------------------------------------------------------- +# helpers +# -------------------------------------------------------------------------- +def _sessionmaker(request: Request) -> async_sessionmaker[AsyncSession]: + maker = getattr(request.app.state, "sessionmaker", None) + if maker is None: + raise RepairPointer( + status_code=503, + code="database_unavailable", + speak="We can't reach your projects right now. Nothing has been lost.", + machine_cause="no database sessionmaker on app.state", + remediation_tool=None, + ) + return maker + + +def _owner_login(caller: Caller) -> str: + """One namespace rule for humans and agents alike (I-6).""" + if caller.actor_type == ActorType.agent and caller.passport: + return f"agent-{caller.passport.lower().replace('-', '')}" + return f"u-{(caller.identity_id or 'unknown')[:24]}" + + +async def _load_repo(session: AsyncSession, repo_id: uuid.UUID, caller: Caller) -> Repo: + repo = (await session.execute(select(Repo).where(Repo.id == repo_id))).scalar_one_or_none() + if repo is None or repo.state == RepoState.deleted_soft: + raise RepairPointer( + status_code=404, + code="project_not_found", + speak="We couldn't find that project.", + machine_cause=f"repo {repo_id} not found or soft-deleted", + remediation_tool=None, + ) + if not await _may_read(session, repo, caller): + # 404, not 403: a stranger should not learn that a private project exists. + raise RepairPointer( + status_code=404, + code="project_not_found", + speak="We couldn't find that project.", + machine_cause=f"caller {caller.subject} has no grant on repo {repo_id}", + remediation_tool=None, + ) + return repo + + +async def _may_read(session: AsyncSession, repo: Repo, caller: Caller) -> bool: + if caller.actor_type == ActorType.system: + return True + if repo.visibility == Visibility.public: + return True + if caller.identity_id and repo.identity_id == caller.identity_id: + return True + if caller.passport and repo.passport == caller.passport: + return True + return await _active_grant(session, repo, caller) is not None + + +async def _active_grant(session: AsyncSession, repo: Repo, caller: Caller) -> RepoGrant | None: + now = datetime.now(UTC) + rows = ( + await session.execute( + select(RepoGrant).where( + RepoGrant.repo_id == repo.id, RepoGrant.revoked_at.is_(None) + ) + ) + ).scalars() + for g in rows: + if g.expires_at is not None and g.expires_at <= now: + continue # expired grants are not grants + if caller.identity_id and g.grantee_identity_id == caller.identity_id: + return g + if caller.passport and g.grantee_passport == caller.passport: + return g + return None + + +# -------------------------------------------------------------------------- +# routes +# -------------------------------------------------------------------------- +@router.post("", status_code=201) +async def create_repo( + body: CreateRepo, + request: Request, + caller: Annotated[Caller, Depends(get_caller)], +) -> dict: + settings = request.app.state.settings + if body.repo_type.value not in settings.repo_types_enabled: + raise RepairPointer( + status_code=409, + code="repo_type_not_enabled", + speak="That kind of project isn't available yet.", + machine_cause=( + f"repo_type={body.repo_type.value} is not in " + f"repo_types_enabled={list(settings.repo_types_enabled)}" + ), + remediation_tool=None, + ) + + gitea = GiteaClient(settings) + owner = _owner_login(caller) + await gitea.ensure_user(owner, f"{owner}@windygit.com") + created = await gitea.create_repo( + owner=owner, + name=body.name, + description=body.description, + private=body.visibility != Visibility.public, + default_branch="main", + ) + + async with _sessionmaker(request)() as session: + repo = Repo( + identity_id=caller.identity_id or f"passport:{caller.passport}", + passport=caller.passport, + slug=body.name, + display_name=body.display_name or body.name, + repo_type=body.repo_type, + gitea_repo_id=created.get("id"), + visibility=body.visibility, + default_branch="main", + created_via=( + CreatedVia.agent if caller.actor_type == ActorType.agent else CreatedVia.portal + ), + ) + session.add(repo) + await session.commit() + await session.refresh(repo) + + return { + "id": str(repo.id), + "name": repo.slug, + "repo_type": repo.repo_type.value, + "visibility": repo.visibility.value, + "clone_url": created.get("clone_url"), + "speak": f"'{repo.display_name}' is ready. Everything you save is kept.", + "state_proof": {"gitea_repo_id": repo.gitea_repo_id, "owner": owner}, + "next_actions": ["windy_git.grant_access", "windy_git.list_versions"], + } + + +@router.get("") +async def list_repos(request: Request, caller: Annotated[Caller, Depends(get_caller)]) -> dict: + async with _sessionmaker(request)() as session: + rows = ( + await session.execute( + select(Repo).where(Repo.state != RepoState.deleted_soft) + ) + ).scalars().all() + mine = [r for r in rows if await _may_read(session, r, caller)] + return { + "repos": [ + { + "id": str(r.id), + "name": r.slug, + "display_name": r.display_name, + "repo_type": r.repo_type.value, + "visibility": r.visibility.value, + } + for r in mine + ], + "count": len(mine), + } + + +@router.get("/{repo_id}/versions") +async def list_versions( + repo_id: uuid.UUID, + request: Request, + caller: Annotated[Caller, Depends(get_caller)], +) -> dict: + """G5.5 — history in words a person recognises. + + Note what is absent from every user-facing string below: 'commit', 'branch', + 'repository'. A person restoring last Tuesday's work should not have to learn + a vocabulary first (I-9, D-9). + """ + async with _sessionmaker(request)() as session: + repo = await _load_repo(session, repo_id, caller) + owner = _owner_login(caller) if repo.passport == caller.passport else None + + gitea = GiteaClient(request.app.state.settings) + owner = owner or f"u-{repo.identity_id[:24]}" + commits = await gitea.list_commits(owner, repo.slug) + + versions = [ + { + "version": len(commits) - i, + "id": c.get("sha"), + "saved_at": (c.get("commit") or {}).get("author", {}).get("date"), + "note": ((c.get("commit") or {}).get("message") or "").strip().split("\n")[0], + "saved_by": (c.get("commit") or {}).get("author", {}).get("name"), + } + for i, c in enumerate(commits) + ] + return { + "versions": versions, + "count": len(versions), + "speak": ( + f"There are {len(versions)} saved versions of '{repo.display_name}'. " + "You can go back to any of them." + if versions + else f"'{repo.display_name}' is empty so far." + ), + } + + +@router.post("/{repo_id}/grants", status_code=201) +async def create_grant( + repo_id: uuid.UUID, + body: CreateGrant, + request: Request, + caller: Annotated[Caller, Depends(get_caller)], +) -> dict: + """G5.3 — the thing Windy Cloud cannot do at all today. + + A grant may name a human OR an agent passport, and agent grants expire by + default (env: 90 days). A permanent agent credential is a standing liability + nobody consciously chose. + """ + settings = request.app.state.settings + async with _sessionmaker(request)() as session: + repo = await _load_repo(session, repo_id, caller) + is_owner = (caller.identity_id and repo.identity_id == caller.identity_id) or ( + caller.passport and repo.passport == caller.passport + ) + if not is_owner and caller.actor_type != ActorType.system: + raise RepairPointer( + status_code=403, + code="not_your_project", + speak="Only the owner can share this project.", + machine_cause=f"{caller.subject} is not the owner of {repo_id}", + remediation_tool=None, + ) + + expires = ( + datetime.now(UTC) + timedelta(days=settings.agent_grant_default_days) + if body.passport + else None + ) + grant = RepoGrant( + repo_id=repo.id, + grantee_identity_id=body.identity_id, + grantee_passport=body.passport, + role=body.role, + granted_by=caller.subject, + expires_at=expires, + ) + session.add(grant) + await session.commit() + await session.refresh(grant) + grant_id, role = grant.id, grant.role + + who = body.identity_id or body.passport + return { + "id": str(grant_id), + "role": role.value, + "grantee": who, + "expires_at": expires.isoformat() if expires else None, + "speak": ( + f"They can now help with '{repo.display_name}'." + + (" Access ends automatically in 90 days." if expires else "") + ), + "next_actions": ["windy_git.list_grants", "windy_git.revoke_access"], + } + + +@router.get("/{repo_id}/grants") +async def list_grants( + repo_id: uuid.UUID, + request: Request, + caller: Annotated[Caller, Depends(get_caller)], +) -> dict: + now = datetime.now(UTC) + async with _sessionmaker(request)() as session: + repo = await _load_repo(session, repo_id, caller) + rows = ( + await session.execute(select(RepoGrant).where(RepoGrant.repo_id == repo.id)) + ).scalars().all() + return { + "grants": [ + { + "id": str(g.id), + "grantee": g.grantee_identity_id or g.grantee_passport, + "kind": "person" if g.grantee_identity_id else "helper", + "role": g.role.value, + "expires_at": g.expires_at.isoformat() if g.expires_at else None, + "active": g.revoked_at is None + and (g.expires_at is None or g.expires_at > now), + } + for g in rows + ] + } + + +@router.delete("/{repo_id}/grants/{grant_id}") +async def revoke_grant( + repo_id: uuid.UUID, + grant_id: uuid.UUID, + request: Request, + caller: Annotated[Caller, Depends(get_caller)], +) -> dict: + async with _sessionmaker(request)() as session: + repo = await _load_repo(session, repo_id, caller) + grant = ( + await session.execute(select(RepoGrant).where(RepoGrant.id == grant_id)) + ).scalar_one_or_none() + if grant is None or grant.repo_id != repo.id: + raise RepairPointer( + status_code=404, + code="grant_not_found", + speak="We couldn't find that access to remove.", + machine_cause=f"grant {grant_id} not on repo {repo_id}", + remediation_tool=None, + ) + grant.revoked_at = datetime.now(UTC) + await session.commit() + return {"revoked": True, "speak": "That access has been removed."} + + +@router.get("/{repo_id}") +async def get_repo( + repo_id: uuid.UUID, + request: Request, + caller: Annotated[Caller, Depends(get_caller)], +) -> dict: + async with _sessionmaker(request)() as session: + repo = await _load_repo(session, repo_id, caller) + versions = ( + await session.execute(select(RepoVersion).where(RepoVersion.repo_id == repo.id)) + ).scalars().all() + return { + "id": str(repo.id), + "name": repo.slug, + "display_name": repo.display_name, + "repo_type": repo.repo_type.value, + "visibility": repo.visibility.value, + "cloud_folder_ref": repo.cloud_folder_ref, + "recorded_versions": len(versions), + "state": repo.state.value, + } diff --git a/api/app/services/gitea_client.py b/api/app/services/gitea_client.py new file mode 100644 index 0000000..7ff6c0f --- /dev/null +++ b/api/app/services/gitea_client.py @@ -0,0 +1,171 @@ +"""The Gitea membrane (I-1 / D-2). + +Everything this cell needs from Gitea goes through this file and through Gitea's +REST API. Nothing else in the codebase imports Gitea concepts, and nothing +anywhere writes Gitea's database directly — it has its own role and its own +database precisely so that boundary is a permission rather than a promise. + +Keeping the dependency here is what makes D-2 affordable: Gitea ships every two +or three months including security fixes, and a diverged fork becomes the whole +job within a year for a small team. One file is a seam. A merged source tree is +a marriage. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from api.app.config import Settings +from api.app.errors import RepairPointer, provider_unconfigured + +_TIMEOUT = httpx.Timeout(20.0, connect=5.0) + + +class GiteaClient: + def __init__(self, settings: Settings) -> None: + self._s = settings + + def _require(self) -> None: + if not self._s.gitea_configured: + raise provider_unconfigured("gitea", "GITEA_ADMIN_TOKEN") + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"token {self._s.gitea_admin_token}", + "Content-Type": "application/json", + } + + async def _request(self, method: str, path: str, **kw: Any) -> httpx.Response: + self._require() + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + return await client.request( + method, + f"{self._s.gitea_base_url}/api/v1{path}", + headers=self._headers(), + **kw, + ) + + # ---- users ------------------------------------------------------------ + async def ensure_user(self, username: str, email: str) -> dict: + """Idempotent. Gitea is the component; our `repos` table is the truth.""" + r = await self._request("GET", f"/users/{username}") + if r.status_code == 200: + return r.json() + r = await self._request( + "POST", + "/admin/users", + json={ + "username": username, + "email": email, + "password": None, + "must_change_password": False, + # Humans arrive through OIDC and agents through scoped tokens. + # Nobody gets a password on this system. + "login_name": username, + }, + ) + if r.status_code not in (200, 201): + raise RepairPointer( + status_code=502, + code="gitea_user_create_failed", + speak="We couldn't finish setting up that account. Nothing was lost.", + machine_cause=f"POST /admin/users -> {r.status_code}: {r.text[:200]}", + remediation_tool="windy_git.repair.retry_user_create", + ) + return r.json() + + # ---- repos ------------------------------------------------------------ + async def create_repo( + self, owner: str, name: str, description: str, private: bool, default_branch: str + ) -> dict: + r = await self._request( + "POST", + f"/admin/users/{owner}/repos", + json={ + "name": name, + "description": description, + "private": private, + "auto_init": True, + "default_branch": default_branch, + # G4.5 — the LFS threshold is load-bearing, not tidiness. A large + # blob inside a git pack cannot be resumed or offloaded, and a + # plain push of one dies at Cloudflare's ~100s ceiling (G4A.5). + "gitignores": "", + }, + ) + if r.status_code not in (200, 201): + raise RepairPointer( + status_code=502 if r.status_code >= 500 else 409, + code="repo_create_failed", + speak="We couldn't create that project. Try a different name.", + machine_cause=f"POST /admin/users/{owner}/repos -> {r.status_code}: {r.text[:200]}", + remediation_tool=None, + ) + return r.json() + + async def delete_repo(self, owner: str, name: str) -> None: + r = await self._request("DELETE", f"/repos/{owner}/{name}") + if r.status_code not in (204, 404): + raise RepairPointer( + status_code=502, + code="repo_delete_failed", + speak="We couldn't remove that project. It is still there and still yours.", + machine_cause=f"DELETE /repos/{owner}/{name} -> {r.status_code}", + remediation_tool="windy_git.repair.retry_delete", + ) + + async def get_repo(self, owner: str, name: str) -> dict | None: + r = await self._request("GET", f"/repos/{owner}/{name}") + return r.json() if r.status_code == 200 else None + + # ---- history (G5.5 / G5.6) ------------------------------------------- + async def list_commits(self, owner: str, name: str, limit: int = 50) -> list[dict]: + r = await self._request( + "GET", f"/repos/{owner}/{name}/commits", params={"limit": limit} + ) + if r.status_code == 409: + return [] # empty repo — a real state, not an error + if r.status_code != 200: + raise RepairPointer( + status_code=502, + code="history_unavailable", + speak="We couldn't load the history for that project just now.", + machine_cause=f"GET commits -> {r.status_code}", + remediation_tool="windy_git.repair.rebuild_index", + ) + return r.json() + + # ---- collaborators (the shelter's enforcement half, G5.3) ------------ + async def put_collaborator(self, owner: str, name: str, user: str, permission: str) -> None: + r = await self._request( + "PUT", + f"/repos/{owner}/{name}/collaborators/{user}", + json={"permission": permission}, + ) + if r.status_code not in (204, 201, 200): + raise RepairPointer( + status_code=502, + code="grant_apply_failed", + speak="We couldn't share that project yet. Nobody was given access.", + machine_cause=f"PUT collaborator -> {r.status_code}: {r.text[:200]}", + remediation_tool="windy_git.repair.resync_grants", + ) + + async def delete_collaborator(self, owner: str, name: str, user: str) -> None: + r = await self._request("DELETE", f"/repos/{owner}/{name}/collaborators/{user}") + if r.status_code not in (204, 404): + raise RepairPointer( + status_code=502, + code="grant_revoke_failed", + # The honest failure: we could not take access away. That is the + # scarier direction, so it is stated plainly rather than softened. + speak="We could not remove that person's access. Please try again.", + machine_cause=f"DELETE collaborator -> {r.status_code}", + remediation_tool="windy_git.repair.resync_grants", + ) + + async def version(self) -> str: + r = await self._request("GET", "/version") + return r.json().get("version", "unknown") diff --git a/api/tests/test_invariants.py b/api/tests/test_invariants.py index fba92ef..d6b85db 100644 --- a/api/tests/test_invariants.py +++ b/api/tests/test_invariants.py @@ -284,3 +284,68 @@ def test_g43_r2_checksum_trap_is_pinned(): """R2 rejects the checksum algorithm S3 clients send by default.""" compose = (ROOT / "docker-compose.yml").read_text() assert "MINIO_CHECKSUM_ALGORITHM" in compose + + +# -------------------------------------------------------------------------- +# G3.6 — the trust status-code law. This is the one with a live sibling bypass. +# -------------------------------------------------------------------------- +def test_g36_trust_client_never_soft_allows(): + """A sibling maps HTTP 400 and 429 to 'unreachable' and then soft-ALLOWS. + That is inducible: an attacker who wants the check skipped only has to make + the check rate-limit itself at 100/min/IP.""" + src = (ROOT / "api" / "app" / "auth.py").read_text() + assert "raise passport_unresolvable" in src + # There must be no return path out of resolve_passport other than a verified + # 200 or a raise. + body = src[src.index("async def resolve_passport") : src.index("async def get_caller")] + returns = [ln for ln in body.splitlines() if ln.strip().startswith("return")] + assert len(returns) == 1, f"resolve_passport has {len(returns)} return paths; expected exactly 1" + + +def test_g36_unverified_human_jwt_is_refused_in_production(): + """I-8 applied to ourselves: an unverified JWT is an authentication bypass, + not a shortcut. Until G3.2's JWKS verifier exists, production refuses.""" + from api.app.config import Settings + + assert Settings().require_verified_jwt is True + src = (ROOT / "api" / "app" / "auth.py").read_text() + assert "human_signin_not_ready" in src + + +def test_no_auth_bypass_env_var_anywhere(): + """The desktop control server is the best Principle-#5 artifact in the + ecosystem partly because it has NO bypass env var. Copied on purpose.""" + src = (ROOT / "api" / "app" / "auth.py").read_text() + for banned in ("SKIP_AUTH", "DISABLE_AUTH", "ALLOW_INSECURE", "AUTH_BYPASS", "DEV_MODE"): + assert banned not in src + + +# -------------------------------------------------------------------------- +# G5.3 — the shelter's grant model +# -------------------------------------------------------------------------- +def test_g53_grant_requires_exactly_one_grantee_in_the_database(): + """Enforced by a CHECK constraint, not by application code. This ecosystem + already has a core invariant enforced only in app code across two files.""" + src = (ROOT / "alembic" / "versions" / "001_genesis.py").read_text() + assert "ck_grant_exactly_one_grantee" in src + assert "(grantee_identity_id IS NULL) <> (grantee_passport IS NULL)" in src + + +def test_g53_agent_grants_expire_by_default(): + from api.app.config import Settings + + assert Settings().agent_grant_default_days == 90 + + +def test_g55_shelter_strings_avoid_developer_vocabulary(): + """D-9/I-9: a person restoring last Tuesday's work should not have to learn + a vocabulary first. Check the strings users actually see.""" + import re as _re + + src = (ROOT / "api" / "app" / "routes" / "repos.py").read_text() + speaks = _re.findall(r'"speak":\s*\(?\s*\n?\s*f?"([^"]+)"', src) + assert speaks, "no speak strings found to audit" + for s in speaks: + low = s.lower() + for jargon in ("commit", "repository", "branch", "sha", "push"): + assert jargon not in low, f"developer vocabulary in a user string: {s!r}"