diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d068b..8e9dbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,171 @@ 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`. +- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped + inline with v0.16.0): `INVITATION_SENT` event fires from + `InvitationsModal.jsx` on successful send with `{ rfc_slug, + role_in_rfc }`; `INVITATION_ACCEPTED` event fires from + `AcceptInvitation.jsx` on successful accept with the same shape — + but the accept path first calls `identify({ user_id, properties: + { invited_at (setOnce), last_invited_to_rfc, + last_invite_role_in_rfc, claim_method: 'rfc-invite' } })` so the + Amplitude user record carries the invite context from the moment + of acceptance. No invitee email or other PII enters the event + body — only the slug, role, and the inviter's identity (through + the standard signed-in identify on the inviter's session). + +### 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.15.0 — 2026-05-28 **Minor — no schema migration; one new build-time env var bound via diff --git a/VERSION b/VERSION index a551051..04a373e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.15.0 +0.16.0 diff --git a/backend/app/api.py b/backend/app/api.py index ce64386..48ed4de 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -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. diff --git a/backend/app/api_admin.py b/backend/app/api_admin.py index f4031de..73fd9b9 100644 --- a/backend/app/api_admin.py +++ b/backend/app/api_admin.py @@ -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 as + " 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 ] diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index 7f0a594..3079942 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -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() diff --git a/backend/app/api_discussion.py b/backend/app/api_discussion.py index 81d6e2f..9e8a000 100644 --- a/backend/app/api_discussion.py +++ b/backend/app/api_discussion.py @@ -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, diff --git a/backend/app/api_invitations.py b/backend/app/api_invitations.py new file mode 100644 index 0000000..9450d94 --- /dev/null +++ b/backend/app/api_invitations.py @@ -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 invited you to be a + — 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//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//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//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//invitations//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 diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index a7cff26..bb9c105 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -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") diff --git a/backend/app/auth.py b/backend/app/auth.py index b62bf35..c554ac1 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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) diff --git a/backend/migrations/018_rfc_invitations.sql b/backend/migrations/018_rfc_invitations.sql new file mode 100644 index 0000000..55f7bd3 --- /dev/null +++ b/backend/migrations/018_rfc_invitations.sql @@ -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); diff --git a/backend/tests/test_e2e_smoke.py b/backend/tests/test_e2e_smoke.py index 894e532..fb23b8a 100644 --- a/backend/tests/test_e2e_smoke.py +++ b/backend/tests/test_e2e_smoke.py @@ -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={}) diff --git a/backend/tests/test_graduation_vertical.py b/backend/tests/test_graduation_vertical.py index bd56020..bc042b0 100644 --- a/backend/tests/test_graduation_vertical.py +++ b/backend/tests/test_graduation_vertical.py @@ -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", diff --git a/backend/tests/test_pr_flow_vertical.py b/backend/tests/test_pr_flow_vertical.py index 906d45c..13cd803 100644 --- a/backend/tests/test_pr_flow_vertical.py +++ b/backend/tests/test_pr_flow_vertical.py @@ -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") diff --git a/backend/tests/test_propose_vertical.py b/backend/tests/test_propose_vertical.py index 4722bdc..e4cd7a8 100644 --- a/backend/tests/test_propose_vertical.py +++ b/backend/tests/test_propose_vertical.py @@ -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 # --------------------------------------------------------------------------- diff --git a/backend/tests/test_rfc_invitations_vertical.py b/backend/tests/test_rfc_invitations_vertical.py new file mode 100644 index 0000000..dfec7cb --- /dev/null +++ b/backend/tests/test_rfc_invitations_vertical.py @@ -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 "@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"] == [] diff --git a/frontend/package.json b/frontend/package.json index f9b6b2b..1beefba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.15.0", + "version": "0.16.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a3d1a83..af76701 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,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' @@ -220,6 +221,12 @@ export default function App() { } /> } /> } /> + {/* 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. */} + + } /> } /> } /> {/* §14.5 / §14.6: cookie-consent companions to /philosophy. diff --git a/frontend/src/api.js b/frontend/src/api.js index 2dd1e79..49d0b2d 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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 diff --git a/frontend/src/components/AcceptInvitation.jsx b/frontend/src/components/AcceptInvitation.jsx new file mode 100644 index 0000000..19c5aa3 --- /dev/null +++ b/frontend/src/components/AcceptInvitation.jsx @@ -0,0 +1,207 @@ +// 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' +import { EVENTS, identify, track } from '../lib/analytics' + +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) + // v0.16.0 + #21 Part C — re-identify with per-RFC invite + // properties on accept, BEFORE the track event fires, so the + // Amplitude user record carries the invite context from the + // moment of acceptance. setOnce on invited_at preserves the + // first-accepted timestamp if the same user accepts multiple + // RFC invitations. + if (viewer?.id != null) { + identify({ + user_id: String(viewer.id), + properties: { + invited_at: ['__setOnce__', new Date().toISOString()], + last_invited_to_rfc: result.rfc_slug, + last_invite_role_in_rfc: result.role_in_rfc || preview?.role_in_rfc, + claim_method: 'rfc-invite', + }, + }) + } + track(EVENTS.INVITATION_ACCEPTED, { + rfc_slug: result.rfc_slug, + role_in_rfc: result.role_in_rfc || preview?.role_in_rfc, + }) + navigate(`/rfc/${result.rfc_slug}`) + } catch (err) { + setAcceptError(err.message || 'Could not accept invitation.') + } finally { + setAccepting(false) + } + } + + if (!token) { + return ( +
+

Invitation link is malformed

+

No token parameter was found. Ask the person who + invited you to re-send the link.

+

Return to the catalog

+
+ ) + } + + if (!viewer) { + return ( +
+

Sign in to accept your invitation

+

+ 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. +

+

+ Sign in +

+
+ ) + } + + if (previewError) { + return ( +
+

Invitation unavailable

+

{previewError}

+

Return to the catalog

+
+ ) + } + + if (!preview) { + return
Loading invitation…
+ } + + const { rfc_title, rfc_slug, role_in_rfc, status, invitee_email, email_matches_you } = preview + + if (status === 'revoked') { + return ( +
+

Invitation revoked

+

+ The owner of {rfc_title} revoked this invitation. + Ask them to re-issue it if you should still have access. +

+

Read the RFC anyway

+
+ ) + } + if (status === 'expired') { + return ( +
+

Invitation expired

+

+ This invitation to {rfc_title} has expired. Ask + the RFC's owner to issue a fresh one. +

+

Read the RFC anyway

+
+ ) + } + if (status === 'accepted') { + return ( +
+

Already accepted

+

+ You've already accepted this invitation. You can{' '} + open {rfc_title} now. +

+
+ ) + } + + if (!email_matches_you) { + return ( +
+

This invitation is for a different account

+

+ This invitation was sent to {invitee_email}. You're + currently signed in as {viewer.email || viewer.gitea_login}. + Sign out and sign back in with the invited address to accept. +

+

Sign out

+
+ ) + } + + return ( +
+

Join {rfc_title}

+

+ You've been invited to {rfc_title} as a{' '} + {role_in_rfc}. +

+

+ {role_in_rfc === 'contributor' + ? 'Contributors can open PRs against this RFC and join its discussion.' + : 'Discussants can post in this RFC\'s discussion.'} +

+ {acceptError &&
{acceptError}
} +

+ +

+

+ or just read the RFC without accepting +

+
+ ) +} diff --git a/frontend/src/components/InvitationsModal.jsx b/frontend/src/components/InvitationsModal.jsx new file mode 100644 index 0000000..863a624 --- /dev/null +++ b/frontend/src/components/InvitationsModal.jsx @@ -0,0 +1,202 @@ +// 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//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' +import { EVENTS, track } from '../lib/analytics' + +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 }) + // v0.16.0 + #21 Part C — Amplitude wiring. No PII (the email + // is the inviter's input, not the invitee's identity in our + // analytics; we record the rfc_slug + role_in_rfc so a future + // invite→accept correlation has both halves). + track(EVENTS.INVITATION_SENT, { rfc_slug: slug, role_in_rfc: 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 ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+

Invitations — {rfcTitle || slug}

+ +
+
+

+ 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 write. +

+ +
+ + setInviteeEmail(e.target.value)} + placeholder="someone@example.com" + autoFocus + required + /> + + +
+ + {submitError && {submitError}} + {submitSuccess && {submitSuccess}} +
+
+ +
+ +

Existing invitations

+ {loadError &&
{loadError}
} + {invitations === null &&
Loading…
} + {invitations !== null && invitations.length === 0 && ( +
No invitations have been sent yet.
+ )} + {invitations !== null && invitations.length > 0 && ( + + + + + + + + + + + + {invitations.map(inv => ( + + + + + + + + ))} + +
EmailRoleStatusSent
{inv.invitee_email}{inv.role_in_rfc} + + {inv.status} + + {inv.status === 'accepted' && inv.accepted_by_display && ( + + by {inv.accepted_by_display} + + )} + + {inv.created_at?.slice(0, 10) || ''} + + {inv.status === 'pending' && ( + + )} +
+ )} +
+
+ +
+
+
+ ) +} diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 5c7f405..0884536 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -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' import { EVENTS, track } from '../lib/analytics' @@ -148,6 +149,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(() => { @@ -633,6 +639,20 @@ export default function RFCView({ viewer }) { Graduate to RFC repo )} + {/* 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)) && ( + + )} {claimError && ( @@ -865,6 +885,14 @@ export default function RFCView({ viewer }) { /> )} + {showInvitationsModal && ( + setShowInvitationsModal(false)} + /> + )} + {showMetadataPane && (