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:
@@ -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
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user