Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Stull a51beecbc9 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>
2026-05-28 04:53:07 -07:00
21 changed files with 2318 additions and 2 deletions
+154
View File
@@ -23,6 +23,160 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.16.0 — 2026-05-28
**Minor — schema migration auto-applied; no operator action.** This
release lands the owner-only invite for per-RFC PR or PR-less
discussion (roadmap item #12). The RFC's owner can now invite
specific users by email to one of two per-RFC roles —
`contributor` (open PRs against the RFC AND join its discussion) or
`discussant` (join the discussion only). Non-invited users keep
the v0.6.0 anonymous-read contract: they can read but cannot
write/discuss that RFC. Invitations are token-encoded in a
transactional email; acceptance lands a per-RFC collaborator row
and surfaces in the admin user-management page (additive on the
existing `/api/admin/users` shape) so the platform-grant decision
has the per-RFC context to inform it. The platform-level grant
remains the admin's call — this release adds a per-RFC membership
layer beneath it, not a new platform-grant path.
The per-RFC write gate is layered on top of the existing
`require_contributor` (v0.8.0) gate, not in place of it: a user
must be platform-granted AND hold an accepted per-RFC role (or
be the RFC owner / a platform admin/owner) to write. A super-
draft with no frontmatter owners yet (pre-§13.1 claim) falls
through to the platform-granted contract — there's no owner to
issue invitations, so the gate is open until one exists. This
preserves the v0.6.0 / v0.7.0 / v0.8.0 contracts inside their
domains and confines item #12's change to "an RFC has owners →
those owners decide who writes."
### Added
- **`backend/migrations/018_rfc_invitations.sql`** — two tables.
`rfc_invitations` carries the lifecycle row (issued, accepted,
revoked, expired) with the opaque token the email link encodes,
the inviter, the invitee email, the role-in-RFC, and the 30-day
expiry. `rfc_collaborators` is the accepted-invitation
substrate — the compact (rfc, user, role) shape the write gate
consults. Both tables are FK-cascaded against `cached_rfcs` and
`users` per §5's cascade rules. Indexed for the owner's listing,
the accept-by-token lookup, and the per-user read.
- **`backend/app/api_invitations.py`** — the §17 surface. Five
endpoints: `POST /api/rfcs/{slug}/invitations` (create + email),
`GET /api/rfcs/{slug}/invitations` (owner's listing),
`POST /api/rfcs/{slug}/invitations/{id}/revoke`,
`GET /api/invitations/accept?token=…` (preview), and
`POST /api/invitations/accept` (redeem). The email reuses
`EmailConfig.from_env()` and the `_SENT` buffer the OTC and
notification mailers share — transactional, no preferences
honored, no unsubscribe footer. A failure to send does NOT
roll back the row; the owner has the token on the listing
surface for an out-of-band share.
- **`backend/app/auth.py`** — four helpers. `is_rfc_owner`
reads the frontmatter `owners_json`. `is_rfc_collaborator`
reads the v0.16.0 `rfc_collaborators` table. `can_discuss_rfc`
and `can_contribute_to_rfc` are the composite predicates the
write endpoints consult (platform admin/owner OR no-owners-yet
fall-through OR RFC owner OR per-RFC collaborator at the right
role). `can_invite_to_rfc` is the issue-side predicate (RFC
owner or platform admin/owner only — collaborators don't get
invite power).
- **`frontend/src/components/InvitationsModal.jsx`** — the RFC
owner's surface: an email input + role picker for sending,
and a status table for listing/revoking. Visible only to the
RFC's owner or a platform admin/owner (the backend gates the
endpoints regardless).
- **`frontend/src/components/AcceptInvitation.jsx`** — the
`/invitations/accept?token=…` landing page. Previews what the
invitation grants, refuses on email mismatch / revoked /
expired with a single sentence each, redirects to the RFC's
view on accept.
- **API client (`frontend/src/api.js`)** — five new helpers:
`listRFCInvitations`, `createRFCInvitation`,
`revokeRFCInvitation`, `previewInvitation`, `acceptInvitation`.
### Changed
- **`backend/app/api.py`** — registers
`api_invitations.make_router()` alongside the existing routers.
- **`backend/app/api_discussion.py`** — `POST .../discussion/threads`
and `POST .../discussion/threads/{thread_id}/messages` now compose
the new `auth.can_discuss_rfc` predicate after the existing
`require_contributor` check. A platform-granted user without a
per-RFC discussion role on an RFC with owners gets 403 with
"This RFC's owner has not invited you to its discussion."
- **`backend/app/api_branches.py`** — `POST .../promote-to-branch`
and `POST .../start-edit-branch` now compose
`auth.can_contribute_to_rfc`. Same shape: platform-granted but
uninvited → 403.
- **`backend/app/api_prs.py`** — `POST .../open-pr` also composes
`auth.can_contribute_to_rfc` so a user whose per-RFC role was
revoked between branch-cut and PR-open is refused at the
ship line.
- **`backend/app/api_admin.py`** — `GET /api/admin/users` carries
a new `rfc_invitations` array per user (empty if none), naming
each accepted per-RFC collaboration with the RFC slug/title,
the role, the inviter, and the timestamp. Additive — the
existing v0.9.0 columns are unchanged; consumers that don't
read the new field see the legacy shape.
- **`frontend/src/App.jsx`** — registers the
`/invitations/accept` route (visible to anonymous + signed-in
viewers; signed-out viewers see a sign-in prompt).
- **`frontend/src/components/RFCView.jsx`** — additive
"Invitations" button in the RFC header strip, visible to RFC
owners and platform admins/owners on both super-drafts and
active RFCs. Mounts the new modal on click.
- **`backend/tests/test_propose_vertical.py`** — adds the
`grant_rfc_collaborator` test helper so v0.5.0/v0.6.0/v0.8.0-era
tests that exercise non-owner contribution can opt into the new
invitation contract without rewriting their setup.
- **`backend/tests/test_pr_flow_vertical.py`,
`backend/tests/test_graduation_vertical.py`,
`backend/tests/test_e2e_smoke.py`** — three tests that signed in
as non-owner contributors now seed an accepted per-RFC
collaborator row first (mirroring the production invite→accept
dance). The test intent is unchanged; the precondition is now
explicit.
### Migration
- **`018_rfc_invitations.sql`** — auto-applied on backend start by
the existing `db.run_migrations()` sweep. The two new tables
are empty at upgrade time; no existing data is touched. No
operator gesture needed.
### Upgrade steps (from 0.15.0, or 0.14.0 if 0.15.0 is skipped)
- You **MUST** rebuild the frontend and restart the backend after
upgrading so the new endpoints, the migration, the gate
composition in `api_discussion`/`api_branches`/`api_prs`, and the
new frontend routes/components are picked up. `frontend/package.json#version`
and `VERSION` both move to `0.16.0`.
- You **MUST NOT** set any new env var — there are no new secrets
and no new overlay keys. The email path reuses the existing
`SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` /
`EMAIL_FROM` / `EMAIL_FROM_NAME` / `APP_URL` / `EMAIL_ENABLED`
variables that the v0.7.0 OTC and v0.5.0 notification paths
already require. Deployments that have those wired need no
configuration change.
- You **MUST NOT** apply the migration manually — the backend's
migration runner picks up `018_rfc_invitations.sql` on next
start. (If you've configured an external migration tool, run it
before starting the backend; the framework's own runner is
idempotent against already-applied migrations.)
- You **SHOULD** inform existing RFC owners that they can now
invite collaborators from the RFC view's header strip. RFCs
with frontmatter owners that pre-date this release see no
behavioral change for the owner; the change is visible to
non-owner contributors who previously could write on any RFC
and now must be invited first.
- You **MAY** seed `rfc_collaborators` rows directly via SQL for
pre-existing per-RFC working relationships you want to
grandfather past the v0.16.0 cutover. The `invitation_id`
column is nullable for exactly this purpose. Production
deployments without that history can ignore this option.
## 0.14.0 — 2026-05-28
**Minor — no operator action required; new optional env var.** This
+1 -1
View File
@@ -1 +1 @@
0.12.0
0.16.0
+7
View File
@@ -22,6 +22,7 @@ from . import (
api_branches,
api_discussion,
api_graduation,
api_invitations,
api_notifications,
api_prs,
auth,
@@ -102,6 +103,12 @@ def make_router(
# 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())
# ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe.
+43
View File
@@ -90,6 +90,17 @@ def make_router(config: Config) -> APIRouter:
`permission_decided_by_login` joins the deciding admin row so
the UI can render "granted by @ben" without a second round-trip.
v0.16.0 (roadmap item #12) additive: each user row now carries
an `rfc_invitations` array — the per-RFC invitations the user
has accepted. This is the "permission-grant requests from
invited users" hook the roadmap text calls for: when a user
accepts a per-RFC invite and they're not yet platform-granted,
the admin sees "here because @ben invited them to <RFC> as
<role>" alongside their pending row, informing (not deciding)
the platform grant. The two write surfaces remain distinct —
the RFC's owner controls per-RFC roles; the admin controls
platform-grant state.
"""
auth.require_admin(request)
rows = db.conn().execute(
@@ -115,6 +126,36 @@ def make_router(config: Config) -> APIRouter:
u.display_name COLLATE NOCASE
"""
).fetchall()
# v0.16.0 — per-user accepted per-RFC invitations. One query
# over the full set, indexed bucket-by-user-id in Python so
# the per-row attachment below is O(1). Empty array for users
# who hold no accepted invitations.
invitation_rows = db.conn().execute(
"""
SELECT c.user_id, c.rfc_slug, c.role_in_rfc, c.created_at,
r.title AS rfc_title,
i.id AS invitation_id, i.invitee_email,
ui.gitea_login AS inviter_login,
ui.display_name AS inviter_display
FROM rfc_collaborators c
LEFT JOIN cached_rfcs r ON r.slug = c.rfc_slug
LEFT JOIN rfc_invitations i ON i.id = c.invitation_id
LEFT JOIN users ui ON ui.id = i.inviter_user_id
ORDER BY c.created_at DESC
"""
).fetchall()
per_user_invites: dict[int, list[dict]] = {}
for ir in invitation_rows:
per_user_invites.setdefault(ir["user_id"], []).append({
"rfc_slug": ir["rfc_slug"],
"rfc_title": ir["rfc_title"] or ir["rfc_slug"],
"role_in_rfc": ir["role_in_rfc"],
"invited_at": ir["created_at"],
"invitation_id": ir["invitation_id"],
"invitee_email": ir["invitee_email"],
"inviter_login": ir["inviter_login"],
"inviter_display": ir["inviter_display"],
})
return {
"items": [
{
@@ -133,6 +174,8 @@ def make_router(config: Config) -> APIRouter:
"permission_decided_at": r["permission_decided_at"],
"permission_decided_by_login": r["decided_by_login"],
"permission_decided_by_display": r["decided_by_display"],
# v0.16.0 additive — never null, always an array.
"rfc_invitations": per_user_invites.get(r["id"], []),
}
for r in rows
]
+17
View File
@@ -279,6 +279,15 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/main/promote-to-branch")
async def promote_to_branch(slug: str, body: PromoteToBranchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
# v0.16.0 (item #12): cutting a contribute branch is the
# PR-shaped write surface gate. A platform-granted user who is
# not invited as a per-RFC contributor cannot start work that
# only exists to land in a PR.
if not auth.can_contribute_to_rfc(viewer, slug):
raise HTTPException(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
@@ -331,6 +340,14 @@ def make_router(
@router.post("/api/rfcs/{slug}/start-edit-branch")
async def start_edit_branch(slug: str, body: StartEditBranchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
# v0.16.0 (item #12): same per-RFC contribute gate as
# promote-to-branch — kicking off a super-draft edit branch is
# also PR-shaped work.
if not auth.can_contribute_to_rfc(viewer, slug):
raise HTTPException(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_super_draft(slug)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
+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,
+575
View File
@@ -0,0 +1,575 @@
"""v0.16.0 / §6 / §10 — owner-only invite for per-RFC PR or PR-less
discussion (roadmap item #12).
The RFC's owner can invite a specific email to one of two per-RFC roles:
* `contributor` — may open PRs against this RFC AND post in its
discussion (PR-permission strictly includes discussion-permission).
* `discussant` — may post in this RFC's PR-less discussion only.
Non-invited users keep the v0.6.0 anonymous-read contract: they can
read but cannot write/discuss the RFC. Reads are not narrowed by
this item.
Endpoints:
* `POST /api/rfcs/{slug}/invitations` — owner: create + email
* `GET /api/rfcs/{slug}/invitations` — owner: list pending/accepted
* `POST /api/rfcs/{slug}/invitations/{id}/revoke` — owner: revoke
* `GET /api/invitations/accept` — token lookup (signed-in user)
* `POST /api/invitations/accept` — token redeem (signed-in user)
The accept endpoints are deliberately platform-scoped (not nested under
the RFC slug) because the user clicking the email link only has the
token and may not even know the slug yet. The GET shape lets the
frontend show a confirmation page ("RFC <X> invited you to be a
<role> — accept?") before the POST commits the membership.
Permission gates (composed with `require_contributor`):
* Issue / list / revoke: `auth.can_invite_to_rfc` — RFC owner or
platform admin/owner.
* Accept: any platform-granted signed-in user; the gate is the
token, not the role. The token also constrains which email the
accept lands under — the accepting user's email must match the
invitation's invitee_email (case-insensitive). This prevents an
invited-but-not-the-account-holder situation from minting a
collaborator row under the wrong identity.
Email shape: a single plain-text body sent via the existing SMTP path
(reuses `EmailConfig.from_env()` like `email_otc.py` does). No
unsubscribe footer — the email is transactional and per-invite, not a
recurring notification. No tracking pixel.
Admin-page hook: when an accept lands and the user's
`permission_state` is still `pending`, that signals to the admin's
`/admin/users` queue that the user is here because they accepted a
per-RFC invitation — informing (not deciding) the admin's
platform-grant call. v0.16.0 surfaces this via additive columns on
the existing `GET /api/admin/users` listing (see `api_admin.py`'s
diff in the same release) — no new endpoint, no restructure.
"""
from __future__ import annotations
import logging
import secrets
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, db
from .email import EmailConfig, _SENT
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pydantic bodies
# ---------------------------------------------------------------------------
class CreateInvitationBody(BaseModel):
"""The owner picks an email and a role-in-RFC. No custom-message
field — that belongs to item #16's platform-level invite surface,
not here.
We validate the email with a deliberately narrow pattern rather
than `pydantic.EmailStr` to avoid pulling in `email-validator` as
a dependency (and v0.7.0's OTC body does the same — see
`OTCRequestBody`'s shape). The validation here is intentionally
permissive: a local-part, an `@`, and a domain part with no
whitespace. Operator-side typo catching is the job of the email
transport; the framework only guards against obviously malformed
input."""
invitee_email: str = Field(min_length=3, max_length=320,
pattern=r"^[^\s@]+@[^\s@]+$")
role_in_rfc: str = Field(pattern="^(contributor|discussant)$")
class AcceptInvitationBody(BaseModel):
token: str = Field(min_length=1, max_length=200)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# 30-day TTL matches the device-trust window the framework already
# ships (v0.11.0). A pending invitation past this is rejected at the
# accept endpoint regardless of the row's `status` column.
INVITATION_TTL_DAYS = 30
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# POST /api/rfcs/<slug>/invitations
# The owner creates an invitation. The endpoint mints the token,
# writes the row, and dispatches the email synchronously. A failure
# to send the email does NOT roll back the row — the owner can
# share the link directly out-of-band if SMTP is briefly down (the
# `GET /api/rfcs/<slug>/invitations` response carries the token
# for that fallback).
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/invitations")
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can invite collaborators",
)
invitee_email = body.invitee_email.strip()
role_in_rfc = body.role_in_rfc
# Refuse re-inviting an email that already has a pending
# invitation on this RFC at the same role. Different-role
# re-invite is allowed (upgrade discussant → contributor)
# — the new row supersedes the old in the UI listing's
# natural ordering, and acceptance of either picks up the
# corresponding role.
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = ? AND status = 'pending'
LIMIT 1
""",
(slug, invitee_email, role_in_rfc),
).fetchone()
if existing:
raise HTTPException(
409,
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
)
token = _mint_token()
cur = db.conn().execute(
"""
INSERT INTO rfc_invitations
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
token, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
""",
(
slug,
viewer.user_id,
invitee_email,
role_in_rfc,
token,
f"+{INVITATION_TTL_DAYS} days",
),
)
invitation_id = cur.lastrowid
# Send the email — synchronous. A send failure logs and
# returns; the row stays so the owner can recover via the
# listing (which carries the token for an out-of-band share).
_send_invitation_email(
to_address=invitee_email,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
rfc_title=rfc["title"],
role_in_rfc=role_in_rfc,
token=token,
)
return {
"id": invitation_id,
"rfc_slug": slug,
"invitee_email": invitee_email,
"role_in_rfc": role_in_rfc,
"status": "pending",
"token": token,
}
# ---------------------------------------------------------------
# GET /api/rfcs/<slug>/invitations
# The owner's listing of every invitation on the RFC, regardless
# of status. Carries the token (for the resend / re-share path).
# ---------------------------------------------------------------
@router.get("/api/rfcs/{slug}/invitations")
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can view invitations",
)
rows = db.conn().execute(
"""
SELECT i.id, i.invitee_email, i.role_in_rfc, i.status, i.token,
i.expires_at, i.created_at, i.accepted_at,
i.inviter_user_id, i.accepted_by_user_id,
u_inviter.display_name AS inviter_display,
u_inviter.gitea_login AS inviter_login,
u_accept.display_name AS accepted_by_display,
u_accept.gitea_login AS accepted_by_login
FROM rfc_invitations i
LEFT JOIN users u_inviter ON u_inviter.id = i.inviter_user_id
LEFT JOIN users u_accept ON u_accept.id = i.accepted_by_user_id
WHERE i.rfc_slug = ?
ORDER BY i.id DESC
""",
(slug,),
).fetchall()
return {
"items": [
{
"id": r["id"],
"invitee_email": r["invitee_email"],
"role_in_rfc": r["role_in_rfc"],
"status": _effective_status(r),
"token": r["token"],
"expires_at": r["expires_at"],
"created_at": r["created_at"],
"accepted_at": r["accepted_at"],
"inviter_display": r["inviter_display"],
"inviter_login": r["inviter_login"],
"accepted_by_display": r["accepted_by_display"],
"accepted_by_login": r["accepted_by_login"],
}
for r in rows
],
}
# ---------------------------------------------------------------
# POST /api/rfcs/<slug>/invitations/<id>/revoke
# Revokes a pending invitation. Already-accepted invitations
# cannot be "revoked" from this surface — the corresponding
# collaborator-removal surface is a §19.2 candidate; v0.16.0
# only lifts the *pending* link.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can revoke invitations",
)
row = db.conn().execute(
"SELECT id, status FROM rfc_invitations WHERE id = ? AND rfc_slug = ?",
(invitation_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Invitation not found")
if row["status"] != "pending":
raise HTTPException(
409,
f"Invitation is {row['status']}; only pending invitations can be revoked",
)
db.conn().execute(
"UPDATE rfc_invitations SET status = 'revoked' WHERE id = ?",
(invitation_id,),
)
return {"ok": True, "id": invitation_id, "status": "revoked"}
# ---------------------------------------------------------------
# GET /api/invitations/accept?token=...
# Lookup-only — returns what the invitation grants so the
# frontend can render a confirmation page before the POST. The
# token is required; no token, no peek.
# ---------------------------------------------------------------
@router.get("/api/invitations/accept")
async def preview_invitation(token: str, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = _lookup_invitation_by_token(token)
if row is None:
raise HTTPException(404, "Invitation not found")
effective = _effective_status(row)
rfc = db.conn().execute(
"SELECT slug, title FROM cached_rfcs WHERE slug = ?", (row["rfc_slug"],),
).fetchone()
return {
"rfc_slug": row["rfc_slug"],
"rfc_title": rfc["title"] if rfc else row["rfc_slug"],
"role_in_rfc": row["role_in_rfc"],
"status": effective,
"invitee_email": row["invitee_email"],
"email_matches_you": (viewer.email or "").strip().lower()
== row["invitee_email"].strip().lower(),
"expires_at": row["expires_at"],
}
# ---------------------------------------------------------------
# POST /api/invitations/accept
# The accept gesture: token → collaborator row.
#
# Requires:
# * an authenticated user (no token-only acceptance — we want
# the per-user audit trail),
# * a valid (pending, non-expired, non-revoked) invitation,
# * the accepting user's email matches invitee_email
# (case-insensitive).
#
# On success the row's status flips to 'accepted' and a
# rfc_collaborators row is inserted (or upgraded if the user
# already had a lower role). Idempotent: re-accepting the same
# already-accepted invitation reads as a 200 no-op with
# `changed=false`.
# ---------------------------------------------------------------
@router.post("/api/invitations/accept")
async def accept_invitation(body: AcceptInvitationBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = _lookup_invitation_by_token(body.token)
if row is None:
raise HTTPException(404, "Invitation not found")
effective = _effective_status(row)
if effective == "revoked":
raise HTTPException(409, "Invitation was revoked")
if effective == "expired":
raise HTTPException(409, "Invitation has expired")
# Email match — case-insensitive. Empty viewer email cannot
# accept (an OAuth-only user with no captured email shape).
viewer_email = (viewer.email or "").strip().lower()
invitee_email = row["invitee_email"].strip().lower()
if not viewer_email or viewer_email != invitee_email:
raise HTTPException(
403,
"This invitation was sent to a different email; sign in with that address",
)
if effective == "accepted":
# Idempotent re-accept — surface the existing collaborator
# row without writing anything new.
collab = db.conn().execute(
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
(row["rfc_slug"], viewer.user_id),
).fetchone()
return {
"ok": True,
"changed": False,
"rfc_slug": row["rfc_slug"],
"role_in_rfc": collab["role_in_rfc"] if collab else row["role_in_rfc"],
}
# First-time accept. Flip the invitation; upsert the
# collaborator. We do the upsert with ON CONFLICT so a
# user who already held a lower role gets upgraded, never
# downgraded (the MAX-style precedence is contributor >
# discussant; lower roles never overwrite higher).
with db.tx() as c:
c.execute(
"""
UPDATE rfc_invitations
SET status = 'accepted',
accepted_at = datetime('now'),
accepted_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, row["id"]),
)
existing = c.execute(
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
(row["rfc_slug"], viewer.user_id),
).fetchone()
target_role = _max_role(
existing["role_in_rfc"] if existing else None,
row["role_in_rfc"],
)
if existing is None:
c.execute(
"""
INSERT INTO rfc_collaborators
(rfc_slug, user_id, role_in_rfc, invitation_id)
VALUES (?, ?, ?, ?)
""",
(row["rfc_slug"], viewer.user_id, target_role, row["id"]),
)
elif existing["role_in_rfc"] != target_role:
c.execute(
"""
UPDATE rfc_collaborators
SET role_in_rfc = ?, invitation_id = ?
WHERE rfc_slug = ? AND user_id = ?
""",
(target_role, row["id"], row["rfc_slug"], viewer.user_id),
)
return {
"ok": True,
"changed": True,
"rfc_slug": row["rfc_slug"],
"role_in_rfc": target_role,
}
return router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _require_rfc(slug: str):
"""The invitation surface only operates on a known, non-withdrawn
RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the
discussion endpoints' `_require_rfc_readable` shape."""
row = db.conn().execute(
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
return row
def _lookup_invitation_by_token(token: str):
return db.conn().execute(
"""
SELECT id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
status, token, expires_at, created_at, accepted_at,
accepted_by_user_id
FROM rfc_invitations
WHERE token = ?
""",
(token,),
).fetchone()
def _effective_status(row) -> str:
"""The row's column status is the authoritative truth except for
`expired` — that is derived from `expires_at` at read time so an
unattended cron isn't required to flip rows. A revoked-then-
expired row reads as `revoked` (the explicit gesture wins)."""
column_status = row["status"]
if column_status != "pending":
return column_status
# Compare via SQL so the comparison is in sqlite-time, matching the
# `datetime('now')` insert. A simpler same-process comparison would
# work too, but routing through the DB keeps the timezone handling
# consistent with the inserts.
is_past = db.conn().execute(
"SELECT datetime(?) <= datetime('now') AS past",
(row["expires_at"],),
).fetchone()["past"]
return "expired" if is_past else "pending"
def _mint_token() -> str:
"""A 256-bit URL-safe token. The token shape is opaque to the
consumer; the email link encodes it as a query param."""
return secrets.token_urlsafe(32)
def _max_role(existing: str | None, new: str) -> str:
"""contributor strictly dominates discussant. A re-accept that
would lower the role is a no-op (the existing role survives)."""
precedence = {"discussant": 0, "contributor": 1}
if existing is None:
return new
if precedence.get(new, 0) > precedence.get(existing, 0):
return new
return existing
# ---------------------------------------------------------------------------
# Email dispatch — transactional, no preferences honored
# ---------------------------------------------------------------------------
def _send_invitation_email(
*,
to_address: str,
inviter_display: str,
rfc_title: str,
role_in_rfc: str,
token: str,
) -> bool:
"""Compose and send the invitation email.
Like `email_otc.send_otc_email`, this writes its own envelope and
reuses `EmailConfig.from_env()` for the SMTP plumbing. The
`_SENT` buffer is appended either way so integration tests can
assert on the outbound shape without a real SMTP server.
Returns True on the happy path / dev fallback; False on SMTP
failure. The caller does not roll back the invitation row on
failure — the owner has the token in the create response and on
the listing surface for an out-of-band share.
"""
cfg = EmailConfig.from_env()
subject = f"{inviter_display} invited you to {rfc_title} on {cfg.from_name}"
role_label = (
"open PRs against the RFC and join its discussion"
if role_in_rfc == "contributor"
else "join the RFC's discussion"
)
link = f"{cfg.app_url}/invitations/accept?token={token}"
body = (
f"{inviter_display} invited you to {rfc_title} on {cfg.from_name} as {role_in_rfc}.\n\n"
f"This invitation lets you {role_label}.\n\n"
f"Click to accept (you'll be asked to sign in first if you aren't already):\n\n"
f" {link}\n\n"
f"The invitation expires in {INVITATION_TTL_DAYS} days. If you weren't expecting\n"
f"this, you can safely ignore the email.\n\n"
f"---\n"
f"{cfg.from_name} · {cfg.app_url}\n"
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "rfc_invitation",
}
_SENT.append(envelope)
if not cfg.enabled:
log.info("invitation email disabled (EMAIL_ENABLED=0): to=%s", to_address)
return True
if not cfg.smtp_host:
# Dev fallback — surface the link at INFO so the operator can
# complete an accept flow without an SMTP relay.
log.info(
"invitation email (stdout fallback): to=%s rfc=%s role=%s link=%s",
to_address, rfc_title, role_in_rfc, link,
)
return True
try:
msg = EmailMessage()
msg["From"] = envelope["from"]
msg["To"] = to_address
msg["Subject"] = subject
msg.set_content(body)
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
smtp.starttls()
if cfg.smtp_user:
smtp.login(cfg.smtp_user, cfg.smtp_password)
smtp.send_message(msg)
finally:
smtp.quit()
return True
except Exception:
log.exception("invitation email send failed: to=%s", to_address)
return False
+11
View File
@@ -112,6 +112,17 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/open-pr")
async def open_pr(slug: str, branch: str, body: OpenPRBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
# v0.16.0 (item #12): opening a PR is the canonical PR-shaped
# write — the gate fires here even though the branch-cutting
# entry points also gate, since a user with prior branch access
# who's since had their per-RFC role revoked shouldn't be able
# to ship the PR. The branch-creation gate is the kickoff
# refusal; this one is the post-work refusal.
if not auth.can_contribute_to_rfc(viewer, slug):
raise HTTPException(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
if branch == "main":
raise HTTPException(409, "PRs open from non-main branches")
+154
View File
@@ -290,5 +290,159 @@ def require_admin(request: Request) -> SessionUser:
return user
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
#
# These don't replace `require_contributor` — they layer on top of it for
# endpoints that an RFC's owner can selectively open up. The "discussion"
# and "PR" write surfaces consult `is_rfc_writer(...)` / `is_rfc_discussant(...)`
# to admit users who are either platform-privileged (admin, RFC owner)
# OR who hold an explicit invitation-accepted per-RFC role.
#
# The platform gate still fires first: a user whose
# `permission_state != 'granted'` cannot write anywhere, invitation or
# not. v0.16.0 doesn't loosen that — a per-RFC invitation is additive
# *within* the granted-platform-user population. (Accepting an
# invitation as a pending user surfaces in the admin-page hook per
# the roadmap text; the platform grant remains the admin's decision.)
def _rfc_owners_set(rfc_slug: str) -> set[str]:
"""The gitea_logins named in the RFC's frontmatter owners array.
Read from `cached_rfcs.owners_json`. Returns an empty set if the RFC
isn't cached (the caller's earlier `_require_rfc_readable` will
already have rejected that case in practice).
"""
import json as _json
row = db.conn().execute(
"SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,),
).fetchone()
if row is None:
return set()
try:
return set(_json.loads(row["owners_json"] or "[]"))
except Exception:
return set()
def is_rfc_owner(user: SessionUser | None, rfc_slug: str) -> bool:
"""True iff the user is named in the RFC's frontmatter `owners`
list. The platform-level admin/owner check is separate; per §6.1 an
app admin/owner has all per-RFC capabilities by construction, but
this predicate is intentionally narrow — it answers "is this
person on the RFC's owners line?" and nothing more.
"""
if user is None:
return False
return user.gitea_login in _rfc_owners_set(rfc_slug)
def is_rfc_collaborator(user: SessionUser | None, rfc_slug: str, *, role_in_rfc: str | None = None) -> bool:
"""True iff the user has an accepted per-RFC collaborator row.
`role_in_rfc`:
* None — any role qualifies (the discussion-write check uses this
shape: contributor strictly includes discussant).
* 'contributor' — only the contributor role qualifies (the PR-write
check uses this shape).
* 'discussant' — only the discussant role qualifies (not used by
v0.16.0 endpoints; included for symmetry).
"""
if user is None:
return False
if role_in_rfc is None:
row = db.conn().execute(
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? LIMIT 1",
(rfc_slug, user.user_id),
).fetchone()
return row is not None
row = db.conn().execute(
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? AND role_in_rfc = ? LIMIT 1",
(rfc_slug, user.user_id, role_in_rfc),
).fetchone()
return row is not None
def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
"""v0.16.0 — admit to PR-less discussion writes on this RFC.
True if ANY of:
* platform admin/owner (the §6.1 maximal-capability path),
* the RFC has no frontmatter owners yet (the gate is open
until an owner exists to set it — relevant for super-drafts
pre-§13.1 claim),
* RFC owner (frontmatter `owners` membership),
* accepted per-RFC collaborator at any role (contributor strictly
includes discussant).
Returns False for anonymous viewers and for users whose
`permission_state != 'granted'` — the platform-level gate must hold
before any per-RFC layer can apply. The platform gate is also
enforced earlier in the request via `require_contributor`; the
helper here is defensive so callers that compose it with
`current_user` directly still respect the gate.
"""
if user is None:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
return True
owners = _rfc_owners_set(rfc_slug)
if not owners:
# No owner to gate the invite-list — fall through to the
# platform-granted contract. The first §13.1 claim engages
# the gate; before that, anyone platform-granted can
# contribute (mirrors the v0.5.0 / v0.6.0 contract).
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None)
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
"""v0.16.0 — admit to PR-shaped writes on this RFC.
True if ANY of:
* platform admin/owner,
* the RFC has no frontmatter owners yet (gate open until an
owner exists),
* RFC owner,
* accepted per-RFC collaborator at role 'contributor' (a
'discussant' row is NOT sufficient — PRs are the
higher-privilege surface).
Same `permission_state` and anonymous-viewer refusals as
`can_discuss_rfc`.
"""
if user is None:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
return True
owners = _rfc_owners_set(rfc_slug)
if not owners:
# Same fall-through as can_discuss_rfc: until an owner exists,
# the gate is open.
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor")
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
"""v0.16.0 — only RFC owners (frontmatter) and platform admin/owner
can issue invitations. Per-RFC collaborators do not get the
invite-others power; that stays with the RFC's owner."""
if user is None:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
return True
return is_rfc_owner(user, rfc_slug)
def new_state() -> str:
return secrets.token_urlsafe(16)
+177
View File
@@ -0,0 +1,177 @@
-- §6 / §10 / v0.16.0: owner-only invite for per-RFC contribution +
-- discussion (roadmap item #12).
--
-- Distinct from a platform-level grant (`users.permission_state`,
-- v0.8.0 / item #6). This row is per-RFC membership: the RFC's owner
-- invites a specific email to either open PRs against that RFC
-- (`role_in_rfc='contributor'`) or to participate in the RFC's PR-less
-- discussion only (`role_in_rfc='discussant'`). Non-invited users keep
-- the v0.6.0 anonymous-read contract — they can read but cannot
-- write/discuss that specific RFC.
--
-- Coordinates with item #16's parallel work this wave: that item
-- adds platform-wide invitation tokens; this one adds per-RFC
-- collaboration rows. To avoid table-name + concept collisions the
-- two surfaces are scoped distinctly — this migration owns slot 018
-- and names everything `rfc_*` (RFC-scoped); #16 will use a later
-- slot and name its tables under a different prefix (`invite_tokens`
-- or similar) at the user/platform level.
--
-- Tables in this migration:
--
-- * `rfc_invitations` — one row per (rfc, invitee_email) invite
-- issued by the RFC's owner. Carries the role-in-RFC the
-- invitation grants, the opaque token the email link encodes,
-- the lifecycle state, and the audit trail (who invited, when
-- accepted, by which user_id if any).
--
-- * `rfc_collaborators` — one row per (rfc, user_id, role_in_rfc)
-- after an invitation is accepted. This is the table the
-- write-gate consults: "is the viewer named here for this RFC?"
-- Separating the two means the invitation row carries the
-- issue/accept lifecycle while the collaborator row is the
-- compact membership-check substrate. A grant via collaborator
-- can exist independently of a live invitation (admin-only
-- direct insert is a §19.2 candidate; v0.16.0 only writes
-- collaborator rows via the accept path).
--
-- Authorization model the application layer enforces on top of these
-- rows (not encoded in SQL — the schema is just storage):
--
-- * Writes (open PR, post discussion message, open discussion
-- thread) to an RFC require ONE of:
-- (a) the viewer is named in this RFC's `rfc_collaborators`
-- with the appropriate role_in_rfc, OR
-- (b) the viewer holds a globally privileged role (admin,
-- owner of the platform) per the existing §6 helpers, OR
-- (c) the viewer is named in the RFC's frontmatter owners
-- list (the §6 RFC-owner concept, which already grants
-- the maximal per-RFC capability).
--
-- * Reads remain on the v0.6.0 anonymous-read contract — anyone
-- can read any non-withdrawn RFC. Item #12 does not narrow this.
--
-- * Only the RFC's owner (per `cached_rfcs.owners_json`) can
-- invite. App admins/owners also can (they have the maximal
-- per-RFC capability by construction).
--
-- Storage shape — `rfc_invitations`:
--
-- * `id` — surrogate key; the revoke-by-id surface addresses a
-- single row without leaking the token shape.
--
-- * `rfc_slug` — TEXT NOT NULL; the RFC the invitation scopes to.
-- We FK against `cached_rfcs(slug)` so a withdrawn/deleted RFC
-- cascades its invitations away cleanly. The §4 cache contract
-- says cached_rfcs is rebuildable from Gitea; per the same
-- contract, invitations are app-truth (no Git substrate), so
-- the cascade is the right direction.
--
-- * `inviter_user_id` — the owner who issued the invite. ON
-- DELETE SET NULL because losing the inviter's user row should
-- not cascade-delete invitations they sent (the row stays as
-- audit; the UI renders "by (deleted user)" the same way the
-- audit log does for orphaned actors).
--
-- * `invitee_email` — TEXT NOT NULL; the email the invitation
-- was sent to. Stored verbatim (case-preserved) so the email
-- body can address the invitee in their original shape; the
-- accept path matches case-insensitively.
--
-- * `role_in_rfc` — CHECK in {'contributor' | 'discussant'}.
-- `contributor` lets the user open PRs against the RFC AND
-- post in its discussion (PR-permission strictly includes
-- discussion-permission); `discussant` only lets them post
-- in discussion. Future roles (e.g., 'arbiter') would be
-- additions; v0.16.0 ships the two.
--
-- * `status` — CHECK in {'pending' | 'accepted' | 'revoked' |
-- 'expired'}. Default 'pending'. `accepted` flips on the
-- accept endpoint; `revoked` on the owner's revoke gesture;
-- `expired` lazily on read (the accept endpoint refuses a
-- row whose expires_at has passed, regardless of the column
-- value).
--
-- * `token` — opaque high-entropy string the email link
-- encodes. Stored verbatim (not hashed) because the
-- invitation token is single-use and lower-stakes than a
-- session token: it grants per-RFC role only, and is bounded
-- by expires_at. Hashing the token here is a §19.2 candidate
-- if/when the threat model demands it. UNIQUE so the accept
-- path is a single-row lookup.
--
-- * `expires_at` — TEXT timestamp. Set to `created_at + 30 days`
-- at insert time by the application layer. Accept refuses past
-- this point; the row can still be revoked or re-issued.
--
-- * `created_at` — when the invitation was issued.
--
-- * `accepted_at` — when the invitee accepted (NULL until then).
--
-- * `accepted_by_user_id` — the user row that accepted. NULL
-- until acceptance. On a fresh email (no platform user yet)
-- the accept endpoint requires the invitee to sign in first
-- via the v0.7.0 OTC path; that path provisions the user row,
-- after which the accept call lands the user_id here.
--
-- Indexing:
--
-- * UNIQUE on `token` so the accept lookup is a primary-key-shape
-- hit and accidental collisions are detectable at insert time.
-- * (rfc_slug, status) for the owner's "list pending/accepted for
-- this RFC" surface — the most frequent query.
-- * (invitee_email, status) for a future cross-RFC "show me my
-- pending invites" inbox; v0.16.0 doesn't ship that surface but
-- the index slot is cheap and aligned with the data shape.
--
-- Storage shape — `rfc_collaborators`:
--
-- * `id` — surrogate key.
-- * `rfc_slug` — TEXT NOT NULL FK cached_rfcs(slug) ON DELETE CASCADE.
-- * `user_id` — INTEGER NOT NULL FK users(id) ON DELETE CASCADE.
-- A deleted user loses every per-RFC role automatically (mirrors
-- the device_trust / passcode cascade shape).
-- * `role_in_rfc` — same CHECK as the invitation table.
-- * `invitation_id` — INTEGER FK rfc_invitations(id) ON DELETE
-- SET NULL. Audit pointer to the row that minted this
-- collaborator; NULL is allowed so a future admin-direct grant
-- path (a §19.2 candidate) can mint a collaborator with no
-- originating invitation. v0.16.0 always populates this.
-- * `created_at` — when the collaborator row was minted.
--
-- Indexing on collaborators:
-- * UNIQUE on (rfc_slug, user_id) — a single user can hold at most
-- one role per RFC. Re-accepting an invitation upgrades the row
-- (discussant → contributor) but never duplicates.
-- * (user_id) for "what RFCs am I a collaborator on?" reads.
CREATE TABLE rfc_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitee_email TEXT NOT NULL,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
accepted_at TEXT,
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
CREATE TABLE rfc_collaborators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (rfc_slug, user_id);
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
+6
View File
@@ -22,6 +22,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -131,6 +132,11 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
# per-RFC contributor invitation to cut a branch. In the
# production flow, ben would invite her via /invitations and
# she'd accept; we shortcut to the same end-state.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
@@ -34,6 +34,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -248,6 +249,9 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
# contributor invitation to cut an edit branch on the super-draft.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
@@ -497,6 +501,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): alice needs per-RFC contributor access.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts an edit branch and starts chatting on it.
sign_in_as(client, user_id=2, gitea_login="alice",
+11
View File
@@ -21,6 +21,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -140,6 +141,9 @@ def test_get_pr_returns_three_column_payload(app_with_fake_gitea):
provision_user_row(user_id=3, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Bob is the non-arbiter contributor — alice is seeded as an RFC owner.
# v0.16.0 (item #12): bob needs an accepted per-RFC contributor
# invitation to cut branches and open PRs on alice's RFC.
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
@@ -292,6 +296,9 @@ def test_merge_by_arbiter_advances_main_and_marks_pr_merged(app_with_fake_gitea)
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Bob is neither owner nor arbiter — the non-merge baseline.
# v0.16.0 (item #12): bob still needs an accepted contributor
# invitation to cut the branch + open the PR.
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
@@ -364,6 +371,10 @@ def test_resolution_branch_replays_clean_and_supersedes_on_merge(app_with_fake_g
provision_user_row(user_id=3, login="bob", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# v0.16.0 (item #12): bob (a non-owner contributor) needs an
# accepted per-RFC invitation to cut a branch on alice's RFC.
# Alice is the seeded RFC owner so she doesn't need one.
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts a branch and accepts a change on it.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
+22
View File
@@ -395,6 +395,28 @@ def provision_user_row(*, user_id: int, login: str, role: str) -> None:
)
def grant_rfc_collaborator(*, user_id: int, rfc_slug: str, role_in_rfc: str = "contributor") -> None:
"""v0.16.0 / item #12 test seam: directly insert an accepted-
invitation collaborator row so a non-owner contributor can pass
the per-RFC write gate without going through the email round-trip.
Equivalent in effect to the invitation→accept dance the production
code drives; lets v0.5.0/v0.6.0/v0.8.0 era tests preserve their
"alice owns OHM, bob contributes" shape without rewriting the
setup. The invitation_id is left NULL — collaborators minted via
a direct admin gesture (a §19.2 candidate) carry the same shape.
"""
from app import db
db.conn().execute(
"""
INSERT OR REPLACE INTO rfc_collaborators
(rfc_slug, user_id, role_in_rfc, invitation_id)
VALUES (?, ?, ?, NULL)
""",
(rfc_slug, user_id, role_in_rfc),
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -0,0 +1,658 @@
"""End-to-end integration tests for v0.16.0's owner-only invite for
per-RFC PR or PR-less discussion (roadmap item #12, §6 / §10).
The release lands a per-RFC membership layer:
* `rfc_invitations` issued by the RFC's owner, addressed to an
email, granting one of two roles ('contributor' or 'discussant').
* `rfc_collaborators` the accepted-invitation substrate; the
table the per-RFC write gate consults.
The tests prove:
* Only the RFC's owner (or a platform admin/owner) can invite —
a platform-granted but non-owner user gets 403.
* Creating an invitation lands a row, mints a token, and queues
an envelope on the SMTP buffer.
* Re-inviting the same (email, role) on the same RFC returns 409.
* The accept endpoint requires the accepting user's email to match
the invitee_email (case-insensitive).
* Acceptance lands a rfc_collaborators row and flips the
invitation to 'accepted'.
* Re-accepting the same invitation is idempotent (200, changed=false).
* An expired invitation refuses 409 even if the row's column status
is still 'pending'.
* A revoked invitation refuses 409.
* The owner's listing carries pending + accepted in one response.
* The per-RFC discussion-write gate refuses a non-invited
platform-granted user 403 (was previously 200 before v0.16.0).
* The same gate admits a user who holds an accepted 'discussant'
invitation.
* The same gate admits a user who holds an accepted 'contributor'
invitation (contributor strictly includes discussion).
* The platform admin/owner is admitted regardless of per-RFC
membership (the platform-level capability path).
* The /api/admin/users listing carries `rfc_invitations` per-user
after an acceptance the §17 admin surface hook.
"""
from __future__ import annotations
# Reuse fixtures and helpers from the propose / RFC-view harnesses.
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _invitation_envelopes(to_address: str | None = None) -> list[dict]:
"""Pluck v0.16.0 invitation envelopes out of the shared _SENT buffer.
Same access pattern as the OTC tests use for `kind='otc'`."""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "rfc_invitation":
continue
if to_address is not None and env["to"] != to_address:
continue
out.append(env)
return out
# ---------------------------------------------------------------------------
# Create / list / revoke (owner-side)
# ---------------------------------------------------------------------------
def test_owner_can_invite_creates_row_and_sends_email(app_with_fake_gitea):
"""The end-to-end create gesture: RFC owner posts an invitation,
a row lands, the token comes back in the response, and an
`rfc_invitation`-kind envelope hits the SMTP buffer."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# The frontmatter owner of the seeded RFC is "alice" (per
# seed_active_rfc's default), so we sign in as that user.
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newperson@example.com", "role_in_rfc": "contributor"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["rfc_slug"] == "ohm"
assert body["invitee_email"] == "newperson@example.com"
assert body["role_in_rfc"] == "contributor"
assert body["status"] == "pending"
assert body["token"] and len(body["token"]) > 16
# Row landed.
row = db.conn().execute(
"SELECT * FROM rfc_invitations WHERE id = ?", (body["id"],),
).fetchone()
assert row["rfc_slug"] == "ohm"
assert row["invitee_email"] == "newperson@example.com"
assert row["inviter_user_id"] == 1
assert row["status"] == "pending"
# Email envelope went out.
envs = _invitation_envelopes("newperson@example.com")
assert len(envs) == 1
assert "OHM" in envs[0]["subject"]
assert body["token"] in envs[0]["body"]
def test_non_owner_cannot_invite(app_with_fake_gitea):
"""A platform-granted user who isn't in the RFC's frontmatter
owners list cannot invite 403. Distinct from the
require_contributor gate (which would be 401 for anonymous)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
# alice is the RFC owner per the seed; bob is a regular
# platform-granted contributor with no per-RFC role.
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=2, gitea_login="bob",
display_name="Bob", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "ignored@example.com", "role_in_rfc": "discussant"},
)
assert r.status_code == 403
def test_platform_admin_can_invite_to_any_rfc(app_with_fake_gitea):
"""Per §6.1 the platform admin/owner role carries the maximal
per-RFC capability, so admins can invite on any RFC even if
they're not in its owners list."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=99, login="adminzero", role="admin")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=99, gitea_login="adminzero",
display_name="Admin Zero", role="admin",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "another@example.com", "role_in_rfc": "discussant"},
)
assert r.status_code == 200, r.text
def test_anonymous_cannot_invite(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "x@example.com", "role_in_rfc": "discussant"},
)
assert r.status_code == 401
def test_re_invite_same_email_and_role_returns_409(app_with_fake_gitea):
"""Refuse a duplicate pending invitation for the same (email, role)
on the same RFC. A different role on the same email is allowed
(the owner may want to upgrade discussant contributor)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r1 = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
)
assert r1.status_code == 200
r2 = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
)
assert r2.status_code == 409
# Same email, different role is allowed.
r3 = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "dup@example.com", "role_in_rfc": "contributor"},
)
assert r3.status_code == 200
def test_owner_can_list_invitations(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
client.post("/api/rfcs/ohm/invitations",
json={"invitee_email": "a@example.com", "role_in_rfc": "discussant"})
client.post("/api/rfcs/ohm/invitations",
json={"invitee_email": "b@example.com", "role_in_rfc": "contributor"})
r = client.get("/api/rfcs/ohm/invitations")
assert r.status_code == 200, r.text
items = r.json()["items"]
emails = sorted(i["invitee_email"] for i in items)
assert emails == ["a@example.com", "b@example.com"]
assert all(i["status"] == "pending" for i in items)
# The inviter is named.
assert all(i["inviter_login"] == "alice" for i in items)
def test_revoke_pending_invitation_works_already_accepted_refuses(app_with_fake_gitea):
"""Revoke flips a pending invitation to 'revoked'. An already-
accepted invitation refuses 409 accepted membership is removed
via a different (future) surface; the v0.16.0 revoke only lifts
the pending link."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "revokee@example.com", "role_in_rfc": "discussant"},
)
invitation_id = r.json()["id"]
r = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
assert r.status_code == 200
assert r.json()["status"] == "revoked"
# Re-revoke refuses 409.
r2 = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
assert r2.status_code == 409
row = db.conn().execute(
"SELECT status FROM rfc_invitations WHERE id = ?", (invitation_id,),
).fetchone()
assert row["status"] == "revoked"
# ---------------------------------------------------------------------------
# Accept (invitee-side)
# ---------------------------------------------------------------------------
def test_accept_invitation_lands_collaborator_row(app_with_fake_gitea):
"""The end-to-end accept gesture: the invitee signs in, posts the
token, and an rfc_collaborators row lands at the issued role."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
# provision_user_row sets the email to "<login>@test", so the
# invitee row we'll create needs the same email shape.
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# alice (owner) invites newbie@test.
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
)
assert r.status_code == 200, r.text
token = r.json()["token"]
# Switch to newbie, accept.
sign_in_as(
client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test",
)
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["changed"] is True
assert body["rfc_slug"] == "ohm"
assert body["role_in_rfc"] == "contributor"
# Collaborator row landed; invitation flipped.
collab = db.conn().execute(
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2",
).fetchone()
assert collab is not None
assert collab["role_in_rfc"] == "contributor"
inv = db.conn().execute(
"SELECT status, accepted_by_user_id FROM rfc_invitations WHERE token = ?",
(token,),
).fetchone()
assert inv["status"] == "accepted"
assert inv["accepted_by_user_id"] == 2
def test_accept_refuses_when_email_does_not_match(app_with_fake_gitea):
"""The accepting user's email must match the invitation's
invitee_email (case-insensitive)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="mallory", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "intended@example.com", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
# mallory's email is "mallory@test", not "intended@example.com".
sign_in_as(client, user_id=2, gitea_login="mallory",
display_name="Mallory", role="contributor",
email="mallory@test")
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 403
def test_accept_refuses_revoked_invitation(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
invitation_id = r.json()["id"]
token = r.json()["token"]
client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 409
def test_accept_refuses_expired_invitation(app_with_fake_gitea):
"""An invitation past its `expires_at` is refused 409 even if
the row's column status is still 'pending'. We backdate the
expires_at directly to model the elapsed-window state."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
invitation_id = r.json()["id"]
# Backdate.
db.conn().execute(
"UPDATE rfc_invitations SET expires_at = datetime('now', '-1 day') WHERE id = ?",
(invitation_id,),
)
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 409
def test_accept_is_idempotent_on_re_accept(app_with_fake_gitea):
"""Re-accepting the same already-accepted invitation reads as a
200 no-op with `changed=false`. The collaborator row is unchanged."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
r1 = client.post("/api/invitations/accept", json={"token": token})
assert r1.status_code == 200
assert r1.json()["changed"] is True
r2 = client.post("/api/invitations/accept", json={"token": token})
assert r2.status_code == 200
assert r2.json()["changed"] is False
# Still exactly one collaborator row.
rows = db.conn().execute(
"SELECT COUNT(*) AS n FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2"
).fetchone()
assert rows["n"] == 1
# ---------------------------------------------------------------------------
# Discussion-write gate enforcement
# ---------------------------------------------------------------------------
def test_non_invited_user_cannot_post_to_discussion(app_with_fake_gitea):
"""v0.16.0 narrows the discussion-write gate: a platform-granted
user with no per-RFC role gets 403 when posting to the
discussion. (v0.6.0 left the gate at require_contributor only;
item #12 layers can_discuss_rfc on top.)"""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# bob is platform-granted but not in OHM's owners list and has
# no invitation. The thread-create surface refuses 403.
sign_in_as(client, user_id=2, gitea_login="bob",
display_name="Bob", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Question", "message": "Should I be allowed?"},
)
assert r.status_code == 403
def test_invited_discussant_can_post_to_discussion(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# alice invites newbie as a discussant.
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
# newbie accepts.
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
client.post("/api/invitations/accept", json={"token": token})
# newbie can now post to the discussion.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Question", "message": "Now I can speak."},
)
assert r.status_code == 200, r.text
def test_contributor_role_includes_discussion(app_with_fake_gitea):
"""A 'contributor' per-RFC role strictly includes discussion
permission accepting a contributor invitation admits the user
to the discussion endpoint too."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
)
token = r.json()["token"]
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
client.post("/api/invitations/accept", json={"token": token})
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Q", "message": "Hello."},
)
assert r.status_code == 200
def test_platform_admin_can_post_to_discussion_without_invitation(app_with_fake_gitea):
"""Per §6.1 / item #12's permission shape: platform admins/owners
can write to any RFC's discussion regardless of per-RFC
membership."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=99, login="adminzero", role="admin")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=99, gitea_login="adminzero",
display_name="Admin Zero", role="admin")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Admin chime", "message": "Drive-by from admin."},
)
assert r.status_code == 200
def test_rfc_owner_can_post_to_discussion(app_with_fake_gitea):
"""The frontmatter RFC owner is admitted by virtue of being on
the owners list they don't need to invite themselves."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Owner thought", "message": "Kicking off the conversation."},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# Admin-page hook (additive on /api/admin/users)
# ---------------------------------------------------------------------------
def test_admin_users_listing_surfaces_per_rfc_invitations(app_with_fake_gitea):
"""v0.16.0 hook into the v0.9.0 admin user-management surface:
each user row carries an `rfc_invitations` array listing the
per-RFC roles they hold. Empty array for users without any."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
provision_user_row(user_id=99, login="adminzero", role="admin")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# alice invites newbie; newbie accepts.
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
)
token = r.json()["token"]
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
client.post("/api/invitations/accept", json={"token": token})
# Admin lists.
sign_in_as(client, user_id=99, gitea_login="adminzero",
display_name="Admin Zero", role="admin")
r = client.get("/api/admin/users")
assert r.status_code == 200
items = r.json()["items"]
newbie_row = next(i for i in items if i["gitea_login"] == "newbie")
assert isinstance(newbie_row["rfc_invitations"], list)
assert len(newbie_row["rfc_invitations"]) == 1
invite = newbie_row["rfc_invitations"][0]
assert invite["rfc_slug"] == "ohm"
assert invite["role_in_rfc"] == "contributor"
assert invite["inviter_login"] == "alice"
# Users with no invitations carry an empty array, not null.
alice_row = next(i for i in items if i["gitea_login"] == "alice")
assert alice_row["rfc_invitations"] == []
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.12.0",
"version": "0.16.0",
"type": "module",
"scripts": {
"dev": "vite",
+7
View File
@@ -14,6 +14,7 @@ import Philosophy from './components/Philosophy.jsx'
import Docs from './components/Docs.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import AcceptInvitation from './components/AcceptInvitation.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
@@ -156,6 +157,12 @@ export default function App() {
<Route path="/welcome" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
{/* v0.16.0 (item #12): per-RFC invitation acceptance landing.
Anonymous viewers see a sign-in prompt; signed-in users
see the preview + accept gesture. */}
<Route path="/invitations/accept" element={
<PolicyShell><AcceptInvitation viewer={viewer} /></PolicyShell>
} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
+42
View File
@@ -322,6 +322,48 @@ export async function resolveThread(slug, branch, threadId) {
return jsonOrThrow(res)
}
// ── v0.16.0: owner-only invite for per-RFC PR or PR-less discussion ──────
//
// roadmap item #12 / §6 / §10. The RFC's owner invites specific emails
// to one of two per-RFC roles ('contributor' or 'discussant'); the
// invitee accepts via the email-encoded token after signing in. The
// platform-level grant remains the admin's decision (per item #6 /
// v0.8.0) — these endpoints control per-RFC membership only.
export async function listRFCInvitations(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/invitations`))
}
export async function createRFCInvitation(slug, { inviteeEmail, roleInRFC }) {
const res = await fetch(`/api/rfcs/${slug}/invitations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invitee_email: inviteeEmail, role_in_rfc: roleInRFC }),
})
return jsonOrThrow(res)
}
export async function revokeRFCInvitation(slug, invitationId) {
const res = await fetch(`/api/rfcs/${slug}/invitations/${invitationId}/revoke`, {
method: 'POST',
})
return jsonOrThrow(res)
}
export async function previewInvitation(token) {
const params = new URLSearchParams({ token })
return jsonOrThrow(await fetch(`/api/invitations/accept?${params}`))
}
export async function acceptInvitation(token) {
const res = await fetch('/api/invitations/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
return jsonOrThrow(res)
}
// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
//
// The substrate is `threads.branch_name IS NULL` — the same threads
@@ -0,0 +1,185 @@
// AcceptInvitation.jsx v0.16.0 / roadmap item #12.
//
// The /invitations/accept?token=... landing page the invitation email
// links to. The page:
//
// 1. Reads `?token=...` from the URL.
// 2. Calls GET /api/invitations/accept?token=... to preview what the
// invitation grants (RFC title, role-in-RFC, expiry, whether the
// currently-signed-in user's email matches the invitee's).
// 3. Renders a confirmation surface name the RFC, name the role,
// and either show "Accept" (when the email matches and the
// invitation is still pending) or a refusal message (expired,
// revoked, email mismatch).
// 4. On accept, POST /api/invitations/accept lands the
// rfc_collaborators row and the page redirects to the RFC's view.
//
// For an anonymous viewer who lands here without signing in, the
// preview call 401s and the page tells them to sign in. After
// signing in (via the existing OTC/passcode surface at /login) they
// can return to the same URL the token is stable.
import { useEffect, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { acceptInvitation, previewInvitation } from '../api'
export default function AcceptInvitation({ viewer }) {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const token = searchParams.get('token') || ''
const [preview, setPreview] = useState(null)
const [previewError, setPreviewError] = useState(null)
const [accepting, setAccepting] = useState(false)
const [acceptError, setAcceptError] = useState(null)
useEffect(() => {
if (!token) {
setPreviewError('No invitation token in the URL.')
return
}
if (!viewer) {
// Not signed in the preview endpoint will 401. We surface a
// sign-in prompt without making the request.
return
}
previewInvitation(token)
.then(setPreview)
.catch(err => setPreviewError(err.message || 'Could not load invitation.'))
}, [token, viewer])
async function handleAccept() {
setAccepting(true)
setAcceptError(null)
try {
const result = await acceptInvitation(token)
navigate(`/rfc/${result.rfc_slug}`)
} catch (err) {
setAcceptError(err.message || 'Could not accept invitation.')
} finally {
setAccepting(false)
}
}
if (!token) {
return (
<div className="accept-invitation">
<h1>Invitation link is malformed</h1>
<p>No <code>token</code> parameter was found. Ask the person who
invited you to re-send the link.</p>
<p><Link to="/">Return to the catalog</Link></p>
</div>
)
}
if (!viewer) {
return (
<div className="accept-invitation">
<h1>Sign in to accept your invitation</h1>
<p>
You've been invited to collaborate on an RFC. Sign in first so we
can attach the membership to your account, then return to this
link.
</p>
<p>
<Link to="/login" className="btn-primary">Sign in</Link>
</p>
</div>
)
}
if (previewError) {
return (
<div className="accept-invitation">
<h1>Invitation unavailable</h1>
<p>{previewError}</p>
<p><Link to="/">Return to the catalog</Link></p>
</div>
)
}
if (!preview) {
return <div className="accept-invitation">Loading invitation</div>
}
const { rfc_title, rfc_slug, role_in_rfc, status, invitee_email, email_matches_you } = preview
if (status === 'revoked') {
return (
<div className="accept-invitation">
<h1>Invitation revoked</h1>
<p>
The owner of <strong>{rfc_title}</strong> revoked this invitation.
Ask them to re-issue it if you should still have access.
</p>
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
</div>
)
}
if (status === 'expired') {
return (
<div className="accept-invitation">
<h1>Invitation expired</h1>
<p>
This invitation to <strong>{rfc_title}</strong> has expired. Ask
the RFC's owner to issue a fresh one.
</p>
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
</div>
)
}
if (status === 'accepted') {
return (
<div className="accept-invitation">
<h1>Already accepted</h1>
<p>
You've already accepted this invitation. You can{' '}
<Link to={`/rfc/${rfc_slug}`}>open {rfc_title}</Link> now.
</p>
</div>
)
}
if (!email_matches_you) {
return (
<div className="accept-invitation">
<h1>This invitation is for a different account</h1>
<p>
This invitation was sent to <strong>{invitee_email}</strong>. You're
currently signed in as <strong>{viewer.email || viewer.gitea_login}</strong>.
Sign out and sign back in with the invited address to accept.
</p>
<p><a className="btn-link" href="/auth/logout">Sign out</a></p>
</div>
)
}
return (
<div className="accept-invitation">
<h1>Join {rfc_title}</h1>
<p>
You've been invited to <strong>{rfc_title}</strong> as a{' '}
<strong>{role_in_rfc}</strong>.
</p>
<p style={{ color: '#666' }}>
{role_in_rfc === 'contributor'
? 'Contributors can open PRs against this RFC and join its discussion.'
: 'Discussants can post in this RFC\'s discussion.'}
</p>
{acceptError && <div className="error-banner">{acceptError}</div>}
<p>
<button
type="button"
className="btn-primary"
onClick={handleAccept}
disabled={accepting}
>
{accepting ? 'Accepting…' : `Accept and open ${rfc_title}`}
</button>
</p>
<p>
<Link to={`/rfc/${rfc_slug}`}>or just read the RFC without accepting</Link>
</p>
</div>
)
}
@@ -0,0 +1,196 @@
// InvitationsModal.jsx v0.16.0 / roadmap item #12.
//
// The RFC owner's surface for issuing per-RFC invitations and watching
// who has accepted. Opens from the RFC view's header strip when the
// viewer is the RFC's owner (or a platform admin/owner). Non-owner
// viewers never see the trigger.
//
// The modal shows two stacked sections:
//
// 1. "Invite someone" email input + role picker
// (contributor | discussant) + Send. The send goes through the
// backend's POST /api/rfcs/<slug>/invitations, which both writes
// the row and dispatches the email to the invitee. Success
// refreshes the list below and clears the input.
//
// 2. "Existing invitations" every invitation (pending +
// accepted + revoked + expired) on this RFC, with revoke
// buttons on the pending ones. The status of each row is the
// effective status (the backend recomputes expired-from-pending
// at read time so an unattended cron isn't required).
//
// No custom-message field that belongs to item #16's platform-
// level surface, not here. No bulk-invite one email at a time
// keeps the gesture deliberate.
import { useEffect, useState } from 'react'
import {
createRFCInvitation,
listRFCInvitations,
revokeRFCInvitation,
} from '../api'
const ROLE_OPTIONS = [
{ value: 'contributor', label: 'Contributor — can open PRs and join discussion' },
{ value: 'discussant', label: 'Discussant — can join discussion only' },
]
export default function InvitationsModal({ slug, rfcTitle, onClose }) {
const [invitations, setInvitations] = useState(null)
const [loadError, setLoadError] = useState(null)
const [inviteeEmail, setInviteeEmail] = useState('')
const [roleInRFC, setRoleInRFC] = useState('contributor')
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState(null)
const [submitSuccess, setSubmitSuccess] = useState(null)
const [revokingId, setRevokingId] = useState(null)
async function refresh() {
setLoadError(null)
try {
const r = await listRFCInvitations(slug)
setInvitations(r.items || [])
} catch (e) {
setLoadError(e.message)
}
}
useEffect(() => { refresh() /* eslint-disable-line react-hooks/exhaustive-deps */ }, [slug])
async function handleSend(e) {
e.preventDefault()
const email = inviteeEmail.trim()
if (!email) return
setSubmitting(true)
setSubmitError(null)
setSubmitSuccess(null)
try {
await createRFCInvitation(slug, { inviteeEmail: email, roleInRFC })
setSubmitSuccess(`Invitation sent to ${email}.`)
setInviteeEmail('')
await refresh()
} catch (err) {
setSubmitError(err.message || 'Failed to send invitation.')
} finally {
setSubmitting(false)
}
}
async function handleRevoke(invitationId) {
setRevokingId(invitationId)
try {
await revokeRFCInvitation(slug, invitationId)
await refresh()
} catch (err) {
setSubmitError(err.message || 'Failed to revoke invitation.')
} finally {
setRevokingId(null)
}
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal" style={{ maxWidth: 640 }}>
<div className="modal-header">
<h2>Invitations {rfcTitle || slug}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<p style={{ marginTop: 0, color: '#666' }}>
Invite people by email to contribute PRs against this RFC or to
join its discussion. Anyone with the link can read this RFC;
this surface controls who can <em>write</em>.
</p>
<form onSubmit={handleSend} className="invitations-form" style={{ marginTop: 16 }}>
<label htmlFor="invitee-email">Invitee email</label>
<input
id="invitee-email"
type="email"
value={inviteeEmail}
onChange={e => setInviteeEmail(e.target.value)}
placeholder="someone@example.com"
autoFocus
required
/>
<label htmlFor="invitee-role" style={{ marginTop: 10 }}>Role on this RFC</label>
<select
id="invitee-role"
value={roleInRFC}
onChange={e => setRoleInRFC(e.target.value)}
>
{ROLE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Sending…' : 'Send invitation'}
</button>
{submitError && <span style={{ color: '#c33' }}>{submitError}</span>}
{submitSuccess && <span style={{ color: '#383' }}>{submitSuccess}</span>}
</div>
</form>
<hr style={{ margin: '20px 0' }} />
<h3 style={{ margin: '0 0 8px' }}>Existing invitations</h3>
{loadError && <div className="error-banner">{loadError}</div>}
{invitations === null && <div>Loading</div>}
{invitations !== null && invitations.length === 0 && (
<div style={{ color: '#666' }}>No invitations have been sent yet.</div>
)}
{invitations !== null && invitations.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 4 }}>Email</th>
<th style={{ textAlign: 'left', padding: 4 }}>Role</th>
<th style={{ textAlign: 'left', padding: 4 }}>Status</th>
<th style={{ textAlign: 'left', padding: 4 }}>Sent</th>
<th style={{ padding: 4 }}></th>
</tr>
</thead>
<tbody>
{invitations.map(inv => (
<tr key={inv.id} style={{ borderTop: '1px solid #eee' }}>
<td style={{ padding: 4 }}>{inv.invitee_email}</td>
<td style={{ padding: 4 }}>{inv.role_in_rfc}</td>
<td style={{ padding: 4 }}>
<span className={`invitation-status status-${inv.status}`}>
{inv.status}
</span>
{inv.status === 'accepted' && inv.accepted_by_display && (
<span style={{ color: '#666', marginLeft: 6 }}>
by {inv.accepted_by_display}
</span>
)}
</td>
<td style={{ padding: 4, color: '#666' }}>
{inv.created_at?.slice(0, 10) || ''}
</td>
<td style={{ padding: 4, textAlign: 'right' }}>
{inv.status === 'pending' && (
<button
type="button"
className="btn-link"
onClick={() => handleRevoke(inv.id)}
disabled={revokingId === inv.id}
>
{revokingId === inv.id ? 'Revoking…' : 'Revoke'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn-link" onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}
+28
View File
@@ -43,6 +43,7 @@ import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import InvitationsModal from './InvitationsModal.jsx'
import { claimOwnership } from '../api'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
@@ -139,6 +140,11 @@ export default function RFCView({ viewer }) {
const [showMetadataPane, setShowMetadataPane] = useState(false)
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
const [claimError, setClaimError] = useState(null)
// v0.16.0 (item #12): the per-RFC invitations modal. Visible only to
// RFC owners (frontmatter) and platform admin/owner the backend
// gates the underlying endpoints regardless, so a leaked toggle
// can't actually leak anything.
const [showInvitationsModal, setShowInvitationsModal] = useState(false)
// Load main view + branch view whenever slug/branch changes.
useEffect(() => {
@@ -624,6 +630,20 @@ export default function RFCView({ viewer }) {
Graduate to RFC repo
</button>
)}
{/* v0.16.0 (item #12): owner-only invitations affordance.
Shown when the viewer is named in the RFC's frontmatter
`owners` list or holds a platform admin/owner role.
Available on both super-drafts and active RFCs. */}
{viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
<button
type="button"
className="btn-link"
onClick={() => setShowInvitationsModal(true)}
title="Invite collaborators to this RFC"
>
Invitations
</button>
)}
</div>
</div>
{claimError && (
@@ -856,6 +876,14 @@ export default function RFCView({ viewer }) {
/>
)}
{showInvitationsModal && (
<InvitationsModal
slug={slug}
rfcTitle={entry?.title}
onClose={() => setShowInvitationsModal(false)}
/>
)}
{showMetadataPane && (
<MetadataPaneModal
slug={slug}