55a8be051a
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>
514 lines
20 KiB
Python
514 lines
20 KiB
Python
"""API surface for Slice 1.
|
|
|
|
Carries the §17 endpoints exercised by the propose-to-super-draft
|
|
vertical, plus the catalog read endpoints (§7) and the super-draft view
|
|
read endpoints (§9.4). The rest of §17 lands in the relevant later
|
|
slices; the dispatch shape here leaves room for them.
|
|
|
|
Routing follows the §17 layout literally — `/api/rfcs`,
|
|
`/api/proposals/<n>`, etc. — so the next slice can extend the same
|
|
modules rather than untangling a layout that drifted.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from . import (
|
|
api_admin,
|
|
api_branches,
|
|
api_graduation,
|
|
api_notifications,
|
|
api_prs,
|
|
auth,
|
|
db,
|
|
entry as entry_mod,
|
|
cache,
|
|
funder,
|
|
philosophy,
|
|
providers as providers_mod,
|
|
)
|
|
from .bot import Bot
|
|
from .config import Config
|
|
from .gitea import Gitea, GiteaError
|
|
from .providers import BaseProvider
|
|
|
|
|
|
class ProposeBody(BaseModel):
|
|
title: str = Field(min_length=1, max_length=200)
|
|
slug: str = Field(min_length=1, max_length=80)
|
|
pitch: str = Field(min_length=1)
|
|
tags: list[str] = Field(default_factory=list)
|
|
|
|
|
|
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,
|
|
bot: Bot,
|
|
providers: dict[str, BaseProvider] | None = None,
|
|
) -> APIRouter:
|
|
# Use `is None` rather than `providers or {}` — an empty dict is
|
|
# falsy, and the test harness mutates the dict the closure holds to
|
|
# inject a fake provider; substituting a fresh `{}` here would
|
|
# silently drop those mutations.
|
|
if providers is None:
|
|
providers = {}
|
|
router = APIRouter()
|
|
# Slice 2: the §8 active-RFC view's endpoints live in api_branches.
|
|
# Mounting them on the same router keeps the §17 layout flat.
|
|
router.include_router(api_branches.make_router(config, gitea, bot, providers))
|
|
# Slice 3: the §10 PR-flow endpoints.
|
|
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
|
# Slice 5: §13 graduation + §13.1 claim.
|
|
router.include_router(api_graduation.make_router(config, gitea, bot))
|
|
# Slice 6: §15 notifications surface (inbox, watches, prefs,
|
|
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
|
|
router.include_router(api_notifications.make_router(config))
|
|
# Slice 7: §14 chrome (/philosophy read endpoint, user search for
|
|
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
|
|
# (role, write-mute, audit-log, graduation-readiness queue).
|
|
router.include_router(api_admin.make_router(config))
|
|
|
|
# ---------------------------------------------------------------
|
|
# §14.2: /api/philosophy — PHILOSOPHY.md served verbatim.
|
|
# No auth gate; anonymous visitors reach `/philosophy` per §14.1.
|
|
# The body is read once at process start and refreshed on demand
|
|
# via the reconciler; see backend/app/philosophy.py.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/philosophy")
|
|
async def get_philosophy() -> dict[str, Any]:
|
|
payload = philosophy.load()
|
|
return {"body": payload["body"]}
|
|
|
|
# ---------------------------------------------------------------
|
|
# Auth surface — extends the prototype's pattern but reads role
|
|
# from our users table per §6.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/auth/me")
|
|
async def auth_me(request: Request) -> dict[str, Any]:
|
|
user = auth.current_user(request)
|
|
if user is None:
|
|
return {"authenticated": False, "user": None}
|
|
return {
|
|
"authenticated": True,
|
|
"user": {
|
|
"id": user.user_id,
|
|
"gitea_login": user.gitea_login,
|
|
"display_name": user.display_name,
|
|
"email": user.email,
|
|
"avatar_url": user.avatar_url,
|
|
"role": user.role,
|
|
},
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §7: the catalog
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/rfcs")
|
|
async def list_rfcs(request: Request) -> dict[str, Any]:
|
|
"""§7's left pane data.
|
|
|
|
The chip-filter / sort / search combinatorics live on the
|
|
client — the server returns the full set and lets the chips
|
|
narrow it. The set is small (hundreds, not thousands) for the
|
|
foreseeable future, so paginating here would buy nothing.
|
|
"""
|
|
viewer = auth.current_user(request)
|
|
viewer_id = viewer.user_id if viewer else None
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT slug, title, state, rfc_id, repo,
|
|
owners_json, arbiters_json, tags_json,
|
|
last_main_commit_at, last_entry_commit_at, updated_at
|
|
FROM cached_rfcs
|
|
WHERE state IN ('super-draft', 'active')
|
|
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
|
"""
|
|
).fetchall()
|
|
|
|
starred = set()
|
|
if viewer_id is not None:
|
|
starred = {
|
|
r["rfc_slug"]
|
|
for r in db.conn().execute(
|
|
"SELECT rfc_slug FROM stars WHERE user_id = ?", (viewer_id,)
|
|
)
|
|
}
|
|
|
|
items = []
|
|
for r in rows:
|
|
items.append(
|
|
{
|
|
"slug": r["slug"],
|
|
"title": r["title"],
|
|
"state": r["state"],
|
|
"id": r["rfc_id"],
|
|
"repo": r["repo"],
|
|
"owners": json.loads(r["owners_json"] or "[]"),
|
|
"arbiters": json.loads(r["arbiters_json"] or "[]"),
|
|
"tags": json.loads(r["tags_json"] or "[]"),
|
|
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
|
"starred_by_me": r["slug"] in starred,
|
|
"has_open_prs": False, # wired in Slice 2 when per-RFC repos exist
|
|
}
|
|
)
|
|
return {"items": items}
|
|
|
|
@router.get("/api/rfcs/{slug}")
|
|
async def get_rfc(slug: str) -> dict[str, Any]:
|
|
row = db.conn().execute(
|
|
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "Not found")
|
|
return _serialize_rfc(row)
|
|
|
|
# ---------------------------------------------------------------
|
|
# §7.3 / §9.3: pending ideas
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/proposals")
|
|
async def list_proposals() -> dict[str, Any]:
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
|
|
FROM cached_prs
|
|
WHERE pr_kind = 'idea' AND state = 'open'
|
|
ORDER BY opened_at DESC
|
|
"""
|
|
).fetchall()
|
|
return {
|
|
"items": [
|
|
{
|
|
"slug": r["rfc_slug"],
|
|
"pr_number": r["pr_number"],
|
|
"title": r["title"],
|
|
"description": r["description"],
|
|
"opened_by": r["opened_by"],
|
|
"opened_at": r["opened_at"],
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
@router.get("/api/proposals/{pr_number}")
|
|
async def get_proposal(pr_number: int, request: Request) -> dict[str, Any]:
|
|
"""§9.3 pending-idea view data.
|
|
|
|
Reads the proposed file from the proposer's branch on the meta
|
|
repo, so the viewer sees the entry as it will land. The chat
|
|
thread is not yet implemented; thread_id surfaces as null until
|
|
Slice 2's chat wiring lands.
|
|
"""
|
|
row = db.conn().execute(
|
|
"""
|
|
SELECT * FROM cached_prs
|
|
WHERE pr_kind = 'idea' AND pr_number = ?
|
|
""",
|
|
(pr_number,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "Not a proposal PR")
|
|
# Read the proposed entry file from the head branch.
|
|
slug = row["rfc_slug"]
|
|
head = row["head_branch"]
|
|
result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head)
|
|
entry_payload: dict[str, Any] | None = None
|
|
if result:
|
|
text, _sha = result
|
|
try:
|
|
entry = entry_mod.parse(text)
|
|
entry_payload = _entry_payload(entry)
|
|
except Exception:
|
|
entry_payload = None
|
|
|
|
viewer = auth.current_user(request)
|
|
affordances = _proposal_affordances(viewer, row)
|
|
return {
|
|
"slug": slug,
|
|
"pr_number": pr_number,
|
|
"title": row["title"],
|
|
"description": row["description"],
|
|
"state": row["state"],
|
|
"opened_by": row["opened_by"],
|
|
"opened_at": row["opened_at"],
|
|
"entry": entry_payload,
|
|
"affordances": affordances,
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §9.1: propose a new RFC
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/rfcs/propose")
|
|
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
|
|
user = auth.require_contributor(request)
|
|
slug = payload.slug.strip().lower()
|
|
if not entry_mod.is_valid_slug(slug):
|
|
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
|
|
|
|
# §9.1 uniqueness — against rfcs/ on main *and* against open idea PRs.
|
|
# We re-check atomically here even though the client also checks
|
|
# on every keystroke, since a concurrent submission could land
|
|
# between dialog-open and submit.
|
|
clash = db.conn().execute(
|
|
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
|
|
).fetchone()
|
|
if clash:
|
|
raise HTTPException(409, f"Slug `{slug}` is already taken")
|
|
idea_clash = db.conn().execute(
|
|
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?",
|
|
(slug,),
|
|
).fetchone()
|
|
if idea_clash:
|
|
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
|
|
|
|
entry = entry_mod.Entry(
|
|
slug=slug,
|
|
title=payload.title.strip(),
|
|
state="super-draft",
|
|
id=None,
|
|
repo=None,
|
|
proposed_by=user.email or user.gitea_login,
|
|
proposed_at=entry_mod.today(),
|
|
graduated_at=None,
|
|
graduated_by=None,
|
|
owners=[],
|
|
arbiters=[],
|
|
tags=[t.strip() for t in payload.tags if t.strip()],
|
|
body=payload.pitch.strip() + "\n",
|
|
)
|
|
contents = entry_mod.serialize(entry)
|
|
pr_title = f"Propose: {entry.title}"
|
|
# Slice 1's AI-drafted PR description is deferred — the v1 spec
|
|
# calls for it (§9.2) but the wiring belongs with the rest of
|
|
# the AI-on-the-propose-modal work; for now we send the pitch.
|
|
pr_description = (
|
|
f"**Topic:** {entry.title}\n\n"
|
|
f"{payload.pitch.strip()}"
|
|
)
|
|
try:
|
|
pr = await bot.open_idea_pr(
|
|
user.as_actor(),
|
|
org=config.gitea_org,
|
|
meta_repo=config.meta_repo,
|
|
slug=slug,
|
|
file_contents=contents,
|
|
pr_title=pr_title,
|
|
pr_description=pr_description,
|
|
)
|
|
except GiteaError as e:
|
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
|
|
|
# Refresh the meta-PRs cache so the proposer sees the new entry
|
|
# on the pending-ideas disclosure immediately, without waiting
|
|
# for the webhook to arrive. (The webhook will arrive too; the
|
|
# cache write is idempotent.)
|
|
await cache.refresh_meta_pulls(config, gitea)
|
|
|
|
return {"pr_number": pr["number"], "slug": slug}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §9.3: merge / decline / withdraw an idea PR
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/proposals/{pr_number}/merge")
|
|
async def merge_proposal(pr_number: int, request: Request) -> dict[str, Any]:
|
|
user = auth.require_admin(request)
|
|
row = _require_open_idea_pr(pr_number)
|
|
try:
|
|
await bot.merge_idea_pr(
|
|
user.as_actor(),
|
|
org=config.gitea_org,
|
|
meta_repo=config.meta_repo,
|
|
pr_number=pr_number,
|
|
slug=row["rfc_slug"],
|
|
)
|
|
except GiteaError as e:
|
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
|
# Refresh both surfaces — the entry is now on main, and the PR
|
|
# is now closed.
|
|
await cache.refresh_meta_repo(config, gitea)
|
|
await cache.refresh_meta_pulls(config, gitea)
|
|
return {"ok": True, "slug": row["rfc_slug"]}
|
|
|
|
@router.post("/api/proposals/{pr_number}/decline")
|
|
async def decline_proposal(pr_number: int, body: DeclineBody, request: Request) -> dict[str, Any]:
|
|
user = auth.require_admin(request)
|
|
row = _require_open_idea_pr(pr_number)
|
|
try:
|
|
await bot.decline_idea_pr(
|
|
user.as_actor(),
|
|
org=config.gitea_org,
|
|
meta_repo=config.meta_repo,
|
|
pr_number=pr_number,
|
|
slug=row["rfc_slug"],
|
|
comment=body.comment,
|
|
)
|
|
except GiteaError as e:
|
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
|
await cache.refresh_meta_pulls(config, gitea)
|
|
return {"ok": True}
|
|
|
|
@router.post("/api/proposals/{pr_number}/withdraw")
|
|
async def withdraw_proposal(pr_number: int, request: Request) -> dict[str, Any]:
|
|
user = auth.require_contributor(request)
|
|
row = _require_open_idea_pr(pr_number)
|
|
# Only the proposer can withdraw their own proposal, except that
|
|
# owner/admin can also act (they have all contributor powers per
|
|
# §6.1, and the withdraw path here doesn't expose decline-only
|
|
# affordances).
|
|
if row["opened_by"] != user.gitea_login and user.role not in ("owner", "admin"):
|
|
raise HTTPException(403, "Only the proposer can withdraw")
|
|
try:
|
|
await bot.withdraw_idea_pr(
|
|
user.as_actor(),
|
|
org=config.gitea_org,
|
|
meta_repo=config.meta_repo,
|
|
pr_number=pr_number,
|
|
slug=row["rfc_slug"],
|
|
)
|
|
except GiteaError as e:
|
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
|
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
|
|
# ---------------------------------------------------------------
|
|
|
|
def _require_open_idea_pr(pr_number: int):
|
|
row = db.conn().execute(
|
|
"""
|
|
SELECT * FROM cached_prs
|
|
WHERE pr_kind = 'idea' AND pr_number = ? AND state = 'open'
|
|
""",
|
|
(pr_number,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "Not an open proposal PR")
|
|
return row
|
|
|
|
return router
|
|
|
|
|
|
def _serialize_rfc(row) -> dict[str, Any]:
|
|
return {
|
|
"slug": row["slug"],
|
|
"title": row["title"],
|
|
"state": row["state"],
|
|
"id": row["rfc_id"],
|
|
"repo": row["repo"],
|
|
"proposed_by": row["proposed_by"],
|
|
"proposed_at": row["proposed_at"],
|
|
"graduated_at": row["graduated_at"],
|
|
"graduated_by": row["graduated_by"],
|
|
"owners": json.loads(row["owners_json"] or "[]"),
|
|
"arbiters": json.loads(row["arbiters_json"] or "[]"),
|
|
"tags": json.loads(row["tags_json"] or "[]"),
|
|
"body": row["body"] or "",
|
|
}
|
|
|
|
|
|
def _entry_payload(entry: entry_mod.Entry) -> dict[str, Any]:
|
|
return {
|
|
"slug": entry.slug,
|
|
"title": entry.title,
|
|
"state": entry.state,
|
|
"id": entry.id,
|
|
"repo": entry.repo,
|
|
"proposed_by": entry.proposed_by,
|
|
"proposed_at": entry.proposed_at,
|
|
"owners": entry.owners,
|
|
"arbiters": entry.arbiters,
|
|
"tags": entry.tags,
|
|
"body": entry.body,
|
|
}
|
|
|
|
|
|
def _proposal_affordances(viewer, row) -> dict[str, bool]:
|
|
"""§9.3 header strip affordances by role."""
|
|
is_owner_admin = viewer is not None and viewer.role in ("owner", "admin")
|
|
is_proposer = viewer is not None and row["opened_by"] == viewer.gitea_login
|
|
return {
|
|
"merge": is_owner_admin,
|
|
"decline": is_owner_admin,
|
|
"withdraw": is_proposer or is_owner_admin,
|
|
}
|