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

View File

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