Files
windy-git/api/app/main.py
Grant Whitmer 099e4be9b1 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>
2026-08-11 15:45:22 -04:00

143 lines
4.7 KiB
Python

"""windy-git — the version, permission and provenance plane over Windy Cloud.
Strand G0. This process is OUR service. Gitea runs beside it as an unforked
component and is reached only over its REST API (D-2 / I-1).
"""
from __future__ import annotations
import logging
import socket
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
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
from api.app.errors import RepairPointer, kit_zero_refused
from api.app.providers.registry import (
DatabaseProvider,
EternitasProvider,
GiteaProvider,
R2Provider,
)
from api.app.routes import health, repos
logging.basicConfig(
level=logging.INFO,
format='{"ts":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","msg":"%(message)s"}',
)
log = logging.getLogger("windy-git")
def _refuse_kit_zero(settings) -> None:
"""D-4 / section 7.8 — only Grant may overturn a never.
Kit 0 is disqualified on four independent grounds, any one sufficient. The
strongest: CI executes arbitrary workflow code, and Kit 0 holds identity, the
certificate authority, inbound SMTP, Matrix, the broker and the admin console.
A guard in a document is a preference; a guard in the boot path is a rule.
"""
if not settings.is_production:
return
try:
local_ips = {
info[4][0] for info in socket.getaddrinfo(socket.gethostname(), None)
}
except socket.gaierror:
return
if settings.kit0_host in local_ips:
raise kit_zero_refused(settings.kit0_host)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
_refuse_kit_zero(settings)
info = get_build_info()
log.info(
"starting windy-git %s commit=%s source=%s env=%s",
info.version,
(info.commit_sha or "unknown")[:12],
info.source,
settings.environment,
)
if info.source == "unknown":
log.warning(
"This process cannot name its own commit. It will report null rather "
"than guess (I-12), but a production deploy in this state is a defect."
)
engine = None
try:
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
except Exception as exc: # noqa: BLE001
log.warning("database engine not created: %s", exc)
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),
R2Provider(settings),
EternitasProvider(settings),
# NOTE: the tunnel is deliberately NOT probed from here. cloudflared
# binds its metrics on the host's loopback, so a container can never
# reach it -- the check would be permanently red no matter what the
# tunnel is doing. A check that structurally cannot succeed is worse
# than no check: it trains people to ignore the dashboard, which is
# exactly how a fleet canary goes 37 days dead without anyone noticing.
# Tunnel health is a host concern and lives where it can be observed:
# systemd Restart=always, plus the runbook's `systemctl status`.
]
yield
if engine is not None:
await engine.dispose()
app = FastAPI(
title="Windy Git",
description=(
"The version, permission and provenance plane over Windy Cloud. "
"Agents are citizens here, not tourists wearing a human's token."
),
version=get_build_info().version,
lifespan=lifespan,
)
app.include_router(health.router)
app.include_router(repos.router)
@app.exception_handler(RepairPointer)
async def _repair_pointer_handler(_, exc: RepairPointer) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=exc.detail)
@app.exception_handler(RequestValidationError)
async def _validation_handler(_, exc: RequestValidationError) -> JSONResponse:
"""G8.3: EVERY error is a repair pointer. Including validation errors.
FastAPI's default 422 body is machine-readable and human-hostile. It is also
the single most common error an agent will hit, so it is the last place to
drop the contract.
"""
return JSONResponse(
status_code=422,
content={
"code": "invalid_request",
"speak": "Something in that request didn't look right, so we didn't act on it.",
"machine_cause": f"request validation failed: {exc.errors()}",
"remediation_tool": None,
},
)