Release 0.16.0: owner-only invite for per-RFC contribution + discussion (+ #21 Part C Amplitude wiring)
Wave 5 / Track B. Roadmap item #12. Folds in #21 Part C Amplitude
wiring inline (operator ask: "best practices from the very get-go").
RFC owners can invite specific users to one of two per-RFC roles:
contributor (open PRs + join discussion) or discussant (discussion
only). Non-invited users keep the v0.6.0 anonymous-read contract.
The per-RFC write gate layers on top of the existing
require_contributor gate; a super-draft with no owners yet falls
through to the platform-granted contract, preserving the
v0.6.0/v0.7.0/v0.8.0 contracts in their domains.
Backend: migration 018_rfc_invitations.sql (auto-applied — two
tables: rfc_invitations + rfc_collaborators); api_invitations.py
with five endpoints + transactional email; auth.py helpers
(is_rfc_owner / is_rfc_collaborator / can_discuss_rfc /
can_contribute_to_rfc / can_invite_to_rfc); api_discussion +
api_branches + api_prs gate composition; api_admin.py additive
rfc_invitations[] per user. 237 backend tests pass (18 new in
test_rfc_invitations_vertical.py).
Frontend: InvitationsModal.jsx (owner surface), AcceptInvitation.jsx
(/invitations/accept route), api.js helpers, RFCView.jsx
Invitations button, App.jsx route registration.
Amplitude wiring (inline, #21 Part C):
- INVITATION_SENT from InvitationsModal { rfc_slug, role_in_rfc }
- INVITATION_ACCEPTED from AcceptInvitation { rfc_slug, role_in_rfc }
- identify() BEFORE the accept event with properties: invited_at
(setOnce), last_invited_to_rfc, last_invite_role_in_rfc,
claim_method: 'rfc-invite'
- EVENTS taxonomy extended with INVITATION_SENT + INVITATION_ACCEPTED
No new secrets, no new overlay keys, no operator gesture beyond the
v0.15.0 overlay-set + restart. Frontend build verified green.
Subagent ν shipped the feature on feature/v0.16.0-owner-invite
(a51beec). Driver-side integration squash-merged into main,
hand-resolved VERSION + package.json + CHANGELOG (strict-descending
0.16.0 → 0.15.0), and added the inline Amplitude wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user