G5: the shelter — repos, grants and version history

The plane Windy Cloud does not have. Verified 2026-08-11: routes/storage.py and
its models contain ZERO occurrences of share/permission/acl/collaborat/seat/
version/snapshot/history/revision. This fills a hole rather than bolting onto
something that already had one.

  - repos: create/list/get, repo_type required (I-7), reserved slugs, Gitea
    reached ONLY through the membrane client (I-1)
  - grants: human identity OR agent passport, exactly one enforced by a database
    CHECK constraint; agent grants expire in 90 days by default
  - versions: history in words a person recognises — no 'commit', no 'branch',
    no 'repository' in any user-facing string (D-9/I-9), with a test that greps
    the speak strings and fails on developer vocabulary
  - private repos 404 rather than 403, so a stranger cannot learn one exists

Auth: three first-class caller classes (human OIDC / agent EPT / internal
service token), NO fourth, and no bypass env var — copied deliberately from the
desktop control server, the ecosystem's best Principle-#5 artifact.

G3.6 status-code law implemented: 400 and 404 REFUSE, 429/5xx retry then REFUSE.
A sibling maps 400/429 to 'unreachable' and soft-ALLOWS, which is inducible —
an attacker who wants the check skipped only has to make it rate-limit itself.
A test asserts resolve_passport has exactly one return path.

And I-8 applied to ourselves: G3.2's JWKS verifier does not exist yet, so the
human token path REFUSES in production rather than accepting an unverified JWT.
An unverified JWT is an authentication bypass, not a shortcut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Grant Whitmer
2026-08-11 15:45:22 -04:00
parent 82044933ed
commit 099e4be9b1
6 changed files with 919 additions and 2 deletions

230
api/app/auth.py Normal file
View File

@@ -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

View File

@@ -50,6 +50,17 @@ class Settings(BaseSettings):
# ---- account-server OIDC (human identity) ----------------------------- # ---- account-server OIDC (human identity) -----------------------------
account_server_base_url: str = "https://account.windyword.ai" 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) ------------------------------------------ # ---- storage law (I-3, G4.4) ------------------------------------------
# Git object databases MUST live on a POSIX filesystem. A test asserts this # Git object databases MUST live on a POSIX filesystem. A test asserts this
# path does not resolve to a network mount. # path does not resolve to a network mount.

View File

@@ -13,7 +13,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse 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.buildinfo import get_build_info
from api.app.config import get_settings from api.app.config import get_settings
@@ -24,7 +24,7 @@ from api.app.providers.registry import (
GiteaProvider, GiteaProvider,
R2Provider, R2Provider,
) )
from api.app.routes import health from api.app.routes import health, repos
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -80,6 +80,9 @@ async def lifespan(app: FastAPI):
app.state.settings = settings app.state.settings = settings
app.state.engine = engine app.state.engine = engine
app.state.sessionmaker = (
async_sessionmaker(engine, expire_on_commit=False) if engine is not None else None
)
app.state.providers = [ app.state.providers = [
DatabaseProvider(engine), DatabaseProvider(engine),
GiteaProvider(settings), GiteaProvider(settings),
@@ -112,6 +115,7 @@ app = FastAPI(
) )
app.include_router(health.router) app.include_router(health.router)
app.include_router(repos.router)
@app.exception_handler(RepairPointer) @app.exception_handler(RepairPointer)

436
api/app/routes/repos.py Normal file
View File

@@ -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,
}

View File

@@ -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")

View File

@@ -284,3 +284,68 @@ def test_g43_r2_checksum_trap_is_pinned():
"""R2 rejects the checksum algorithm S3 clients send by default.""" """R2 rejects the checksum algorithm S3 clients send by default."""
compose = (ROOT / "docker-compose.yml").read_text() compose = (ROOT / "docker-compose.yml").read_text()
assert "MINIO_CHECKSUM_ALGORITHM" in compose 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}"