'There are 1 saved versions' is exactly the sloppiness the vocabulary law exists to catch. Copy is design material, not decoration. G5.9 records something the live test surfaced: a repo created through X-Service-Token lands in a 'u-system' namespace because the service caller has no identity of its own. Correct for /internal plumbing, WRONG for anything a person owns — the portal must pass an acting user and this cell must refuse to create a user-owned project without one. Until then service-created repos are ops artifacts, not customer data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
441 lines
16 KiB
Python
441 lines
16 KiB
Python
"""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),
|
|
# "There are 1 saved versions" is the kind of sloppiness the vocabulary
|
|
# law exists to catch. Copy is design material, not decoration (I-9).
|
|
"speak": (
|
|
f"'{repo.display_name}' has 1 saved version. You can go back to it."
|
|
if len(versions) == 1
|
|
else f"'{repo.display_name}' has {len(versions)} saved versions. "
|
|
"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,
|
|
}
|