Release 0.16.0: owner-only invite for per-RFC contribution + discussion

Lands roadmap item #12: the RFC's owner can invite specific users by
email to one of two per-RFC roles — contributor (open PRs and join
discussion) or discussant (join discussion only). Non-invited users
keep the v0.6.0 anonymous-read contract: they can read but cannot
write/discuss that RFC. The platform-level grant (v0.8.0 / item #6)
is unchanged; this release adds a per-RFC membership layer beneath
it.

Migration 018_rfc_invitations.sql adds rfc_invitations (the
lifecycle row with the email token and 30-day expiry) and
rfc_collaborators (the accepted-invitation substrate the write
gate consults). FK-cascaded against cached_rfcs and users per §5.

New endpoints (in backend/app/api_invitations.py):
  POST   /api/rfcs/{slug}/invitations              (owner)
  GET    /api/rfcs/{slug}/invitations              (owner)
  POST   /api/rfcs/{slug}/invitations/{id}/revoke  (owner)
  GET    /api/invitations/accept?token=…           (signed-in)
  POST   /api/invitations/accept                   (signed-in)

The discussion / branch / open-pr write surfaces compose new
auth.can_discuss_rfc and auth.can_contribute_to_rfc predicates
after the existing require_contributor check. A super-draft with
no frontmatter owners yet falls through to the platform-granted
contract — the gate engages only once an owner exists.

The email path reuses the existing SMTP plumbing (EmailConfig.from_env)
the v0.7.0 OTC and v0.5.0 notification mailers share — transactional
envelope, no preferences honored, no unsubscribe footer.

The §17 admin user-management surface (item #7, v0.9.0) is extended
additively: GET /api/admin/users carries a new per-user
rfc_invitations array naming each accepted per-RFC collaboration
with inviter / RFC / role / timestamp. The platform-grant decision
keeps its context without restructuring the existing shape.

Frontend additions: InvitationsModal.jsx (owner-only RFC view
header strip affordance), AcceptInvitation.jsx (the
/invitations/accept landing page), five api.js helpers.

237 backend tests pass (18 new in test_rfc_invitations_vertical.py,
three older tests updated to opt into the per-RFC contributor
contract via the new grant_rfc_collaborator test seam). Frontend
build clean.

No new env vars. No new overlay keys. Migration auto-applied on
backend start by the existing db.run_migrations() sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 04:53:07 -07:00
parent b3f1b15f65
commit a51beecbc9
21 changed files with 2318 additions and 2 deletions
+17
View File
@@ -116,6 +116,17 @@ def make_router() -> APIRouter:
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
# v0.16.0 (roadmap item #12): the per-RFC discussion is now a
# gated surface. The platform-level `require_contributor` above
# ensures the user is signed in + admin-granted; this layer
# narrows further to "is this user named for this RFC?" The
# 403 here is structurally the v0.6.0 anon-write refusal
# extended to non-invited platform users.
if not auth.can_discuss_rfc(viewer, slug):
raise HTTPException(
403,
"This RFC's owner has not invited you to its discussion",
)
cur = db.conn().execute(
"""
INSERT INTO threads
@@ -175,6 +186,12 @@ def make_router() -> APIRouter:
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
# v0.16.0 (item #12): same per-RFC gate as create_discussion_thread.
if not auth.can_discuss_rfc(viewer, slug):
raise HTTPException(
403,
"This RFC's owner has not invited you to its discussion",
)
_require_discussion_thread(slug, thread_id)
message_id = chat_layer.append_user_message(
thread_id=thread_id,