fcc3c84d76
Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.
Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
requester, requested_role, message, status, granted_role); one-open-per
(scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
{id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
Accept grants via memberships.grant; the request POST does not require the
scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
_scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.
Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
collection directory + catalog footer; JoinRequestRow in the inbox.
Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.
Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1373 lines
61 KiB
Python
1373 lines
61 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 fastapi.responses import PlainTextResponse, Response
|
|
from pydantic import BaseModel, Field
|
|
|
|
from . import (
|
|
api_admin,
|
|
api_branches,
|
|
api_collections,
|
|
api_contributions,
|
|
api_deployment,
|
|
api_discussion,
|
|
api_graduation,
|
|
api_invitations,
|
|
api_join_requests,
|
|
api_memberships,
|
|
api_notifications,
|
|
api_prs,
|
|
auth,
|
|
collections as collections_mod,
|
|
projects as projects_mod,
|
|
db,
|
|
device_trust as device_trust_mod,
|
|
docs as docs_mod,
|
|
docs_sessions,
|
|
docs_specs,
|
|
entry as entry_mod,
|
|
cache,
|
|
funder,
|
|
health,
|
|
notify,
|
|
philosophy,
|
|
providers as providers_mod,
|
|
tag_suggest,
|
|
)
|
|
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)
|
|
# Roadmap #26: optional "What will you be using this RFC for?" — the
|
|
# concrete ground-truth use case, distinct from the `pitch`'s abstract
|
|
# "why is this needed." Optional (NULL/omitted accepted), no minimum,
|
|
# generous cap matching the pitch's free-text body bound.
|
|
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
|
|
|
|
|
class SuggestTagsBody(BaseModel):
|
|
# Roadmap #27: the partial propose-RFC draft, sent as the user types
|
|
# (debounced on the frontend). All fields optional — suggestions
|
|
# refine as the draft fills in. `pitch` is the "why is this needed"
|
|
# rationale; `use_case` is the #26 optional ground-truth field.
|
|
# Bounds mirror the propose body's free-text caps.
|
|
title: str = Field(default="", max_length=200)
|
|
pitch: str = Field(default="", max_length=8000)
|
|
use_case: str = Field(default="", max_length=8000)
|
|
|
|
|
|
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)
|
|
|
|
|
|
class LastStateBody(BaseModel):
|
|
# v0.23.0 / roadmap item #29: server-side sign-in state resume.
|
|
# `route` is a frontend pathname the user was last on (bounded so a
|
|
# hostile client can't stuff arbitrary blobs through). `state` is an
|
|
# optional bag of *light* view state (scroll anchors, open tab,
|
|
# filter chips). PRIVACY: it MUST NOT carry draft-buffer contents —
|
|
# the frontend only ever sends ephemeral view state, and the column
|
|
# comment in migration 022 + SPEC §6.2 are the binding contract.
|
|
route: str = Field(min_length=1, max_length=2048)
|
|
state: dict[str, Any] | None = None
|
|
|
|
|
|
class BetaRequestBody(BaseModel):
|
|
# v0.8.0 — captured on the first OTC sign-in. All three fields are
|
|
# required so the admin queue has a coherent triage shape.
|
|
# The bounds match the v0.7.0 OTC body (320 chars for email-ish
|
|
# headers; 4000 for the free-text reason — the same upper bound
|
|
# DeclineBody uses elsewhere in this file).
|
|
first_name: str = Field(min_length=1, max_length=120)
|
|
last_name: str = Field(min_length=1, max_length=120)
|
|
beta_request_reason: str = Field(min_length=1, max_length=4000)
|
|
|
|
|
|
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))
|
|
# v0.5.0: §5 / §7 / §10 — PR-less per-RFC discussion endpoints.
|
|
# The substrate is the existing threads/thread_messages tables;
|
|
# rows whose branch_name IS NULL scope to the RFC's main view.
|
|
# Contribution still requires a PR (api_prs above); this surface
|
|
# is for discussion that does not yet warrant a branch.
|
|
router.include_router(api_discussion.make_router())
|
|
# v0.16.0 (roadmap item #12): owner-only invite for per-RFC
|
|
# contribution + discussion. The RFC's owner can invite specific
|
|
# users by email to either open PRs or join the discussion; non-
|
|
# invited users keep read access but cannot write (v0.6.0
|
|
# contract extended to per-RFC scope).
|
|
router.include_router(api_invitations.make_router())
|
|
# v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending
|
|
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
|
|
# on accept; lands the request + owner notifications via §15 notify.
|
|
router.include_router(api_contributions.make_router())
|
|
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
|
|
# VITE_APP_NAME) + the old-URL 308 redirects.
|
|
router.include_router(api_deployment.make_router(config, gitea, bot))
|
|
router.include_router(api_collections.make_router(config, gitea, bot))
|
|
# §22 S4 (C.2): the scope-role invitation surface — Owners grant
|
|
# {owner, contributor} at project/collection scope to existing accounts.
|
|
router.include_router(api_memberships.make_router())
|
|
# §22.8 S6: request-to-join + the cross-collection inbox — a user asks into a
|
|
# scope (naming a role); the scope's Owners across the subtree accept (writing
|
|
# the membership row) or decline.
|
|
router.include_router(api_join_requests.make_router())
|
|
|
|
# ---------------------------------------------------------------
|
|
# §17: /api/health — unauthenticated post-flight probe.
|
|
# Used by ops tooling (flotilla) to verify a deploy landed via
|
|
# version-match. v1 always reports ok; see backend/app/health.py.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/health")
|
|
async def get_health() -> dict[str, Any]:
|
|
return {"version": health.VERSION, "status": "ok"}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §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"]}
|
|
|
|
# ---------------------------------------------------------------
|
|
# /api/docs — DOCS.md served verbatim. Sibling of /api/philosophy:
|
|
# no auth gate, same disk-first load + cache shape, same intent —
|
|
# public read surface for a markdown file checked into the repo.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/docs")
|
|
async def get_docs() -> dict[str, Any]:
|
|
payload = docs_mod.load()
|
|
return {"body": payload["body"]}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.19.0 / roadmap item #30 — /api/docs/sessions/*
|
|
#
|
|
# The framework mediates reads against the public
|
|
# `wiggleverse/ohm-session-history` gitea repo so the rendered
|
|
# `/docs/sessions/*` surface inherits the same chrome as the
|
|
# /docs/user-guide route and doesn't require a cross-origin
|
|
# gesture from the frontend. See backend/app/docs_sessions.py
|
|
# for the cache shape and env knobs.
|
|
#
|
|
# The route mapping for the three `status` values returned by
|
|
# the fetchers:
|
|
#
|
|
# "ok" → HTTP 200, payload as documented per endpoint
|
|
# "404" → HTTP 200/404 depending on the endpoint (the
|
|
# manifest's empty state is 200 + {} so the
|
|
# frontend can short-circuit without an error
|
|
# banner; transcripts/about return 404 so the
|
|
# frontend can render its own empty-state)
|
|
# "error" → HTTP 502, {"error": ..., "detail": ...} so the
|
|
# frontend retry surface reads as "couldn't reach
|
|
# the session-history repo" rather than as a
|
|
# generic 5xx.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/docs/sessions/manifest")
|
|
async def get_sessions_manifest() -> dict[str, Any]:
|
|
result = await docs_sessions.fetch_manifest()
|
|
if result["status"] == "ok":
|
|
return result["manifest"]
|
|
if result["status"] == "404":
|
|
# Empty-state contract: render no session rows in the
|
|
# flyout but don't show an error banner. The frontend
|
|
# treats `{}` as "no sessions published yet".
|
|
return {}
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail={
|
|
"error": "session-history fetch failed",
|
|
"detail": result.get("detail", "unknown"),
|
|
},
|
|
)
|
|
|
|
@router.get("/api/docs/sessions/about")
|
|
async def get_sessions_about() -> Response:
|
|
result = await docs_sessions.fetch_about()
|
|
if result["status"] == "ok":
|
|
return PlainTextResponse(
|
|
content=result["body"],
|
|
media_type="text/markdown; charset=utf-8",
|
|
)
|
|
if result["status"] == "404":
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="session-history README not yet published",
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail={
|
|
"error": "session-history fetch failed",
|
|
"detail": result.get("detail", "unknown"),
|
|
},
|
|
)
|
|
|
|
@router.get("/api/docs/sessions/{nnnn}/index")
|
|
async def get_sessions_index(nnnn: str) -> dict[str, Any]:
|
|
if not docs_sessions._is_valid_session_dir(nnnn):
|
|
# 400 over 404: the request itself is malformed (the
|
|
# session directory name doesn't match `^\d{4}$`),
|
|
# distinct from "no such session published yet".
|
|
raise HTTPException(status_code=400, detail="invalid session directory")
|
|
result = await docs_sessions.fetch_session_index(nnnn)
|
|
if result["status"] == "ok":
|
|
return {"files": result["files"]}
|
|
if result["status"] == "404":
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="no transcripts published for this session",
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail={
|
|
"error": "session-history fetch failed",
|
|
"detail": result.get("detail", "unknown"),
|
|
},
|
|
)
|
|
|
|
@router.get("/api/docs/sessions/{nnnn}/{filename}")
|
|
async def get_sessions_transcript(nnnn: str, filename: str) -> Response:
|
|
# Path-shape validation before any network — refuses anything
|
|
# that would resolve outside the `NNNN/SESSION-...md` layout
|
|
# (e.g. legacy `SESSION-A-TRANSCRIPT.md` at the repo root,
|
|
# `../etc/passwd`, or any non-numeric session dir).
|
|
if not docs_sessions._is_valid_session_dir(nnnn):
|
|
raise HTTPException(status_code=400, detail="invalid session directory")
|
|
if not docs_sessions._is_valid_transcript_filename(filename):
|
|
raise HTTPException(status_code=400, detail="invalid transcript filename")
|
|
result = await docs_sessions.fetch_transcript(nnnn, filename)
|
|
if result["status"] == "ok":
|
|
return PlainTextResponse(
|
|
content=result["body"],
|
|
media_type="text/markdown; charset=utf-8",
|
|
)
|
|
if result["status"] == "404":
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="transcript not found",
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail={
|
|
"error": "session-history fetch failed",
|
|
"detail": result.get("detail", "unknown"),
|
|
},
|
|
)
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.20.0 — /api/docs/specs/*
|
|
#
|
|
# Sibling of the v0.19.0 docs-sessions surface: the framework
|
|
# mediates a fetch against the public gitea raw URL for each
|
|
# configured spec so the rendered `/docs/specs/*` route inherits
|
|
# the same chrome (and the same auth-less reach) as the user
|
|
# guide and the session-history browser. See
|
|
# backend/app/docs_specs.py for the manifest shape, the env
|
|
# knobs, and the cache.
|
|
#
|
|
# Status-to-HTTP mapping mirrors docs_sessions:
|
|
# "ok" → HTTP 200, payload as documented per endpoint
|
|
# "404" → HTTP 200 / 404 (manifest 404 doesn't apply here —
|
|
# the manifest is derived from env, never 404s; spec
|
|
# 404 returns HTTP 404 so the frontend can render
|
|
# "spec not yet published / unknown name")
|
|
# "error" → HTTP 502
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/docs/specs/manifest")
|
|
async def get_specs_manifest() -> dict[str, Any]:
|
|
# The manifest is derived from env (`OHM_DOCS_SPECS`) and
|
|
# never fails — a malformed value falls back to the framework
|
|
# default at parse time. So this endpoint always returns 200
|
|
# + a list (the framework default is non-empty).
|
|
result = docs_specs.fetch_specs_manifest()
|
|
return {"specs": result["specs"]}
|
|
|
|
@router.get("/api/docs/specs/{name}")
|
|
async def get_spec(name: str) -> Response:
|
|
# Slug validation before any network — refuses `..`, `/`,
|
|
# uppercase, whitespace, etc. Same defense-in-depth posture
|
|
# as the docs-sessions transcript endpoint.
|
|
if not docs_specs._is_valid_name(name):
|
|
raise HTTPException(status_code=400, detail="invalid spec name")
|
|
result = await docs_specs.fetch_spec(name)
|
|
if result["status"] == "ok":
|
|
return PlainTextResponse(
|
|
content=result["body"],
|
|
media_type="text/markdown; charset=utf-8",
|
|
)
|
|
if result["status"] == "404":
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="spec not found",
|
|
)
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail={
|
|
"error": "specs fetch failed",
|
|
"detail": result.get("detail", "unknown"),
|
|
},
|
|
)
|
|
|
|
# ---------------------------------------------------------------
|
|
# Auth surface — 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}
|
|
# v0.8.0 + v0.10.0: single round-trip for everything the
|
|
# frontend gates UI off of — beta-access state + passcode state.
|
|
row = db.conn().execute(
|
|
"SELECT first_name, last_name, beta_request_reason, "
|
|
"passcode_hash, passcode_set_at "
|
|
"FROM users WHERE id = ?",
|
|
(user.user_id,),
|
|
).fetchone()
|
|
first_name = (row["first_name"] if row else None) or ""
|
|
last_name = (row["last_name"] if row else None) or ""
|
|
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
|
|
# "Needs profile" iff the user is pending AND hasn't yet
|
|
# filed their beta-request capture. Granted users never see
|
|
# the capture prompt; pending users who already filed see
|
|
# the /beta-pending page without the capture form.
|
|
needs_profile = (
|
|
user.permission_state == "pending"
|
|
and not first_name
|
|
and not last_name
|
|
and not beta_request_reason
|
|
)
|
|
has_passcode = bool(row and row["passcode_hash"])
|
|
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
|
|
# v0.23.0 / item #29: fold the sign-in-resume state onto the
|
|
# same round-trip the frontend already makes on boot. When
|
|
# resume is disabled (resume_enabled = 0) we hand back a null
|
|
# route so the client never redirects; the stored row stays put
|
|
# so re-enabling later resumes the last-known route.
|
|
state_row = db.conn().execute(
|
|
"SELECT last_route, last_route_state, resume_enabled "
|
|
"FROM user_session_state WHERE user_id = ?",
|
|
(user.user_id,),
|
|
).fetchone()
|
|
resume_enabled = bool(state_row["resume_enabled"]) if state_row else True
|
|
last_route = (
|
|
state_row["last_route"]
|
|
if (state_row and resume_enabled)
|
|
else None
|
|
)
|
|
last_route_state = None
|
|
if state_row and resume_enabled and state_row["last_route_state"]:
|
|
try:
|
|
last_route_state = json.loads(state_row["last_route_state"])
|
|
except (ValueError, TypeError):
|
|
last_route_state = 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,
|
|
"permission_state": user.permission_state,
|
|
"first_name": first_name,
|
|
"last_name": last_name,
|
|
"beta_request_reason": beta_request_reason,
|
|
"needs_profile": needs_profile,
|
|
"has_passcode": has_passcode,
|
|
"passcode_set_at": passcode_set_at,
|
|
# v0.23.0 / item #29 — sign-in state resume.
|
|
"resume_enabled": resume_enabled,
|
|
"last_route": last_route,
|
|
"last_route_state": last_route_state,
|
|
},
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
|
|
# (roadmap item #6). Lands first name, last name, and the free-
|
|
# text "why I should be included in the beta" on the signed-in
|
|
# user's row. Idempotent for the same already-pending user;
|
|
# refuses to overwrite a row that's already granted (so a
|
|
# bored already-granted user can't accidentally re-submit the
|
|
# form and clobber the admin's audit trail). Uses
|
|
# `require_user` rather than `require_contributor` because
|
|
# `require_contributor` already enforces `permission_state =
|
|
# 'granted'` and would refuse a pending user; the whole point
|
|
# of this endpoint is to register the request _from_ a pending
|
|
# user.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/auth/me/beta-request")
|
|
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
|
|
user = auth.require_user(request)
|
|
row = db.conn().execute(
|
|
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
|
(user.user_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
# Defensive — the session pointed at a deleted row.
|
|
raise HTTPException(404, "User not found")
|
|
# Granted users have no business filing a beta request.
|
|
# 'revoked' likewise — the request flow is for fresh users
|
|
# only. Both shapes refuse with 409 (conflict) so the client
|
|
# can distinguish "you already have access" from
|
|
# "your access was revoked".
|
|
if row["permission_state"] == "granted":
|
|
raise HTTPException(409, "Your account is already granted access")
|
|
if row["permission_state"] == "revoked":
|
|
raise HTTPException(409, "Your account's access has been revoked")
|
|
# Re-submission from a pending user updates the row — the
|
|
# admin sees the latest text rather than a stale draft.
|
|
# The state stays 'pending'; only an admin can flip it.
|
|
db.conn().execute(
|
|
"""
|
|
UPDATE users
|
|
SET first_name = ?,
|
|
last_name = ?,
|
|
beta_request_reason = ?
|
|
WHERE id = ?
|
|
""",
|
|
(
|
|
body.first_name.strip(),
|
|
body.last_name.strip(),
|
|
body.beta_request_reason.strip(),
|
|
user.user_id,
|
|
),
|
|
)
|
|
# v0.9.0 (roadmap item #7): notify every admin/owner of the
|
|
# fresh request. Only the first submission is the
|
|
# "newly-pending" gesture — re-submits from the same user
|
|
# would otherwise carpet the admin inbox. We fire only when
|
|
# this is the row's first time getting all three fields
|
|
# populated (the prior row carried at least one NULL).
|
|
prior = row # captured before the UPDATE above
|
|
was_already_complete = bool(
|
|
prior["first_name"] and prior["last_name"] and prior["beta_request_reason"]
|
|
)
|
|
if not was_already_complete:
|
|
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
|
return {"ok": True}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.23.0 (§6.2, roadmap item #29): server-side sign-in state
|
|
# resume. The frontend debounce-posts the user's current route +
|
|
# a small bag of light view state here on every route change; the
|
|
# next sign-in reads `last_route` off `/api/auth/me` and redirects.
|
|
#
|
|
# Per-user (NOT per-device) — one row per user, keyed on user_id.
|
|
# `resume_enabled` is the opt-out flag (default on); when it's 0
|
|
# this endpoint no-ops so a user who turned resume off doesn't keep
|
|
# silently rewriting their stored route. PRIVACY: the body carries
|
|
# route + light state ONLY, never draft-buffer contents (migration
|
|
# 022 column comment + SPEC §6.2 are the binding contract).
|
|
#
|
|
# `require_user` (not `require_contributor`) — a pending/granted
|
|
# distinction is irrelevant for "remember where I was", and a
|
|
# pending user navigating read-only surfaces should still resume.
|
|
# Anonymous callers get the 401 `require_user` raises.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.put("/api/me/last-state")
|
|
async def put_last_state(body: LastStateBody, request: Request) -> dict[str, Any]:
|
|
user = auth.require_user(request)
|
|
# Respect the opt-out: if a row already exists with resume
|
|
# disabled, leave it untouched and report the no-op. A first-
|
|
# ever POST (no row yet) defaults to enabled and stores.
|
|
existing = db.conn().execute(
|
|
"SELECT resume_enabled FROM user_session_state WHERE user_id = ?",
|
|
(user.user_id,),
|
|
).fetchone()
|
|
if existing is not None and not existing["resume_enabled"]:
|
|
return {"ok": True, "stored": False}
|
|
state_json = json.dumps(body.state) if body.state is not None else None
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO user_session_state
|
|
(user_id, last_route, last_route_state, last_updated_at)
|
|
VALUES (?, ?, ?, datetime('now'))
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
last_route = excluded.last_route,
|
|
last_route_state = excluded.last_route_state,
|
|
last_updated_at = excluded.last_updated_at
|
|
""",
|
|
(user.user_id, body.route, state_json),
|
|
)
|
|
return {"ok": True, "stored": True}
|
|
|
|
# ---------------------------------------------------------------
|
|
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
|
#
|
|
# The mint path lives on the OAuth router (issuing the cookie is
|
|
# coupled to OTC/passcode verify). This module owns the read/revoke
|
|
# surface the /settings/devices page calls.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/auth/me/devices")
|
|
async def list_my_devices(request: Request) -> dict[str, Any]:
|
|
"""Active device-trust rows for the signed-in user.
|
|
|
|
Active = not revoked, not expired. The current request's
|
|
device (if any) is *not* singled out here — the surface
|
|
shows the same row shape for every device so the user can
|
|
revoke any of them without the page leaking which row
|
|
carries the cookie they're using right now.
|
|
"""
|
|
user = auth.require_user(request)
|
|
rows = device_trust_mod.list_for_user(user.user_id)
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": r.id,
|
|
"created_at": r.created_at,
|
|
"expires_at": r.expires_at,
|
|
"last_seen_at": r.last_seen_at,
|
|
"user_agent": r.user_agent,
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
@router.delete("/api/auth/me/devices/{device_id}")
|
|
async def revoke_my_device(device_id: int, request: Request) -> dict[str, Any]:
|
|
"""Revoke a single device-trust row for the signed-in user.
|
|
|
|
The user-id scope is enforced in SQL so a hostile client
|
|
cannot revoke another user's row by guessing ids. A row that
|
|
doesn't exist, doesn't belong to this user, or is already
|
|
revoked reads as 404 — the wrong-vs-already-revoked
|
|
distinction would only help a probing client enumerate ids.
|
|
"""
|
|
user = auth.require_user(request)
|
|
ok = device_trust_mod.revoke(user.user_id, device_id)
|
|
if not ok:
|
|
raise HTTPException(404, "Device not found")
|
|
return {"ok": True}
|
|
|
|
@router.delete("/api/auth/me/devices")
|
|
async def revoke_all_my_devices(request: Request) -> dict[str, Any]:
|
|
"""Revoke every active device-trust row for the signed-in user.
|
|
|
|
The user's current request stays authenticated via its
|
|
session cookie; the device-trust cookie carried on the
|
|
current device is also revoked, but `rfc_session` keeps the
|
|
request flow alive until sign-out / expiry.
|
|
"""
|
|
user = auth.require_user(request)
|
|
count = device_trust_mod.revoke_all(user.user_id)
|
|
return {"ok": True, "revoked": count}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §7: the catalog
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.get("/api/rfcs")
|
|
async def list_rfcs(request: Request, unreviewed: str | None = None) -> 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.
|
|
|
|
§22.4c: pass `?unreviewed=true` to narrow to active entries
|
|
still awaiting owner review.
|
|
"""
|
|
viewer = auth.current_user(request)
|
|
viewer_id = viewer.user_id if viewer else None
|
|
# §22.5: a gated project's entries never surface in a non-member's
|
|
# catalog. For the single public default project this is the full set.
|
|
visible = auth.visible_project_ids(viewer)
|
|
if not visible:
|
|
return {"items": []}
|
|
placeholders = ",".join("?" for _ in visible)
|
|
params = list(visible)
|
|
unreviewed_clause = ""
|
|
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
|
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
|
rows = db.conn().execute(
|
|
f"""
|
|
SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
|
|
r.owners_json, r.arbiters_json, r.tags_json,
|
|
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
|
|
FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
|
|
WHERE r.state IN ('super-draft', 'active')
|
|
AND c.project_id IN ({placeholders}){unreviewed_clause}
|
|
ORDER BY COALESCE(r.last_main_commit_at, r.last_entry_commit_at) DESC
|
|
""",
|
|
params,
|
|
).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, request: Request) -> dict[str, Any]:
|
|
row = db.conn().execute(
|
|
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "Not found")
|
|
viewer = auth.current_user(request)
|
|
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
|
|
# 404 to non-members. Recover the project via the entry's collection.
|
|
auth.require_project_readable(viewer, auth.project_of_rfc(slug))
|
|
# §13.7: a retired entry is removed from every browsing surface. The
|
|
# sole exception is a site owner, so the un-retire affordance has
|
|
# somewhere to live; everyone else gets a plain 404.
|
|
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
|
|
raise HTTPException(404, "Not found")
|
|
payload = _serialize_rfc(row)
|
|
# Roadmap #26: surface the optional propose-time use case on the
|
|
# RFC view. The idea PR closes on merge, but the canonical row in
|
|
# `proposed_use_cases` persists; look it up by slug (the latest
|
|
# 'rfc'-scope row for this slug). NULL == "left blank".
|
|
uc = db.conn().execute(
|
|
"""
|
|
SELECT use_case FROM proposed_use_cases
|
|
WHERE scope = 'rfc' AND rfc_slug = ?
|
|
ORDER BY id DESC LIMIT 1
|
|
""",
|
|
(slug,),
|
|
).fetchone()
|
|
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
|
return payload
|
|
|
|
# ---------------------------------------------------------------
|
|
# §22.4 (Plan B): per-project RFC serving — the catalog + entry view
|
|
# scoped to one project, identified by its own slug namespace
|
|
# (project_id, slug). The unscoped /api/rfcs[/{slug}] above stay as the
|
|
# default-project compat path; the frontend reads these scoped routes so a
|
|
# second project's corpus renders under /p/<id>/.
|
|
# ---------------------------------------------------------------
|
|
|
|
def _require_collection_in_project(collection_id: str, project_id: str) -> None:
|
|
# §22 S2: a collection-scoped route 404s when the collection does not
|
|
# belong to the project in the path (shape matches an unknown id).
|
|
if collections_mod.project_of_collection(collection_id) != project_id:
|
|
raise HTTPException(404, "Not found")
|
|
|
|
def _list_rfcs_for_collection(
|
|
collection_id: str, viewer, unreviewed: str | None
|
|
) -> dict[str, Any]:
|
|
viewer_id = viewer.user_id if viewer else None
|
|
unreviewed_clause = ""
|
|
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
|
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
|
rows = db.conn().execute(
|
|
f"""
|
|
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')
|
|
AND collection_id = ?{unreviewed_clause}
|
|
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
|
""",
|
|
(collection_id,),
|
|
).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 = ? AND collection_id = ?",
|
|
(viewer_id, collection_id),
|
|
)
|
|
}
|
|
items = [
|
|
{
|
|
"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,
|
|
}
|
|
for r in rows
|
|
]
|
|
return {"items": items}
|
|
|
|
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
|
row = db.conn().execute(
|
|
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
|
|
(collection_id, slug),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "Not found")
|
|
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
|
|
raise HTTPException(404, "Not found")
|
|
payload = _serialize_rfc(row)
|
|
uc = db.conn().execute(
|
|
"""
|
|
SELECT use_case FROM proposed_use_cases
|
|
WHERE scope = 'rfc' AND rfc_slug = ? AND collection_id = ?
|
|
ORDER BY id DESC LIMIT 1
|
|
""",
|
|
(slug, collection_id),
|
|
).fetchone()
|
|
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
|
return payload
|
|
|
|
@router.get("/api/projects/{project_id}/rfcs")
|
|
async def list_project_rfcs(
|
|
project_id: str, request: Request, unreviewed: str | None = None
|
|
) -> dict[str, Any]:
|
|
viewer = auth.current_user(request)
|
|
# §22.5 read gate: a gated project's catalog 404s to a non-member.
|
|
auth.require_project_readable(viewer, project_id)
|
|
# §22 S1: the project-scoped route serves the default collection.
|
|
collection_id = collections_mod.default_collection_id(project_id)
|
|
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
|
|
|
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
|
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
|
viewer = auth.current_user(request)
|
|
auth.require_project_readable(viewer, project_id)
|
|
collection_id = collections_mod.default_collection_id(project_id)
|
|
return _get_rfc_for_collection(collection_id, slug, viewer)
|
|
|
|
# §22 S2: collection-scoped serve + propose. The catalog/entry views read
|
|
# these under /p/<project>/c/<collection>/; the project-scoped routes above
|
|
# stay as the default-collection compat surface.
|
|
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs")
|
|
async def list_collection_rfcs(
|
|
project_id: str, collection_id: str, request: Request,
|
|
unreviewed: str | None = None,
|
|
) -> dict[str, Any]:
|
|
viewer = auth.current_user(request)
|
|
auth.require_project_readable(viewer, project_id)
|
|
_require_collection_in_project(collection_id, project_id)
|
|
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
|
|
auth.require_collection_readable(viewer, collection_id)
|
|
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
|
|
|
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
|
async def get_collection_rfc(
|
|
project_id: str, collection_id: str, slug: str, request: Request
|
|
) -> dict[str, Any]:
|
|
viewer = auth.current_user(request)
|
|
auth.require_project_readable(viewer, project_id)
|
|
_require_collection_in_project(collection_id, project_id)
|
|
auth.require_collection_readable(viewer, collection_id)
|
|
return _get_rfc_for_collection(collection_id, slug, viewer)
|
|
|
|
# ---------------------------------------------------------------
|
|
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
|
|
async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
|
"""§22.4c — clear an active entry's `unreviewed` flag. Authority is the
|
|
§B.2 collection Owner (a collection/project/global Owner or deployment
|
|
owner/admin reaching the entry's collection)."""
|
|
viewer = auth.require_user(request)
|
|
auth.require_project_readable(viewer, project_id)
|
|
collection_id = collections_mod.default_collection_id(project_id)
|
|
if not auth.is_collection_superuser(viewer, collection_id):
|
|
raise HTTPException(403, "Only a collection owner can mark an entry reviewed")
|
|
row = db.conn().execute(
|
|
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
|
|
(slug, collection_id),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "Not found")
|
|
if row["state"] != "active" or not row["unreviewed"]:
|
|
raise HTTPException(409, "Entry is not an unreviewed active entry")
|
|
try:
|
|
await bot.mark_entry_reviewed(
|
|
viewer.as_actor(),
|
|
org=config.gitea_org,
|
|
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
|
slug=slug,
|
|
reviewed_by=viewer.gitea_login,
|
|
reviewed_at=entry_mod.today(),
|
|
)
|
|
except GiteaError as e:
|
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
|
await cache.refresh_meta_repo(config, gitea)
|
|
return {"ok": True}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §7.3 / §9.3: pending ideas
|
|
# ---------------------------------------------------------------
|
|
|
|
def _proposal_use_case(pr_number: int) -> str | None:
|
|
"""Roadmap #26: read the optional use case for an idea PR from the
|
|
canonical side table. Returns None when none was supplied (the
|
|
"left blank" sentinel the frontend renders tastefully)."""
|
|
row = db.conn().execute(
|
|
"SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
|
|
(pr_number,),
|
|
).fetchone()
|
|
return row["use_case"] if row else None
|
|
|
|
@router.get("/api/proposals")
|
|
async def list_proposals(request: Request) -> dict[str, Any]:
|
|
# §22.5: idea PRs in a gated project never surface to non-members.
|
|
visible = auth.visible_project_ids(auth.current_user(request))
|
|
if not visible:
|
|
return {"items": []}
|
|
placeholders = ",".join("?" for _ in visible)
|
|
rows = db.conn().execute(
|
|
f"""
|
|
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
|
|
FROM cached_prs
|
|
WHERE pr_kind = 'idea' AND state = 'open'
|
|
AND project_id IN ({placeholders})
|
|
ORDER BY opened_at DESC
|
|
""",
|
|
visible,
|
|
).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"],
|
|
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
|
}
|
|
for r in rows
|
|
]
|
|
}
|
|
|
|
@router.get("/api/projects/{project_id}/proposals")
|
|
async def list_project_proposals(project_id: str, request: Request) -> dict[str, Any]:
|
|
# §22.4/§22.5: the pending idea-PRs scoped to one project.
|
|
viewer = auth.current_user(request)
|
|
auth.require_project_readable(viewer, project_id)
|
|
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' AND project_id = ?
|
|
ORDER BY opened_at DESC
|
|
""",
|
|
(project_id,),
|
|
).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"],
|
|
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
|
}
|
|
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")
|
|
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
|
auth.require_project_readable(auth.current_user(request), row["project_id"])
|
|
# 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, (projects_mod.default_content_repo(config) or ""), 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,
|
|
"proposed_use_case": _proposal_use_case(pr_number),
|
|
}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §9.1: propose a new RFC
|
|
# ---------------------------------------------------------------
|
|
|
|
async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]:
|
|
# Default-collection wrapper (§22 S1/S2): resolve the project's default
|
|
# collection and delegate. Keeps the project-scoped propose routes intact.
|
|
return await _propose_into_collection(
|
|
project_id, collections_mod.default_collection_id(project_id), payload, user
|
|
)
|
|
|
|
async def _propose_into_collection(
|
|
project_id: str, collection_id: str, payload: ProposeBody, user
|
|
) -> dict[str, Any]:
|
|
# §B.2 (S3): proposing a new entry requires contribute standing in the
|
|
# *target collection* — the four-layer scope-role union, with the
|
|
# grandfathered implicit-public baseline on the default collection.
|
|
if not auth.can_contribute_in_collection(user, collection_id):
|
|
raise HTTPException(403, "You do not have contribute access to this collection")
|
|
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 = ? AND collection_id = ?", (slug, collection_id)
|
|
).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 = ? AND project_id = ?",
|
|
(slug, project_id),
|
|
).fetchone()
|
|
if idea_clash:
|
|
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
|
|
|
|
# §22.4b: the target collection's landing state (the per-corpus field
|
|
# moved down to the collection in migration 029).
|
|
landing_state = (
|
|
"active" if collections_mod.collection_initial_state(collection_id) == "active"
|
|
else "super-draft"
|
|
)
|
|
|
|
entry = entry_mod.Entry(
|
|
slug=slug,
|
|
title=payload.title.strip(),
|
|
state=landing_state,
|
|
id=None,
|
|
repo=None,
|
|
proposed_by=user.email or user.gitea_login,
|
|
proposed_at=entry_mod.today(),
|
|
graduated_at=None,
|
|
graduated_by=None,
|
|
# §9.2: the proposer is the implicit first owner at propose time.
|
|
# The §13.1 claim flow exists for *other* contributors to become
|
|
# owners on an RFC they didn't propose; the proposer never needs
|
|
# to claim their own RFC.
|
|
owners=[user.gitea_login],
|
|
arbiters=[],
|
|
tags=[t.strip() for t in payload.tags if t.strip()],
|
|
body=payload.pitch.strip() + "\n",
|
|
unreviewed=(landing_state == "active"),
|
|
)
|
|
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()}"
|
|
)
|
|
# §22 S2: write the entry under the target collection's <subfolder>/rfcs.
|
|
subfolder = collections_mod.subfolder_of(collection_id)
|
|
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
|
try:
|
|
pr = await bot.open_idea_pr(
|
|
user.as_actor(),
|
|
org=config.gitea_org,
|
|
meta_repo=(projects_mod.content_repo(project_id) or ""),
|
|
slug=slug,
|
|
file_contents=contents,
|
|
pr_title=pr_title,
|
|
pr_description=pr_description,
|
|
rfcs_dir=rfcs_dir,
|
|
)
|
|
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)
|
|
|
|
# Roadmap #26: persist the optional use case to the canonical,
|
|
# reconcile-proof side table keyed by the idea PR number. NULL/
|
|
# blank simply writes no row (absence == "left blank"). Done after
|
|
# the refresh so the cache row exists; the mirror onto cached_prs
|
|
# keeps the cache column in parity for any read that uses it.
|
|
use_case = (payload.proposed_use_case or "").strip()
|
|
if use_case:
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, collection_id)
|
|
VALUES ('rfc', ?, ?, ?, ?)
|
|
ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
|
""",
|
|
(slug, pr["number"], use_case, collection_id),
|
|
)
|
|
db.conn().execute(
|
|
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
|
|
(use_case, pr["number"], project_id),
|
|
)
|
|
|
|
return {"pr_number": pr["number"], "slug": slug}
|
|
|
|
@router.post("/api/rfcs/propose")
|
|
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
|
|
# Default-project compat path (pre-multi-project clients).
|
|
user = auth.require_contributor(request)
|
|
return await _propose_into_project(projects_mod.resolved_default_id(config), payload, user)
|
|
|
|
@router.post("/api/projects/{project_id}/rfcs/propose")
|
|
async def propose_project_rfc(
|
|
project_id: str, payload: ProposeBody, request: Request
|
|
) -> dict[str, Any]:
|
|
# §22.4: propose a new entry into a specific project (read-gated first
|
|
# so a gated project 404s a non-member before the contribute check).
|
|
user = auth.require_contributor(request)
|
|
auth.require_project_readable(user, project_id)
|
|
return await _propose_into_project(project_id, payload, user)
|
|
|
|
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/propose")
|
|
async def propose_collection_rfc(
|
|
project_id: str, collection_id: str, payload: ProposeBody, request: Request
|
|
) -> dict[str, Any]:
|
|
# §22 S2: propose a new entry into a specific collection of a project.
|
|
user = auth.require_contributor(request)
|
|
auth.require_project_readable(user, project_id)
|
|
_require_collection_in_project(collection_id, project_id)
|
|
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer
|
|
# before the contribute check (existence is not revealed).
|
|
auth.require_collection_readable(user, collection_id)
|
|
return await _propose_into_collection(project_id, collection_id, payload, user)
|
|
|
|
# ---------------------------------------------------------------
|
|
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
|
|
# propose-RFC fields fill in. The modal debounce-posts the partial
|
|
# draft; we constrain Haiku to the corpus's existing tag set and
|
|
# return a short ranked list of clickable chips. Gated to
|
|
# contributors (same gate as propose) so the cost surface is bounded
|
|
# to people who can actually file an RFC; rate-limited per user as a
|
|
# backstop. Degrades to an empty list (no error) when no Anthropic
|
|
# key is bound, the corpus has no tags yet, or the draft is empty —
|
|
# so the modal simply shows nothing extra.
|
|
# ---------------------------------------------------------------
|
|
|
|
@router.post("/api/rfcs/suggest-tags")
|
|
async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
|
|
user = auth.require_contributor(request)
|
|
if not tag_suggest.rate_limit_ok(user.user_id):
|
|
raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
|
|
provider = tag_suggest.haiku_provider(config)
|
|
if provider is None:
|
|
return {"suggestions": []}
|
|
universe = tag_suggest.gather_tag_universe()
|
|
draft = tag_suggest.Draft(
|
|
title=payload.title, pitch=payload.pitch, use_case=payload.use_case
|
|
)
|
|
suggestions = tag_suggest.suggest(provider, draft, universe)
|
|
return {"suggestions": suggestions}
|
|
|
|
# ---------------------------------------------------------------
|
|
# §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=(projects_mod.default_content_repo(config) or ""),
|
|
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=(projects_mod.default_content_repo(config) or ""),
|
|
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=(projects_mod.default_content_repo(config) or ""),
|
|
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")
|
|
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
|
auth.require_project_readable(user, auth.project_of_rfc(slug))
|
|
# §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,
|
|
}
|