Post-v1: per-RFC credential delegation (funder role) folded into §6.7

Second §19.2 settlement after v1. New §6.7 alongside §6.6: optional
`funder:` frontmatter field names a single gitea_login; a
`funder_consents` app-db row records funder-side opt-in; both halves
required for the binding to activate (two-key rule). Funder universe
replaces — does not augment — the operator universe per-RFC for
attribution-clean resolution. Funder role grants zero §6.1/§6.3
authority. Three revocation paths each restore the operator-credentials
status quo.

§19.2's credential-delegation entry is split: lighter half marked
settled with a pointer to §6.7; operational-realities half (mid-call
failure, rotation, billing, rate-limit attribution) lives on as its
own entry. Test suite is 125/125 green (106 prior + 19 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 06:08:43 -07:00
parent a255429e57
commit 55a8be051a
12 changed files with 1437 additions and 43 deletions
+68
View File
@@ -27,7 +27,9 @@ from . import (
db,
entry as entry_mod,
cache,
funder,
philosophy,
providers as providers_mod,
)
from .bot import Bot
from .config import Config
@@ -46,6 +48,11 @@ class DeclineBody(BaseModel):
comment: str = Field(min_length=1, max_length=4000)
class FunderCredentialBody(BaseModel):
provider: str = Field(min_length=1, max_length=40)
api_key: str = Field(min_length=1, max_length=2048)
def make_router(
config: Config,
gitea: Gitea,
@@ -381,6 +388,67 @@ def make_router(
await cache.refresh_meta_pulls(config, gitea)
return {"ok": True}
# ---------------------------------------------------------------
# §6.7 / §17: per-RFC credential delegation — the funder surface.
# /api/users/me/funder is the self-serve read; the two write
# endpoints register credentials and the two slug-scoped endpoints
# toggle consent. Per §6.7, the funder cannot expand the operator
# universe: registering for a provider family the operator has not
# enabled is refused. A consent without registered credentials is
# also refused since it would be operationally inert.
# ---------------------------------------------------------------
@router.get("/api/users/me/funder")
async def get_funder_self(request: Request) -> dict[str, Any]:
user = auth.require_user(request)
return {
"credentials": funder.list_credentials(user.user_id),
"consents": funder.list_consents(user.user_id),
}
@router.post("/api/users/me/funder/credentials")
async def register_funder_credential(payload: FunderCredentialBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
provider = payload.provider.strip().lower()
if provider not in providers_mod.FUNDER_PROVIDER_FAMILIES:
raise HTTPException(422, f"Unknown provider `{provider}`")
# §6.7: the funder cannot expand the operator universe. The
# provider family must back at least one operator-enabled key.
operator_family_keys = providers_mod.picker_keys_for_family(provider, list(providers.keys()))
if not operator_family_keys:
raise HTTPException(409, f"Operator has not enabled any `{provider}` models")
funder.upsert_credential(user.user_id, provider, payload.api_key.strip())
return {"ok": True, "provider": provider}
@router.delete("/api/users/me/funder/credentials/{provider}")
async def delete_funder_credential(provider: str, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
provider = provider.strip().lower()
funder.delete_credential(user.user_id, provider)
return {"ok": True, "provider": provider}
@router.post("/api/rfcs/{slug}/funder/consent")
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
rfc = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §6.7: refuse consent from a user with no registered credentials
# — a consent without a universe would be inert and the surface
# should fail loudly rather than silently.
if not funder.has_any_credentials(user.user_id):
raise HTTPException(409, "Register credentials before consenting to fund")
funder.add_consent(user.user_id, slug)
return {"ok": True, "slug": slug}
@router.delete("/api/rfcs/{slug}/funder/consent")
async def remove_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
funder.remove_consent(user.user_id, slug)
return {"ok": True, "slug": slug}
# ---------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------